@rebasepro/server-postgres 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BranchService-CucnFcSE.js → BranchService-BRt78gfa.js} +35 -18
- package/dist/BranchService-BRt78gfa.js.map +1 -0
- package/dist/PostgresBackendDriver.d.ts +116 -4
- package/dist/auth/services.d.ts +99 -22
- package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
- package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
- package/dist/backup/backup-cli.d.ts +22 -0
- package/dist/{backup-cli-DqBakiMO.js → backup-cli-DW5p9_zv.js} +17 -14
- package/dist/backup-cli-DW5p9_zv.js.map +1 -0
- package/dist/{backup-service-CaMOS76G.js → backup-service-EwcDVG-8.js} +7 -9
- package/dist/{backup-service-CaMOS76G.js.map → backup-service-EwcDVG-8.js.map} +1 -1
- package/dist/{cli-errors-Dka89exj.js → cli-errors-DsA-K9uP.js} +101 -1
- package/dist/cli-errors-DsA-K9uP.js.map +1 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-flags-BglvpjHv.js +138 -0
- package/dist/cli-flags-BglvpjHv.js.map +1 -0
- package/dist/cli-flags.d.ts +28 -0
- package/dist/cli-helpers.d.ts +18 -12
- package/dist/cli-scratch-database.d.ts +34 -0
- package/dist/cli.js +177 -287
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-1-LQD0yI.js} +44 -38
- package/dist/column-plan-helpers-1-LQD0yI.js.map +1 -0
- package/dist/data-transformer.d.ts +0 -8
- package/dist/{doctor-CU9IogdL.js → doctor-C-sYWbmt.js} +228 -46
- package/dist/doctor-C-sYWbmt.js.map +1 -0
- package/dist/{ensure-collection-policies-Dzd-2S81.js → ensure-collection-policies-B1ureSIV.js} +73 -18
- package/dist/ensure-collection-policies-B1ureSIV.js.map +1 -0
- package/dist/{ensure-collection-tables-CpAgy51F.js → ensure-collection-tables-kkkHk8oo.js} +124 -37
- package/dist/ensure-collection-tables-kkkHk8oo.js.map +1 -0
- package/dist/{ensure-tables-Cr5B4UmH.js → ensure-tables-BmI_tRxc.js} +10 -3
- package/dist/ensure-tables-BmI_tRxc.js.map +1 -0
- package/dist/{generate-drizzle-schema-B537GIvz.js → generate-drizzle-schema-93M0lUxK.js} +2 -2
- package/dist/{generate-drizzle-schema-B537GIvz.js.map → generate-drizzle-schema-93M0lUxK.js.map} +1 -1
- package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js → generate-drizzle-schema-logic-sSFDp6LR.js} +26 -8
- package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-BJsLaVNX.js +152 -0
- package/dist/generate-postgres-ddl-logic-BJsLaVNX.js.map +1 -0
- package/dist/generated-sql.d.ts +28 -0
- package/dist/index.es.js +4768 -1117
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-kCETE8TY.js} +532 -39
- package/dist/introspect-db-logic-kCETE8TY.js.map +1 -0
- package/dist/introspect-db-queries-C_Q5VgQw.js +317 -0
- package/dist/introspect-db-queries-C_Q5VgQw.js.map +1 -0
- package/dist/{plan-schema-CboAIwLN.js → plan-schema-DU9exq6C.js} +291 -529
- package/dist/plan-schema-DU9exq6C.js.map +1 -0
- package/dist/{policy-drift-xJfy9xG7.js → policy-drift-B-J2hhm0.js} +3 -3
- package/dist/policy-drift-B-J2hhm0.js.map +1 -0
- package/dist/{generate-postgres-ddl-logic-D7imhYV8.js → render-ddl-Ds2t_d9V.js} +11 -149
- package/dist/render-ddl-Ds2t_d9V.js.map +1 -0
- package/dist/{rls-bootstrap-sql-H3rCFi3F.js → rls-bootstrap-sql-_KNnjanK.js} +562 -35
- package/dist/rls-bootstrap-sql-_KNnjanK.js.map +1 -0
- package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-CfXOJJaW.js} +10 -2
- package/dist/rls-enforcement-CfXOJJaW.js.map +1 -0
- package/dist/schema/atlas-argv.d.ts +15 -0
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/classify-change.d.ts +29 -1
- package/dist/schema/column-plan-helpers.d.ts +51 -20
- package/dist/schema/destructive-sql.d.ts +71 -1
- package/dist/schema/doctor-cli.js +4 -4
- package/dist/schema/doctor.d.ts +34 -1
- package/dist/schema/ensure-collection-policies.d.ts +22 -0
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
- package/dist/schema/generate-postgres-ddl.js +1 -1
- package/dist/schema/generate-schema-commit.d.ts +12 -0
- package/dist/schema/introspect-db-logic.d.ts +31 -0
- package/dist/schema/introspect-db-queries.d.ts +1 -1
- package/dist/schema/introspect-db-search.d.ts +22 -0
- package/dist/schema/introspect-db-storage.d.ts +83 -0
- package/dist/schema/introspect-db.js +15 -314
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/plan/diff-plan.d.ts +4 -3
- package/dist/schema/plan/plan-schema.d.ts +26 -11
- package/dist/schema/plan/render-ddl.d.ts +6 -0
- package/dist/schema/plan/types.d.ts +43 -12
- package/dist/search-column-BM-GV6vH.js +442 -0
- package/dist/search-column-BM-GV6vH.js.map +1 -0
- package/dist/security/policy-drift.d.ts +1 -1
- package/dist/security/rls-enforcement.d.ts +7 -0
- package/dist/services/BranchService.d.ts +22 -1
- package/dist/services/FetchService.d.ts +102 -32
- package/dist/services/PersistService.d.ts +41 -5
- package/dist/services/RelationService.d.ts +29 -0
- package/dist/services/RelationWriteService.d.ts +6 -0
- package/dist/services/cdc/CdcListener.d.ts +15 -5
- package/dist/services/cdc/identity-columns.d.ts +19 -0
- package/dist/services/cdc/trigger-cdc.d.ts +45 -7
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
- package/dist/services/channel-history.d.ts +17 -1
- package/dist/services/collection-helpers.d.ts +21 -0
- package/dist/services/dataService.d.ts +3 -20
- package/dist/services/field-op-sql.d.ts +71 -0
- package/dist/services/junction-writes.d.ts +19 -3
- package/dist/services/pg-notify-listener.d.ts +95 -6
- package/dist/services/read-field-access.d.ts +19 -0
- package/dist/services/realtimeService.d.ts +232 -44
- package/dist/services/row-pipeline.d.ts +5 -0
- package/dist/services/socket-liveness.d.ts +45 -0
- package/dist/services/soft-delete.d.ts +12 -2
- package/dist/services/sql-script.d.ts +71 -0
- package/dist/services/write-depth.d.ts +16 -0
- package/dist/services/write-transaction-scope.d.ts +42 -0
- package/dist/utils/drizzle-conditions.d.ts +49 -4
- package/dist/utils/sql-redaction.d.ts +21 -0
- package/dist/websocket.d.ts +57 -18
- package/package.json +9 -9
- package/dist/BranchService-CucnFcSE.js.map +0 -1
- package/dist/backup-cli-DqBakiMO.js.map +0 -1
- package/dist/cli-errors-Dka89exj.js.map +0 -1
- package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
- package/dist/doctor-CU9IogdL.js.map +0 -1
- package/dist/ensure-collection-policies-Dzd-2S81.js.map +0 -1
- package/dist/ensure-collection-tables-CpAgy51F.js.map +0 -1
- package/dist/ensure-tables-Cr5B4UmH.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-BcMl7VSy.js.map +0 -1
- package/dist/generate-postgres-ddl-logic-D7imhYV8.js.map +0 -1
- package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
- package/dist/plan-schema-CboAIwLN.js.map +0 -1
- package/dist/policy-drift-xJfy9xG7.js.map +0 -1
- package/dist/rls-bootstrap-sql-H3rCFi3F.js.map +0 -1
- package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-column-BM-GV6vH.js","names":[],"sources":["../src/schema/search-column.ts"],"sourcesContent":["/**\n * The one place a collection's `search` block becomes SQL.\n *\n * Four things describe a Postgres table in this codebase — the DDL generator,\n * the Drizzle schema generator, the runtime table builder for BaaS mode, and\n * the boot-time schema ensure — and each of them has, at some point, described\n * a column differently from the others. The `varchar(255)` note in\n * `generate-postgres-ddl-logic` is one such scar: the same property produced a\n * capped column down one path and an uncapped one down the other, and nothing\n * failed until a user hit the cap.\n *\n * So the search column is not implemented four times. It is computed once,\n * here, and every generator renders the same {@link SearchColumnSpec}. There is\n * a test asserting exactly that (`search-column-contract.test.ts`); the point of\n * this module is that the test has something to assert *about*.\n *\n * ## Why the expressions look the way they do\n *\n * A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and\n * Postgres is stricter here than intuition. Verified against PostgreSQL 18:\n *\n * | expression | immutable |\n * |-----------------------------------------|-----------|\n * | `to_tsvector('spanish', col)` | yes |\n * | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |\n * | `array_to_string(col, ' ')` | **no** |\n * | `col::text` on `text[]` | **no** |\n * | `to_jsonb(col)` | **no** |\n * | `unaccent(col)` | **no** — dictionary lookup is STABLE |\n * | `jsonb_to_tsvector('spanish', j, '[\"string\"]')` | yes |\n * | `setweight(...) || setweight(...)` | yes |\n *\n * Three of the four things a real search column needs are therefore unavailable\n * directly, which is why {@link searchHelperFunctions} exists: each wraps a\n * stable built-in in an SQL function declared IMMUTABLE. That declaration is a\n * promise, and it is a true one for these three — array joining, JSON string\n * extraction and accent folding are all deterministic for a given input; the\n * built-ins are marked stable only because they must account for element types\n * and dictionaries in general.\n *\n * The alternative was to skip `unaccent` and text arrays entirely. That is not\n * a real option in an accented language: Postgres stems `auditoría` to\n * `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query\n * typed without accents misses every row that carries them.\n */\nimport {\n CollectionConfig,\n Property,\n StringProperty,\n ArrayProperty,\n MapProperty,\n SearchConfig,\n SearchField,\n SearchMode,\n SearchWeight,\n isPostgresCollectionConfig,\n DEFAULT_SEARCH_COLUMN,\n DEFAULT_SEARCH_MODE,\n DEFAULT_SEARCH_LANGUAGE,\n DEFAULT_SEARCH_WEIGHT,\n DEFAULT_FUZZY_THRESHOLD\n} from \"@rebasepro/types\";\nimport { createHash } from \"node:crypto\";\nimport { getTableName } from \"@rebasepro/common\";\nimport { toSnakeCase, toPostgresIdentifier } from \"@rebasepro/utils\";\n\n/** Schema-qualified so a collection outside `public` still resolves them. */\nconst HELPER_SCHEMA = \"public\";\n\n/**\n * Names of the helper functions. Frozen: they are recorded in the stored\n * generation expression of every search column ever created, so renaming one\n * orphans every table that already has a search column.\n */\nexport const SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;\nexport const SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;\n\n/** How a declared path reaches text, which decides the SQL that extracts it. */\ntype FieldKind = \"text\" | \"text_array\" | \"jsonb\";\n\n/** One resolved field: where it lives, how to read it, what it is worth. */\nexport interface ResolvedSearchField {\n /** The path exactly as the author wrote it, for error messages. */\n path: string;\n /** The physical column the path starts at. */\n column: string;\n /** Dotted remainder addressed inside a JSONB column, if any. */\n jsonPath: string[];\n kind: FieldKind;\n weight: SearchWeight;\n /** The `setweight(to_tsvector(…), 'X')` term this field contributes. */\n sql: string;\n /** The plain-text term this field contributes, for the fuzzy column. */\n textSql: string;\n /**\n * The same text with accents folded **unconditionally**, for the substring\n * half of {@link SearchMode} `\"hybrid\"`.\n *\n * Separate from {@link textSql} rather than replacing it, and that\n * separation is the whole migration story for `mode`. `textSql` feeds the\n * *stored* generated columns, so changing it changes their generation\n * expression, which changes the fingerprint, which makes the next boot\n * refuse (see `searchStampGuards`). This one is only ever interpolated into\n * a WHERE clause, so a collection can switch to `\"hybrid\"` — and gain\n * accent folding on the substring half — without rebuilding a column or\n * taking an ACCESS EXCLUSIVE lock.\n */\n foldedTextSql: string;\n}\n\n/** Everything the generators need to render one collection's search column. */\nexport interface SearchColumnSpec {\n schema: string;\n table: string;\n /** The generated `tsvector` column. */\n column: string;\n language: string;\n unaccent: boolean;\n /**\n * How the query side matches. Deliberately absent from\n * {@link SearchColumnSpec.expression} and from every fingerprint: it\n * describes the WHERE clause, not the column.\n */\n mode: SearchMode;\n fields: ResolvedSearchField[];\n /** Body of `GENERATED ALWAYS AS ( … ) STORED` for the tsvector column. */\n expression: string;\n indexName: string;\n /** Extensions that must exist before the column can be created. */\n extensions: string[];\n fuzzy?: {\n column: string;\n expression: string;\n indexName: string;\n threshold: number;\n };\n}\n\n/** Raised when a `search` block names something that cannot be searched. */\nexport class SearchConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SearchConfigError\";\n }\n}\n\n/** The `search` block of a collection, or undefined when it has none. */\nexport const getSearchConfig = (collection: CollectionConfig): SearchConfig | undefined =>\n isPostgresCollectionConfig(collection) ? collection.search : undefined;\n\n/**\n * Refuse a `search` block on a collection this engine does not store.\n *\n * The type only permits one on a `PostgresCollectionConfig`, so TypeScript\n * already stops the ordinary case. This catches the rest — a JS config, a cast,\n * a collection whose `engine` was changed after the block was written — because\n * the alternative is the exact failure the block exists to prevent: a developer\n * who declared what to index, saw no error, and got the substring fallback.\n *\n * Called with *every* collection, before the Postgres ones are filtered out.\n */\nexport const assertSearchIsPostgresOnly = (collections: CollectionConfig[]): void => {\n for (const collection of collections) {\n if (isPostgresCollectionConfig(collection)) continue;\n if (!(collection as { search?: unknown }).search) continue;\n const engine = (collection as { engine?: string }).engine ?? \"non-postgres\";\n throw new SearchConfigError(\n `${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \\`${engine}\\`. ` +\n \"Remove the block — it would otherwise look configured while `.search()` kept using the default substring match.\"\n );\n }\n};\n\nconst columnNameOf = (propName: string, prop?: Property | null): string =>\n prop && \"columnName\" in prop && typeof prop.columnName === \"string\" ? prop.columnName : toSnakeCase(propName);\n\n/**\n * Classify a property for search purposes.\n *\n * Deliberately narrower than the schema plan's `PgType`: search only cares\n * whether a value reaches text, and the mapping from property to *physical*\n * type is asserted against the plan in the contract test rather than duplicated\n * here.\n *\n * Returns null for anything that is not text-bearing, which the caller turns\n * into a boot error naming the property.\n */\nconst classify = (prop: Property): { kind: FieldKind; reason?: string } | null => {\n switch (prop.type) {\n case \"string\": {\n const sp = prop as StringProperty;\n if (sp.enum) {\n return { kind: \"text\", reason: \"enum\" };\n }\n if (sp.isId === \"uuid\" || sp.columnType === \"uuid\") {\n return { kind: \"text\", reason: \"uuid\" };\n }\n return { kind: \"text\" };\n }\n case \"map\": {\n const mp = prop as MapProperty;\n // A `json` column is not `jsonb`, and the cast between them is not\n // immutable. Declaring `columnType: \"json\"` puts the value out of\n // reach of a generated column.\n if (mp.columnType === \"json\") return { kind: \"jsonb\", reason: \"json\" };\n return { kind: \"jsonb\" };\n }\n case \"array\": {\n const ap = prop as ArrayProperty;\n let colType = ap.columnType;\n if (!colType && ap.of && !Array.isArray(ap.of)) {\n const of = ap.of as Property;\n if (of.type === \"string\") colType = \"text[]\";\n else if (of.type === \"number\") colType = of.validation?.integer ? \"integer[]\" : \"numeric[]\";\n else if (of.type === \"boolean\") colType = \"boolean[]\";\n }\n if (colType === \"text[]\") return { kind: \"text_array\" };\n if (colType === \"json\") return { kind: \"jsonb\", reason: \"json\" };\n if (colType === \"integer[]\" || colType === \"boolean[]\" || colType === \"numeric[]\") {\n return { kind: \"text_array\", reason: \"non_text_array\" };\n }\n // Everything else lands in JSONB, which the JSON extractor handles.\n return { kind: \"jsonb\" };\n }\n default:\n return null;\n }\n};\n\nconst normalize = (inner: string, unaccent: boolean): string =>\n unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;\n\n/** SQL reading one field as plain text, before normalization. */\nconst rawTextSql = (field: { column: string; jsonPath: string[]; kind: FieldKind }): string => {\n const col = `\"${field.column}\"`;\n if (field.kind === \"text\") return `coalesce(${col}, '')`;\n if (field.kind === \"text_array\") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;\n // JSONB, optionally addressed at a path inside the document.\n const target = field.jsonPath.length === 0\n ? col\n : field.jsonPath.length === 1\n ? `${col} -> ${quote(field.jsonPath[0])}`\n : `${col} #> ${quote(`{${field.jsonPath.join(\",\")}}`)}`;\n return `${SEARCH_TEXT_FN}(coalesce(${target}, '{}'::jsonb))`;\n};\n\nconst quote = (v: string): string => `'${v.replace(/'/g, \"''\")}'`;\n\n/**\n * Resolve and validate one declared field path.\n *\n * A path that does not resolve throws. The whole point of an explicit block is\n * that the author knows what is indexed; a silently dropped field would make it\n * a guess again, and the failure — a search that returns nothing for content\n * that is plainly in the row — is invisible from the outside.\n */\nconst resolveField = (\n entry: string | SearchField,\n collection: CollectionConfig,\n cfg: SearchConfig\n): ResolvedSearchField => {\n const path = typeof entry === \"string\" ? entry : entry.path;\n const weight = (typeof entry === \"string\" ? undefined : entry.weight) ?? DEFAULT_SEARCH_WEIGHT;\n const where = `${collection.slug}.search`;\n\n if (!path || typeof path !== \"string\") {\n throw new SearchConfigError(`${where}: every entry in \\`fields\\` needs a property path.`);\n }\n\n const [head, ...rest] = path.split(\".\");\n const prop = collection.properties?.[head] as Property | undefined;\n if (!prop) {\n const known = Object.keys(collection.properties ?? {}).join(\", \");\n throw new SearchConfigError(\n `${where}: \"${path}\" starts at property \"${head}\", which this collection does not declare. Known properties: ${known}.`\n );\n }\n\n const classified = classify(prop);\n if (!classified) {\n throw new SearchConfigError(\n `${where}: \"${path}\" is a \\`${prop.type}\\` property, which holds no text to search. ` +\n `Searchable kinds are \\`string\\`, \\`string[]\\` and \\`map\\` (or a path inside one).`\n );\n }\n if (classified.reason === \"enum\") {\n throw new SearchConfigError(\n `${where}: \"${path}\" is an enum. Enums are a fixed vocabulary — filter on them with \\`where\\` instead, which is exact and uses an index.`\n );\n }\n if (classified.reason === \"uuid\") {\n throw new SearchConfigError(\n `${where}: \"${path}\" is a UUID column. Look it up by id rather than searching it.`\n );\n }\n if (classified.reason === \"json\") {\n throw new SearchConfigError(\n `${where}: \"${path}\" is a \\`json\\` column, and the cast from \\`json\\` to \\`jsonb\\` is not immutable, so it cannot feed a generated column. Declare the property as \\`jsonb\\` (the default) to search it.`\n );\n }\n if (classified.reason === \"non_text_array\") {\n throw new SearchConfigError(\n `${where}: \"${path}\" is an array of numbers or booleans. Only \\`string[]\\` carries text to search.`\n );\n }\n\n if (rest.length > 0 && classified.kind !== \"jsonb\") {\n throw new SearchConfigError(\n `${where}: \"${path}\" addresses a path inside \"${head}\", but \"${head}\" is a \\`${prop.type}\\` property, not a \\`map\\`. Only map properties have paths inside them.`\n );\n }\n\n const column = columnNameOf(head, prop);\n const field = { column, jsonPath: rest, kind: classified.kind };\n const raw = rawTextSql(field);\n const textSql = normalize(raw, cfg.unaccent === true);\n const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;\n\n return {\n path,\n column,\n jsonPath: rest,\n kind: classified.kind,\n weight,\n sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,\n textSql,\n foldedTextSql: normalize(raw, true)\n };\n};\n\n/**\n * Build the full spec for a collection, or undefined when it has not opted in.\n *\n * Throws {@link SearchConfigError} on a config that cannot be honoured. Callers\n * at boot surface that as a startup failure — a search block that half-works is\n * worse than one that refuses.\n */\nexport const buildSearchColumnSpec = (collection: CollectionConfig): SearchColumnSpec | undefined => {\n const cfg = getSearchConfig(collection);\n if (!cfg) return undefined;\n\n if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) {\n throw new SearchConfigError(\n `${collection.slug}.search: \\`fields\\` is empty. Name the properties to index, or remove the \\`search\\` block to keep the default ILIKE behaviour.`\n );\n }\n\n const table = getTableName(collection);\n const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : \"public\";\n const column = cfg.column ?? DEFAULT_SEARCH_COLUMN;\n\n if (collection.properties?.[column]) {\n throw new SearchConfigError(\n `${collection.slug}.search: the generated column \"${column}\" collides with a declared property of the same name. Set \\`search.column\\` to something else.`\n );\n }\n\n const fields = cfg.fields.map(entry => resolveField(entry, collection, cfg));\n\n const seen = new Set<string>();\n for (const f of fields) {\n if (seen.has(f.path)) {\n throw new SearchConfigError(`${collection.slug}.search: \"${f.path}\" is listed twice.`);\n }\n seen.add(f.path);\n }\n\n const mode = cfg.mode ?? DEFAULT_SEARCH_MODE;\n\n const extensions: string[] = [];\n // `hybrid` folds accents on its substring half whatever `unaccent` says, so\n // it needs the dictionary and the helper even on a block that never asked\n // for folding in the stored column. Both statements are idempotent, which\n // is what keeps turning the mode on out of the column-rebuild business.\n if (cfg.unaccent || mode === \"hybrid\") extensions.push(\"unaccent\");\n if (cfg.fuzzy) extensions.push(\"pg_trgm\");\n\n const spec: SearchColumnSpec = {\n schema,\n table,\n column,\n language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,\n unaccent: cfg.unaccent === true,\n mode,\n fields,\n expression: fields.map(f => f.sql).join(\" || \"),\n indexName: toPostgresIdentifier(`${table}_${column}_gin`),\n extensions\n };\n\n if (cfg.fuzzy) {\n const fuzzyColumn = `${column}_text`;\n if (collection.properties?.[fuzzyColumn]) {\n throw new SearchConfigError(\n `${collection.slug}.search: \\`fuzzy\\` needs the column \"${fuzzyColumn}\", which collides with a declared property. Set \\`search.column\\` to something else.`\n );\n }\n spec.fuzzy = {\n column: fuzzyColumn,\n // Concatenated with spaces so a trigram never spans two fields.\n expression: fields.map(f => f.textSql).join(\" || ' ' || \"),\n indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),\n threshold: cfg.fuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD\n };\n }\n\n return spec;\n};\n\n/**\n * The IMMUTABLE wrappers the generated expressions call.\n *\n * `CREATE OR REPLACE` so a boot against an existing database is a no-op rather\n * than an error, and idempotent for the same reason every other boot-time DDL\n * statement here is.\n *\n * The bodies are stable built-ins wrapped in an immutable promise — see the\n * module comment for why that promise is sound. `STRICT` matters: it makes NULL\n * in mean NULL out without executing the body, which is what the `coalesce` at\n * each call site then absorbs.\n */\nexport const searchHelperFunctions = (spec: SearchColumnSpec): string[] => {\n const statements: string[] = [\n // text[] → \" \"-joined text.\n `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\\n` +\n ` LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\\n` +\n ` $$ SELECT array_to_string($1, ' ') $$;`,\n // jsonb → every string value at or below the node, space-joined. Keys\n // are not values: indexing them would make `certifications` itself a\n // search term on every row that has the field at all.\n `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\\n` +\n ` LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\\n` +\n ` $$ SELECT coalesce(string_agg(v, ' '), '')\\n` +\n ` FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == \"string\")')) AS v $$;`\n ];\n if (spec.unaccent || spec.mode === \"hybrid\") {\n // The two-argument form with an explicit dictionary is the one that can\n // honestly be called immutable: the single-argument form resolves the\n // dictionary through the current search_path at call time.\n statements.push(\n `CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\\n` +\n ` LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\\n` +\n ` $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`\n );\n }\n return statements;\n};\n\n/**\n * `CREATE EXTENSION` statements the spec's expressions depend on.\n *\n * `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified\n * `CREATE EXTENSION` installs into the first schema on `search_path`, which\n * defaults to `\"$user\", public` — and the scaffold's database role is named\n * `rebase`, the same as the schema the generator creates one statement earlier.\n * So the moment that schema exists, `CREATE EXTENSION unaccent` puts the\n * dictionary in `rebase`, and every reference to `public.unaccent` below fails\n * with \"text search dictionary does not exist\". Observed, not theorised.\n */\nexport const searchExtensionStatements = (spec: SearchColumnSpec): string[] =>\n spec.extensions.map(e => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);\n\n/**\n * Everything after the column name — the type and the generation expression.\n *\n * Split out because four emitters need it and only two of them have a place to\n * put the name: `CREATE TABLE` and `ADD COLUMN` write `\"col\" <this>`, while the\n * schema plan carries it as the column's SQL definition and the boot-time\n * rebuild statement interpolates it on its own.\n */\nexport const searchColumnTypeSql = (expression: string, kind: \"tsvector\" | \"text\"): string =>\n `${kind} GENERATED ALWAYS AS (${expression}) STORED`;\n\n/** The column definition as it appears inside `CREATE TABLE`. */\nexport const searchColumnDefinition = (spec: SearchColumnSpec): string =>\n `\"${spec.column}\" ${searchColumnTypeSql(spec.expression, \"tsvector\")}`;\n\n/** The fuzzy column definition, when the spec asks for one. */\nexport const fuzzyColumnDefinition = (spec: SearchColumnSpec): string | undefined =>\n spec.fuzzy ? `\"${spec.fuzzy.column}\" ${searchColumnTypeSql(spec.fuzzy.expression, \"text\")}` : undefined;\n\n/**\n * Index statements for the spec.\n *\n * `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a\n * SQL file replayed as one unit — a migration, or `search.sql` — where a\n * concurrent build is not allowed. The boot-time ensure path runs statement by\n * statement against tables that are live and populated, and uses the\n * concurrent form instead; see `ensureSearchColumns`.\n */\nexport const searchIndexStatements = (spec: SearchColumnSpec): string[] => {\n const statements = [\n `CREATE INDEX IF NOT EXISTS \"${spec.indexName}\" ON \"${spec.schema}\".\"${spec.table}\" USING GIN (\"${spec.column}\");`\n ];\n if (spec.fuzzy) {\n statements.push(\n // The operator class is resolved through `search_path` like any\n // other object, so it is qualified for the same reason the\n // extension is installed explicitly.\n `CREATE INDEX IF NOT EXISTS \"${spec.fuzzy.indexName}\" ON \"${spec.schema}\".\"${spec.table}\" USING GIN (\"${spec.fuzzy.column}\" ${HELPER_SCHEMA}.gin_trgm_ops);`\n );\n }\n return statements;\n};\n\n// ── Telling a changed `search` block from an unchanged one ──────────────────\n\n/**\n * Marker on the comment of every generated search column this module creates.\n *\n * Versioned because the fingerprint below is only comparable against itself: a\n * future change to how it is computed has to read as \"not stamped by this\n * version\" rather than as drift on every existing column.\n */\nexport const SEARCH_STAMP_PREFIX = \"rebase:search:v1:\";\n\n/**\n * A stable fingerprint of one generated column's expression.\n *\n * Why a stamp rather than reading the expression back: Postgres stores a\n * generated column's expression *parsed*, and hands it back deparsed — casts\n * made explicit, identifiers requoted, schema qualifications added or dropped\n * according to `search_path`. Comparing that text to the text we generated\n * would report drift on wording, and this comparison decides whether a boot\n * refuses, so a false positive is an outage. The stamp is written by the same\n * code that writes the column, so equality means what it says.\n */\nexport const searchExpressionFingerprint = (expression: string): string =>\n `${SEARCH_STAMP_PREFIX}${createHash(\"sha256\").update(expression).digest(\"hex\").slice(0, 16)}`;\n\n/** One generated column, with the fingerprint that identifies its expression. */\nexport interface SearchColumnStamp {\n column: string;\n /** The expression the column is generated from. */\n expression: string;\n fingerprint: string;\n /** `COMMENT ON COLUMN …`, which is where the fingerprint is recorded. */\n sql: string;\n}\n\n/**\n * The stamps for a spec's generated columns — one per column, never shared.\n *\n * Per column on purpose: turning `fuzzy` on adds a second column and changes\n * nothing about the first, and a spec-wide fingerprint would report the\n * untouched `tsvector` column as drifted and refuse a boot over a change that\n * is purely additive.\n */\nexport const searchColumnStamps = (spec: SearchColumnSpec): SearchColumnStamp[] => {\n const stamp = (column: string, expression: string): SearchColumnStamp => {\n const fingerprint = searchExpressionFingerprint(expression);\n return {\n column,\n expression,\n fingerprint,\n sql: `COMMENT ON COLUMN \"${spec.schema}\".\"${spec.table}\".\"${column}\" IS ${quote(fingerprint)};`\n };\n };\n const stamps = [stamp(spec.column, spec.expression)];\n if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));\n return stamps;\n};\n\n/**\n * The same drift check as the boot ensure, for the SQL file.\n *\n * Needed because {@link searchColumnStamps} would otherwise *launder* drift on\n * the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that\n * exists, so a re-generated `search.sql` would stamp a stale column with the\n * new block's fingerprint and the next boot would find them in agreement.\n * Guarding first means the file refuses instead — `rebase db push` is attended,\n * and the operator reading the failure is the person who changed the block.\n */\nexport const searchStampGuards = (spec: SearchColumnSpec): string[] =>\n searchColumnStamps(spec).map(stamp => {\n const relation = quote(`\"${spec.schema}\".\"${spec.table}\"`);\n return `DO $rebase_search$\nDECLARE recorded text;\nBEGIN\n SELECT col_description(a.attrelid, a.attnum) INTO recorded\n FROM pg_attribute a\n WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;\n IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN\n RAISE EXCEPTION 'Rebase: the search block for ${spec.schema}.${spec.table} changed after the generated column \"${stamp.column}\" was built (recorded %, expected ${stamp.fingerprint}). Postgres cannot alter a generated expression in place. Drop the column and re-apply this file — it rewrites the table and rebuilds the index: ALTER TABLE ${relation.slice(1, -1)} DROP COLUMN \"${stamp.column}\";', recorded;\n END IF;\nEND\n$rebase_search$;`;\n });\n\n/**\n * The index names the spec creates.\n *\n * Needed by name, not just by statement, so Atlas can be told to exclude them\n * from its diff — see `searchExcludePatterns`.\n */\nexport const searchIndexNames = (spec: SearchColumnSpec): string[] =>\n spec.fuzzy ? [spec.indexName, spec.fuzzy.indexName] : [spec.indexName];\n\n// ── Keeping the generated columns out of responses ──────────────────────────\n\n/**\n * The generated column names a collection's search block adds, if any.\n *\n * These are physical columns on the table, so `SELECT *` returns them. They are\n * an index in column form — a list of lexeme positions, or a concatenation of\n * every searchable field on the row — and nothing outside the query planner has\n * any use for them. Left in, every list response carries a second, larger copy\n * of the row's text.\n */\nexport const searchColumnNames = (collection: CollectionConfig): string[] => {\n let spec: SearchColumnSpec | undefined;\n try {\n spec = buildSearchColumnSpec(collection);\n } catch {\n // A malformed block is reported at boot, loudly. A read is the wrong\n // place to raise it a second time, and returning the row without the\n // exclusion would be worse than returning it with.\n return [];\n }\n if (!spec) return [];\n return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];\n};\n\n/**\n * True for a column whose type only ever holds a search index.\n *\n * Independent of any collection config on purpose: an introspected database\n * (BaaS mode) can carry a `tsvector` column this framework never created —\n * Pagila's `film.fulltext` is the canonical one — and it should not be returned\n * to callers either. `isDerivedIndexColumn` already keeps such a column out of\n * the *properties*; this keeps it out of the *rows*.\n */\nexport const isSearchIndexColumn = (column: { getSQLType?: () => string }): boolean => {\n const sqlType = typeof column?.getSQLType === \"function\" ? column.getSQLType().toLowerCase() : \"\";\n return sqlType === \"tsvector\" || sqlType === \"tsquery\";\n};\n\n/**\n * A drizzle select projection over `table` with the search columns dropped.\n *\n * Returns undefined when nothing needs dropping, so the common case keeps using\n * a plain `select()` and this stays invisible in the generated SQL.\n */\nexport const visibleColumnProjection = (\n tableColumns: Record<string, { getSQLType?: () => string }> | undefined,\n collection?: CollectionConfig\n): Record<string, unknown> | undefined => {\n const excluded = excludedColumnNames(tableColumns, collection);\n if (!tableColumns || excluded.length === 0) return undefined;\n const projection: Record<string, unknown> = {};\n for (const [name, column] of Object.entries(tableColumns)) {\n if (!excluded.includes(name)) projection[name] = column;\n }\n return projection;\n};\n\n/** The same exclusion as a drizzle `db.query` `columns` denylist. */\nexport const hiddenColumnsOption = (\n tableColumns: Record<string, { getSQLType?: () => string }> | undefined,\n collection?: CollectionConfig\n): Record<string, false> | undefined => {\n const excluded = excludedColumnNames(tableColumns, collection);\n if (excluded.length === 0) return undefined;\n return Object.fromEntries(excluded.map(name => [name, false as const]));\n};\n\n/**\n * The columns to keep out of a response, by name.\n *\n * `tableColumns` is whatever `getTableColumns` returned, which is `undefined`\n * for anything that is not a real drizzle table — a stub in a test, a derived\n * or nested path with no table behind it. Nothing to exclude is the right\n * answer there, and it has to be an answer rather than a throw: this runs on\n * the read path of every collection, opted in or not.\n */\nconst excludedColumnNames = (\n tableColumns: Record<string, { getSQLType?: () => string }> | undefined,\n collection?: CollectionConfig\n): string[] => {\n if (!tableColumns || typeof tableColumns !== \"object\") return [];\n const byName = new Set(collection ? searchColumnNames(collection) : []);\n return Object.keys(tableColumns).filter(\n name => byName.has(name) || isSearchIndexColumn(tableColumns[name])\n );\n};\n"],"mappings":"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"}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* table is indistinguishable from a table with no data.
|
|
11
11
|
*
|
|
12
12
|
* Expected policies are parsed from `generatePostgresPoliciesDdl`, the same
|
|
13
|
-
* function `db push` uses to write
|
|
13
|
+
* function `db push` uses to write `.rebase/sql/policies.sql`, so this compares
|
|
14
14
|
* against exactly what would be applied rather than a reimplementation.
|
|
15
15
|
*/
|
|
16
16
|
import { type CollectionConfig } from "@rebasepro/types";
|
|
@@ -115,6 +115,13 @@ export interface AuthContext {
|
|
|
115
115
|
*/
|
|
116
116
|
claims?: Record<string, unknown>;
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Take every privilege the user role holds on one table back — what a table
|
|
120
|
+
* gets when RLS cannot be switched on for it, so it is unreachable rather than
|
|
121
|
+
* unprotected. Quoted exactly: a mixed-case adopted table (`"User"`) folded to
|
|
122
|
+
* lower case names a table that does not exist.
|
|
123
|
+
*/
|
|
124
|
+
export declare const revokeUserRoleSql: (schema: string, table: string) => string;
|
|
118
125
|
/**
|
|
119
126
|
* Warn when the connection role shares its name with an existing schema.
|
|
120
127
|
*
|
|
@@ -23,10 +23,31 @@ export declare class BranchingUnsupportedError extends Error {
|
|
|
23
23
|
readonly code = "BRANCHING_UNSUPPORTED";
|
|
24
24
|
constructor(message: string);
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Where a server's branches are kept: the main database — what a branch is
|
|
28
|
+
* copied from unless told otherwise, and what may never be dropped — and the
|
|
29
|
+
* connection whose `rebase.branches` records them.
|
|
30
|
+
*/
|
|
31
|
+
export interface BranchHome {
|
|
32
|
+
/** The main database's name. */
|
|
33
|
+
database(): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* A connection on the database the registry is in. Asked for at each use:
|
|
36
|
+
* a pool to the main database is closed before it is copied.
|
|
37
|
+
*/
|
|
38
|
+
registry(): Promise<DrizzleClient>;
|
|
39
|
+
}
|
|
26
40
|
export declare class BranchService {
|
|
27
41
|
private db;
|
|
28
42
|
private poolManager;
|
|
29
|
-
|
|
43
|
+
private readonly home;
|
|
44
|
+
/**
|
|
45
|
+
* @param db A connection on the server the branches are made on. Branch
|
|
46
|
+
* DDL runs here.
|
|
47
|
+
* @param home Where branches are kept. Without one, the database the
|
|
48
|
+
* pool manager's connection string names, and `db`'s registry.
|
|
49
|
+
*/
|
|
50
|
+
constructor(db: DrizzleClient, poolManager: DatabasePoolManager, home?: BranchHome);
|
|
30
51
|
/**
|
|
31
52
|
* Refuse a branch mutation the connected server cannot honour.
|
|
32
53
|
*
|
|
@@ -141,6 +141,11 @@ export declare class FetchService {
|
|
|
141
141
|
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
142
142
|
*/
|
|
143
143
|
static readonly SCORE_FIELD = "_score";
|
|
144
|
+
/**
|
|
145
|
+
* The distance a vector search attaches to every row it serves, and to no
|
|
146
|
+
* other. Like `_score` it is computed per query and stored nowhere.
|
|
147
|
+
*/
|
|
148
|
+
static readonly DISTANCE_FIELD = "_distance";
|
|
144
149
|
private resolveOrderTarget;
|
|
145
150
|
/**
|
|
146
151
|
* The aggregate a sort key names, as an expression, or `undefined` if the
|
|
@@ -181,15 +186,17 @@ export declare class FetchService {
|
|
|
181
186
|
*/
|
|
182
187
|
private static nullsLast;
|
|
183
188
|
/**
|
|
184
|
-
* The full `ORDER BY`: the caller's keys, then the
|
|
189
|
+
* The full `ORDER BY`: the caller's keys, then the primary key.
|
|
185
190
|
*
|
|
186
|
-
* The
|
|
187
|
-
* what makes the ordering *total*, and a cursor over
|
|
188
|
-
* repeats and skips rows among the ties. Every keyset
|
|
189
|
-
* {@link buildCursorConditions} ends on the same
|
|
190
|
-
* to agree: they did not, and an ascending sort
|
|
191
|
-
* `ORDER BY … , id DESC`, so rows sharing a
|
|
192
|
-
* every page after the first.
|
|
191
|
+
* The key is always last and always descending, every column of it. It is
|
|
192
|
+
* not decoration — it is what makes the ordering *total*, and a cursor over
|
|
193
|
+
* a non-total order repeats and skips rows among the ties. Every keyset
|
|
194
|
+
* comparison built by {@link buildCursorConditions} ends on the same key
|
|
195
|
+
* `DESC`, and the two have to agree: they did not, and an ascending sort
|
|
196
|
+
* paged with `id >` against an `ORDER BY … , id DESC`, so rows sharing a
|
|
197
|
+
* sort value were dropped from every page after the first. Nor is the
|
|
198
|
+
* first column of a composite key enough: it ties every row that shares
|
|
199
|
+
* it, which is exactly the rows a cursor then skipped.
|
|
193
200
|
*
|
|
194
201
|
* Where the NULLs go is written out rather than inherited. Postgres already
|
|
195
202
|
* defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
|
|
@@ -202,22 +209,6 @@ export declare class FetchService {
|
|
|
202
209
|
*/
|
|
203
210
|
private buildOrderExpressions;
|
|
204
211
|
private resolveOrderByField;
|
|
205
|
-
/**
|
|
206
|
-
* Build the `with` config for Drizzle's relational query API.
|
|
207
|
-
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
208
|
-
*
|
|
209
|
-
* When `include` is provided, only those relations are loaded.
|
|
210
|
-
* When `include` is absent, ALL relations are loaded (the admin path).
|
|
211
|
-
*
|
|
212
|
-
* Automatically detects many-to-many junction tables and nests
|
|
213
|
-
* the target relation so actual row data is returned.
|
|
214
|
-
*/
|
|
215
|
-
private buildWithConfig;
|
|
216
|
-
/**
|
|
217
|
-
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
218
|
-
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
219
|
-
*/
|
|
220
|
-
private getJunctionTargetRelationName;
|
|
221
212
|
/**
|
|
222
213
|
* The relations one level of an include tree names, resolved.
|
|
223
214
|
*
|
|
@@ -303,22 +294,73 @@ export declare class FetchService {
|
|
|
303
294
|
*/
|
|
304
295
|
cursorFor(collectionPath: string, row: Record<string, unknown>, orderBy?: OrderByTuple[]): string | undefined;
|
|
305
296
|
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
297
|
+
* Every relation of one row, as the refs the admin renders, attached in
|
|
298
|
+
* place.
|
|
299
|
+
*
|
|
300
|
+
* Loaded by the batched loaders an `include` takes, so a target reached from
|
|
301
|
+
* this row is read as the caller may see it: its `beforeQuery` scope and its
|
|
302
|
+
* soft delete applied, its unreadable fields stripped. Populated through
|
|
303
|
+
* drizzle's relational `with`, the single get knew neither, and a
|
|
304
|
+
* tenant-scoped user fetching an owner was handed another tenant's docs —
|
|
305
|
+
* and the trashed ones — under a relation `?include=` answered correctly.
|
|
306
|
+
*
|
|
307
|
+
* A to-many relation that reaches nothing is `[]`; a to-one is left off. A
|
|
308
|
+
* relation that fails for a reason other than the database is logged and
|
|
309
|
+
* left off, as on the include path, rather than failing the whole row.
|
|
309
310
|
*/
|
|
310
|
-
private
|
|
311
|
+
private loadRelationRefs;
|
|
311
312
|
/**
|
|
312
313
|
* Extract cursor pagination conditions from startAfter options.
|
|
313
314
|
*
|
|
314
315
|
* "Every row that sorts after this one", written out as a comparison over
|
|
315
|
-
* the same keys the `ORDER BY` uses and ending on the same
|
|
316
|
-
* one key that is the familiar
|
|
317
|
-
* several it nests, each key's
|
|
316
|
+
* the same keys the `ORDER BY` uses and ending on the same primary key,
|
|
317
|
+
* descending. With one sort key that is the familiar
|
|
318
|
+
* `k > v OR (k = v AND id < cursorId)`; with several it nests, each key's
|
|
319
|
+
* tie handing the decision to the next.
|
|
318
320
|
*/
|
|
319
321
|
private buildCursorConditions;
|
|
322
|
+
/** The table's primary key columns, in key order. */
|
|
323
|
+
private static keyColumns;
|
|
324
|
+
/**
|
|
325
|
+
* The cursor row's key, one value per key column — or `undefined` for a
|
|
326
|
+
* cursor that names no row.
|
|
327
|
+
*
|
|
328
|
+
* A cursor carries the row's address, which for a composite key is every
|
|
329
|
+
* part joined (see {@link cursorFor}). One that carries fewer parts than
|
|
330
|
+
* the key has columns — the first column's value alone, say — cannot say
|
|
331
|
+
* which row it stopped at, and is refused rather than read as a guess.
|
|
332
|
+
*/
|
|
333
|
+
private static cursorKey;
|
|
334
|
+
/**
|
|
335
|
+
* "Sorts after the cursor row" on the key alone: the key descending, as the
|
|
336
|
+
* `ORDER BY` ends.
|
|
337
|
+
*
|
|
338
|
+
* Every column runs the same direction, so for a composite key the
|
|
339
|
+
* row-value comparison is exactly the lexicographic order the `ORDER BY`
|
|
340
|
+
* produces.
|
|
341
|
+
*/
|
|
342
|
+
private static keyAfter;
|
|
343
|
+
/**
|
|
344
|
+
* The cursor row's value for a timestamp key, to the microsecond.
|
|
345
|
+
*
|
|
346
|
+
* A cursor is built from the row as served, and a timestamp is served to
|
|
347
|
+
* the millisecond while Postgres stores it to the microsecond. Seeking past
|
|
348
|
+
* the served value compares the column against something up to 999µs short
|
|
349
|
+
* of the cursor row's own, so every row in that sliver lands on the wrong
|
|
350
|
+
* side of it: paging `createdAt desc` over rows written in one transaction
|
|
351
|
+
* stopped after the first page, and ascending repeated the cursor row
|
|
352
|
+
* forever.
|
|
353
|
+
*
|
|
354
|
+
* So the exact value is read back from the cursor row by its key — but only
|
|
355
|
+
* while that row still holds the millisecond the cursor carries. A row
|
|
356
|
+
* deleted or re-stamped since the page was served falls back to the stored
|
|
357
|
+
* value, which is where the listing was when the cursor was issued.
|
|
358
|
+
*
|
|
359
|
+
* Any other key, and a NULL, is returned as it came.
|
|
360
|
+
*/
|
|
361
|
+
private preciseCursorValue;
|
|
320
362
|
/**
|
|
321
|
-
* "Sorts strictly after the cursor row", over `keys` and then the
|
|
363
|
+
* "Sorts strictly after the cursor row", over `keys` and then the primary key.
|
|
322
364
|
*
|
|
323
365
|
* Built by recursion rather than as a row-value comparison — `(a, b) > (x, y)`
|
|
324
366
|
* would be shorter, but it is only correct when every key runs the same
|
|
@@ -426,6 +468,8 @@ export declare class FetchService {
|
|
|
426
468
|
fields?: string[];
|
|
427
469
|
/** `SELECT DISTINCT` over the projection. */
|
|
428
470
|
distinct?: boolean;
|
|
471
|
+
/** See `FetchCollectionProps.withDeleted`. */
|
|
472
|
+
withDeleted?: WithDeleted;
|
|
429
473
|
}): Promise<Record<string, unknown>[]>;
|
|
430
474
|
/**
|
|
431
475
|
* Search rows by text
|
|
@@ -493,9 +537,35 @@ export declare class FetchService {
|
|
|
493
537
|
logical?: LogicalCondition;
|
|
494
538
|
searchString?: string;
|
|
495
539
|
limit?: number;
|
|
540
|
+
/** Groups to skip. Applied only to a grouped aggregate, like `limit`. */
|
|
541
|
+
offset?: number;
|
|
542
|
+
/**
|
|
543
|
+
* Sort for the groups: by a `groupBy` field or an aggregate's alias.
|
|
544
|
+
* Applied only to a grouped aggregate; see {@link aggregateOrdering}.
|
|
545
|
+
*/
|
|
546
|
+
orderBy?: OrderByTuple[];
|
|
496
547
|
/** See `FetchCollectionProps.withDeleted`. */
|
|
497
548
|
withDeleted?: WithDeleted;
|
|
498
549
|
}): Promise<Record<string, unknown>[]>;
|
|
550
|
+
/**
|
|
551
|
+
* The `ORDER BY` of a grouped aggregate: the caller's keys, then every
|
|
552
|
+
* group key they did not name.
|
|
553
|
+
*
|
|
554
|
+
* A grouped aggregate is paged like a listing — `limit` bounds it, so more
|
|
555
|
+
* groups than one page holds are reached with `offset` — and a page is only
|
|
556
|
+
* a page over a total order. With no `ORDER BY` at all, Postgres returns the
|
|
557
|
+
* groups in whatever order its plan produces them, so "the next fifty" was
|
|
558
|
+
* not a thing that existed. The group keys identify a group, so ending on
|
|
559
|
+
* them makes the order total and puts every page boundary in the same place
|
|
560
|
+
* on every request.
|
|
561
|
+
*
|
|
562
|
+
* A key may name a `groupBy` field or an aggregate by its alias
|
|
563
|
+
* (`count`, `sum_total`), sorted on the aggregate's own expression — a
|
|
564
|
+
* drizzle selection is not aliased in the SQL, so there is no output name
|
|
565
|
+
* to refer to. Anything else is a 400: Postgres would refuse it too, but as
|
|
566
|
+
* a `GROUP BY` error from inside the statement.
|
|
567
|
+
*/
|
|
568
|
+
private aggregateOrdering;
|
|
499
569
|
/**
|
|
500
570
|
* Check if a field value is unique.
|
|
501
571
|
*
|
|
@@ -1,9 +1,37 @@
|
|
|
1
|
+
import { Properties } from "@rebasepro/types";
|
|
1
2
|
import { RelationService } from "./RelationService.js";
|
|
2
|
-
import type
|
|
3
|
+
import { type ReadCallContextProvider } from "./read-scope.js";
|
|
3
4
|
import { RelationWriteService } from "./RelationWriteService.js";
|
|
4
5
|
import { FetchService } from "./FetchService.js";
|
|
5
6
|
import { DrizzleClient } from "../interfaces.js";
|
|
6
7
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry.js";
|
|
8
|
+
/**
|
|
9
|
+
* The top-level properties whose value is stamped once, when the row is
|
|
10
|
+
* created: `on_create` dates and `user_on_create` identities.
|
|
11
|
+
*
|
|
12
|
+
* What they hold is a fact about the row's creation, so a write to a row that
|
|
13
|
+
* already exists has nothing to say about them — an update, and the
|
|
14
|
+
* conflict-update of an upsert that met a stored row. Top level only: a stamp
|
|
15
|
+
* nested inside a `map` is part of that map's value, not a column a write can
|
|
16
|
+
* leave out.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createStampKeys(properties: Properties | undefined): string[];
|
|
19
|
+
/** How {@link PersistService.save} writes a row that may already be stored. */
|
|
20
|
+
export interface PersistSaveOptions {
|
|
21
|
+
/** INSERT ... ON CONFLICT DO UPDATE. See `SaveProps.upsert`. */
|
|
22
|
+
upsert?: boolean;
|
|
23
|
+
/** The conflict target, instead of the primary key. See `SaveProps.onConflict`. */
|
|
24
|
+
onConflict?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* Keys of `values` that belong to the INSERT alone: what the create
|
|
27
|
+
* pipeline filled in rather than what the caller wrote — a declared
|
|
28
|
+
* `defaultValue`, a tenant stamp. When an upsert's INSERT meets a stored
|
|
29
|
+
* row, those columns keep the value they have. The create-time stamps
|
|
30
|
+
* (`on_create`, `user_on_create`) are left out of the conflict-update
|
|
31
|
+
* without being named here.
|
|
32
|
+
*/
|
|
33
|
+
insertOnlyKeys?: readonly string[];
|
|
34
|
+
}
|
|
7
35
|
/**
|
|
8
36
|
* Service for handling all row write operations.
|
|
9
37
|
* Handles saving, deleting, and updating rows.
|
|
@@ -82,6 +110,10 @@ export declare class PersistService {
|
|
|
82
110
|
* the row already exists — which is what a re-runnable import needs. The
|
|
83
111
|
* conflict is matched on the primary key unless `options.onConflict` names
|
|
84
112
|
* other columns; see {@link SaveProps.onConflict} for why that matters.
|
|
113
|
+
* When the INSERT meets a stored row, the conflict-update sets what the
|
|
114
|
+
* caller wrote and nothing the create alone decides — see
|
|
115
|
+
* {@link PersistSaveOptions.insertOnlyKeys} — and only on a row the caller's
|
|
116
|
+
* `beforeQuery` scope reaches.
|
|
85
117
|
*
|
|
86
118
|
* `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
|
|
87
119
|
* split out here rather than at the REST boundary because every request
|
|
@@ -89,10 +121,14 @@ export declare class PersistService {
|
|
|
89
121
|
* one method, and a rule applied at one door is a rule the other doors do
|
|
90
122
|
* not have. What they compile to is `field-op-sql.ts`.
|
|
91
123
|
*/
|
|
92
|
-
save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: PersistSaveOptions): Promise<Record<string, unknown>>;
|
|
125
|
+
/**
|
|
126
|
+
* The caller's `beforeQuery` scope over this table, as one condition: what
|
|
127
|
+
* a stored row must satisfy for a write that meets it to change it. The
|
|
128
|
+
* same scope the update gate and the single get apply; `undefined` when the
|
|
129
|
+
* collection declares no hook.
|
|
130
|
+
*/
|
|
131
|
+
private writeScope;
|
|
96
132
|
/**
|
|
97
133
|
* Get the RelationService instance for external use
|
|
98
134
|
*/
|
|
@@ -16,6 +16,24 @@ import { type ReadCallContextProvider } from "./read-scope.js";
|
|
|
16
16
|
* the same claim about the same drizzle behaviour in a second spelling.
|
|
17
17
|
*/
|
|
18
18
|
export declare function applyDynamicJoin<T>(query: T, joinTable: PgTable, condition: SQL): T;
|
|
19
|
+
/**
|
|
20
|
+
* The ON condition of one `joinPath` step: every `from` column equal to its
|
|
21
|
+
* `to` column, or `undefined` naming the first column either table lacks.
|
|
22
|
+
*
|
|
23
|
+
* `on.from`/`on.to` are a column or a tuple, and a tuple is how a step joins on
|
|
24
|
+
* a composite key. Comparing the first pair alone joined each row to every row
|
|
25
|
+
* sharing that column — every locale of a translation, not the one addressed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function joinStepCondition(currentTable: PgTable, joinTable: PgTable, step: {
|
|
28
|
+
on: {
|
|
29
|
+
from: string | string[];
|
|
30
|
+
to: string | string[];
|
|
31
|
+
};
|
|
32
|
+
}): {
|
|
33
|
+
condition: SQL;
|
|
34
|
+
} | {
|
|
35
|
+
missing: string;
|
|
36
|
+
};
|
|
19
37
|
/**
|
|
20
38
|
* Service for handling all relation-related operations.
|
|
21
39
|
* Handles fetching, updating, and managing row relations.
|
|
@@ -75,6 +93,17 @@ export declare class RelationService {
|
|
|
75
93
|
* dynamic relation builder all apply it or none of them do.
|
|
76
94
|
*/
|
|
77
95
|
private narrowTargetRead;
|
|
96
|
+
/**
|
|
97
|
+
* The target rows a caller may see through `relation`: the target's
|
|
98
|
+
* `beforeQuery` scope and its soft delete, as one condition on the target
|
|
99
|
+
* table, or `undefined` when neither applies.
|
|
100
|
+
*
|
|
101
|
+
* What every loader here narrows a read by, exposed for the membership
|
|
102
|
+
* writes. A write that diffs a new link set against "what is linked now"
|
|
103
|
+
* has to diff against what the caller could have read — otherwise saving
|
|
104
|
+
* back the list they were shown unlinks every row they were not.
|
|
105
|
+
*/
|
|
106
|
+
visibleTargetCondition(parentCollection: CollectionConfig, relation: ResolvedRelation, parentId?: string | number): Promise<SQL | undefined>;
|
|
78
107
|
/**
|
|
79
108
|
* One target row, as the {@link RelatedRow} everything here returns.
|
|
80
109
|
*
|
|
@@ -64,6 +64,12 @@ export declare class RelationWriteService {
|
|
|
64
64
|
* lost update the membership diff exists to avoid.
|
|
65
65
|
*/
|
|
66
66
|
updateRelationPivot(tx: DrizzleClient, hop: NestedPathHop, targetId: string | number, pivot: Record<string, unknown>): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* The target rows of `relation` this caller can see, for a junction diff —
|
|
69
|
+
* or `undefined` when that is all of them. See
|
|
70
|
+
* {@link RelationService.visibleTargetCondition}.
|
|
71
|
+
*/
|
|
72
|
+
private visibleTargets;
|
|
67
73
|
/** A collection's id, parsed to the type its primary key column holds. */
|
|
68
74
|
private parsedId;
|
|
69
75
|
/** The same, for the membership list a to-many write names. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PgNotifyListenerStatus } from "../pg-notify-listener.js";
|
|
1
2
|
/**
|
|
2
3
|
* A single database change captured by the CDC triggers and delivered over the
|
|
3
4
|
* `rebase_cdc` NOTIFY channel.
|
|
@@ -7,12 +8,13 @@ export interface CdcChangeEvent {
|
|
|
7
8
|
table: string;
|
|
8
9
|
op: "INSERT" | "UPDATE" | "DELETE";
|
|
9
10
|
/**
|
|
10
|
-
* The changed
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The changed row's identity, by column: its key, and for a junction table
|
|
12
|
+
* the two ids naming the child list (from NEW for insert/update, OLD for
|
|
13
|
+
* delete). Never the row's other values — any login can LISTEN; see
|
|
14
|
+
* `buildCdcFunctionSql`.
|
|
13
15
|
*/
|
|
14
16
|
row: Record<string, unknown>;
|
|
15
|
-
/** True when the
|
|
17
|
+
/** True when even the key was too large to notify, so `row` is empty. */
|
|
16
18
|
truncated?: boolean;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
@@ -31,7 +33,12 @@ export declare function parseCdcPayload(payload: string): CdcChangeEvent | null;
|
|
|
31
33
|
*/
|
|
32
34
|
export declare class CdcListener {
|
|
33
35
|
private readonly listener;
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* @param onReconnect Called when the connection is listening again after a
|
|
38
|
+
* drop. Every change committed in the gap was notified to nobody, so
|
|
39
|
+
* this is where subscribers are told to look again.
|
|
40
|
+
*/
|
|
41
|
+
constructor(connectionString: string, onEvent: (event: CdcChangeEvent) => void | Promise<void>, onReconnect?: () => void);
|
|
35
42
|
/**
|
|
36
43
|
* Connect and begin listening. Idempotent.
|
|
37
44
|
*
|
|
@@ -44,4 +51,7 @@ export declare class CdcListener {
|
|
|
44
51
|
start(): Promise<void>;
|
|
45
52
|
/** Stop listening and release the connection. */
|
|
46
53
|
stop(): Promise<void>;
|
|
54
|
+
/** Listening on a connection that answered its last heartbeat — see {@link PgNotifyListener}. */
|
|
55
|
+
get connected(): boolean;
|
|
56
|
+
status(): PgNotifyListenerStatus;
|
|
47
57
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import type { PostgresCollectionRegistry } from "../../collections/PostgresCollectionRegistry.js";
|
|
3
|
+
/** One key field of a collection, and the column it is stored in. */
|
|
4
|
+
export interface KeyColumn {
|
|
5
|
+
fieldName: string;
|
|
6
|
+
columnName: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The columns a collection's rows are addressed by.
|
|
10
|
+
*
|
|
11
|
+
* A change notification names its row by column — that is all a trigger sees —
|
|
12
|
+
* and an address is built from the key's *field* names. Both ends read the
|
|
13
|
+
* mapping from here: the provisioner, to attach the trigger with exactly these
|
|
14
|
+
* columns (and nothing else leaves the database), and the consumer, to turn the
|
|
15
|
+
* captured columns back into an address. A key declared as `userId` over
|
|
16
|
+
* `user_id` used to be looked up by field name on the captured row, never
|
|
17
|
+
* found, and every external write reached no single-row subscriber.
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectionKeyColumns(collection: CollectionConfig, registry: PostgresCollectionRegistry): KeyColumn[];
|
|
@@ -27,23 +27,49 @@ export declare const CDC_TRIGGER_FUNCTION = "rebase.rebase_cdc_notify";
|
|
|
27
27
|
export declare const CDC_TRIGGER_NAME = "rebase_cdc_trigger";
|
|
28
28
|
/**
|
|
29
29
|
* SQL that (re)creates the generic CDC trigger function. Safe to run repeatedly:
|
|
30
|
-
* `CREATE OR REPLACE` updates in place without dropping dependent triggers
|
|
30
|
+
* `CREATE OR REPLACE` updates in place without dropping dependent triggers —
|
|
31
|
+
* which is also how a database instrumented by an older version gets this body
|
|
32
|
+
* on its next boot, for every table at once, re-attached or not.
|
|
31
33
|
*
|
|
32
|
-
* The function emits `{ schema, table, op, row }
|
|
33
|
-
* tuple
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* The function emits `{ schema, table, op, row }`, and `row` is an **identity,
|
|
35
|
+
* never the tuple**. Postgres puts no privilege on `LISTEN`: any role that can
|
|
36
|
+
* connect can listen on this channel, whatever it may `SELECT`. When `row` was
|
|
37
|
+
* `to_jsonb(NEW)`, a login with no grant on anything received every changed row
|
|
38
|
+
* of every instrumented table — `password_hash` and verification tokens from
|
|
39
|
+
* the auth table included — past RLS and column grants. The consumer never
|
|
40
|
+
* read anything but the key: every subscriber re-reads the row under its own
|
|
41
|
+
* scope.
|
|
42
|
+
*
|
|
43
|
+
* The identity is, in order:
|
|
44
|
+
* - the columns the trigger was attached with (`TG_ARGV`) — the key the
|
|
45
|
+
* consumer addresses the row by, and for a junction table the two ids that
|
|
46
|
+
* name the child list it changed; see {@link buildCdcTriggerSql};
|
|
47
|
+
* - otherwise the table's primary key, read from the catalogue — a trigger
|
|
48
|
+
* attached with no columns, by an older boot or on a table no collection
|
|
49
|
+
* maps any more;
|
|
50
|
+
* - otherwise `id`, if the table has one, and else nothing: a change the
|
|
51
|
+
* consumer can only treat as collection-wide.
|
|
37
52
|
*/
|
|
38
53
|
export declare function buildCdcFunctionSql(): string;
|
|
39
54
|
/**
|
|
40
55
|
* SQL that (re)attaches the CDC trigger to a single table. `DROP ... IF EXISTS`
|
|
41
56
|
* before `CREATE` keeps it idempotent and picks up any function signature change.
|
|
57
|
+
*
|
|
58
|
+
* `identityColumns` are what the payload names the changed row by — see
|
|
59
|
+
* {@link buildCdcFunctionSql}. Passed as trigger arguments, so one shared
|
|
60
|
+
* function serves every table without a catalogue read per row. Empty means
|
|
61
|
+
* "the primary key".
|
|
42
62
|
*/
|
|
43
|
-
export declare function buildCdcTriggerSql(schema: string, table: string): string;
|
|
63
|
+
export declare function buildCdcTriggerSql(schema: string, table: string, identityColumns?: readonly string[]): string;
|
|
44
64
|
export interface CdcTableRef {
|
|
45
65
|
schema: string;
|
|
46
66
|
table: string;
|
|
67
|
+
/**
|
|
68
|
+
* The columns a change to this table is announced by — the collection's
|
|
69
|
+
* key columns, or a junction's two id columns. Absent means the primary
|
|
70
|
+
* key. Nothing else ever leaves the trigger; see {@link buildCdcFunctionSql}.
|
|
71
|
+
*/
|
|
72
|
+
identityColumns?: string[];
|
|
47
73
|
}
|
|
48
74
|
export interface ProvisionResult {
|
|
49
75
|
/** Tables the trigger was successfully attached to. */
|
|
@@ -53,6 +79,18 @@ export interface ProvisionResult {
|
|
|
53
79
|
reason: string;
|
|
54
80
|
}>;
|
|
55
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Replace an already-installed trigger function with the current body, and
|
|
84
|
+
* install nothing where there is none.
|
|
85
|
+
*
|
|
86
|
+
* For a process that owns the schema but is not provisioning capture on this
|
|
87
|
+
* boot — `REALTIME_CDC=off`, or no direct URL to listen on. The triggers an
|
|
88
|
+
* earlier boot attached keep firing whether anything consumes them or not, so
|
|
89
|
+
* a database instrumented by a version whose function put whole rows on the
|
|
90
|
+
* channel kept doing so, for every login that cares to `LISTEN`, until capture
|
|
91
|
+
* was switched back on. Returns whether a function was there to replace.
|
|
92
|
+
*/
|
|
93
|
+
export declare function refreshInstalledCdcFunction(run: RawSqlRunner): Promise<boolean>;
|
|
56
94
|
/**
|
|
57
95
|
* Idempotently install the CDC trigger function and per-table triggers.
|
|
58
96
|
*
|