Rebuild: fresh AdonisJS 7 API app on Postgres
The rebuild branch starts over (REBUILD.md): the API starter kit, with Postgres instead of SQLite (its own database, DB_SEARCH_PATH for a shared one in development) and the #lib alias. Phase 1's code is imported from the current app module by module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
699395c51f
commit
96f0ade10e
264 files changed
+777
-24132
No files matched your search
@@ -1,21 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'groups'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('name').notNullable().unique()
|
||||
table.boolean('active').notNullable().defaultTo(true)
|
||||
table.bigInteger('last_indexed_id').defaultTo(0)
|
||||
|
||||
table.timestamp('created_at', { useTz: true })
|
||||
table.timestamp('updated_at', { useTz: true })
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'files'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').primary()
|
||||
table.string('filename').notNullable()
|
||||
table.string('poster').notNullable()
|
||||
table.bigInteger('date').notNullable()
|
||||
table.integer('parts').notNullable()
|
||||
table.jsonb('message_ids').notNullable()
|
||||
table.jsonb('groups')
|
||||
|
||||
table.timestamp('created_at', { useTz: true })
|
||||
table.timestamp('updated_at', { useTz: true })
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Files that are still being collected from XOVER. One row per uploaded file,
|
||||
* identified by file_key (see lib/subject/file_key.ts).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'binaries'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.bigIncrements('id')
|
||||
table.string('file_key', 40).notNullable().unique()
|
||||
table.text('poster').notNullable()
|
||||
table.text('subject').notNullable()
|
||||
table.text('name').notNullable()
|
||||
table.integer('total_parts').notNullable()
|
||||
table.jsonb('groups').notNullable()
|
||||
table.timestamp('posted_at', { useTz: true })
|
||||
table.timestamp('first_seen_at', { useTz: true }).notNullable()
|
||||
table.timestamp('last_seen_at', { useTz: true }).notNullable().index()
|
||||
table.timestamp('completed_at', { useTz: true })
|
||||
// Set when two different articles claim the same part number (see IngestService).
|
||||
table.timestamp('conflicted_at', { useTz: true })
|
||||
table.integer('file_id').unsigned().references('files.id').onDelete('SET NULL')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Segments (articles) of a binary. The primary key makes re-ingesting the
|
||||
* same range a no-op.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'parts'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.bigInteger('binary_id').notNullable().references('binaries.id').onDelete('CASCADE')
|
||||
table.integer('number').notNullable()
|
||||
table.text('message_id').notNullable()
|
||||
table.integer('bytes').notNullable()
|
||||
table.bigInteger('article_number')
|
||||
table.primary(['binary_id', 'number'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'files'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('file_key', 40).unique()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('file_key')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* A release is one download: the files of one post (archive volumes, par2
|
||||
* set, nfo). See lib/release/release_key.ts and CONCEPT.md (D16).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'releases'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.bigIncrements('id')
|
||||
table.string('release_key', 40).notNullable().unique()
|
||||
table.text('name').notNullable()
|
||||
// Null for obfuscated posts, whose poster is random per article.
|
||||
table.text('poster')
|
||||
// Number of files announced by the [i/M] subject counter, if any.
|
||||
table.integer('file_total')
|
||||
table.jsonb('groups').notNullable()
|
||||
table.timestamp('posted_at', { useTz: true })
|
||||
table.timestamp('first_seen_at', { useTz: true }).notNullable()
|
||||
table.timestamp('last_seen_at', { useTz: true }).notNullable()
|
||||
})
|
||||
|
||||
this.schema.alterTable('binaries', (table) => {
|
||||
table.bigInteger('release_id').references('releases.id').onDelete('SET NULL').index()
|
||||
})
|
||||
this.schema.alterTable('files', (table) => {
|
||||
table.bigInteger('release_id').references('releases.id').onDelete('SET NULL').index()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('files', (table) => table.dropColumn('release_id'))
|
||||
this.schema.alterTable('binaries', (table) => table.dropColumn('release_id'))
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Par2-based grouping of obfuscated posts (CONCEPT.md D16): the par2 file
|
||||
* list of a release, and the exact file size of body-derived binaries to
|
||||
* match against it.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('binaries', (table) => {
|
||||
// Exact file size from =ybegin; only known for body-derived binaries.
|
||||
table.bigInteger('size').index()
|
||||
})
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
// How the release was recognised: subject, stem, weak-stem or par2.
|
||||
table.string('source', 16)
|
||||
})
|
||||
this.schema.createTable('release_files', (table) => {
|
||||
table.bigInteger('release_id').notNullable().references('releases.id').onDelete('CASCADE')
|
||||
table.string('par2_file_id', 32).notNullable()
|
||||
table.text('name').notNullable()
|
||||
table.bigInteger('size').notNullable().index()
|
||||
table.string('md5_16k', 32).notNullable()
|
||||
table.primary(['release_id', 'par2_file_id'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('release_files')
|
||||
this.schema.alterTable('releases', (table) => table.dropColumn('source'))
|
||||
this.schema.alterTable('binaries', (table) => table.dropColumn('size'))
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Article ranges handed to worker:fetch, so a range only counts as indexed
|
||||
* once it was fetched successfully (CONCEPT.md D18).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('groups', (table) => {
|
||||
// How far the scheduler has queued; last_indexed_id becomes the
|
||||
// watermark below which every article was fetched.
|
||||
table.bigInteger('last_queued_id').notNullable().defaultTo(0)
|
||||
})
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('UPDATE groups SET last_queued_id = last_indexed_id')
|
||||
})
|
||||
|
||||
this.schema.createTable('fetch_ranges', (table) => {
|
||||
table.bigIncrements('id')
|
||||
table.integer('group_id').notNullable().references('groups.id').onDelete('CASCADE')
|
||||
table.bigInteger('start_id').notNullable()
|
||||
table.bigInteger('end_id').notNullable()
|
||||
// queued | done | failed
|
||||
table.string('status', 8).notNullable().defaultTo('queued')
|
||||
table.integer('requeues').notNullable().defaultTo(0)
|
||||
table.text('error')
|
||||
table.timestamp('created_at', { useTz: true }).notNullable()
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable()
|
||||
table.index(['group_id', 'status'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('fetch_ranges')
|
||||
this.schema.alterTable('groups', (table) => table.dropColumn('last_queued_id'))
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** Archive listings from the first RAR volume (CONCEPT.md D19). */
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('files', (table) => {
|
||||
// RarInfo of a first volume: format, flags and entries.
|
||||
table.jsonb('archive')
|
||||
})
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
// Password protected: encrypted file data or encrypted headers.
|
||||
table.boolean('encrypted')
|
||||
// Entries of the release's first archive volume.
|
||||
table.jsonb('contents')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
table.dropColumn('contents')
|
||||
table.dropColumn('encrypted')
|
||||
})
|
||||
this.schema.alterTable('files', (table) => table.dropColumn('archive'))
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Splitting equal-sized obfuscated volumes (CONCEPT.md D20): the extra
|
||||
* articles of conflicted binaries are kept, and the split-off volumes point
|
||||
* to the conflicted binary they came from.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.createTable('part_conflicts', (table) => {
|
||||
table.bigInteger('binary_id').notNullable().references('binaries.id').onDelete('CASCADE')
|
||||
table.integer('number').notNullable()
|
||||
table.text('message_id').notNullable()
|
||||
table.integer('bytes').notNullable()
|
||||
table.bigInteger('article_number')
|
||||
table.primary(['binary_id', 'number', 'message_id'])
|
||||
})
|
||||
this.schema.alterTable('binaries', (table) => {
|
||||
table.bigInteger('parent_binary_id').references('binaries.id').onDelete('SET NULL').index()
|
||||
// Last time the splitter processed this conflicted binary.
|
||||
table.timestamp('split_at', { useTz: true })
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('binaries', (table) => {
|
||||
table.dropColumn('split_at')
|
||||
table.dropColumn('parent_binary_id')
|
||||
})
|
||||
this.schema.dropTable('part_conflicts')
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** Full-file MD5 from par2, to verify assembled (e.g. split) volumes. */
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'release_files'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('md5', 32)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => table.dropColumn('md5'))
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Binaries that were flagged conflicted before part_conflicts existed lost
|
||||
* their extra articles, so splitting them only reshuffles the first article
|
||||
* per part number, a mix of different volumes. Mark them as never to be
|
||||
* split, and remove the volumes and files the first splitter run made from
|
||||
* them (see LOG.md, 2026-09-28).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
WITH boundary AS (
|
||||
SELECT migration_time FROM adonis_schema WHERE name LIKE '%1768620764790%'
|
||||
), legacy AS (
|
||||
SELECT id FROM binaries
|
||||
WHERE conflicted_at IS NOT NULL AND parent_binary_id IS NULL
|
||||
AND conflicted_at < (SELECT migration_time FROM boundary)
|
||||
), instances AS (
|
||||
SELECT id, file_id FROM binaries WHERE parent_binary_id IN (SELECT id FROM legacy)
|
||||
), deleted_files AS (
|
||||
DELETE FROM files WHERE id IN (SELECT file_id FROM instances WHERE file_id IS NOT NULL)
|
||||
), deleted_instances AS (
|
||||
DELETE FROM binaries WHERE id IN (SELECT id FROM instances)
|
||||
)
|
||||
-- 'infinity': last_seen_at > split_at is never true, so SplitService skips them.
|
||||
UPDATE binaries SET split_at = 'infinity' WHERE id IN (SELECT id FROM legacy)
|
||||
`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
// The removed volumes were unverifiable; nothing to restore.
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* yEnc CRCs of body-derived articles, so split volumes can be verified
|
||||
* without downloading them (CONCEPT.md D20): the combined part CRCs of a
|
||||
* volume must equal the whole-file crc32 in its last part.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
for (const tableName of ['parts', 'part_conflicts']) {
|
||||
this.schema.alterTable(tableName, (table) => {
|
||||
table.string('pcrc32', 8)
|
||||
// Decoded length of the part (=ypart end - begin + 1).
|
||||
table.integer('part_size')
|
||||
// Whole-file CRC32, when the poster included it (usually in the last part).
|
||||
table.string('file_crc32', 8)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async down() {
|
||||
for (const tableName of ['parts', 'part_conflicts']) {
|
||||
this.schema.alterTable(tableName, (table) => {
|
||||
table.dropColumn('pcrc32')
|
||||
table.dropColumn('part_size')
|
||||
table.dropColumn('file_crc32')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Names and groupings from file contents (CONCEPT.md D21): a fingerprint
|
||||
* (exact size + MD5 of the first 16 KiB, as par2 records it) and the real
|
||||
* filename recovered from par2.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('binaries', (table) => {
|
||||
table.string('md5_16k', 32).index()
|
||||
// Filename recovered from file contents (par2); `name` stays as posted.
|
||||
table.text('real_name')
|
||||
})
|
||||
this.schema.alterTable('files', (table) => {
|
||||
table.text('real_name')
|
||||
})
|
||||
for (const tableName of ['parts', 'part_conflicts']) {
|
||||
this.schema.alterTable(tableName, (table) => {
|
||||
// Only for part 1: MD5 of the first 16 KiB of the file.
|
||||
table.string('md5_16k', 32)
|
||||
})
|
||||
}
|
||||
this.schema.alterTable('release_files', (table) => {
|
||||
table.index(['md5_16k'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('release_files', (table) => table.dropIndex(['md5_16k']))
|
||||
for (const tableName of ['parts', 'part_conflicts']) {
|
||||
this.schema.alterTable(tableName, (table) => table.dropColumn('md5_16k'))
|
||||
}
|
||||
this.schema.alterTable('files', (table) => table.dropColumn('real_name'))
|
||||
this.schema.alterTable('binaries', (table) => {
|
||||
table.dropColumn('real_name')
|
||||
table.dropColumn('md5_16k')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Web interface (CONCEPT.md D22): trigram indexes for substring search, and
|
||||
* SQL versions of the "readable name" rule used for filtering.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('CREATE EXTENSION IF NOT EXISTS pg_trgm')
|
||||
// Per-file suffixes stripped, as in lib/release fileStem.
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION name_stem(name text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT regexp_replace(name,
|
||||
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z(\\.\\d{3})?|zip|\\d{3}|nfo|sfv|nzb|tar|zst|gz|xz|mkv|mp4|avi|m4v|ts|iso|img|flac|mp3|epub|pdf|jpg|png))+$',
|
||||
'', 'i')
|
||||
$$`)
|
||||
// Not an obfuscated token (as in ArchiveService.looksRandom).
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT name IS NOT NULL AND NOT (
|
||||
(name_stem(name) ~ '^[A-Za-z0-9]{16,}$' AND name_stem(name) ~ '\\d'
|
||||
AND name_stem(name) ~ '[a-z]' AND name_stem(name) ~ '[A-Z]')
|
||||
OR name_stem(name) ~* '^[0-9a-f]{16,}$')
|
||||
$$`)
|
||||
await db.rawQuery('CREATE INDEX IF NOT EXISTS releases_name_trgm ON releases USING gin (name gin_trgm_ops)')
|
||||
await db.rawQuery('CREATE INDEX IF NOT EXISTS files_filename_trgm ON files USING gin (filename gin_trgm_ops)')
|
||||
await db.rawQuery('CREATE INDEX IF NOT EXISTS files_real_name_trgm ON files USING gin (real_name gin_trgm_ops)')
|
||||
await db.rawQuery('CREATE INDEX IF NOT EXISTS releases_posted_at_idx ON releases (posted_at DESC NULLS LAST)')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('DROP INDEX IF EXISTS releases_posted_at_idx')
|
||||
await db.rawQuery('DROP INDEX IF EXISTS files_real_name_trgm')
|
||||
await db.rawQuery('DROP INDEX IF EXISTS files_filename_trgm')
|
||||
await db.rawQuery('DROP INDEX IF EXISTS releases_name_trgm')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS is_readable_name(text)')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS name_stem(text)')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Per-release totals for browsing and sorting (CONCEPT.md D22). A trigger on
|
||||
* `files` keeps `releases.file_count` and `releases.size` current, whichever
|
||||
* code path assigns files to releases.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('files', (table) => {
|
||||
table.bigInteger('size')
|
||||
})
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
table.integer('file_count').notNullable().defaultTo(0).index()
|
||||
table.bigInteger('size').notNullable().defaultTo(0).index()
|
||||
})
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
UPDATE files f SET size = s.size
|
||||
FROM (SELECT id, (SELECT coalesce(sum((v->>'size')::bigint), 0) FROM jsonb_each(message_ids) e(k, v)) AS size FROM files) s
|
||||
WHERE s.id = f.id`)
|
||||
await db.rawQuery(`
|
||||
UPDATE releases r SET file_count = t.n, size = t.size
|
||||
FROM (SELECT release_id, count(*) AS n, coalesce(sum(size), 0) AS size FROM files
|
||||
WHERE release_id IS NOT NULL GROUP BY release_id) t
|
||||
WHERE t.release_id = r.id`)
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION files_release_totals() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') AND OLD.release_id IS NOT NULL THEN
|
||||
UPDATE releases SET file_count = file_count - 1, size = size - coalesce(OLD.size, 0) WHERE id = OLD.release_id;
|
||||
END IF;
|
||||
IF TG_OP IN ('UPDATE', 'INSERT') AND NEW.release_id IS NOT NULL THEN
|
||||
UPDATE releases SET file_count = file_count + 1, size = size + coalesce(NEW.size, 0) WHERE id = NEW.release_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await db.rawQuery(`
|
||||
CREATE TRIGGER files_release_totals
|
||||
AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files
|
||||
FOR EACH ROW EXECUTE FUNCTION files_release_totals()`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('DROP TRIGGER IF EXISTS files_release_totals ON files')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS files_release_totals()')
|
||||
})
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
table.dropColumn('size')
|
||||
table.dropColumn('file_count')
|
||||
})
|
||||
this.schema.alterTable('files', (table) => table.dropColumn('size'))
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Stored completeness for the "Complete" filter (CONCEPT.md D22); computing
|
||||
* it per request took ~30 s on 34k releases. Triggers keep it current when
|
||||
* files, par2 lists, sizes or announced totals change.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('CREATE INDEX IF NOT EXISTS binaries_file_id_idx ON binaries (file_id)')
|
||||
await db.rawQuery('CREATE INDEX IF NOT EXISTS releases_contents_trgm ON releases USING gin ((contents::text) gin_trgm_ops)')
|
||||
await db.rawQuery('ALTER TABLE releases ADD COLUMN complete boolean NOT NULL DEFAULT false')
|
||||
await db.rawQuery('CREATE INDEX releases_complete_idx ON releases (complete)')
|
||||
|
||||
// All files announced by [i/M] are there, or every par2-listed file
|
||||
// (>= 64 KiB) is present by exact size or real name.
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION release_complete(rid bigint) RETURNS boolean STABLE LANGUAGE sql AS $$
|
||||
SELECT coalesce((
|
||||
SELECT CASE
|
||||
WHEN r.file_total IS NOT NULL THEN r.file_count >= r.file_total
|
||||
WHEN EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = r.id) THEN NOT EXISTS (
|
||||
SELECT 1 FROM release_files rf
|
||||
WHERE rf.release_id = r.id AND rf.size >= 65536
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM files f JOIN binaries b ON b.file_id = f.id
|
||||
WHERE f.release_id = r.id AND (b.size = rf.size OR f.real_name = rf.name)))
|
||||
ELSE false END
|
||||
FROM releases r WHERE r.id = rid), false)
|
||||
$$`)
|
||||
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION refresh_release_complete(rid bigint) RETURNS void LANGUAGE sql AS $$
|
||||
UPDATE releases SET complete = release_complete(rid) WHERE id = rid AND complete IS DISTINCT FROM release_complete(rid)
|
||||
$$`)
|
||||
|
||||
// files: after the totals trigger has updated file_count (trigger names sort alphabetically).
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION files_release_complete() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') AND OLD.release_id IS NOT NULL THEN PERFORM refresh_release_complete(OLD.release_id); END IF;
|
||||
IF TG_OP IN ('UPDATE', 'INSERT') AND NEW.release_id IS NOT NULL THEN PERFORM refresh_release_complete(NEW.release_id); END IF;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await db.rawQuery(`
|
||||
CREATE TRIGGER files_zz_release_complete
|
||||
AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files
|
||||
FOR EACH ROW EXECUTE FUNCTION files_release_complete()`)
|
||||
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION release_files_release_complete() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM refresh_release_complete(NEW.release_id);
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await db.rawQuery(`
|
||||
CREATE TRIGGER release_files_release_complete
|
||||
AFTER INSERT ON release_files FOR EACH ROW EXECUTE FUNCTION release_files_release_complete()`)
|
||||
|
||||
// An exact size from content inspection can make a par2-listed file "present".
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION binaries_release_complete() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.file_id IS NOT NULL THEN
|
||||
PERFORM refresh_release_complete(f.release_id) FROM files f WHERE f.id = NEW.file_id AND f.release_id IS NOT NULL;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await db.rawQuery(`
|
||||
CREATE TRIGGER binaries_release_complete
|
||||
AFTER UPDATE OF size ON binaries FOR EACH ROW
|
||||
WHEN (OLD.size IS DISTINCT FROM NEW.size) EXECUTE FUNCTION binaries_release_complete()`)
|
||||
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION releases_file_total_complete() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM refresh_release_complete(NEW.id);
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await db.rawQuery(`
|
||||
CREATE TRIGGER releases_file_total_complete
|
||||
AFTER UPDATE OF file_total ON releases FOR EACH ROW
|
||||
WHEN (OLD.file_total IS DISTINCT FROM NEW.file_total) EXECUTE FUNCTION releases_file_total_complete()`)
|
||||
|
||||
await db.rawQuery('UPDATE releases SET complete = release_complete(id) WHERE file_count > 0')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('DROP TRIGGER IF EXISTS releases_file_total_complete ON releases')
|
||||
await db.rawQuery('DROP TRIGGER IF EXISTS binaries_release_complete ON binaries')
|
||||
await db.rawQuery('DROP TRIGGER IF EXISTS release_files_release_complete ON release_files')
|
||||
await db.rawQuery('DROP TRIGGER IF EXISTS files_zz_release_complete ON files')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS releases_file_total_complete()')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS binaries_release_complete()')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS release_files_release_complete()')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS files_release_complete()')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS refresh_release_complete(bigint)')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS release_complete(bigint)')
|
||||
await db.rawQuery('ALTER TABLE releases DROP COLUMN IF EXISTS complete')
|
||||
await db.rawQuery('DROP INDEX IF EXISTS releases_contents_trgm')
|
||||
await db.rawQuery('DROP INDEX IF EXISTS binaries_file_id_idx')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* par2 set id per listed file (traceability; see LOG.md 2026-09-28 for the
|
||||
* cross-post merges it helps diagnose), and a stricter "random name" rule:
|
||||
* long letter-only tokens with many capitals count as random too
|
||||
* (e.g. RmISakytFwXSCEzjVEpaKk).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('release_files', (table) => {
|
||||
table.string('set_id', 32)
|
||||
})
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT name IS NOT NULL AND NOT (
|
||||
(name_stem(name) ~ '^[A-Za-z0-9]{16,}$' AND name_stem(name) ~ '[a-z]' AND name_stem(name) ~ '[A-Z]'
|
||||
AND (name_stem(name) ~ '\\d' OR length(regexp_replace(name_stem(name), '[^A-Z]', '', 'g')) >= 6))
|
||||
OR name_stem(name) ~* '^[0-9a-f]{16,}$')
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('release_files', (table) => table.dropColumn('set_id'))
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Newznab category per release (CONCEPT.md D23), derived from the name and
|
||||
* the archive contents. A stored generated column, so it follows renames
|
||||
* and newly listed contents without any application code.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION release_category(name text, contents jsonb) RETURNS integer IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT CASE
|
||||
-- TV: S01E02, 1x02, or a season pack (S01).
|
||||
WHEN name ~* '(^|[._ -])(S\\d{1,2}E\\d{1,3}|\\d{1,2}x\\d{2})([._ -]|$)' OR name ~* '[._ -]S\\d{1,2}([._ -]|$)' THEN
|
||||
CASE WHEN name ~* '2160p|(^|[._ -])(uhd|4k)([._ -]|$)' THEN 5045
|
||||
WHEN name ~* '720p|1080p' THEN 5040 ELSE 5030 END
|
||||
-- Movies: a year plus a source or quality marker.
|
||||
WHEN name ~* '(^|[._ (-])(19|20)\\d{2}([._ )-]|$)'
|
||||
AND name ~* '(480p|576p|720p|1080p|2160p|blu-?ray|web-?dl|webrip|bdrip|brrip|dvdrip|hdtv|x26[45]|h\\.?26[45]|hevc|remux)' THEN
|
||||
CASE WHEN name ~* '2160p|(^|[._ -])(uhd|4k)([._ -]|$)' THEN 2045
|
||||
WHEN name ~* '720p|1080p|blu-?ray' THEN 2040 ELSE 2030 END
|
||||
-- Audio: lossless or mp3 by contents, otherwise scene music names (-WEB-2026-GROUP).
|
||||
WHEN coalesce(contents::text, '') ~* '\\.flac"' OR name ~* '(^|[._ -])flac([._ -]|$)' THEN 3040
|
||||
WHEN coalesce(contents::text, '') ~* '\\.mp3"' OR name ~* '(^|[._ -])mp3([._ -]|$)'
|
||||
OR name ~* '-(WEB|CD|CDM|CDS|EP|VINYL|VLS|SAT|FM|DAB)-(19|20)\\d{2}-[A-Za-z0-9]+$' THEN 3010
|
||||
WHEN coalesce(contents::text, '') ~* '\\.(epub|mobi|azw3)"' OR name ~* '(^|[._ -])(epub|ebook|mobi)([._ -]|$)' THEN 7020
|
||||
WHEN coalesce(contents::text, '') ~* '\\.(exe|msi|dmg)"' THEN 4000
|
||||
ELSE 8010
|
||||
END
|
||||
$$`)
|
||||
await db.rawQuery(
|
||||
'ALTER TABLE releases ADD COLUMN category integer GENERATED ALWAYS AS (release_category(name, contents)) STORED'
|
||||
)
|
||||
await db.rawQuery('CREATE INDEX releases_category_idx ON releases (category)')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('ALTER TABLE releases DROP COLUMN IF EXISTS category')
|
||||
await db.rawQuery('DROP FUNCTION IF EXISTS release_category(text, jsonb)')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* A second article with the same part CRC is a repost of the same data, not
|
||||
* a different file (CONCEPT.md D20). Drop such recorded conflicts and unflag
|
||||
* binaries whose only conflicts were reposts. Binaries flagged before CRCs
|
||||
* were stored (split_at = 'infinity') cannot be checked and stay flagged.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
DELETE FROM part_conflicts c USING parts p
|
||||
WHERE p.binary_id = c.binary_id AND p.number = c.number
|
||||
AND p.pcrc32 IS NOT NULL AND c.pcrc32 IS NOT NULL AND p.pcrc32 = c.pcrc32`)
|
||||
// Split volumes made from reposts are copies of their parent's file.
|
||||
await db.rawQuery(`
|
||||
DELETE FROM binaries v USING binaries b
|
||||
WHERE v.parent_binary_id = b.id AND v.completed_at IS NULL
|
||||
AND b.conflicted_at IS NOT NULL AND b.split_at IS DISTINCT FROM 'infinity'
|
||||
AND NOT EXISTS (SELECT 1 FROM part_conflicts c WHERE c.binary_id = b.id)`)
|
||||
await db.rawQuery(`
|
||||
UPDATE binaries b SET conflicted_at = NULL
|
||||
WHERE b.conflicted_at IS NOT NULL AND b.parent_binary_id IS NULL
|
||||
AND b.split_at IS DISTINCT FROM 'infinity'
|
||||
AND NOT EXISTS (SELECT 1 FROM part_conflicts c WHERE c.binary_id = b.id)`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Body downloads per group (CONCEPT.md D24). Identifying obfuscated articles
|
||||
* costs one body download each; busy groups carry far more of them than the
|
||||
* line can fetch, so they are indexed header-only (normal subjects) unless
|
||||
* enabled.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'groups'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.boolean('fetch_bodies').notNullable().defaultTo(false)
|
||||
})
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`UPDATE groups SET fetch_bodies = true WHERE name = 'alt.binaries.test'`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => table.dropColumn('fetch_bodies'))
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* is_readable_name(): also judge the stem without an unknown short
|
||||
* extension, so `326678d1d07ef12a.bin` counts as random (CONCEPT.md D22).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
|
||||
SELECT name IS NOT NULL AND NOT (
|
||||
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
|
||||
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
|
||||
OR stem ~* '^[0-9a-f]{16,}$')
|
||||
FROM s
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* File type by magic bytes (lib/content), and releases that are only an
|
||||
* encrypted container (CONCEPT.md D25).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('files', (table) => {
|
||||
table.string('content_type', 16).index()
|
||||
})
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
// e.g. 'nzbe': an encrypted NZB, useless without its key.
|
||||
table.string('container', 16)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('releases', (table) => table.dropColumn('container'))
|
||||
this.schema.alterTable('files', (table) => table.dropColumn('content_type'))
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* NZBs posted to Usenet are read into releases (CONCEPT.md D26): the archive
|
||||
* password they may carry, and a lookup of files by their first segment.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
// From <meta type="password"> of a posted NZB; passed on in our NZBs.
|
||||
table.text('password')
|
||||
})
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`CREATE INDEX IF NOT EXISTS files_first_segment_idx ON files ((message_ids->'1'->>'id'))`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery('DROP INDEX IF EXISTS files_first_segment_idx')
|
||||
})
|
||||
this.schema.alterTable('releases', (table) => table.dropColumn('password'))
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** name_stem(): also strip subtitle extensions, as lib/release fileStem does. */
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION name_stem(name text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT regexp_replace(name,
|
||||
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z(\\.\\d{3})?|zip|\\d{3}|nfo|sfv|nzb|tar|zst|gz|xz|mkv|mp4|avi|m4v|ts|iso|img|flac|mp3|epub|pdf|jpg|png|srt|sub|idx|ass|ssa))+$',
|
||||
'', 'i')
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* name_stem(): strip a 3-digit split suffix only after a file extension
|
||||
* (x.mkv.001), not codec names like H.264 / H.265, matching lib/release.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION name_stem(name text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT regexp_replace(
|
||||
regexp_replace(name, '(\\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\\.\\d{3}$', '\\1', 'i'),
|
||||
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z|zip|nfo|sfv|nzb|tar|zst|gz|xz|mkv|mp4|avi|m4v|ts|iso|img|flac|mp3|epub|pdf|jpg|png|srt|sub|idx|ass|ssa))+$',
|
||||
'', 'i')
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** name_stem(): same extension list as lib/release fileStem (adds bin, m3u, cue, log, bz2). */
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION name_stem(name text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT regexp_replace(
|
||||
regexp_replace(name, '(\\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\\.\\d{3}$', '\\1', 'i'),
|
||||
'(\\.(part\\d+\\.rar|r\\d{2,3}|rar|vol\\d+[+-]\\d+\\.par2|par2|7z|zip|nfo|sfv|nzb|srr|srs|txt|md5|sha1|tar|zst|gz|xz|bz2|mkv|mp4|avi|m4v|ts|iso|img|bin|flac|mp3|epub|pdf|jpg|png|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log))+$',
|
||||
'', 'i')
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Files of uploaders whose bodies are not fetched right away (ngPost, see
|
||||
* CONCEPT.md D28): one row per file, from the headers alone. The row keeps
|
||||
* the article range, so the file can be found again, and is the queue for
|
||||
* worker:deferred, which fetches one body per file.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.createTable('deferred_files', (table) => {
|
||||
table.bigIncrements('id')
|
||||
table.string('file_key', 40).notNullable().unique()
|
||||
// File name from the subject (random for ngPost).
|
||||
table.text('name').notNullable()
|
||||
table.text('subject').notNullable()
|
||||
table.text('poster').notNullable()
|
||||
// Article numbers are counted in the group where the file was first seen.
|
||||
table.text('group_name').notNullable()
|
||||
table.bigInteger('first_article').notNullable()
|
||||
table.text('first_message_id').notNullable()
|
||||
table.bigInteger('last_article').notNullable()
|
||||
table.integer('article_count').notNullable()
|
||||
table.bigInteger('bytes').notNullable()
|
||||
table.timestamp('posted_at', { useTz: true })
|
||||
table.timestamp('first_seen_at', { useTz: true }).notNullable()
|
||||
table.timestamp('last_seen_at', { useTz: true }).notNullable()
|
||||
// pending → sampled (one body read) or expanded (sent to the body queue) or failed.
|
||||
table.string('status', 16).notNullable().defaultTo('pending')
|
||||
table.timestamp('processed_at', { useTz: true })
|
||||
// From the sampled body.
|
||||
table.text('yenc_name')
|
||||
table.bigInteger('size')
|
||||
table.integer('total_parts')
|
||||
table.integer('sampled_part')
|
||||
table.string('content_type', 16)
|
||||
table.boolean('encrypted')
|
||||
table.text('error')
|
||||
table.index(['status', 'last_seen_at'])
|
||||
table.index(['first_seen_at'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('deferred_files')
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Deferred articles whose headers don't say which file they belong to
|
||||
* (ngPost with random subjects and a new poster every few articles, see
|
||||
* CONCEPT.md D28). Only the article range per fetch batch is kept;
|
||||
* `deferred:expand-ranges` finds the articles again and queues their bodies.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.createTable('deferred_ranges', (table) => {
|
||||
table.bigIncrements('id')
|
||||
table.text('group_name').notNullable()
|
||||
table.bigInteger('first_article').notNullable()
|
||||
table.bigInteger('last_article').notNullable()
|
||||
table.integer('article_count').notNullable()
|
||||
table.bigInteger('bytes').notNullable()
|
||||
table.timestamp('created_at', { useTz: true }).notNullable()
|
||||
table.timestamp('expanded_at', { useTz: true })
|
||||
table.index(['expanded_at', 'id'])
|
||||
table.index(['created_at'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('deferred_ranges')
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Spotnet spots (CONCEPT.md D29): signed posts in free.pt that point to a
|
||||
* compressed NZB. Filled from headers by worker:fetch, then worker:spots
|
||||
* verifies each spot, reads its NZB and imports it as a release.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.createTable('spots', (table) => {
|
||||
table.text('message_id').primary()
|
||||
table.text('group_name').notNullable()
|
||||
table.bigInteger('article_number')
|
||||
table.text('title').notNullable()
|
||||
table.text('tag')
|
||||
table.text('poster').notNullable()
|
||||
table.integer('category')
|
||||
table.jsonb('subcats')
|
||||
table.bigInteger('size')
|
||||
table.timestamp('posted_at', { useTz: true })
|
||||
table.integer('key_id')
|
||||
// Spotweb's spotter id (CRC of the poster's key), to check personal disposes.
|
||||
table.text('spotter_id')
|
||||
// pending → imported, or unverified / no-nzb / moderation / removed / failed.
|
||||
table.string('status', 16).notNullable().defaultTo('pending')
|
||||
table.bigInteger('release_id').references('releases.id').onDelete('SET NULL')
|
||||
table.text('website')
|
||||
table.text('error')
|
||||
table.timestamp('created_at', { useTz: true }).notNullable()
|
||||
table.timestamp('processed_at', { useTz: true })
|
||||
table.index(['status', 'created_at'])
|
||||
table.index(['release_id'])
|
||||
table.index(['status', 'release_id'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('spots')
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Smaller indexes (LOG.md 2026-09-28 (17)). 99.5% of releases have no files
|
||||
* yet (most never will), and search only looks at releases with files; most
|
||||
* binaries have no size, fingerprint, file or parent. Indexing only the rows
|
||||
* that queries can match saves GBs and work on every insert. Built
|
||||
* concurrently, so ingest keeps running.
|
||||
*/
|
||||
const INDEXES: [table: string, name: string, definition: string][] = [
|
||||
['releases', 'releases_name_trgm', `USING gin (name gin_trgm_ops) WHERE file_count > 0`],
|
||||
['releases', 'releases_contents_trgm', `USING gin ((contents::text) gin_trgm_ops) WHERE file_count > 0`],
|
||||
['releases', 'releases_posted_at_idx', `(posted_at DESC NULLS LAST) WHERE file_count > 0`],
|
||||
['releases', 'releases_size_index', `(size) WHERE file_count > 0`],
|
||||
['releases', 'releases_file_count_index', `(file_count) WHERE file_count > 0`],
|
||||
['releases', 'releases_complete_idx', `(complete) WHERE file_count > 0`],
|
||||
['releases', 'releases_category_idx', `(category) WHERE file_count > 0`],
|
||||
['binaries', 'binaries_md5_16k_index', `(md5_16k) WHERE md5_16k IS NOT NULL`],
|
||||
['binaries', 'binaries_size_index', `(size) WHERE size IS NOT NULL`],
|
||||
['binaries', 'binaries_file_id_idx', `(file_id) WHERE file_id IS NOT NULL`],
|
||||
['binaries', 'binaries_parent_binary_id_index', `(parent_binary_id) WHERE parent_binary_id IS NOT NULL`],
|
||||
]
|
||||
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
for (const [table, name, definition] of INDEXES) {
|
||||
// Build the partial index under a temporary name, then swap it in.
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS ${name}_partial`)
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY ${name}_partial ON ${table} ${definition}`)
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS ${name}`)
|
||||
await this.db.rawQuery(`ALTER INDEX ${name}_partial RENAME TO ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async down() {
|
||||
for (const [table, name, definition] of INDEXES) {
|
||||
const full = definition.replace(/ WHERE .*$/, '')
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS ${name}`)
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY ${name} ON ${table} ${full}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Spot listings (CONCEPT.md D29): old spots become a release without files
|
||||
* (source `spot`), whose NZB is fetched when first downloaded. Search shows
|
||||
* releases with files or spot listings, so the partial release indexes
|
||||
* cover both. Spots are verified from their overview when fetched.
|
||||
*/
|
||||
const VISIBLE = `(file_count > 0 OR source = 'spot')`
|
||||
const INDEXES: [name: string, definition: string][] = [
|
||||
['releases_name_trgm', `USING gin (name gin_trgm_ops)`],
|
||||
['releases_contents_trgm', `USING gin ((contents::text) gin_trgm_ops)`],
|
||||
['releases_posted_at_idx', `(posted_at DESC NULLS LAST)`],
|
||||
['releases_size_index', `(size)`],
|
||||
['releases_file_count_index', `(file_count)`],
|
||||
['releases_complete_idx', `(complete)`],
|
||||
['releases_category_idx', `(category)`],
|
||||
]
|
||||
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
await this.db.rawQuery(`ALTER TABLE spots ADD COLUMN IF NOT EXISTS verified boolean`)
|
||||
// worker:spots claims recent spots first, then the history in posting order.
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_article_idx ON spots (status, article_number)`)
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_posted_idx ON spots (status, posted_at)`)
|
||||
await this.swap(VISIBLE)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.swap('file_count > 0')
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS spots_status_article_idx`)
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS spots_status_posted_idx`)
|
||||
await this.db.rawQuery(`ALTER TABLE spots DROP COLUMN IF EXISTS verified`)
|
||||
}
|
||||
|
||||
private async swap(predicate: string) {
|
||||
for (const [name, definition] of INDEXES) {
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS ${name}_next`)
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY ${name}_next ON releases ${definition} WHERE ${predicate}`)
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS ${name}`)
|
||||
await this.db.rawQuery(`ALTER INDEX ${name}_next RENAME TO ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Spot details (CONCEPT.md D29): the description and image from the spot's
|
||||
* full header. Stored whenever the header is read (recent spots, moderation,
|
||||
* downloads); for other old spots on first view. `details_at` marks the read.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('spots', (table) => {
|
||||
table.text('description')
|
||||
table.jsonb('image')
|
||||
table.timestamp('details_at', { useTz: true })
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('spots', (table) => {
|
||||
table.dropColumn('description')
|
||||
table.dropColumn('image')
|
||||
table.dropColumn('details_at')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Where a release's archive password came from: `nzb` (the NZB's
|
||||
* `<meta type="password">`) or `spot` (the spot's title or description).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('releases', (table) => table.string('password_source', 8))
|
||||
this.schema.raw(`UPDATE releases SET password_source = 'nzb' WHERE password IS NOT NULL`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('releases', (table) => table.dropColumn('password_source'))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
import { packSegments, unpackSegments } from '#lib/nzb/segments'
|
||||
|
||||
const BATCH = 5000
|
||||
|
||||
/**
|
||||
* Segment lists compressed (CONCEPT.md D31): `files.message_ids` (jsonb,
|
||||
* ~38 bytes per segment after pglz) becomes `files.segments` (zstd, ~20),
|
||||
* plus `first_message_id` for the lookups the old expression index served.
|
||||
* Run with the workers stopped; reclaim the space with VACUUM FULL files.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
await this.db.rawQuery(`ALTER TABLE files ADD COLUMN IF NOT EXISTS segments bytea, ADD COLUMN IF NOT EXISTS first_message_id text`)
|
||||
let last = 0
|
||||
let done = 0
|
||||
while (true) {
|
||||
const { rows } = await this.db.rawQuery(
|
||||
`SELECT id, message_ids FROM files WHERE id > ? AND segments IS NULL ORDER BY id LIMIT ?`,
|
||||
[last, BATCH]
|
||||
)
|
||||
if (rows.length === 0) break
|
||||
const ids: number[] = []
|
||||
const packed: Buffer[] = []
|
||||
const firsts: (string | null)[] = []
|
||||
for (const row of rows) {
|
||||
const segments = Object.entries((row.message_ids ?? {}) as Record<string, { id: string; size?: number }>)
|
||||
.map(([number, s]) => ({ number: Number(number), id: s.id, size: Number(s.size ?? 0) }))
|
||||
.sort((a, b) => a.number - b.number)
|
||||
ids.push(Number(row.id))
|
||||
packed.push(packSegments(segments))
|
||||
firsts.push(segments[0]?.id ?? null)
|
||||
}
|
||||
await this.db.rawQuery(
|
||||
`UPDATE files f SET segments = v.segments, first_message_id = v.first
|
||||
FROM unnest(?::bigint[], ?::bytea[], ?::text[]) AS v(id, segments, first) WHERE f.id = v.id`,
|
||||
[ids, packed, firsts] as any
|
||||
)
|
||||
last = ids[ids.length - 1]
|
||||
done += rows.length
|
||||
if (done % 100_000 < BATCH) console.log(`Packed the segments of ${done} files.`)
|
||||
}
|
||||
await this.db.rawQuery(`ALTER TABLE files ALTER COLUMN segments SET NOT NULL`)
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_first_message_id_idx ON files (first_message_id)`)
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS files_first_segment_idx`)
|
||||
await this.db.rawQuery(`ALTER TABLE files DROP COLUMN message_ids`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`ALTER TABLE files ADD COLUMN IF NOT EXISTS message_ids jsonb`)
|
||||
const { rows } = await this.db.rawQuery(`SELECT id, segments FROM files`)
|
||||
for (const row of rows) {
|
||||
const json = Object.fromEntries(unpackSegments(row.segments).map((s) => [s.number, { id: s.id, size: s.size }]))
|
||||
await this.db.rawQuery(`UPDATE files SET message_ids = ? WHERE id = ?`, [JSON.stringify(json), row.id])
|
||||
}
|
||||
await this.db.rawQuery(`ALTER TABLE files ALTER COLUMN message_ids SET NOT NULL`)
|
||||
await this.db.rawQuery(`CREATE INDEX IF NOT EXISTS files_first_segment_idx ON files ((message_ids->'1'->>'id'))`)
|
||||
await this.db.rawQuery(`DROP INDEX IF EXISTS files_first_message_id_idx`)
|
||||
await this.db.rawQuery(`ALTER TABLE files DROP COLUMN segments, DROP COLUMN first_message_id`)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Header backfill (CONCEPT.md D32): a group walks backwards from
|
||||
* `backfill_next_id` down to `backfill_until_id` in low-priority,
|
||||
* header-only fetch ranges (`fetch_ranges.backfill`).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('groups', (table) => {
|
||||
table.bigInteger('backfill_until_id')
|
||||
table.bigInteger('backfill_next_id')
|
||||
})
|
||||
this.schema.alterTable('fetch_ranges', (table) => {
|
||||
table.boolean('backfill').notNullable().defaultTo(false)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('groups', (table) => {
|
||||
table.dropColumn('backfill_until_id')
|
||||
table.dropColumn('backfill_next_id')
|
||||
})
|
||||
this.schema.alterTable('fetch_ranges', (table) => table.dropColumn('backfill'))
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
const VISIBLE = `(file_count > 0 OR source = 'spot')`
|
||||
const BATCH = 20_000
|
||||
|
||||
/**
|
||||
* Search and index cleanup (CONCEPT.md D33).
|
||||
* - `releases.search_text`: name, archive listing and readable file-name
|
||||
* stems, normalised (lowercase; dots, dashes, underscores and spaces as
|
||||
* one space), kept by triggers; one trigram index replaces the four the
|
||||
* old per-table OR search could hardly use.
|
||||
* - Spot status indexes become partial indexes over the few rows the worker
|
||||
* and search look for.
|
||||
* - Unused release indexes go (size: never scanned; complete: a boolean).
|
||||
* Runs online: indexes are built and dropped concurrently.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
const q = (sql: string, bindings: unknown[] = []) => this.db.rawQuery(sql, bindings as any)
|
||||
// For IndexMaintenance; needs a superuser (it is one in the compose setup).
|
||||
await q(`CREATE EXTENSION IF NOT EXISTS pgstattuple`).catch((error) => console.warn(`pgstattuple not installed: ${error.message}`))
|
||||
|
||||
// Spots: the worker only looks for pending/processing and un-detailed imported spots, search for hidden ones.
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_todo_idx ON spots (article_number) WHERE status IN ('pending', 'processing')`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_hidden_release_idx ON spots (release_id) WHERE status IN ('removed', 'dummy')`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_details_todo_idx ON spots (posted_at DESC) WHERE status = 'imported' AND details_at IS NULL`)
|
||||
for (const index of ['spots_status_posted_idx', 'spots_status_article_idx', 'spots_status_created_at_index', 'spots_status_release_id_index']) {
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS ${index}`)
|
||||
}
|
||||
for (const index of ['releases_size_index', 'releases_complete_idx']) {
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS ${index}`)
|
||||
}
|
||||
|
||||
// Search text.
|
||||
await q(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS search_files text, ADD COLUMN IF NOT EXISTS search_text text`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION search_normalize(t text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT btrim(lower(regexp_replace(coalesce(t, ''), '[._[:space:]-]+', ' ', 'g')))
|
||||
$$`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION release_search_text(name text, contents jsonb, files text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT left(concat_ws(' ',
|
||||
search_normalize(name),
|
||||
nullif(search_normalize((SELECT string_agg(e->>'name', ' ') FROM (
|
||||
SELECT e FROM jsonb_array_elements(CASE WHEN jsonb_typeof(contents) = 'array' THEN contents ELSE '[]'::jsonb END) e LIMIT 200) x)), ''),
|
||||
nullif(files, '')), 8000)
|
||||
$$`)
|
||||
// Visible releases only; computed when one becomes visible or its name, listing or file names change.
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION releases_search_text() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT (NEW.file_count > 0 OR NEW.source = 'spot') THEN
|
||||
NEW.search_text := NULL;
|
||||
ELSIF TG_OP = 'INSERT' OR NEW.search_text IS NULL
|
||||
OR NEW.name IS DISTINCT FROM OLD.name OR NEW.contents IS DISTINCT FROM OLD.contents
|
||||
OR NEW.search_files IS DISTINCT FROM OLD.search_files THEN
|
||||
NEW.search_text := release_search_text(NEW.name, NEW.contents, NEW.search_files);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$`)
|
||||
await q(`DROP TRIGGER IF EXISTS releases_search_text ON releases`)
|
||||
await q(`CREATE TRIGGER releases_search_text BEFORE INSERT OR UPDATE ON releases FOR EACH ROW EXECUTE FUNCTION releases_search_text()`)
|
||||
// A file's readable stem joins its release's search text when it adds something.
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION files_release_search() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
n text := coalesce(NEW.real_name, NEW.filename);
|
||||
s text;
|
||||
BEGIN
|
||||
IF NEW.release_id IS NULL OR NOT is_readable_name(n) THEN RETURN NULL; END IF;
|
||||
s := search_normalize(name_stem(n));
|
||||
IF s = '' THEN RETURN NULL; END IF;
|
||||
UPDATE releases SET search_files = left(concat_ws(' ', search_files, s), 4000)
|
||||
WHERE id = NEW.release_id AND position(s IN coalesce(search_text, search_normalize(name))) = 0
|
||||
AND length(coalesce(search_files, '')) < 4000;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await q(`DROP TRIGGER IF EXISTS files_release_search ON files`)
|
||||
await q(`CREATE TRIGGER files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`)
|
||||
|
||||
// Fill: readable file stems per release (one pass over files), then every visible release in batches.
|
||||
await q(`DROP TABLE IF EXISTS search_stems_tmp`)
|
||||
await q(`
|
||||
CREATE UNLOGGED TABLE search_stems_tmp AS
|
||||
SELECT release_id, left(string_agg(DISTINCT s, ' '), 4000) AS stems FROM (
|
||||
SELECT f.release_id, search_normalize(name_stem(coalesce(f.real_name, f.filename))) AS s
|
||||
FROM files f WHERE f.release_id IS NOT NULL AND is_readable_name(coalesce(f.real_name, f.filename))) x
|
||||
WHERE s <> '' GROUP BY release_id`)
|
||||
await q(`CREATE UNIQUE INDEX ON search_stems_tmp (release_id)`)
|
||||
let last = 0
|
||||
let done = 0
|
||||
while (true) {
|
||||
const { rows } = await q(`SELECT id FROM releases WHERE id > ? AND ${VISIBLE} ORDER BY id LIMIT ?`, [last, BATCH])
|
||||
if (rows.length === 0) break
|
||||
const to = Number(rows[rows.length - 1].id)
|
||||
await q(
|
||||
`UPDATE releases r SET search_files = (
|
||||
SELECT nullif(array_to_string(array(
|
||||
SELECT w FROM unnest(string_to_array(t.stems, ' ')) w
|
||||
WHERE position(w IN search_normalize(r.name)) = 0), ' '), '')
|
||||
FROM search_stems_tmp t WHERE t.release_id = r.id),
|
||||
search_text = NULL
|
||||
WHERE r.id > ? AND r.id <= ? AND ${VISIBLE.replaceAll('file_count', 'r.file_count').replace('source', 'r.source')}`,
|
||||
[last, to]
|
||||
)
|
||||
last = to
|
||||
done += rows.length
|
||||
if (done % 200_000 < BATCH) console.log(`Search text for ${done} releases.`)
|
||||
}
|
||||
await q(`DROP TABLE search_stems_tmp`)
|
||||
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_search_trgm ON releases USING gin (search_text gin_trgm_ops) WHERE ${VISIBLE}`)
|
||||
for (const index of ['releases_name_trgm', 'releases_contents_trgm', 'files_filename_trgm', 'files_real_name_trgm']) {
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS ${index}`)
|
||||
}
|
||||
}
|
||||
|
||||
async down() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_name_trgm ON releases USING gin (name gin_trgm_ops) WHERE ${VISIBLE}`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_contents_trgm ON releases USING gin ((contents::text) gin_trgm_ops) WHERE ${VISIBLE}`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_filename_trgm ON files USING gin (filename gin_trgm_ops)`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_real_name_trgm ON files USING gin (real_name gin_trgm_ops)`)
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS releases_search_trgm`)
|
||||
await q(`DROP TRIGGER IF EXISTS files_release_search ON files`)
|
||||
await q(`DROP TRIGGER IF EXISTS releases_search_text ON releases`)
|
||||
await q(`DROP FUNCTION IF EXISTS files_release_search()`)
|
||||
await q(`DROP FUNCTION IF EXISTS releases_search_text()`)
|
||||
await q(`DROP FUNCTION IF EXISTS release_search_text(text, jsonb, text)`)
|
||||
await q(`DROP FUNCTION IF EXISTS search_normalize(text)`)
|
||||
await q(`ALTER TABLE releases DROP COLUMN IF EXISTS search_files, DROP COLUMN IF EXISTS search_text`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_size_index ON releases (size) WHERE ${VISIBLE}`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_complete_idx ON releases (complete) WHERE ${VISIBLE}`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_posted_idx ON spots (status, posted_at)`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_article_idx ON spots (status, article_number)`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_created_at_index ON spots (status, created_at)`)
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS spots_status_release_id_index ON spots (status, release_id)`)
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS spots_todo_idx`)
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS spots_hidden_release_idx`)
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS spots_details_todo_idx`)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* is_readable_name(): also hex with a Unix time, UUIDs, digit-only names
|
||||
* (and rclone's "83111763 n"), lowercase base32/36 tokens and short tokens
|
||||
* with capitals all over. The same rules as looksRandom() in ArchiveService.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
|
||||
SELECT name IS NOT NULL AND NOT (
|
||||
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
|
||||
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
|
||||
OR stem ~* '^[0-9a-f]{16,}$'
|
||||
OR stem ~* '^[0-9a-f]{16,}\\.\\d{9,}$'
|
||||
OR stem ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
OR stem ~ '^\\d{6,}( n){0,1}$'
|
||||
OR (stem ~ '^[a-z0-9]{12,}$' AND length(regexp_replace(stem, '\\D', '', 'g')) >= 2
|
||||
AND length(regexp_replace(stem, '[^a-z]', '', 'g')) >= 4)
|
||||
OR (stem ~ '^[A-Za-z0-9]{8,15}$' AND stem ~ '\\d' AND stem ~ '[a-z]'
|
||||
AND length(regexp_replace(substr(stem, 2), '[^A-Z]', '', 'g')) >= 3))
|
||||
FROM s
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
|
||||
SELECT name IS NOT NULL AND NOT (
|
||||
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
|
||||
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
|
||||
OR stem ~* '^[0-9a-f]{16,}$')
|
||||
FROM s
|
||||
$$`)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* SFV listings (CONCEPT.md D34): the files an SFV names belong to its
|
||||
* release; files arriving later are matched by name.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.createTable('sfv_entries', (table) => {
|
||||
table.bigInteger('release_id').notNullable().references('releases.id').onDelete('CASCADE')
|
||||
table.text('name').notNullable()
|
||||
table.string('crc', 8).notNullable()
|
||||
table.text('poster')
|
||||
table.timestamp('posted_at', { useTz: true })
|
||||
table.primary(['release_id', 'name'])
|
||||
table.index(['name'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('sfv_entries')
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** SFV grouping (CONCEPT.md D34) looks files up by their name. */
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
// On the NVMe (default tablespace), like the files table's other indexes.
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS files_name_idx ON files ((coalesce(real_name, filename)))`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS files_name_idx`)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* is_readable_name(): generic folder/file names (VIDEO_TS, Sample, CD1) are
|
||||
* no name either. Same list as GENERIC_NAMES in lib/release/release_key.ts.
|
||||
*/
|
||||
const FUNCTION = (generic: boolean) => `
|
||||
CREATE OR REPLACE FUNCTION is_readable_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
WITH s AS (SELECT regexp_replace(name_stem(name), '\\.[A-Za-z0-9]{1,4}$', '') AS stem)
|
||||
SELECT name IS NOT NULL AND NOT (
|
||||
(stem ~ '^[A-Za-z0-9]{16,}$' AND stem ~ '[a-z]' AND stem ~ '[A-Z]'
|
||||
AND (stem ~ '\\d' OR length(regexp_replace(stem, '[^A-Z]', '', 'g')) >= 6))
|
||||
OR stem ~* '^[0-9a-f]{16,}$'
|
||||
OR stem ~* '^[0-9a-f]{16,}\\.\\d{9,}$'
|
||||
OR stem ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
OR stem ~ '^\\d{6,}( n){0,1}$'
|
||||
OR (stem ~ '^[a-z0-9]{12,}$' AND length(regexp_replace(stem, '\\D', '', 'g')) >= 2
|
||||
AND length(regexp_replace(stem, '[^a-z]', '', 'g')) >= 4)
|
||||
OR (stem ~ '^[A-Za-z0-9]{8,15}$' AND stem ~ '\\d' AND stem ~ '[a-z]'
|
||||
AND length(regexp_replace(substr(stem, 2), '[^A-Z]', '', 'g')) >= 3)
|
||||
${generic ? `OR btrim(stem) ~* '^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples{0,1}|subs{0,1}|subtitles{0,1}|proof|cover|covers|extras{0,1}|featurettes{0,1}|(cd|dvd|disc|disk)[ ._-]{0,1}\\d{1,2})$'` : ''})
|
||||
FROM s
|
||||
$$`
|
||||
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(FUNCTION(true))
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db.rawQuery(FUNCTION(false))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Staged segments (CONCEPT.md D35): a header segment whose file is new and
|
||||
* alone in its batch waits here until a second segment of the file shows
|
||||
* up; ~97% never do (uploads with a random subject per article). Unlogged:
|
||||
* no WAL, emptied by a crash, which only costs those waiting segments.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`
|
||||
CREATE UNLOGGED TABLE staged_segments (
|
||||
file_key text PRIMARY KEY,
|
||||
segments jsonb NOT NULL,
|
||||
staged_at timestamptz NOT NULL
|
||||
)`)
|
||||
// Rows arrive in time order, so a BRIN index serves the expiry cheaply.
|
||||
await this.db.rawQuery(`CREATE INDEX staged_segments_staged_at_brin ON staged_segments USING brin (staged_at)`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP TABLE staged_segments`)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Counter grouping (CONCEPT.md D36): staged segments also carry the key of
|
||||
* the file they'd belong to by poster, file counter `[i/M]`, part count and
|
||||
* posting day, so uploads with a random subject per article become files.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
await this.db.rawQuery(`ALTER TABLE staged_segments ADD COLUMN IF NOT EXISTS counter_key text`)
|
||||
await this.db.rawQuery(`CREATE INDEX CONCURRENTLY IF NOT EXISTS staged_segments_counter_key_idx ON staged_segments (counter_key) WHERE counter_key IS NOT NULL`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS staged_segments_counter_key_idx`)
|
||||
await this.db.rawQuery(`ALTER TABLE staged_segments DROP COLUMN IF EXISTS counter_key`)
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
const VISIBLE = `(file_count > 0 OR source = 'spot')`
|
||||
const BATCH = 20_000
|
||||
|
||||
/**
|
||||
* `releases.extensions`: the file types in a release (`{mkv,nfo,par2}`), from
|
||||
* its files' names and its archive listing, for the search's extension
|
||||
* filter (LOG.md 2026-10-04 (3)). search_text can't serve it: file names
|
||||
* enter it as stems without extension, and matching the long, TOASTed text
|
||||
* took seconds per search.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
const q = (sql: string, bindings: unknown[] = []) => this.db.rawQuery(sql, bindings as any)
|
||||
|
||||
await q(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS extensions text[]`)
|
||||
// Two to four characters with a letter (mkv, flac, 7z, par2); split
|
||||
// archive volumes count as their archive type (.7z.001, .r00).
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION name_extension(n text) RETURNS text IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT CASE
|
||||
WHEN lower(n) ~ '\\.(7z|zip)\\.[0-9]{3}$' THEN substring(lower(n) from '\\.(7z|zip)\\.[0-9]{3}$')
|
||||
WHEN lower(n) ~ '\\.r[0-9]{2,3}$' THEN 'rar'
|
||||
ELSE substring(lower(n) from '\\.([a-z0-9]*[a-z][a-z0-9]*)$')
|
||||
END
|
||||
$$`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION listing_extensions(contents jsonb) RETURNS text[] IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT array(SELECT DISTINCT e FROM (
|
||||
SELECT name_extension(c->>'name') AS e
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(contents) = 'array' THEN contents ELSE '[]'::jsonb END) c) x
|
||||
WHERE e IS NOT NULL AND length(e) BETWEEN 2 AND 4 ORDER BY e)
|
||||
$$`)
|
||||
|
||||
// A new archive listing adds its entries' types.
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION releases_extensions() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' OR NEW.contents IS DISTINCT FROM OLD.contents THEN
|
||||
NEW.extensions := nullif(array(
|
||||
SELECT DISTINCT e FROM unnest(coalesce(NEW.extensions, '{}') || listing_extensions(NEW.contents)) e ORDER BY e), '{}');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$`)
|
||||
await q(`DROP TRIGGER IF EXISTS releases_extensions ON releases`)
|
||||
await q(`CREATE TRIGGER releases_extensions BEFORE INSERT OR UPDATE OF contents ON releases FOR EACH ROW EXECUTE FUNCTION releases_extensions()`)
|
||||
|
||||
// A file adds its type to its release (once: most files of a release share it).
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION files_release_extension() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
e text := name_extension(coalesce(NEW.real_name, NEW.filename));
|
||||
BEGIN
|
||||
IF NEW.release_id IS NULL OR e IS NULL OR length(e) NOT BETWEEN 2 AND 4 THEN RETURN NULL; END IF;
|
||||
UPDATE releases SET extensions = array(SELECT DISTINCT x FROM unnest(coalesce(extensions, '{}') || e) x ORDER BY x)
|
||||
WHERE id = NEW.release_id AND NOT (coalesce(extensions, '{}') @> ARRAY[e]);
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await q(`DROP TRIGGER IF EXISTS files_release_extension ON files`)
|
||||
await q(`CREATE TRIGGER files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`)
|
||||
|
||||
// Fill: file types per release in one sequential pass over files (random
|
||||
// reads per release would take over an hour on the HDD), then the
|
||||
// visible releases in batches by id, adding their listing's types.
|
||||
await q(`DROP TABLE IF EXISTS file_extensions_tmp`)
|
||||
await q(`
|
||||
CREATE UNLOGGED TABLE file_extensions_tmp AS
|
||||
SELECT release_id, array_agg(DISTINCT e) AS exts FROM (
|
||||
SELECT release_id, name_extension(coalesce(real_name, filename)) AS e FROM files WHERE release_id IS NOT NULL) x
|
||||
WHERE e IS NOT NULL AND length(e) BETWEEN 2 AND 4 GROUP BY release_id`)
|
||||
await q(`CREATE UNIQUE INDEX ON file_extensions_tmp (release_id)`)
|
||||
let last = 0
|
||||
let done = 0
|
||||
while (true) {
|
||||
const { rows } = await q(`SELECT id FROM releases WHERE id > ? AND ${VISIBLE} ORDER BY id LIMIT ?`, [last, BATCH])
|
||||
if (rows.length === 0) break
|
||||
const to = Number(rows[rows.length - 1].id)
|
||||
await q(
|
||||
`UPDATE releases r SET extensions = x.exts FROM (
|
||||
SELECT r2.id, array(SELECT DISTINCT e FROM unnest(coalesce(t.exts, '{}') || listing_extensions(r2.contents)) e ORDER BY e) AS exts
|
||||
FROM releases r2 LEFT JOIN file_extensions_tmp t ON t.release_id = r2.id
|
||||
WHERE r2.id > ? AND r2.id <= ? AND (r2.file_count > 0 OR r2.source = 'spot')) x
|
||||
WHERE r.id = x.id AND cardinality(x.exts) > 0 AND r.extensions IS DISTINCT FROM x.exts`,
|
||||
[last, to]
|
||||
)
|
||||
last = to
|
||||
done += rows.length
|
||||
if (done % 500_000 < BATCH) console.log(`Extensions for ${done} releases.`)
|
||||
}
|
||||
await q(`DROP TABLE file_extensions_tmp`)
|
||||
|
||||
await q(`CREATE INDEX CONCURRENTLY IF NOT EXISTS releases_extensions_gin ON releases USING gin (extensions) WHERE ${VISIBLE}`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
await q(`DROP INDEX CONCURRENTLY IF EXISTS releases_extensions_gin`)
|
||||
await q(`DROP TRIGGER IF EXISTS files_release_extension ON files`)
|
||||
await q(`DROP TRIGGER IF EXISTS releases_extensions ON releases`)
|
||||
await q(`DROP FUNCTION IF EXISTS files_release_extension()`)
|
||||
await q(`DROP FUNCTION IF EXISTS releases_extensions()`)
|
||||
await q(`DROP FUNCTION IF EXISTS listing_extensions(jsonb)`)
|
||||
await q(`DROP FUNCTION IF EXISTS name_extension(text)`)
|
||||
await q(`ALTER TABLE releases DROP COLUMN IF EXISTS extensions`)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The seed orchestrator (CONCEPT.md D38): nodes that lease article ranges
|
||||
* over HTTPS and submit what they aggregated (D37), and the ranges planned
|
||||
* for them. A range's id is its priority: plans insert newest first.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.createTable('seed_nodes', (table) => {
|
||||
table.increments('id')
|
||||
table.text('name').notNullable().unique()
|
||||
// sha256 of the bearer token; the token itself is shown once, when it's made.
|
||||
table.string('token_hash', 64).notNullable().unique()
|
||||
table.boolean('disabled').notNullable().defaultTo(false)
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
|
||||
table.timestamp('last_seen_at', { useTz: true }).nullable()
|
||||
})
|
||||
|
||||
this.schema.createTable('seed_ranges', (table) => {
|
||||
table.bigIncrements('id')
|
||||
table.text('group_name').notNullable()
|
||||
table.bigInteger('start_id').notNullable()
|
||||
table.bigInteger('end_id').notNullable()
|
||||
// First article not reported yet; a re-leased range resumes here.
|
||||
table.bigInteger('next_id').notNullable()
|
||||
table.string('status', 8).notNullable().defaultTo('queued')
|
||||
table.integer('node_id').nullable().references('id').inTable('seed_nodes').onDelete('SET NULL')
|
||||
table.integer('attempts').notNullable().defaultTo(0)
|
||||
table.timestamp('leased_at', { useTz: true }).nullable()
|
||||
table.timestamp('heartbeat_at', { useTz: true }).nullable()
|
||||
table.timestamp('done_at', { useTz: true }).nullable()
|
||||
table.bigInteger('headers').notNullable().defaultTo(0)
|
||||
table.bigInteger('files').notNullable().defaultTo(0)
|
||||
table.bigInteger('fragments').notNullable().defaultTo(0)
|
||||
table.text('error').nullable()
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
|
||||
table.index(['group_name', 'start_id'])
|
||||
})
|
||||
this.schema.raw(`CREATE INDEX seed_ranges_open_idx ON seed_ranges (id) WHERE status IN ('queued', 'leased')`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable('seed_ranges')
|
||||
this.schema.dropTable('seed_nodes')
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** Ids per partition: 4M files is a few days of indexing, ~2 GB of heap (FilePartitions uses the same). */
|
||||
const FILE_PARTITION_SIZE = 4_000_000
|
||||
|
||||
/**
|
||||
* `files` partitioned by id, i.e. by age (CONCEPT.md D39). New rows went into
|
||||
* free space scattered through the 9.6 GB heap on the HDD (left by
|
||||
* inspection's updates), one random read per insert; now they go into a
|
||||
* small recent partition that stays in memory. The existing table becomes
|
||||
* the first partition as it is: only a CHECK scan, no rewrite.
|
||||
*
|
||||
* Postgres can't enforce a unique file_key across partitions: every
|
||||
* partition has its own unique index, and FileStore.insert looks keys up in
|
||||
* the older ones. Run with the writers stopped (a few minutes).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
const { rows } = await q(`SELECT coalesce(max(id), 0) AS max FROM files`)
|
||||
// The first new partition starts at the next million.
|
||||
const boundary = (Math.floor(Number(rows[0].max) / 1_000_000) + 1) * 1_000_000
|
||||
// The HDD tablespace on homelab (D32), the default elsewhere; new partitions inherit the parent's.
|
||||
const { rows: ts } = await q(`SELECT t.spcname FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE c.oid = 'files'::regclass`)
|
||||
const tablespace = ts[0]?.spcname ? ` TABLESPACE ${ts[0].spcname}` : ''
|
||||
|
||||
// The old table, renamed; its constraint and index names get a files_p0 prefix.
|
||||
await q(`ALTER TABLE files ADD CONSTRAINT files_p0_bound CHECK (id < ${boundary}) NOT VALID`)
|
||||
await q(`ALTER TABLE files VALIDATE CONSTRAINT files_p0_bound`)
|
||||
await q(`ALTER TABLE files RENAME TO files_p0`)
|
||||
await q(`ALTER TABLE files_p0 RENAME CONSTRAINT files_pkey TO files_p0_pkey`)
|
||||
await q(`ALTER TABLE files_p0 RENAME CONSTRAINT files_file_key_unique TO files_p0_file_key_unique`)
|
||||
await q(`ALTER TABLE files_p0 RENAME CONSTRAINT files_release_id_foreign TO files_p0_release_id_foreign`)
|
||||
for (const index of ['files_content_type_index', 'files_first_message_id_idx', 'files_name_idx', 'files_release_id_index']) {
|
||||
await q(`ALTER INDEX ${index} RENAME TO ${index.replace(/^files_/, 'files_p0_')}`)
|
||||
}
|
||||
// Recreated on the parent, which clones them to every partition.
|
||||
for (const trigger of ['files_release_extension', 'files_release_search', 'files_release_totals', 'files_zz_release_complete']) {
|
||||
await q(`DROP TRIGGER ${trigger} ON files_p0`)
|
||||
}
|
||||
|
||||
// The parent: same columns and defaults (the id sequence), partitioned by id, in the old table's tablespace.
|
||||
await q(`CREATE TABLE files (LIKE files_p0 INCLUDING DEFAULTS) PARTITION BY RANGE (id)${tablespace}`)
|
||||
await q(`ALTER SEQUENCE files_id_seq OWNED BY files.id`)
|
||||
await q(`ALTER TABLE files ADD CONSTRAINT files_pkey PRIMARY KEY (id)`)
|
||||
await q(`ALTER TABLE files ADD CONSTRAINT files_release_id_foreign FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE SET NULL`)
|
||||
await q(`CREATE INDEX files_content_type_index ON files (content_type)`)
|
||||
await q(`CREATE INDEX files_first_message_id_idx ON files (first_message_id)`)
|
||||
await q(`CREATE INDEX files_name_idx ON files ((coalesce(real_name, filename::text)))`)
|
||||
await q(`CREATE INDEX files_release_id_index ON files (release_id)`)
|
||||
// Matching indexes and the foreign key of files_p0 are taken over, not rebuilt; the CHECK spares the scan.
|
||||
await q(`ALTER TABLE files ATTACH PARTITION files_p0 FOR VALUES FROM (MINVALUE) TO (${boundary})`)
|
||||
await q(`ALTER TABLE files_p0 DROP CONSTRAINT files_p0_bound`)
|
||||
|
||||
await q(`CREATE TRIGGER files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`)
|
||||
await q(`CREATE TRIGGER files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`)
|
||||
await q(`CREATE TRIGGER files_release_totals AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`)
|
||||
await q(`CREATE TRIGGER files_zz_release_complete AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`)
|
||||
|
||||
// binaries.file_id followed the rename to files_p0; point it at the parent (no scan needed).
|
||||
await q(`ALTER TABLE binaries DROP CONSTRAINT binaries_file_id_foreign`)
|
||||
await q(`ALTER TABLE binaries ADD CONSTRAINT binaries_file_id_foreign FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL NOT VALID`)
|
||||
|
||||
// The next two partitions; FilePartitions.ensure() (gc:scheduler) keeps two ahead from now on.
|
||||
for (const start of [boundary, boundary + FILE_PARTITION_SIZE]) {
|
||||
const name = `files_p${start}`
|
||||
await q(`CREATE TABLE ${name} PARTITION OF files FOR VALUES FROM (${start}) TO (${start + FILE_PARTITION_SIZE})`)
|
||||
await q(`ALTER TABLE ${name} ADD CONSTRAINT ${name}_file_key_unique UNIQUE (file_key)`)
|
||||
}
|
||||
}
|
||||
|
||||
async down() {
|
||||
throw new Error('Not reversible: merging partitions back means rewriting the files table.')
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
const TRIGGERS = [
|
||||
`files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`,
|
||||
`files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`,
|
||||
`files_release_totals AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`,
|
||||
`files_zz_release_complete AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`,
|
||||
]
|
||||
|
||||
/**
|
||||
* files.id (and binaries.file_id, the id sequence) to 64 bits (CONCEPT.md
|
||||
* D39): seeding adds ~14M files a day, and 32 bits run out at 2.1 billion.
|
||||
* Postgres can't change the type of a partition key, so the partitions are
|
||||
* detached, rewritten one by one as plain tables (files_p0: ~100 GB with its
|
||||
* segment lists, 1–2 hours on homelab's HDD) and attached to a new 64-bit
|
||||
* parent. A CHECK per partition, added before its rewrite, spares the attach
|
||||
* scan. Run with the writers stopped and Postgres on wal_level=minimal, or the
|
||||
* rewrite also goes through the WAL (LOG.md 2026-10-05 (8)).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
// One transaction: a failure anywhere leaves files as it was (it drops and recreates the parent).
|
||||
|
||||
async up() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
const { rows: partitions } = await q(
|
||||
`SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
|
||||
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'files'::regclass ORDER BY c.relname`
|
||||
)
|
||||
const { rows: ts } = await q(`SELECT t.spcname FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE c.oid = 'files'::regclass`)
|
||||
const tablespace = ts[0]?.spcname ? ` TABLESPACE ${ts[0].spcname}` : ''
|
||||
|
||||
// The sequence belongs to the old parent: keep it when that goes.
|
||||
await q(`ALTER SEQUENCE files_id_seq OWNED BY NONE`)
|
||||
await q(`ALTER SEQUENCE files_id_seq AS bigint`)
|
||||
await q(`ALTER TABLE binaries DROP CONSTRAINT IF EXISTS binaries_file_id_foreign`)
|
||||
await q(`ALTER TABLE binaries ALTER COLUMN file_id TYPE bigint`)
|
||||
|
||||
for (const p of partitions) {
|
||||
await q(`ALTER TABLE files DETACH PARTITION ${p.name}`)
|
||||
// Triggers cloned from the parent may stay behind on a detached partition; the new parent clones them again.
|
||||
for (const t of TRIGGERS) await q(`DROP TRIGGER IF EXISTS ${t.split(' ')[0]} ON ${p.name}`)
|
||||
const [, from, to] = /FROM \((.+)\) TO \((.+)\)/.exec(p.bound.replaceAll("'", ''))!
|
||||
const conditions = [from === 'MINVALUE' ? null : `id >= ${from}`, to === 'MAXVALUE' ? null : `id < ${to}`].filter(Boolean)
|
||||
if (conditions.length > 0) await q(`ALTER TABLE ${p.name} ADD CONSTRAINT ${p.name}_bound CHECK (${conditions.join(' AND ')})`)
|
||||
}
|
||||
await q(`DROP TABLE files`)
|
||||
|
||||
for (const p of partitions) {
|
||||
await q(`ALTER TABLE ${p.name} ALTER COLUMN id TYPE bigint`)
|
||||
}
|
||||
|
||||
await q(`
|
||||
CREATE TABLE files (
|
||||
id bigint NOT NULL DEFAULT nextval('files_id_seq'),
|
||||
filename varchar(255) NOT NULL,
|
||||
poster varchar(255) NOT NULL,
|
||||
date bigint NOT NULL,
|
||||
parts integer NOT NULL,
|
||||
groups jsonb,
|
||||
created_at timestamptz,
|
||||
updated_at timestamptz,
|
||||
file_key varchar(40),
|
||||
release_id bigint,
|
||||
archive jsonb,
|
||||
real_name text,
|
||||
size bigint,
|
||||
content_type varchar(16),
|
||||
segments bytea NOT NULL,
|
||||
first_message_id text
|
||||
) PARTITION BY RANGE (id)${tablespace}`)
|
||||
await q(`ALTER SEQUENCE files_id_seq OWNED BY files.id`)
|
||||
await q(`ALTER TABLE files ADD CONSTRAINT files_pkey PRIMARY KEY (id)`)
|
||||
await q(`ALTER TABLE files ADD CONSTRAINT files_release_id_foreign FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE SET NULL`)
|
||||
await q(`CREATE INDEX files_content_type_index ON files (content_type)`)
|
||||
await q(`CREATE INDEX files_first_message_id_idx ON files (first_message_id)`)
|
||||
await q(`CREATE INDEX files_name_idx ON files ((coalesce(real_name, filename::text)))`)
|
||||
await q(`CREATE INDEX files_release_id_index ON files (release_id)`)
|
||||
for (const p of partitions) {
|
||||
await q(`ALTER TABLE files ATTACH PARTITION ${p.name} ${p.bound}`)
|
||||
await q(`ALTER TABLE ${p.name} DROP CONSTRAINT IF EXISTS ${p.name}_bound`)
|
||||
}
|
||||
for (const t of TRIGGERS) await q(`CREATE TRIGGER ${t}`)
|
||||
await q(`ALTER TABLE binaries ADD CONSTRAINT binaries_file_id_foreign FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL NOT VALID`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
throw new Error('Not reversible: ids above 2^31 may exist.')
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** When an archive walk (CONCEPT.md D40) looked at a release, so it isn't walked again. */
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('releases', (table) => {
|
||||
table.timestamp('contents_walked_at', { useTz: true })
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('releases', (table) => table.dropColumn('contents_walked_at'))
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Release completeness (D24) without cubic cost (LOG.md 2026-10-06 (4)). A
|
||||
* par2 set listing n files was inserted into release_files row by row, each
|
||||
* row's trigger ran release_complete() twice, and that matched every listed
|
||||
* file against all binaries in the database: n × 2 × n × (all binaries). One
|
||||
* insert of a few thousand rows ran for over 20 minutes at full CPU.
|
||||
*
|
||||
* - release_complete(): the release's files are read once, by release id,
|
||||
* and each listed file is matched against those by size or by name. The
|
||||
* correlated subquery read every binary in the database per listed file
|
||||
* (binaries_size_index), filtering by release only afterwards.
|
||||
* - refresh_release_complete(): computes it once.
|
||||
* - release_files: one refresh per release per INSERT statement.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION release_complete(rid bigint) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
WITH r AS (SELECT file_total, file_count FROM releases WHERE id = rid),
|
||||
-- The release's files, once, by index (read only when there's a par2 listing to check).
|
||||
have AS MATERIALIZED (
|
||||
SELECT b.size, f.real_name FROM files f JOIN binaries b ON b.file_id = f.id WHERE f.release_id = rid)
|
||||
SELECT coalesce((
|
||||
SELECT CASE
|
||||
WHEN r.file_total IS NOT NULL THEN r.file_count >= r.file_total
|
||||
WHEN EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = rid) THEN NOT EXISTS (
|
||||
SELECT 1 FROM release_files rf
|
||||
WHERE rf.release_id = rid AND rf.size >= 65536
|
||||
AND NOT EXISTS (SELECT 1 FROM have h WHERE h.size = rf.size)
|
||||
AND NOT EXISTS (SELECT 1 FROM have h WHERE h.real_name = rf.name))
|
||||
ELSE false END
|
||||
FROM r), false)
|
||||
$$`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION refresh_release_complete(rid bigint) RETURNS void LANGUAGE sql AS $$
|
||||
UPDATE releases r SET complete = c.complete
|
||||
FROM (SELECT release_complete(rid) AS complete) c
|
||||
WHERE r.id = rid AND r.complete IS DISTINCT FROM c.complete
|
||||
$$`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION release_files_release_complete_statement() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM refresh_release_complete(n.release_id) FROM (SELECT DISTINCT release_id FROM new_rows) n;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
// Swapping the trigger locks release_files: inserts still running the old cubic check
|
||||
// (minutes) are cancelled rather than waited for; their jobs are retried, and run fast.
|
||||
await this.db.rawQuery(`SET LOCAL lock_timeout = '2min'`)
|
||||
await this.db.rawQuery(`
|
||||
SELECT pg_cancel_backend(pid) FROM pg_stat_activity
|
||||
WHERE pid <> pg_backend_pid() AND state = 'active' AND query ILIKE 'insert into "release_files"%'
|
||||
AND now() - query_start > interval '30 seconds'`)
|
||||
await this.db.rawQuery(`DROP TRIGGER IF EXISTS release_files_release_complete ON release_files`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE TRIGGER release_files_release_complete AFTER INSERT ON release_files
|
||||
REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION release_files_release_complete_statement()`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP TRIGGER IF EXISTS release_files_release_complete ON release_files`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE TRIGGER release_files_release_complete AFTER INSERT ON release_files
|
||||
FOR EACH ROW EXECUTE FUNCTION release_files_release_complete()`)
|
||||
await this.db.rawQuery(`DROP FUNCTION IF EXISTS release_files_release_complete_statement()`)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Cheaper release bookkeeping for file inserts (LOG.md 2026-10-06 (6)).
|
||||
*
|
||||
* Every inserted file fired four row triggers, each updating its release:
|
||||
* file_count/size, search_files, extensions and completeness, so a seed
|
||||
* batch of thousands of files rewrote its releases thousands of times (a
|
||||
* quarter of Postgres' active time on homelab). Now one statement-level
|
||||
* trigger updates each release once per INSERT, with all four.
|
||||
*
|
||||
* Completeness (D24), the expensive part, is no longer computed in triggers:
|
||||
* refresh_release_complete() only notes the release in release_dirty, and
|
||||
* gc:scheduler recomputes the noted releases every 30 minutes
|
||||
* (recompute_release_complete). Counts, sizes, search text and extensions
|
||||
* stay current: code decides on them right away (weak releases, deleting
|
||||
* empty releases).
|
||||
*
|
||||
* Also an index on release_files (release_id, size) for the par2 size
|
||||
* match's "listed once in its set" check (IngestService.matchPar2Releases).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
// Swapping triggers locks files and its partitions. Taken first and in one statement: a
|
||||
// DROP TRIGGER later in the transaction deadlocked with ingest (2026-10-06). Give up rather
|
||||
// than queue every writer behind it for long.
|
||||
await q(`SET LOCAL lock_timeout = '2min'`)
|
||||
await q(`LOCK TABLE files, release_files IN ACCESS EXCLUSIVE MODE`)
|
||||
// Built CONCURRENTLY by hand on homelab (2026-10-06); IF NOT EXISTS keeps that one.
|
||||
await q(`CREATE INDEX IF NOT EXISTS release_files_release_size_idx ON release_files (release_id, size)`)
|
||||
|
||||
// Append-only (no key): writers never wait on each other or on the sweep.
|
||||
await q(`CREATE TABLE IF NOT EXISTS release_dirty (release_id bigint NOT NULL)`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION recompute_release_complete(rid bigint) RETURNS void LANGUAGE sql AS $$
|
||||
UPDATE releases r SET complete = c.complete
|
||||
FROM (SELECT release_complete(rid) AS complete) c
|
||||
WHERE r.id = rid AND r.complete IS DISTINCT FROM c.complete
|
||||
$$`)
|
||||
// Every trigger that called this (files, release_files, binaries.size, releases.file_total) now just notes the release.
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION refresh_release_complete(rid bigint) RETURNS void LANGUAGE sql AS $$
|
||||
INSERT INTO release_dirty (release_id) SELECT rid WHERE rid IS NOT NULL
|
||||
$$`)
|
||||
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION files_release_insert_statement() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
WITH added AS (
|
||||
SELECT release_id, count(*) AS n, coalesce(sum(size), 0) AS bytes,
|
||||
array_agg(DISTINCT e) FILTER (WHERE length(e) BETWEEN 2 AND 4) AS exts,
|
||||
array_agg(DISTINCT w) FILTER (WHERE w <> '') AS words
|
||||
FROM (
|
||||
SELECT release_id, size, name_extension(nm) AS e,
|
||||
CASE WHEN is_readable_name(nm) THEN search_normalize(name_stem(nm)) END AS w
|
||||
FROM (SELECT release_id, size, coalesce(real_name, filename) AS nm FROM new_rows WHERE release_id IS NOT NULL) f
|
||||
) x
|
||||
GROUP BY release_id
|
||||
)
|
||||
UPDATE releases r SET
|
||||
file_count = r.file_count + a.n,
|
||||
size = r.size + a.bytes,
|
||||
extensions = CASE
|
||||
WHEN a.exts IS NULL OR coalesce(r.extensions, '{}') @> a.exts THEN r.extensions
|
||||
ELSE array(SELECT DISTINCT x FROM unnest(coalesce(r.extensions, '{}') || a.exts) x ORDER BY x) END,
|
||||
search_files = CASE
|
||||
WHEN a.words IS NULL OR length(coalesce(r.search_files, '')) >= 4000 THEN r.search_files
|
||||
ELSE nullif(left(concat_ws(' ', r.search_files, (
|
||||
SELECT string_agg(w, ' ') FROM unnest(a.words) w
|
||||
WHERE position(w IN coalesce(r.search_text, search_normalize(r.name))) = 0)), 4000), '') END
|
||||
FROM added a
|
||||
WHERE r.id = a.release_id;
|
||||
|
||||
INSERT INTO release_dirty (release_id) SELECT DISTINCT release_id FROM new_rows WHERE release_id IS NOT NULL;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
|
||||
// The row triggers keep UPDATE and DELETE (renames, moves between releases); INSERT is the statement trigger.
|
||||
for (const name of ['files_release_extension', 'files_release_search', 'files_release_totals', 'files_zz_release_complete']) {
|
||||
await q(`DROP TRIGGER IF EXISTS ${name} ON files`)
|
||||
}
|
||||
await q(`CREATE TRIGGER files_release_extension AFTER UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`)
|
||||
await q(`CREATE TRIGGER files_release_search AFTER UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`)
|
||||
await q(`CREATE TRIGGER files_release_totals AFTER DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`)
|
||||
await q(`CREATE TRIGGER files_zz_release_complete AFTER DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`)
|
||||
await q(`CREATE TRIGGER files_release_insert AFTER INSERT ON files REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION files_release_insert_statement()`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
await q(`DROP TRIGGER IF EXISTS files_release_insert ON files`)
|
||||
for (const name of ['files_release_extension', 'files_release_search', 'files_release_totals', 'files_zz_release_complete']) {
|
||||
await q(`DROP TRIGGER IF EXISTS ${name} ON files`)
|
||||
}
|
||||
await q(`CREATE TRIGGER files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`)
|
||||
await q(`CREATE TRIGGER files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`)
|
||||
await q(`CREATE TRIGGER files_release_totals AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`)
|
||||
await q(`CREATE TRIGGER files_zz_release_complete AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`)
|
||||
await q(`DROP FUNCTION IF EXISTS files_release_insert_statement()`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION refresh_release_complete(rid bigint) RETURNS void LANGUAGE sql AS $$
|
||||
UPDATE releases r SET complete = c.complete
|
||||
FROM (SELECT release_complete(rid) AS complete) c
|
||||
WHERE r.id = rid AND r.complete IS DISTINCT FROM c.complete
|
||||
$$`)
|
||||
await q(`DROP FUNCTION IF EXISTS recompute_release_complete(bigint)`)
|
||||
await q(`DROP TABLE IF EXISTS release_dirty`)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/** Seed ranges of a higher priority are leased first (`seed:plan --priority`), e.g. a gap in recent history. */
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
this.schema.alterTable('seed_ranges', (table) => {
|
||||
table.smallint('priority').notNullable().defaultTo(0)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable('seed_ranges', (table) => table.dropColumn('priority'))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Stored parts per binary, kept by IngestService as it inserts parts
|
||||
* (LOG.md 2026-10-06 (7)). claimCompleted counted every binary's parts in
|
||||
* every batch (15% of Postgres' active time); a binary of thousands of
|
||||
* parts that gets a few per batch was counted thousands of times. NULL:
|
||||
* not counted yet (binaries from before), counted exactly on the next new
|
||||
* part. Only parts of binaries without a file are deleted, so it stays exact
|
||||
* until a binary completes.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`SET LOCAL lock_timeout = '2min'`)
|
||||
await this.db.rawQuery(`ALTER TABLE binaries ADD COLUMN IF NOT EXISTS part_count integer`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`ALTER TABLE binaries DROP COLUMN IF EXISTS part_count`)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* A 64-bit hash of every file_key, in one index (LOG.md 2026-10-06 (7)).
|
||||
* file_key is unique per files partition only (D39), so FileStore.insert
|
||||
* looked every key up in every partition: 7 partitions and growing, 35,000
|
||||
* random index reads for a batch of 5,000 files. With file_keys complete it
|
||||
* asks file_keys first, and only keys whose hash is there (a duplicate, or
|
||||
* rarely a collision) are looked up in files.
|
||||
*
|
||||
* New keys come from the files insert statement trigger. The keys from
|
||||
* before are copied in the background (FileKeys.fill, gc:scheduler):
|
||||
* copying them here took over half an hour of HDD reads and held up the
|
||||
* deploy. Until file_keys_fill.complete, FileStore checks every partition.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
await q(`CREATE TABLE IF NOT EXISTS file_keys (key_hash bigint PRIMARY KEY)`)
|
||||
// One row: the fill's position in files.id, the id it must reach, and whether it got there.
|
||||
await q(`
|
||||
CREATE TABLE IF NOT EXISTS file_keys_fill (
|
||||
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
next_id bigint NOT NULL DEFAULT 0,
|
||||
until_id bigint,
|
||||
complete boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now())`)
|
||||
await q(`INSERT INTO file_keys_fill (id) VALUES (1) ON CONFLICT DO NOTHING`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION files_release_insert_statement() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
WITH added AS (
|
||||
SELECT release_id, count(*) AS n, coalesce(sum(size), 0) AS bytes,
|
||||
array_agg(DISTINCT e) FILTER (WHERE length(e) BETWEEN 2 AND 4) AS exts,
|
||||
array_agg(DISTINCT w) FILTER (WHERE w <> '') AS words
|
||||
FROM (
|
||||
SELECT release_id, size, name_extension(nm) AS e,
|
||||
CASE WHEN is_readable_name(nm) THEN search_normalize(name_stem(nm)) END AS w
|
||||
FROM (SELECT release_id, size, coalesce(real_name, filename) AS nm FROM new_rows WHERE release_id IS NOT NULL) f
|
||||
) x
|
||||
GROUP BY release_id
|
||||
)
|
||||
UPDATE releases r SET
|
||||
file_count = r.file_count + a.n,
|
||||
size = r.size + a.bytes,
|
||||
extensions = CASE
|
||||
WHEN a.exts IS NULL OR coalesce(r.extensions, '{}') @> a.exts THEN r.extensions
|
||||
ELSE array(SELECT DISTINCT x FROM unnest(coalesce(r.extensions, '{}') || a.exts) x ORDER BY x) END,
|
||||
search_files = CASE
|
||||
WHEN a.words IS NULL OR length(coalesce(r.search_files, '')) >= 4000 THEN r.search_files
|
||||
ELSE nullif(left(concat_ws(' ', r.search_files, (
|
||||
SELECT string_agg(w, ' ') FROM unnest(a.words) w
|
||||
WHERE position(w IN coalesce(r.search_text, search_normalize(r.name))) = 0)), 4000), '') END
|
||||
FROM added a
|
||||
WHERE r.id = a.release_id;
|
||||
|
||||
INSERT INTO release_dirty (release_id) SELECT DISTINCT release_id FROM new_rows WHERE release_id IS NOT NULL;
|
||||
INSERT INTO file_keys (key_hash)
|
||||
SELECT DISTINCT hashtextextended(file_key, 0) FROM new_rows WHERE file_key IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS file_keys_fill`)
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS file_keys`)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* A completed binary whose file is queued for content inspection keeps its
|
||||
* row until inspection has stored the file's fingerprint on it (LOG.md
|
||||
* 2026-10-06 (11)): par2 naming (D16) matches par2 listings against binary
|
||||
* fingerprints and attaches sets to binaries. With the inspection queue
|
||||
* days behind, the GC deleted them first (after 6 hours unfingerprinted,
|
||||
* 48 hours stale). TRUE while waiting; cleared by inspection.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`SET LOCAL lock_timeout = '2min'`)
|
||||
await this.db.rawQuery(`ALTER TABLE binaries ADD COLUMN IF NOT EXISTS awaiting_inspection boolean`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`ALTER TABLE binaries DROP COLUMN IF EXISTS awaiting_inspection`)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Indexes for the GC's deletes of finished binaries, without the ones
|
||||
* waiting for inspection (LOG.md 2026-10-06 (11)): those can be millions
|
||||
* when inspection is behind, and the deletes must not read past them every
|
||||
* sweep. Built CONCURRENTLY: binaries takes writes all the time.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
static disableTransactions = true
|
||||
|
||||
async up() {
|
||||
await this.db.rawQuery(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS binaries_done_last_seen_idx ON binaries (last_seen_at)
|
||||
WHERE file_id IS NOT NULL AND awaiting_inspection IS NOT TRUE`
|
||||
)
|
||||
await this.db.rawQuery(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS binaries_awaiting_inspection_idx ON binaries (last_seen_at)
|
||||
WHERE awaiting_inspection IS TRUE`
|
||||
)
|
||||
await this.db.rawQuery(
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS binaries_open_last_seen_idx ON binaries (last_seen_at)
|
||||
WHERE completed_at IS NULL OR conflicted_at IS NOT NULL`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS binaries_open_last_seen_idx`)
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS binaries_awaiting_inspection_idx`)
|
||||
await this.db.rawQuery(`DROP INDEX CONCURRENTLY IF EXISTS binaries_done_last_seen_idx`)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Release completeness (D24) for fewer releases, and without binaries
|
||||
* (LOG.md 2026-10-07 (7)).
|
||||
*
|
||||
* - Only releases whose completeness can change are noted for the sweep: a
|
||||
* par2 listing, a file_total, or complete now (it can become incomplete).
|
||||
* For any other release release_complete() is false by definition; noting
|
||||
* every release a file went into made a sweep check 430,000 releases in
|
||||
* 51 minutes.
|
||||
* - release_complete() matches listed files by name against the files
|
||||
* themselves (coalesce(real_name, filename)); the size match still goes
|
||||
* through a binary when there is one. Seeding no longer creates binaries
|
||||
* for most files and the GC deletes them, so a name only reachable through
|
||||
* a binary was lost.
|
||||
* - Notes that don't qualify are dropped.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
const q = (sql: string) => this.db.rawQuery(sql)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION release_completeness_matters(rid bigint) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS (SELECT 1 FROM releases r WHERE r.id = rid AND (r.file_total IS NOT NULL OR r.complete))
|
||||
OR EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = rid)
|
||||
$$`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION refresh_release_complete(rid bigint) RETURNS void LANGUAGE sql AS $$
|
||||
INSERT INTO release_dirty (release_id) SELECT rid WHERE rid IS NOT NULL AND release_completeness_matters(rid)
|
||||
$$`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION release_complete(rid bigint) RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||
WITH r AS (SELECT file_total, file_count FROM releases WHERE id = rid),
|
||||
-- The release's files, once, by index (read only when there's a par2 listing to check).
|
||||
have AS MATERIALIZED (
|
||||
SELECT b.size, coalesce(f.real_name, f.filename) AS name
|
||||
FROM files f LEFT JOIN binaries b ON b.file_id = f.id
|
||||
WHERE f.release_id = rid)
|
||||
SELECT coalesce((
|
||||
SELECT CASE
|
||||
WHEN r.file_total IS NOT NULL THEN r.file_count >= r.file_total
|
||||
WHEN EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = rid) THEN NOT EXISTS (
|
||||
SELECT 1 FROM release_files rf
|
||||
WHERE rf.release_id = rid AND rf.size >= 65536
|
||||
AND NOT EXISTS (SELECT 1 FROM have h WHERE h.size = rf.size)
|
||||
AND NOT EXISTS (SELECT 1 FROM have h WHERE h.name = rf.name))
|
||||
ELSE false END
|
||||
FROM r), false)
|
||||
$$`)
|
||||
await q(`
|
||||
CREATE OR REPLACE FUNCTION files_release_insert_statement() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
WITH added AS (
|
||||
SELECT release_id, count(*) AS n, coalesce(sum(size), 0) AS bytes,
|
||||
array_agg(DISTINCT e) FILTER (WHERE length(e) BETWEEN 2 AND 4) AS exts,
|
||||
array_agg(DISTINCT w) FILTER (WHERE w <> '') AS words
|
||||
FROM (
|
||||
SELECT release_id, size, name_extension(nm) AS e,
|
||||
CASE WHEN is_readable_name(nm) THEN search_normalize(name_stem(nm)) END AS w
|
||||
FROM (SELECT release_id, size, coalesce(real_name, filename) AS nm FROM new_rows WHERE release_id IS NOT NULL) f
|
||||
) x
|
||||
GROUP BY release_id
|
||||
)
|
||||
UPDATE releases r SET
|
||||
file_count = r.file_count + a.n,
|
||||
size = r.size + a.bytes,
|
||||
extensions = CASE
|
||||
WHEN a.exts IS NULL OR coalesce(r.extensions, '{}') @> a.exts THEN r.extensions
|
||||
ELSE array(SELECT DISTINCT x FROM unnest(coalesce(r.extensions, '{}') || a.exts) x ORDER BY x) END,
|
||||
search_files = CASE
|
||||
WHEN a.words IS NULL OR length(coalesce(r.search_files, '')) >= 4000 THEN r.search_files
|
||||
ELSE nullif(left(concat_ws(' ', r.search_files, (
|
||||
SELECT string_agg(w, ' ') FROM unnest(a.words) w
|
||||
WHERE position(w IN coalesce(r.search_text, search_normalize(r.name))) = 0)), 4000), '') END
|
||||
FROM added a
|
||||
WHERE r.id = a.release_id;
|
||||
|
||||
-- Only releases whose completeness can change (LOG.md 2026-10-07 (7)).
|
||||
INSERT INTO release_dirty (release_id)
|
||||
SELECT n.release_id FROM (SELECT DISTINCT release_id FROM new_rows WHERE release_id IS NOT NULL) n
|
||||
WHERE release_completeness_matters(n.release_id);
|
||||
INSERT INTO file_keys (key_hash)
|
||||
SELECT DISTINCT hashtextextended(file_key, 0) FROM new_rows WHERE file_key IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
RETURN NULL;
|
||||
END $$`)
|
||||
await q(`DELETE FROM release_dirty d WHERE NOT release_completeness_matters(d.release_id)`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
// The previous definitions are in migrations …180, …190 and …220.
|
||||
throw new Error('Not reversible: re-run the earlier definitions by hand if needed.')
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* The content inspection backlog (LOG.md 2026-10-07 (8)): files waiting to be
|
||||
* inspected, in Postgres instead of as BullMQ jobs. 5.4M waiting jobs took
|
||||
* 3.3 GB of Redis memory and most of its single thread; InspectionQueue.feed
|
||||
* keeps at most INSPECTION_FEED_MAX of them in BullMQ, where the workers and
|
||||
* nodes take them. Lower priority first, then oldest.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`
|
||||
CREATE TABLE IF NOT EXISTS inspection_queue (
|
||||
file_id bigint PRIMARY KEY,
|
||||
priority smallint NOT NULL DEFAULT 0,
|
||||
queued_at timestamptz NOT NULL DEFAULT now())`)
|
||||
await this.db.rawQuery(`CREATE INDEX IF NOT EXISTS inspection_queue_order_idx ON inspection_queue (priority, queued_at)`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS inspection_queue`)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Dropping garbage uploads early (CONCEPT.md D42): random-named uploads that
|
||||
* never get a real name or password.
|
||||
* - upload_signatures: per posting tool (from the Message-ID) and first group,
|
||||
* how many probed random-named releases got a real name; `dropped` ones
|
||||
* aren't stored any more (GarbageFilter).
|
||||
* - signature_probes: a sample of probed random-named releases, what
|
||||
* upload_signatures is learned from (the outcome is read from the release
|
||||
* at learning time, so names that come later count too).
|
||||
* - dropped_releases: releases deleted after their probe, by release_key, so
|
||||
* seeding and ingest don't add them again.
|
||||
* - is_real_release_name(): a name someone would search for. is_readable_name
|
||||
* lets spam through (usenetszene net, VTS_01_1.VOB, Nieuwe map, 100 MiB):
|
||||
* this also wants a year, episode, resolution, codec or language tag, or
|
||||
* three words (LOG.md 2026-10-08 (5)). No `?` in its regexes: knex takes it
|
||||
* for a binding (hence {0,1}).
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`
|
||||
CREATE TABLE IF NOT EXISTS upload_signatures (
|
||||
tool varchar(32) NOT NULL,
|
||||
group_name text NOT NULL,
|
||||
probed integer NOT NULL DEFAULT 0,
|
||||
named integer NOT NULL DEFAULT 0,
|
||||
dropped boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tool, group_name))`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE TABLE IF NOT EXISTS signature_probes (
|
||||
release_id bigint PRIMARY KEY,
|
||||
tool varchar(32) NOT NULL,
|
||||
group_name text NOT NULL,
|
||||
probed_at timestamptz NOT NULL DEFAULT now())`)
|
||||
await this.db.rawQuery(`CREATE INDEX IF NOT EXISTS signature_probes_probed_at_idx ON signature_probes (probed_at)`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE TABLE IF NOT EXISTS dropped_releases (
|
||||
release_key varchar(40) PRIMARY KEY,
|
||||
reason varchar(16) NOT NULL,
|
||||
dropped_at timestamptz NOT NULL DEFAULT now())`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE OR REPLACE FUNCTION is_real_release_name(name text) RETURNS boolean IMMUTABLE LANGUAGE sql AS $$
|
||||
SELECT name IS NOT NULL AND is_readable_name(name)
|
||||
AND NOT (
|
||||
name ~* '(usenet|video_ts|nieuwe map|new folder|(^|[^a-z0-9])mib([^a-z0-9]|$))'
|
||||
OR name ~* '^(vts_|sample|cd[0-9])'
|
||||
OR name ~ '^\\(.*\\)$'
|
||||
OR name ~ '^[A-Za-z0-9]{8,}$')
|
||||
AND (
|
||||
name ~* '(^|[^a-z0-9])((19|20)[0-9]{2}|(480|576|720|1080|2160)[pi]|dl|nl)([^a-z0-9]|$)'
|
||||
OR name ~* '(s[0-9]{2}e[0-9]{2}|x26[45]|h\\.{0,1}26[45]|hevc|web[- .]{0,1}(dl|rip)|bluray|remux|german|dutch|multi|flac|mp3|epub|pdf|ac3|dts|xxx)'
|
||||
OR (SELECT count(*) FROM regexp_matches(name, '[A-Za-z]{3,}', 'g')) >= 3)
|
||||
$$`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP FUNCTION IF EXISTS is_real_release_name(text)`)
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS dropped_releases`)
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS signature_probes`)
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS upload_signatures`)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* Listed spots (D29) were only read from their overview: no description, no
|
||||
* NZB, so neither a password in the spot's text nor the NZB's own
|
||||
* `<meta type="password">` was known (LOG.md 2026-10-08 (10)). nzb_read_at:
|
||||
* when the spot's article and NZB were read for that (SpotService.readListing),
|
||||
* also set while a reader holds it. The index finds the ones still to read,
|
||||
* newest first.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
await this.db.rawQuery(`ALTER TABLE spots ADD COLUMN IF NOT EXISTS nzb_read_at timestamptz`)
|
||||
await this.db.rawQuery(
|
||||
`CREATE INDEX IF NOT EXISTS spots_listing_unread_idx ON spots (posted_at DESC) WHERE status = 'listed' AND nzb_read_at IS NULL`
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP INDEX IF EXISTS spots_listing_unread_idx`)
|
||||
await this.db.rawQuery(`ALTER TABLE spots DROP COLUMN IF EXISTS nzb_read_at`)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
/**
|
||||
* What inspection learns about a file (content type, archive listing) lives
|
||||
* in file_details, on the NVMe, instead of in `files` (LOG.md 2026-10-08 (14)).
|
||||
* `files` sits on the HDD and was updated after its insert by every probe:
|
||||
* a new row version on a random page and an entry in each of its indexes
|
||||
* (3.7M updates against 49.5M inserts in a day, 27% HOT). Its rows are now
|
||||
* only inserted (and moved between releases or renamed, rarely).
|
||||
* The existing values are copied by `files:move-details` after this deploy;
|
||||
* a later migration drops the columns.
|
||||
*/
|
||||
export default class extends BaseSchema {
|
||||
async up() {
|
||||
// The foreign key locks `files` briefly: take the lock first, so it can't deadlock with writers.
|
||||
await this.db.rawQuery(`SET LOCAL lock_timeout = '60s'`)
|
||||
await this.db.rawQuery(`LOCK TABLE files IN SHARE ROW EXCLUSIVE MODE`)
|
||||
await this.db.rawQuery(`
|
||||
CREATE TABLE IF NOT EXISTS file_details (
|
||||
file_id bigint PRIMARY KEY REFERENCES files (id) ON DELETE CASCADE,
|
||||
content_type varchar(16),
|
||||
archive jsonb)`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
await this.db.rawQuery(`DROP TABLE IF EXISTS file_details`)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user