Files
usenet-indexer/database/migrations/1768620765130_release_extensions.ts
T
Daan MeijerandClaude Opus 5.5 7ca338d4f3
Test and deploy / test (push) Successful in 1m28s
Test and deploy / deploy (push) Successful in 58m9s
Extension filter via a releases.extensions column
The first version matched extensions in search_text, which was wrong and
slow: file names enter search_text as stems without extension (so plainly
posted .mkv files were missed), and matching the TOASTed text took 3-4 s
for common types.

- Migration …130: releases.extensions text[] (two to four characters with
  a letter; .7z.001 and .r00 count as 7z and rar), kept current by triggers
  on files (name, release) and on releases (archive listing), filled with
  one sequential pass over files, with a GIN index.
- Search: ext matches with `extensions && ?`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:54:38 +02:00

110 lines
5.5 KiB
TypeScript

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`)
}
}