Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions server/lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,22 +148,53 @@ export async function ensureSchema() {
tags TEXT[] DEFAULT '{}',
embedding vector(768),
embedding_model VARCHAR(100),
search_tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english',
coalesce(payload->>'description', '') || ' ' ||
coalesce(payload->>'notes', '') || ' ' ||
coalesce(payload->>'background', '') || ' ' ||
coalesce(payload->>'summary', '')
), 'B')
) STORED,
origin_instance_id VARCHAR(36),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
sync_sequence BIGSERIAL
)`,
// Postgres can't ALTER the expression of a STORED generated column, so when
// the v2 expansion needs to land we DROP and re-ADD `search_tsv`. The
// conditional below (executed after the table CREATE, before the
// ADD-only fallback) inspects pg_attrdef and rewrites the column ONLY
// when the current generation expression is missing a v2-only field
// (`physicalDescription`). That keeps boot O(1) on already-v2 installs —
// an unconditional DROP+ADD would AccessExclusive-lock the table, rewrite
// every row, and rebuild the GIN index on every server start.
// Fresh installs (no column yet) fall through to the ADD IF NOT EXISTS
// below and skip the DROP entirely.
`DO $$
DECLARE
expr TEXT;
BEGIN
SELECT pg_get_expr(d.adbin, d.adrelid)
INTO expr
FROM pg_attribute a
JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = 'catalog_ingredients'::regclass
AND a.attname = 'search_tsv'
AND a.attgenerated = 's';
IF expr IS NOT NULL AND position('physicalDescription' in expr) = 0 THEN
EXECUTE 'ALTER TABLE catalog_ingredients DROP COLUMN search_tsv';
END IF;
END$$`,
`ALTER TABLE catalog_ingredients ADD COLUMN IF NOT EXISTS search_tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english',
coalesce(payload->>'description', '') || ' ' ||
coalesce(payload->>'physicalDescription', '') || ' ' ||
coalesce(payload->>'personality', '') || ' ' ||
coalesce(payload->>'background', '') || ' ' ||
coalesce(payload->>'summary', '') || ' ' ||
coalesce(payload->>'notes', '') || ' ' ||
coalesce(payload->>'role', '') || ' ' ||
coalesce(payload->>'motivations', '') || ' ' ||
coalesce(payload->>'significance', '')
), 'B')
) STORED`,
`CREATE INDEX IF NOT EXISTS idx_catalog_ing_embedding
ON catalog_ingredients USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)`,
Expand Down
21 changes: 15 additions & 6 deletions server/lib/schemaVersions.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,21 @@ export const PORTOS_SCHEMA_VERSIONS = Object.freeze({
// pauses with old peers; issues/universes keep flowing.
pipelineSeries: 2,
mediaCollections: 1,
// v1 = creative ingredients catalog (Postgres tables: catalog_scraps,
// catalog_ingredients, catalog_ingredient_sources, catalog_ingredient_refs).
// Per-category gate so a new peer can sync its catalog independently of
// whether other categories are version-locked. `cat-ingredient` and
// `cat-scrap` record kinds map back here via RECORD_KIND_SCHEMA_CATEGORIES.
catalog: 1,
// v2 = `catalog_ingredients.search_tsv` expanded to also index the
// character canon fields (physicalDescription, personality) and the
// type-specific role/motivations/significance fields, so bible-promoted
// characters become searchable on their main narrative text. The schema
// is a DROP+re-ADD of the STORED generated column (Postgres can't ALTER
// its expression); applied in lockstep by `ensureSchema` in
// server/lib/db.js. Per-category gate so a new peer can sync its catalog
// independently of whether other categories are version-locked. An older
// v1 peer pushing to a v2 receiver is sender-behind on `catalog` (not
// ahead), so the receiver still accepts and re-derives `search_tsv`
// locally via the STORED expression. A v2 peer pushing to a v1 receiver
// is sender-ahead and gets 412 — the older code can't index the new
// payload fields. `cat-ingredient` and `cat-scrap` record kinds map back
// here via RECORD_KIND_SCHEMA_CATEGORIES.
catalog: 2,
// NOTE: `videoHistory` is intentionally NOT listed here. The version gate
// rejects the ENTIRE snapshot/push payload on ANY ahead-mismatch (the
// comparator walks the union of keys), so declaring a brand-new key would
Expand Down
55 changes: 43 additions & 12 deletions server/scripts/init-db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -161,25 +161,56 @@ CREATE TABLE IF NOT EXISTS catalog_ingredients (
tags TEXT[] DEFAULT '{}',
embedding vector(768),
embedding_model VARCHAR(100),
-- Weighted FTS column. Name carries the most weight (A); description/notes/
-- background fall under B. Generated/stored so the GIN index stays fresh
-- without trigger code.
search_tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english',
coalesce(payload->>'description', '') || ' ' ||
coalesce(payload->>'notes', '') || ' ' ||
coalesce(payload->>'background', '') || ' ' ||
coalesce(payload->>'summary', '')
), 'B')
) STORED,
origin_instance_id VARCHAR(36),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
sync_sequence BIGSERIAL
);
-- Weighted FTS column. Name carries the most weight (A); the character canon
-- fields (description, physicalDescription, personality, background, summary,
-- notes) plus the role/motivations/significance type-specific fields fall under
-- B. Generated/stored so the GIN index stays fresh without trigger code.
-- Postgres can't ALTER the expression of a STORED generated column, so when
-- the v2 expansion needs to land we DROP and re-ADD the column. The DO block
-- below inspects pg_attrdef and only drops when the existing expression is
-- missing a v2-only field (`physicalDescription`) — fresh runs of this script
-- skip the drop entirely (column absent), already-v2 installs skip it too, and
-- only an upgrading v1 install pays the table-rewrite cost. ensureSchema in
-- server/lib/db.js mirrors the same gate. PORTOS_SCHEMA_VERSIONS.catalog is
-- bumped to 2 in lockstep so older peers can't push pre-expansion-shape rows
-- that would mismatch the indexed expression.
DO $$
DECLARE
expr TEXT;
BEGIN
SELECT pg_get_expr(d.adbin, d.adrelid)
INTO expr
FROM pg_attribute a
JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = 'catalog_ingredients'::regclass
AND a.attname = 'search_tsv'
AND a.attgenerated = 's';
IF expr IS NOT NULL AND position('physicalDescription' in expr) = 0 THEN
EXECUTE 'ALTER TABLE catalog_ingredients DROP COLUMN search_tsv';
END IF;
END$$;
ALTER TABLE catalog_ingredients ADD COLUMN IF NOT EXISTS search_tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english',
coalesce(payload->>'description', '') || ' ' ||
coalesce(payload->>'physicalDescription', '') || ' ' ||
coalesce(payload->>'personality', '') || ' ' ||
coalesce(payload->>'background', '') || ' ' ||
coalesce(payload->>'summary', '') || ' ' ||
coalesce(payload->>'notes', '') || ' ' ||
coalesce(payload->>'role', '') || ' ' ||
coalesce(payload->>'motivations', '') || ' ' ||
coalesce(payload->>'significance', '')
), 'B')
) STORED;
CREATE INDEX IF NOT EXISTS idx_catalog_ing_embedding
ON catalog_ingredients USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Expand Down