@rebasepro/server-postgres 0.23.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-DGPL6G_C.js → BranchService-BRt78gfa.js} +35 -18
- package/dist/BranchService-BRt78gfa.js.map +1 -0
- package/dist/PostgresBackendDriver.d.ts +84 -12
- package/dist/auth/services.d.ts +72 -14
- 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-cli-24v4OlSp.js → backup-cli-DW5p9_zv.js} +2 -2
- package/dist/{backup-cli-24v4OlSp.js.map → backup-cli-DW5p9_zv.js.map} +1 -1
- package/dist/{backup-service-HQ9GC4tN.js → backup-service-EwcDVG-8.js} +7 -9
- package/dist/{backup-service-HQ9GC4tN.js.map → backup-service-EwcDVG-8.js.map} +1 -1
- package/dist/{cli-errors-B8qHg02P.js → cli-errors-DsA-K9uP.js} +96 -1
- package/dist/{cli-errors-B8qHg02P.js.map → cli-errors-DsA-K9uP.js.map} +1 -1
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +12 -12
- package/dist/cli.js +91 -37
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-DF-8dTVa.js → column-plan-helpers-1-LQD0yI.js} +34 -24
- package/dist/column-plan-helpers-1-LQD0yI.js.map +1 -0
- package/dist/data-transformer.d.ts +0 -8
- package/dist/{doctor-Cb2thZ8s.js → doctor-C-sYWbmt.js} +224 -43
- package/dist/doctor-C-sYWbmt.js.map +1 -0
- package/dist/{ensure-collection-policies-RHUcEp4v.js → ensure-collection-policies-B1ureSIV.js} +73 -18
- package/dist/ensure-collection-policies-B1ureSIV.js.map +1 -0
- package/dist/{ensure-collection-tables-BEEjn5cn.js → ensure-collection-tables-kkkHk8oo.js} +76 -20
- package/dist/ensure-collection-tables-kkkHk8oo.js.map +1 -0
- package/dist/{ensure-tables-Dhn9KM3B.js → ensure-tables-BmI_tRxc.js} +10 -3
- package/dist/ensure-tables-BmI_tRxc.js.map +1 -0
- package/dist/{generate-drizzle-schema-yzY_BLhr.js → generate-drizzle-schema-93M0lUxK.js} +2 -2
- package/dist/{generate-drizzle-schema-yzY_BLhr.js.map → generate-drizzle-schema-93M0lUxK.js.map} +1 -1
- package/dist/{generate-drizzle-schema-logic-BfzK7UQd.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 +3415 -917
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-WMuAfxvw.js → introspect-db-logic-kCETE8TY.js} +523 -349
- 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-C0fxM8dY.js → plan-schema-DU9exq6C.js} +139 -504
- package/dist/plan-schema-DU9exq6C.js.map +1 -0
- package/dist/{policy-drift-DljYdrpW.js → policy-drift-B-J2hhm0.js} +3 -3
- package/dist/policy-drift-B-J2hhm0.js.map +1 -0
- package/dist/{generate-postgres-ddl-logic-Bt2d2mRH.js → render-ddl-Ds2t_d9V.js} +11 -149
- package/dist/render-ddl-Ds2t_d9V.js.map +1 -0
- package/dist/{rls-bootstrap-sql-B8EclDyM.js → rls-bootstrap-sql-_KNnjanK.js} +263 -39
- 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/auth-schema.d.ts +170 -0
- package/dist/schema/classify-change.d.ts +29 -1
- package/dist/schema/column-plan-helpers.d.ts +33 -21
- package/dist/schema/destructive-sql.d.ts +20 -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 -3
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/plan/diff-plan.d.ts +1 -1
- package/dist/schema/plan/plan-schema.d.ts +16 -8
- package/dist/schema/plan/render-ddl.d.ts +6 -0
- package/dist/schema/plan/types.d.ts +37 -11
- 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 +5 -4
- package/dist/services/RelationService.d.ts +18 -0
- package/dist/services/cdc/CdcListener.d.ts +9 -4
- 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 +11 -1
- package/dist/services/pg-notify-listener.d.ts +88 -6
- package/dist/services/realtimeService.d.ts +159 -45
- package/dist/services/socket-liveness.d.ts +45 -0
- package/dist/services/soft-delete.d.ts +12 -0
- 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 +6 -3
- package/dist/utils/drizzle-conditions.d.ts +42 -2
- package/dist/utils/sql-redaction.d.ts +21 -0
- package/dist/websocket.d.ts +50 -18
- package/package.json +7 -7
- package/dist/BranchService-DGPL6G_C.js.map +0 -1
- package/dist/column-plan-helpers-DF-8dTVa.js.map +0 -1
- package/dist/doctor-Cb2thZ8s.js.map +0 -1
- package/dist/ensure-collection-policies-RHUcEp4v.js.map +0 -1
- package/dist/ensure-collection-tables-BEEjn5cn.js.map +0 -1
- package/dist/ensure-tables-Dhn9KM3B.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-BfzK7UQd.js.map +0 -1
- package/dist/generate-postgres-ddl-logic-Bt2d2mRH.js.map +0 -1
- package/dist/introspect-db-logic-WMuAfxvw.js.map +0 -1
- package/dist/plan-schema-C0fxM8dY.js.map +0 -1
- package/dist/policy-drift-DljYdrpW.js.map +0 -1
- package/dist/rls-bootstrap-sql-B8EclDyM.js.map +0 -1
- package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
|
@@ -200,6 +200,13 @@ export interface ColumnPlan {
|
|
|
200
200
|
column: string;
|
|
201
201
|
type: PgType;
|
|
202
202
|
nullable: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* This column alone is the table's primary key, declared inline on it.
|
|
205
|
+
*
|
|
206
|
+
* `false` for a column of a composite key: several inline `PRIMARY KEY`
|
|
207
|
+
* clauses would be several primary keys, so a composite key is one table
|
|
208
|
+
* constraint over {@link TablePlan.primaryKey} instead.
|
|
209
|
+
*/
|
|
203
210
|
primaryKey: boolean;
|
|
204
211
|
unique: boolean;
|
|
205
212
|
default?: ColumnDefault;
|
|
@@ -326,7 +333,13 @@ export interface TablePlan {
|
|
|
326
333
|
*/
|
|
327
334
|
declaringSlugs?: string[];
|
|
328
335
|
columns: ColumnPlan[];
|
|
329
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* The primary key's column names, in key order.
|
|
338
|
+
*
|
|
339
|
+
* One entry is a key declared inline on its column ({@link ColumnPlan.primaryKey}).
|
|
340
|
+
* Several — a junction's two endpoints, or a collection marking several
|
|
341
|
+
* properties `isId` — are rendered as one `PRIMARY KEY (a, b)` constraint.
|
|
342
|
+
*/
|
|
330
343
|
primaryKey: string[];
|
|
331
344
|
/** The declared `indexes:` block, with its frozen names. */
|
|
332
345
|
indexes: CollectionIndexSpec[];
|
|
@@ -355,23 +368,35 @@ export interface TablePlan {
|
|
|
355
368
|
* pair them; the rule that derives it is `sharedRelationName`, which both sides
|
|
356
369
|
* compute independently from the table that owns the column.
|
|
357
370
|
*/
|
|
358
|
-
export
|
|
371
|
+
export type RelationPlan = OneRelationPlan | ManyRelationPlan;
|
|
372
|
+
interface RelationPlanBase {
|
|
359
373
|
/** The Drizzle table variable this entry belongs to. */
|
|
360
374
|
tableVar: string;
|
|
361
375
|
/** The key in the relations object. */
|
|
362
376
|
key: string;
|
|
363
|
-
kind: "one" | "many";
|
|
364
377
|
targetVar: string;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
378
|
+
relationName: string;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* A `one()`. Every one names its join — Drizzle has no `one()` paired by
|
|
382
|
+
* `relationName` alone, and one paired by table is ambiguous as soon as two
|
|
383
|
+
* links join the same pair of tables.
|
|
384
|
+
*/
|
|
385
|
+
export interface OneRelationPlan extends RelationPlanBase {
|
|
386
|
+
kind: "one";
|
|
371
387
|
/** Property keys on this table. */
|
|
372
|
-
fields
|
|
388
|
+
fields: string[];
|
|
373
389
|
/** Property keys on the target table. */
|
|
374
|
-
references
|
|
390
|
+
references: string[];
|
|
391
|
+
/**
|
|
392
|
+
* Set on a `hasOne`, whose `fields` are this table's own key: the target
|
|
393
|
+
* row may not exist although every column in `fields` is NOT NULL, which
|
|
394
|
+
* is what Drizzle reads a `one()`'s nullability from.
|
|
395
|
+
*/
|
|
396
|
+
nullable?: true;
|
|
397
|
+
}
|
|
398
|
+
export interface ManyRelationPlan extends RelationPlanBase {
|
|
399
|
+
kind: "many";
|
|
375
400
|
}
|
|
376
401
|
export interface PlanOptions {
|
|
377
402
|
/**
|
|
@@ -413,3 +438,4 @@ export interface SchemaPlan {
|
|
|
413
438
|
collections: CollectionConfig[];
|
|
414
439
|
options: PlanOptions;
|
|
415
440
|
}
|
|
441
|
+
export {};
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
__createRequire(import.meta.url);
|
|
3
|
+
import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_MODE, DEFAULT_SEARCH_WEIGHT, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
4
|
+
import { getTableName } from "@rebasepro/common";
|
|
5
|
+
import { toPostgresIdentifier, toSnakeCase } from "@rebasepro/utils";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
//#region src/schema/search-column.ts
|
|
8
|
+
/**
|
|
9
|
+
* The one place a collection's `search` block becomes SQL.
|
|
10
|
+
*
|
|
11
|
+
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
12
|
+
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
13
|
+
* the boot-time schema ensure — and each of them has, at some point, described
|
|
14
|
+
* a column differently from the others. The `varchar(255)` note in
|
|
15
|
+
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
16
|
+
* capped column down one path and an uncapped one down the other, and nothing
|
|
17
|
+
* failed until a user hit the cap.
|
|
18
|
+
*
|
|
19
|
+
* So the search column is not implemented four times. It is computed once,
|
|
20
|
+
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
21
|
+
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
22
|
+
* this module is that the test has something to assert *about*.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the expressions look the way they do
|
|
25
|
+
*
|
|
26
|
+
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
27
|
+
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
28
|
+
*
|
|
29
|
+
* | expression | immutable |
|
|
30
|
+
* |-----------------------------------------|-----------|
|
|
31
|
+
* | `to_tsvector('spanish', col)` | yes |
|
|
32
|
+
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
33
|
+
* | `array_to_string(col, ' ')` | **no** |
|
|
34
|
+
* | `col::text` on `text[]` | **no** |
|
|
35
|
+
* | `to_jsonb(col)` | **no** |
|
|
36
|
+
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
37
|
+
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
38
|
+
* | `setweight(...) || setweight(...)` | yes |
|
|
39
|
+
*
|
|
40
|
+
* Three of the four things a real search column needs are therefore unavailable
|
|
41
|
+
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
42
|
+
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
43
|
+
* promise, and it is a true one for these three — array joining, JSON string
|
|
44
|
+
* extraction and accent folding are all deterministic for a given input; the
|
|
45
|
+
* built-ins are marked stable only because they must account for element types
|
|
46
|
+
* and dictionaries in general.
|
|
47
|
+
*
|
|
48
|
+
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
49
|
+
* a real option in an accented language: Postgres stems `auditoría` to
|
|
50
|
+
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
51
|
+
* typed without accents misses every row that carries them.
|
|
52
|
+
*/
|
|
53
|
+
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
54
|
+
var HELPER_SCHEMA = "public";
|
|
55
|
+
/**
|
|
56
|
+
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
57
|
+
* generation expression of every search column ever created, so renaming one
|
|
58
|
+
* orphans every table that already has a search column.
|
|
59
|
+
*/
|
|
60
|
+
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
61
|
+
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
62
|
+
/** Raised when a `search` block names something that cannot be searched. */
|
|
63
|
+
var SearchConfigError = class extends Error {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "SearchConfigError";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/** The `search` block of a collection, or undefined when it has none. */
|
|
70
|
+
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
71
|
+
/**
|
|
72
|
+
* Refuse a `search` block on a collection this engine does not store.
|
|
73
|
+
*
|
|
74
|
+
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
75
|
+
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
76
|
+
* a collection whose `engine` was changed after the block was written — because
|
|
77
|
+
* the alternative is the exact failure the block exists to prevent: a developer
|
|
78
|
+
* who declared what to index, saw no error, and got the substring fallback.
|
|
79
|
+
*
|
|
80
|
+
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
81
|
+
*/
|
|
82
|
+
var assertSearchIsPostgresOnly = (collections) => {
|
|
83
|
+
for (const collection of collections) {
|
|
84
|
+
if (isPostgresCollectionConfig(collection)) continue;
|
|
85
|
+
if (!collection.search) continue;
|
|
86
|
+
const engine = collection.engine ?? "non-postgres";
|
|
87
|
+
throw new SearchConfigError(`${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \`${engine}\`. Remove the block — it would otherwise look configured while \`.search()\` kept using the default substring match.`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
91
|
+
/**
|
|
92
|
+
* Classify a property for search purposes.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately narrower than the schema plan's `PgType`: search only cares
|
|
95
|
+
* whether a value reaches text, and the mapping from property to *physical*
|
|
96
|
+
* type is asserted against the plan in the contract test rather than duplicated
|
|
97
|
+
* here.
|
|
98
|
+
*
|
|
99
|
+
* Returns null for anything that is not text-bearing, which the caller turns
|
|
100
|
+
* into a boot error naming the property.
|
|
101
|
+
*/
|
|
102
|
+
var classify = (prop) => {
|
|
103
|
+
switch (prop.type) {
|
|
104
|
+
case "string": {
|
|
105
|
+
const sp = prop;
|
|
106
|
+
if (sp.enum) return {
|
|
107
|
+
kind: "text",
|
|
108
|
+
reason: "enum"
|
|
109
|
+
};
|
|
110
|
+
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
111
|
+
kind: "text",
|
|
112
|
+
reason: "uuid"
|
|
113
|
+
};
|
|
114
|
+
return { kind: "text" };
|
|
115
|
+
}
|
|
116
|
+
case "map":
|
|
117
|
+
if (prop.columnType === "json") return {
|
|
118
|
+
kind: "jsonb",
|
|
119
|
+
reason: "json"
|
|
120
|
+
};
|
|
121
|
+
return { kind: "jsonb" };
|
|
122
|
+
case "array": {
|
|
123
|
+
const ap = prop;
|
|
124
|
+
let colType = ap.columnType;
|
|
125
|
+
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
126
|
+
const of = ap.of;
|
|
127
|
+
if (of.type === "string") colType = "text[]";
|
|
128
|
+
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
129
|
+
else if (of.type === "boolean") colType = "boolean[]";
|
|
130
|
+
}
|
|
131
|
+
if (colType === "text[]") return { kind: "text_array" };
|
|
132
|
+
if (colType === "json") return {
|
|
133
|
+
kind: "jsonb",
|
|
134
|
+
reason: "json"
|
|
135
|
+
};
|
|
136
|
+
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
137
|
+
kind: "text_array",
|
|
138
|
+
reason: "non_text_array"
|
|
139
|
+
};
|
|
140
|
+
return { kind: "jsonb" };
|
|
141
|
+
}
|
|
142
|
+
default: return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
146
|
+
/** SQL reading one field as plain text, before normalization. */
|
|
147
|
+
var rawTextSql = (field) => {
|
|
148
|
+
const col = `"${field.column}"`;
|
|
149
|
+
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
150
|
+
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
151
|
+
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote(field.jsonPath[0])}` : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
152
|
+
};
|
|
153
|
+
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
154
|
+
/**
|
|
155
|
+
* Resolve and validate one declared field path.
|
|
156
|
+
*
|
|
157
|
+
* A path that does not resolve throws. The whole point of an explicit block is
|
|
158
|
+
* that the author knows what is indexed; a silently dropped field would make it
|
|
159
|
+
* a guess again, and the failure — a search that returns nothing for content
|
|
160
|
+
* that is plainly in the row — is invisible from the outside.
|
|
161
|
+
*/
|
|
162
|
+
var resolveField = (entry, collection, cfg) => {
|
|
163
|
+
const path = typeof entry === "string" ? entry : entry.path;
|
|
164
|
+
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? DEFAULT_SEARCH_WEIGHT;
|
|
165
|
+
const where = `${collection.slug}.search`;
|
|
166
|
+
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
167
|
+
const [head, ...rest] = path.split(".");
|
|
168
|
+
const prop = collection.properties?.[head];
|
|
169
|
+
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
170
|
+
const classified = classify(prop);
|
|
171
|
+
if (!classified) throw new SearchConfigError(`${where}: "${path}" is a \`${prop.type}\` property, which holds no text to search. Searchable kinds are \`string\`, \`string[]\` and \`map\` (or a path inside one).`);
|
|
172
|
+
if (classified.reason === "enum") throw new SearchConfigError(`${where}: "${path}" is an enum. Enums are a fixed vocabulary — filter on them with \`where\` instead, which is exact and uses an index.`);
|
|
173
|
+
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
174
|
+
if (classified.reason === "json") throw new SearchConfigError(`${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.`);
|
|
175
|
+
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
176
|
+
if (rest.length > 0 && classified.kind !== "jsonb") throw new SearchConfigError(`${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`);
|
|
177
|
+
const column = columnNameOf(head, prop);
|
|
178
|
+
const raw = rawTextSql({
|
|
179
|
+
column,
|
|
180
|
+
jsonPath: rest,
|
|
181
|
+
kind: classified.kind
|
|
182
|
+
});
|
|
183
|
+
const textSql = normalize(raw, cfg.unaccent === true);
|
|
184
|
+
const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;
|
|
185
|
+
return {
|
|
186
|
+
path,
|
|
187
|
+
column,
|
|
188
|
+
jsonPath: rest,
|
|
189
|
+
kind: classified.kind,
|
|
190
|
+
weight,
|
|
191
|
+
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
192
|
+
textSql,
|
|
193
|
+
foldedTextSql: normalize(raw, true)
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Build the full spec for a collection, or undefined when it has not opted in.
|
|
198
|
+
*
|
|
199
|
+
* Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
|
|
200
|
+
* at boot surface that as a startup failure — a search block that half-works is
|
|
201
|
+
* worse than one that refuses.
|
|
202
|
+
*/
|
|
203
|
+
var buildSearchColumnSpec = (collection) => {
|
|
204
|
+
const cfg = getSearchConfig(collection);
|
|
205
|
+
if (!cfg) return void 0;
|
|
206
|
+
if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
|
|
207
|
+
const table = getTableName(collection);
|
|
208
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
209
|
+
const column = cfg.column ?? DEFAULT_SEARCH_COLUMN;
|
|
210
|
+
if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
|
|
211
|
+
const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
|
|
212
|
+
const seen = /* @__PURE__ */ new Set();
|
|
213
|
+
for (const f of fields) {
|
|
214
|
+
if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
|
|
215
|
+
seen.add(f.path);
|
|
216
|
+
}
|
|
217
|
+
const mode = cfg.mode ?? DEFAULT_SEARCH_MODE;
|
|
218
|
+
const extensions = [];
|
|
219
|
+
if (cfg.unaccent || mode === "hybrid") extensions.push("unaccent");
|
|
220
|
+
if (cfg.fuzzy) extensions.push("pg_trgm");
|
|
221
|
+
const spec = {
|
|
222
|
+
schema,
|
|
223
|
+
table,
|
|
224
|
+
column,
|
|
225
|
+
language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,
|
|
226
|
+
unaccent: cfg.unaccent === true,
|
|
227
|
+
mode,
|
|
228
|
+
fields,
|
|
229
|
+
expression: fields.map((f) => f.sql).join(" || "),
|
|
230
|
+
indexName: toPostgresIdentifier(`${table}_${column}_gin`),
|
|
231
|
+
extensions
|
|
232
|
+
};
|
|
233
|
+
if (cfg.fuzzy) {
|
|
234
|
+
const fuzzyColumn = `${column}_text`;
|
|
235
|
+
if (collection.properties?.[fuzzyColumn]) throw new SearchConfigError(`${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`);
|
|
236
|
+
spec.fuzzy = {
|
|
237
|
+
column: fuzzyColumn,
|
|
238
|
+
expression: fields.map((f) => f.textSql).join(" || ' ' || "),
|
|
239
|
+
indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
|
|
240
|
+
threshold: cfg.fuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return spec;
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* The IMMUTABLE wrappers the generated expressions call.
|
|
247
|
+
*
|
|
248
|
+
* `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
|
|
249
|
+
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
250
|
+
* statement here is.
|
|
251
|
+
*
|
|
252
|
+
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
253
|
+
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
254
|
+
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
255
|
+
* each call site then absorbs.
|
|
256
|
+
*/
|
|
257
|
+
var searchHelperFunctions = (spec) => {
|
|
258
|
+
const statements = [`CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT array_to_string($1, ' ') $$;`, `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT coalesce(string_agg(v, ' '), '')\n FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`];
|
|
259
|
+
if (spec.unaccent || spec.mode === "hybrid") statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
|
|
260
|
+
return statements;
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
264
|
+
*
|
|
265
|
+
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
266
|
+
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
267
|
+
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
268
|
+
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
269
|
+
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
270
|
+
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
271
|
+
* with "text search dictionary does not exist". Observed, not theorised.
|
|
272
|
+
*/
|
|
273
|
+
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
274
|
+
/**
|
|
275
|
+
* Everything after the column name — the type and the generation expression.
|
|
276
|
+
*
|
|
277
|
+
* Split out because four emitters need it and only two of them have a place to
|
|
278
|
+
* put the name: `CREATE TABLE` and `ADD COLUMN` write `"col" <this>`, while the
|
|
279
|
+
* schema plan carries it as the column's SQL definition and the boot-time
|
|
280
|
+
* rebuild statement interpolates it on its own.
|
|
281
|
+
*/
|
|
282
|
+
var searchColumnTypeSql = (expression, kind) => `${kind} GENERATED ALWAYS AS (${expression}) STORED`;
|
|
283
|
+
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
284
|
+
var searchColumnDefinition = (spec) => `"${spec.column}" ${searchColumnTypeSql(spec.expression, "tsvector")}`;
|
|
285
|
+
/** The fuzzy column definition, when the spec asks for one. */
|
|
286
|
+
var fuzzyColumnDefinition = (spec) => spec.fuzzy ? `"${spec.fuzzy.column}" ${searchColumnTypeSql(spec.fuzzy.expression, "text")}` : void 0;
|
|
287
|
+
/**
|
|
288
|
+
* Index statements for the spec.
|
|
289
|
+
*
|
|
290
|
+
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
291
|
+
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
292
|
+
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
293
|
+
* statement against tables that are live and populated, and uses the
|
|
294
|
+
* concurrent form instead; see `ensureSearchColumns`.
|
|
295
|
+
*/
|
|
296
|
+
var searchIndexStatements = (spec) => {
|
|
297
|
+
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
298
|
+
if (spec.fuzzy) statements.push(`CREATE INDEX IF NOT EXISTS "${spec.fuzzy.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.fuzzy.column}" ${HELPER_SCHEMA}.gin_trgm_ops);`);
|
|
299
|
+
return statements;
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Marker on the comment of every generated search column this module creates.
|
|
303
|
+
*
|
|
304
|
+
* Versioned because the fingerprint below is only comparable against itself: a
|
|
305
|
+
* future change to how it is computed has to read as "not stamped by this
|
|
306
|
+
* version" rather than as drift on every existing column.
|
|
307
|
+
*/
|
|
308
|
+
var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
309
|
+
/**
|
|
310
|
+
* A stable fingerprint of one generated column's expression.
|
|
311
|
+
*
|
|
312
|
+
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
313
|
+
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
314
|
+
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
315
|
+
* according to `search_path`. Comparing that text to the text we generated
|
|
316
|
+
* would report drift on wording, and this comparison decides whether a boot
|
|
317
|
+
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
318
|
+
* code that writes the column, so equality means what it says.
|
|
319
|
+
*/
|
|
320
|
+
var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
321
|
+
/**
|
|
322
|
+
* The stamps for a spec's generated columns — one per column, never shared.
|
|
323
|
+
*
|
|
324
|
+
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
325
|
+
* nothing about the first, and a spec-wide fingerprint would report the
|
|
326
|
+
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
327
|
+
* is purely additive.
|
|
328
|
+
*/
|
|
329
|
+
var searchColumnStamps = (spec) => {
|
|
330
|
+
const stamp = (column, expression) => {
|
|
331
|
+
const fingerprint = searchExpressionFingerprint(expression);
|
|
332
|
+
return {
|
|
333
|
+
column,
|
|
334
|
+
expression,
|
|
335
|
+
fingerprint,
|
|
336
|
+
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
const stamps = [stamp(spec.column, spec.expression)];
|
|
340
|
+
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
341
|
+
return stamps;
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* The same drift check as the boot ensure, for the SQL file.
|
|
345
|
+
*
|
|
346
|
+
* Needed because {@link searchColumnStamps} would otherwise *launder* drift on
|
|
347
|
+
* the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that
|
|
348
|
+
* exists, so a re-generated `search.sql` would stamp a stale column with the
|
|
349
|
+
* new block's fingerprint and the next boot would find them in agreement.
|
|
350
|
+
* Guarding first means the file refuses instead — `rebase db push` is attended,
|
|
351
|
+
* and the operator reading the failure is the person who changed the block.
|
|
352
|
+
*/
|
|
353
|
+
var searchStampGuards = (spec) => searchColumnStamps(spec).map((stamp) => {
|
|
354
|
+
const relation = quote(`"${spec.schema}"."${spec.table}"`);
|
|
355
|
+
return `DO $rebase_search$
|
|
356
|
+
DECLARE recorded text;
|
|
357
|
+
BEGIN
|
|
358
|
+
SELECT col_description(a.attrelid, a.attnum) INTO recorded
|
|
359
|
+
FROM pg_attribute a
|
|
360
|
+
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;
|
|
361
|
+
IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN
|
|
362
|
+
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;
|
|
363
|
+
END IF;
|
|
364
|
+
END
|
|
365
|
+
$rebase_search$;`;
|
|
366
|
+
});
|
|
367
|
+
/**
|
|
368
|
+
* The index names the spec creates.
|
|
369
|
+
*
|
|
370
|
+
* Needed by name, not just by statement, so Atlas can be told to exclude them
|
|
371
|
+
* from its diff — see `searchExcludePatterns`.
|
|
372
|
+
*/
|
|
373
|
+
var searchIndexNames = (spec) => spec.fuzzy ? [spec.indexName, spec.fuzzy.indexName] : [spec.indexName];
|
|
374
|
+
/**
|
|
375
|
+
* The generated column names a collection's search block adds, if any.
|
|
376
|
+
*
|
|
377
|
+
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
378
|
+
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
379
|
+
* every searchable field on the row — and nothing outside the query planner has
|
|
380
|
+
* any use for them. Left in, every list response carries a second, larger copy
|
|
381
|
+
* of the row's text.
|
|
382
|
+
*/
|
|
383
|
+
var searchColumnNames = (collection) => {
|
|
384
|
+
let spec;
|
|
385
|
+
try {
|
|
386
|
+
spec = buildSearchColumnSpec(collection);
|
|
387
|
+
} catch {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
if (!spec) return [];
|
|
391
|
+
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* True for a column whose type only ever holds a search index.
|
|
395
|
+
*
|
|
396
|
+
* Independent of any collection config on purpose: an introspected database
|
|
397
|
+
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
398
|
+
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
399
|
+
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
400
|
+
* the *properties*; this keeps it out of the *rows*.
|
|
401
|
+
*/
|
|
402
|
+
var isSearchIndexColumn = (column) => {
|
|
403
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
404
|
+
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* A drizzle select projection over `table` with the search columns dropped.
|
|
408
|
+
*
|
|
409
|
+
* Returns undefined when nothing needs dropping, so the common case keeps using
|
|
410
|
+
* a plain `select()` and this stays invisible in the generated SQL.
|
|
411
|
+
*/
|
|
412
|
+
var visibleColumnProjection = (tableColumns, collection) => {
|
|
413
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
414
|
+
if (!tableColumns || excluded.length === 0) return void 0;
|
|
415
|
+
const projection = {};
|
|
416
|
+
for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
|
|
417
|
+
return projection;
|
|
418
|
+
};
|
|
419
|
+
/** The same exclusion as a drizzle `db.query` `columns` denylist. */
|
|
420
|
+
var hiddenColumnsOption = (tableColumns, collection) => {
|
|
421
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
422
|
+
if (excluded.length === 0) return void 0;
|
|
423
|
+
return Object.fromEntries(excluded.map((name) => [name, false]));
|
|
424
|
+
};
|
|
425
|
+
/**
|
|
426
|
+
* The columns to keep out of a response, by name.
|
|
427
|
+
*
|
|
428
|
+
* `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
|
|
429
|
+
* for anything that is not a real drizzle table — a stub in a test, a derived
|
|
430
|
+
* or nested path with no table behind it. Nothing to exclude is the right
|
|
431
|
+
* answer there, and it has to be an answer rather than a throw: this runs on
|
|
432
|
+
* the read path of every collection, opted in or not.
|
|
433
|
+
*/
|
|
434
|
+
var excludedColumnNames = (tableColumns, collection) => {
|
|
435
|
+
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
436
|
+
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
437
|
+
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
438
|
+
};
|
|
439
|
+
//#endregion
|
|
440
|
+
export { visibleColumnProjection as _, buildSearchColumnSpec as a, searchColumnDefinition as c, searchColumnTypeSql as d, searchExtensionStatements as f, searchStampGuards as g, searchIndexStatements as h, assertSearchIsPostgresOnly as i, searchColumnNames as l, searchIndexNames as m, SEARCH_TEXT_FN as n, fuzzyColumnDefinition as o, searchHelperFunctions as p, SEARCH_UNACCENT_FN as r, hiddenColumnsOption as s, SEARCH_STAMP_PREFIX as t, searchColumnStamps as u };
|
|
441
|
+
|
|
442
|
+
//# sourceMappingURL=search-column-BM-GV6vH.js.map
|
|
@@ -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
|
*
|