@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g249daa1

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.
Files changed (70) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
  3. package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
  4. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  5. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  6. package/dist/cli-helpers.d.ts +57 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
  10. package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
  11. package/dist/index.es.js +765 -223
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
  14. package/dist/policy-CPkCqVTz.js.map +1 -0
  15. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  16. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  17. package/dist/schema/auth-users-columns.d.ts +97 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +2 -2
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  21. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  22. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  23. package/dist/schema/search-column.d.ts +199 -0
  24. package/dist/security/rls-enforcement.d.ts +53 -2
  25. package/dist/services/FetchService.d.ts +25 -7
  26. package/dist/services/dataService.d.ts +3 -0
  27. package/dist/services/realtimeService.d.ts +27 -21
  28. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  29. package/dist/src-C_wvdMnl.js.map +1 -0
  30. package/dist/utils/drizzle-conditions.d.ts +71 -2
  31. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  32. package/dist/websocket-D0TBU3ia.js.map +1 -0
  33. package/package.json +9 -8
  34. package/src/PostgresBackendDriver.ts +165 -3
  35. package/src/PostgresBootstrapper.ts +41 -2
  36. package/src/auth/ensure-tables.ts +185 -86
  37. package/src/cli-helpers.ts +129 -10
  38. package/src/cli.ts +232 -30
  39. package/src/collections/validate-relations.ts +124 -17
  40. package/src/data-transformer.ts +31 -3
  41. package/src/history/ensure-history-table.ts +7 -0
  42. package/src/schema/auth-users-columns.ts +131 -0
  43. package/src/schema/doctor.ts +7 -5
  44. package/src/schema/ensure-collection-tables.ts +165 -20
  45. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  46. package/src/schema/generate-postgres-ddl-logic.ts +266 -15
  47. package/src/schema/generate-postgres-ddl.ts +25 -2
  48. package/src/schema/generated-schema-staleness.ts +169 -0
  49. package/src/schema/introspect-db-logic.ts +1 -1
  50. package/src/schema/non-sql-collections.test.ts +131 -0
  51. package/src/schema/rls-bootstrap-sql.ts +288 -0
  52. package/src/schema/search-column.ts +558 -0
  53. package/src/security/anonymous-grants.test.ts +4 -2
  54. package/src/security/rls-enforcement.ts +141 -3
  55. package/src/services/BranchService.ts +5 -0
  56. package/src/services/FetchService.ts +148 -108
  57. package/src/services/PersistService.ts +14 -1
  58. package/src/services/RelationService.ts +2 -1
  59. package/src/services/channel-history.ts +8 -0
  60. package/src/services/channel-presence.ts +6 -0
  61. package/src/services/dataService.ts +3 -0
  62. package/src/services/realtimeService.ts +46 -37
  63. package/src/utils/drizzle-conditions.ts +223 -2
  64. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  65. package/dist/policy-CeA1JcxP.js.map +0 -1
  66. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  67. package/dist/src-DlPBctw_.js.map +0 -1
  68. package/dist/src-DoU9yPqq.js.map +0 -1
  69. package/dist/websocket-B2LsrINK.js.map +0 -1
  70. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -0,0 +1,558 @@
1
+ /**
2
+ * The one place a collection's `search` block becomes SQL.
3
+ *
4
+ * Four things describe a Postgres table in this codebase — the DDL generator,
5
+ * the Drizzle schema generator, the runtime table builder for BaaS mode, and
6
+ * the boot-time schema ensure — and each of them has, at some point, described
7
+ * a column differently from the others. The `varchar(255)` note in
8
+ * `generate-postgres-ddl-logic` is one such scar: the same property produced a
9
+ * capped column down one path and an uncapped one down the other, and nothing
10
+ * failed until a user hit the cap.
11
+ *
12
+ * So the search column is not implemented four times. It is computed once,
13
+ * here, and every generator renders the same {@link SearchColumnSpec}. There is
14
+ * a test asserting exactly that (`search-column-contract.test.ts`); the point of
15
+ * this module is that the test has something to assert *about*.
16
+ *
17
+ * ## Why the expressions look the way they do
18
+ *
19
+ * A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
20
+ * Postgres is stricter here than intuition. Verified against PostgreSQL 18:
21
+ *
22
+ * | expression | immutable |
23
+ * |-----------------------------------------|-----------|
24
+ * | `to_tsvector('spanish', col)` | yes |
25
+ * | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
26
+ * | `array_to_string(col, ' ')` | **no** |
27
+ * | `col::text` on `text[]` | **no** |
28
+ * | `to_jsonb(col)` | **no** |
29
+ * | `unaccent(col)` | **no** — dictionary lookup is STABLE |
30
+ * | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
31
+ * | `setweight(...) || setweight(...)` | yes |
32
+ *
33
+ * Three of the four things a real search column needs are therefore unavailable
34
+ * directly, which is why {@link searchHelperFunctions} exists: each wraps a
35
+ * stable built-in in an SQL function declared IMMUTABLE. That declaration is a
36
+ * promise, and it is a true one for these three — array joining, JSON string
37
+ * extraction and accent folding are all deterministic for a given input; the
38
+ * built-ins are marked stable only because they must account for element types
39
+ * and dictionaries in general.
40
+ *
41
+ * The alternative was to skip `unaccent` and text arrays entirely. That is not
42
+ * a real option in an accented language: Postgres stems `auditoría` to
43
+ * `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
44
+ * typed without accents misses every row that carries them.
45
+ */
46
+ import {
47
+ CollectionConfig,
48
+ Property,
49
+ StringProperty,
50
+ ArrayProperty,
51
+ MapProperty,
52
+ SearchConfig,
53
+ SearchField,
54
+ SearchWeight,
55
+ isPostgresCollectionConfig,
56
+ DEFAULT_SEARCH_COLUMN,
57
+ DEFAULT_SEARCH_LANGUAGE,
58
+ DEFAULT_SEARCH_WEIGHT,
59
+ DEFAULT_FUZZY_THRESHOLD
60
+ } from "@rebasepro/types";
61
+ import { getTableName } from "@rebasepro/common";
62
+ import { toSnakeCase, toPostgresIdentifier } from "@rebasepro/utils";
63
+
64
+ /** Schema-qualified so a collection outside `public` still resolves them. */
65
+ const HELPER_SCHEMA = "public";
66
+
67
+ /**
68
+ * Names of the helper functions. Frozen: they are recorded in the stored
69
+ * generation expression of every search column ever created, so renaming one
70
+ * orphans every table that already has a search column.
71
+ */
72
+ export const SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
73
+ export const SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
74
+
75
+ /** How a declared path reaches text, which decides the SQL that extracts it. */
76
+ type FieldKind = "text" | "text_array" | "jsonb";
77
+
78
+ /** One resolved field: where it lives, how to read it, what it is worth. */
79
+ export interface ResolvedSearchField {
80
+ /** The path exactly as the author wrote it, for error messages. */
81
+ path: string;
82
+ /** The physical column the path starts at. */
83
+ column: string;
84
+ /** Dotted remainder addressed inside a JSONB column, if any. */
85
+ jsonPath: string[];
86
+ kind: FieldKind;
87
+ weight: SearchWeight;
88
+ /** The `setweight(to_tsvector(…), 'X')` term this field contributes. */
89
+ sql: string;
90
+ /** The plain-text term this field contributes, for the fuzzy column. */
91
+ textSql: string;
92
+ }
93
+
94
+ /** Everything the generators need to render one collection's search column. */
95
+ export interface SearchColumnSpec {
96
+ schema: string;
97
+ table: string;
98
+ /** The generated `tsvector` column. */
99
+ column: string;
100
+ language: string;
101
+ unaccent: boolean;
102
+ fields: ResolvedSearchField[];
103
+ /** Body of `GENERATED ALWAYS AS ( … ) STORED` for the tsvector column. */
104
+ expression: string;
105
+ indexName: string;
106
+ /** Extensions that must exist before the column can be created. */
107
+ extensions: string[];
108
+ fuzzy?: {
109
+ column: string;
110
+ expression: string;
111
+ indexName: string;
112
+ threshold: number;
113
+ };
114
+ }
115
+
116
+ /** Raised when a `search` block names something that cannot be searched. */
117
+ export class SearchConfigError extends Error {
118
+ constructor(message: string) {
119
+ super(message);
120
+ this.name = "SearchConfigError";
121
+ }
122
+ }
123
+
124
+ /** The `search` block of a collection, or undefined when it has none. */
125
+ export const getSearchConfig = (collection: CollectionConfig): SearchConfig | undefined =>
126
+ isPostgresCollectionConfig(collection) ? collection.search : undefined;
127
+
128
+ /**
129
+ * Refuse a `search` block on a collection this engine does not store.
130
+ *
131
+ * The type only permits one on a `PostgresCollectionConfig`, so TypeScript
132
+ * already stops the ordinary case. This catches the rest — a JS config, a cast,
133
+ * a collection whose `engine` was changed after the block was written — because
134
+ * the alternative is the exact failure the block exists to prevent: a developer
135
+ * who declared what to index, saw no error, and got the substring fallback.
136
+ *
137
+ * Called with *every* collection, before the Postgres ones are filtered out.
138
+ */
139
+ export const assertSearchIsPostgresOnly = (collections: CollectionConfig[]): void => {
140
+ for (const collection of collections) {
141
+ if (isPostgresCollectionConfig(collection)) continue;
142
+ if (!(collection as { search?: unknown }).search) continue;
143
+ const engine = (collection as { engine?: string }).engine ?? "non-postgres";
144
+ throw new SearchConfigError(
145
+ `${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \`${engine}\`. ` +
146
+ "Remove the block — it would otherwise look configured while `.search()` kept using the default substring match."
147
+ );
148
+ }
149
+ };
150
+
151
+ const columnNameOf = (propName: string, prop?: Property | null): string =>
152
+ prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
153
+
154
+ /**
155
+ * Classify a property for search purposes.
156
+ *
157
+ * Deliberately narrower than `getSqlColumnType`: search only cares whether a
158
+ * value reaches text, and the mapping from property to *physical* type is
159
+ * asserted against `getSqlColumnType` in the contract test rather than
160
+ * duplicated here.
161
+ *
162
+ * Returns null for anything that is not text-bearing, which the caller turns
163
+ * into a boot error naming the property.
164
+ */
165
+ const classify = (prop: Property): { kind: FieldKind; reason?: string } | null => {
166
+ switch (prop.type) {
167
+ case "string": {
168
+ const sp = prop as StringProperty;
169
+ if (sp.enum) {
170
+ return { kind: "text", reason: "enum" };
171
+ }
172
+ if (sp.isId === "uuid" || sp.columnType === "uuid") {
173
+ return { kind: "text", reason: "uuid" };
174
+ }
175
+ return { kind: "text" };
176
+ }
177
+ case "map": {
178
+ const mp = prop as MapProperty;
179
+ // A `json` column is not `jsonb`, and the cast between them is not
180
+ // immutable. Declaring `columnType: "json"` puts the value out of
181
+ // reach of a generated column.
182
+ if (mp.columnType === "json") return { kind: "jsonb", reason: "json" };
183
+ return { kind: "jsonb" };
184
+ }
185
+ case "array": {
186
+ const ap = prop as ArrayProperty;
187
+ let colType = ap.columnType;
188
+ if (!colType && ap.of && !Array.isArray(ap.of)) {
189
+ const of = ap.of as Property;
190
+ if (of.type === "string") colType = "text[]";
191
+ else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
192
+ else if (of.type === "boolean") colType = "boolean[]";
193
+ }
194
+ if (colType === "text[]") return { kind: "text_array" };
195
+ if (colType === "json") return { kind: "jsonb", reason: "json" };
196
+ if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") {
197
+ return { kind: "text_array", reason: "non_text_array" };
198
+ }
199
+ // Everything else lands in JSONB, which the JSON extractor handles.
200
+ return { kind: "jsonb" };
201
+ }
202
+ default:
203
+ return null;
204
+ }
205
+ };
206
+
207
+ const normalize = (inner: string, unaccent: boolean): string =>
208
+ unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
209
+
210
+ /** SQL reading one field as plain text, before normalization. */
211
+ const rawTextSql = (field: { column: string; jsonPath: string[]; kind: FieldKind }): string => {
212
+ const col = `"${field.column}"`;
213
+ if (field.kind === "text") return `coalesce(${col}, '')`;
214
+ if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
215
+ // JSONB, optionally addressed at a path inside the document.
216
+ const target = field.jsonPath.length === 0
217
+ ? col
218
+ : field.jsonPath.length === 1
219
+ ? `${col} -> ${quote(field.jsonPath[0])}`
220
+ : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`;
221
+ return `${SEARCH_TEXT_FN}(coalesce(${target}, '{}'::jsonb))`;
222
+ };
223
+
224
+ const quote = (v: string): string => `'${v.replace(/'/g, "''")}'`;
225
+
226
+ /**
227
+ * Resolve and validate one declared field path.
228
+ *
229
+ * A path that does not resolve throws. The whole point of an explicit block is
230
+ * that the author knows what is indexed; a silently dropped field would make it
231
+ * a guess again, and the failure — a search that returns nothing for content
232
+ * that is plainly in the row — is invisible from the outside.
233
+ */
234
+ const resolveField = (
235
+ entry: string | SearchField,
236
+ collection: CollectionConfig,
237
+ cfg: SearchConfig
238
+ ): ResolvedSearchField => {
239
+ const path = typeof entry === "string" ? entry : entry.path;
240
+ const weight = (typeof entry === "string" ? undefined : entry.weight) ?? DEFAULT_SEARCH_WEIGHT;
241
+ const where = `${collection.slug}.search`;
242
+
243
+ if (!path || typeof path !== "string") {
244
+ throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
245
+ }
246
+
247
+ const [head, ...rest] = path.split(".");
248
+ const prop = collection.properties?.[head] as Property | undefined;
249
+ if (!prop) {
250
+ const known = Object.keys(collection.properties ?? {}).join(", ");
251
+ throw new SearchConfigError(
252
+ `${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${known}.`
253
+ );
254
+ }
255
+
256
+ const classified = classify(prop);
257
+ if (!classified) {
258
+ throw new SearchConfigError(
259
+ `${where}: "${path}" is a \`${prop.type}\` property, which holds no text to search. ` +
260
+ `Searchable kinds are \`string\`, \`string[]\` and \`map\` (or a path inside one).`
261
+ );
262
+ }
263
+ if (classified.reason === "enum") {
264
+ throw new SearchConfigError(
265
+ `${where}: "${path}" is an enum. Enums are a fixed vocabulary — filter on them with \`where\` instead, which is exact and uses an index.`
266
+ );
267
+ }
268
+ if (classified.reason === "uuid") {
269
+ throw new SearchConfigError(
270
+ `${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`
271
+ );
272
+ }
273
+ if (classified.reason === "json") {
274
+ throw new SearchConfigError(
275
+ `${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.`
276
+ );
277
+ }
278
+ if (classified.reason === "non_text_array") {
279
+ throw new SearchConfigError(
280
+ `${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`
281
+ );
282
+ }
283
+
284
+ if (rest.length > 0 && classified.kind !== "jsonb") {
285
+ throw new SearchConfigError(
286
+ `${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`
287
+ );
288
+ }
289
+
290
+ const column = columnNameOf(head, prop);
291
+ const field = { column, jsonPath: rest, kind: classified.kind };
292
+ const textSql = normalize(rawTextSql(field), cfg.unaccent === true);
293
+ const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;
294
+
295
+ return {
296
+ path,
297
+ column,
298
+ jsonPath: rest,
299
+ kind: classified.kind,
300
+ weight,
301
+ sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
302
+ textSql
303
+ };
304
+ };
305
+
306
+ /**
307
+ * Build the full spec for a collection, or undefined when it has not opted in.
308
+ *
309
+ * Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
310
+ * at boot surface that as a startup failure — a search block that half-works is
311
+ * worse than one that refuses.
312
+ */
313
+ export const buildSearchColumnSpec = (collection: CollectionConfig): SearchColumnSpec | undefined => {
314
+ const cfg = getSearchConfig(collection);
315
+ if (!cfg) return undefined;
316
+
317
+ if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) {
318
+ throw new SearchConfigError(
319
+ `${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`
320
+ );
321
+ }
322
+
323
+ const table = getTableName(collection);
324
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
325
+ const column = cfg.column ?? DEFAULT_SEARCH_COLUMN;
326
+
327
+ if (collection.properties?.[column]) {
328
+ throw new SearchConfigError(
329
+ `${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`
330
+ );
331
+ }
332
+
333
+ const fields = cfg.fields.map(entry => resolveField(entry, collection, cfg));
334
+
335
+ const seen = new Set<string>();
336
+ for (const f of fields) {
337
+ if (seen.has(f.path)) {
338
+ throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
339
+ }
340
+ seen.add(f.path);
341
+ }
342
+
343
+ const extensions: string[] = [];
344
+ if (cfg.unaccent) extensions.push("unaccent");
345
+ if (cfg.fuzzy) extensions.push("pg_trgm");
346
+
347
+ const spec: SearchColumnSpec = {
348
+ schema,
349
+ table,
350
+ column,
351
+ language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,
352
+ unaccent: cfg.unaccent === true,
353
+ fields,
354
+ expression: fields.map(f => f.sql).join(" || "),
355
+ indexName: toPostgresIdentifier(`${table}_${column}_gin`),
356
+ extensions
357
+ };
358
+
359
+ if (cfg.fuzzy) {
360
+ const fuzzyColumn = `${column}_text`;
361
+ if (collection.properties?.[fuzzyColumn]) {
362
+ throw new SearchConfigError(
363
+ `${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`
364
+ );
365
+ }
366
+ spec.fuzzy = {
367
+ column: fuzzyColumn,
368
+ // Concatenated with spaces so a trigram never spans two fields.
369
+ expression: fields.map(f => f.textSql).join(" || ' ' || "),
370
+ indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
371
+ threshold: cfg.fuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD
372
+ };
373
+ }
374
+
375
+ return spec;
376
+ };
377
+
378
+ /**
379
+ * The IMMUTABLE wrappers the generated expressions call.
380
+ *
381
+ * `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
382
+ * than an error, and idempotent for the same reason every other boot-time DDL
383
+ * statement here is.
384
+ *
385
+ * The bodies are stable built-ins wrapped in an immutable promise — see the
386
+ * module comment for why that promise is sound. `STRICT` matters: it makes NULL
387
+ * in mean NULL out without executing the body, which is what the `coalesce` at
388
+ * each call site then absorbs.
389
+ */
390
+ export const searchHelperFunctions = (spec: SearchColumnSpec): string[] => {
391
+ const statements: string[] = [
392
+ // text[] → " "-joined text.
393
+ `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n` +
394
+ ` LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n` +
395
+ ` $$ SELECT array_to_string($1, ' ') $$;`,
396
+ // jsonb → every string value at or below the node, space-joined. Keys
397
+ // are not values: indexing them would make `certifications` itself a
398
+ // search term on every row that has the field at all.
399
+ `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n` +
400
+ ` LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n` +
401
+ ` $$ SELECT coalesce(string_agg(v, ' '), '')\n` +
402
+ ` FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`
403
+ ];
404
+ if (spec.unaccent) {
405
+ // The two-argument form with an explicit dictionary is the one that can
406
+ // honestly be called immutable: the single-argument form resolves the
407
+ // dictionary through the current search_path at call time.
408
+ statements.push(
409
+ `CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n` +
410
+ ` LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n` +
411
+ ` $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`
412
+ );
413
+ }
414
+ return statements;
415
+ };
416
+
417
+ /**
418
+ * `CREATE EXTENSION` statements the spec's expressions depend on.
419
+ *
420
+ * `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
421
+ * `CREATE EXTENSION` installs into the first schema on `search_path`, which
422
+ * defaults to `"$user", public` — and the scaffold's database role is named
423
+ * `rebase`, the same as the schema the generator creates one statement earlier.
424
+ * So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
425
+ * dictionary in `rebase`, and every reference to `public.unaccent` below fails
426
+ * with "text search dictionary does not exist". Observed, not theorised.
427
+ */
428
+ export const searchExtensionStatements = (spec: SearchColumnSpec): string[] =>
429
+ spec.extensions.map(e => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
430
+
431
+ /** The column definition as it appears inside `CREATE TABLE`. */
432
+ export const searchColumnDefinition = (spec: SearchColumnSpec): string =>
433
+ `"${spec.column}" tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`;
434
+
435
+ /** The fuzzy column definition, when the spec asks for one. */
436
+ export const fuzzyColumnDefinition = (spec: SearchColumnSpec): string | undefined =>
437
+ spec.fuzzy ? `"${spec.fuzzy.column}" text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED` : undefined;
438
+
439
+ /**
440
+ * Index statements for the spec.
441
+ *
442
+ * `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
443
+ * SQL file replayed as one unit — a migration, or `search.sql` — where a
444
+ * concurrent build is not allowed. The boot-time ensure path runs statement by
445
+ * statement against tables that are live and populated, and uses the
446
+ * concurrent form instead; see `ensureSearchColumns`.
447
+ */
448
+ export const searchIndexStatements = (spec: SearchColumnSpec): string[] => {
449
+ const statements = [
450
+ `CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`
451
+ ];
452
+ if (spec.fuzzy) {
453
+ statements.push(
454
+ // The operator class is resolved through `search_path` like any
455
+ // other object, so it is qualified for the same reason the
456
+ // extension is installed explicitly.
457
+ `CREATE INDEX IF NOT EXISTS "${spec.fuzzy.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.fuzzy.column}" ${HELPER_SCHEMA}.gin_trgm_ops);`
458
+ );
459
+ }
460
+ return statements;
461
+ };
462
+
463
+ /**
464
+ * The index names the spec creates.
465
+ *
466
+ * Needed by name, not just by statement, so Atlas can be told to exclude them
467
+ * from its diff — see `searchExcludePatterns`.
468
+ */
469
+ export const searchIndexNames = (spec: SearchColumnSpec): string[] =>
470
+ spec.fuzzy ? [spec.indexName, spec.fuzzy.indexName] : [spec.indexName];
471
+
472
+ // ── Keeping the generated columns out of responses ──────────────────────────
473
+
474
+ /**
475
+ * The generated column names a collection's search block adds, if any.
476
+ *
477
+ * These are physical columns on the table, so `SELECT *` returns them. They are
478
+ * an index in column form — a list of lexeme positions, or a concatenation of
479
+ * every searchable field on the row — and nothing outside the query planner has
480
+ * any use for them. Left in, every list response carries a second, larger copy
481
+ * of the row's text.
482
+ */
483
+ export const searchColumnNames = (collection: CollectionConfig): string[] => {
484
+ let spec: SearchColumnSpec | undefined;
485
+ try {
486
+ spec = buildSearchColumnSpec(collection);
487
+ } catch {
488
+ // A malformed block is reported at boot, loudly. A read is the wrong
489
+ // place to raise it a second time, and returning the row without the
490
+ // exclusion would be worse than returning it with.
491
+ return [];
492
+ }
493
+ if (!spec) return [];
494
+ return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
495
+ };
496
+
497
+ /**
498
+ * True for a column whose type only ever holds a search index.
499
+ *
500
+ * Independent of any collection config on purpose: an introspected database
501
+ * (BaaS mode) can carry a `tsvector` column this framework never created —
502
+ * Pagila's `film.fulltext` is the canonical one — and it should not be returned
503
+ * to callers either. `isDerivedIndexColumn` already keeps such a column out of
504
+ * the *properties*; this keeps it out of the *rows*.
505
+ */
506
+ export const isSearchIndexColumn = (column: { getSQLType?: () => string }): boolean => {
507
+ const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
508
+ return sqlType === "tsvector" || sqlType === "tsquery";
509
+ };
510
+
511
+ /**
512
+ * A drizzle select projection over `table` with the search columns dropped.
513
+ *
514
+ * Returns undefined when nothing needs dropping, so the common case keeps using
515
+ * a plain `select()` and this stays invisible in the generated SQL.
516
+ */
517
+ export const visibleColumnProjection = (
518
+ tableColumns: Record<string, { getSQLType?: () => string }> | undefined,
519
+ collection?: CollectionConfig
520
+ ): Record<string, unknown> | undefined => {
521
+ const excluded = excludedColumnNames(tableColumns, collection);
522
+ if (!tableColumns || excluded.length === 0) return undefined;
523
+ const projection: Record<string, unknown> = {};
524
+ for (const [name, column] of Object.entries(tableColumns)) {
525
+ if (!excluded.includes(name)) projection[name] = column;
526
+ }
527
+ return projection;
528
+ };
529
+
530
+ /** The same exclusion as a drizzle `db.query` `columns` denylist. */
531
+ export const hiddenColumnsOption = (
532
+ tableColumns: Record<string, { getSQLType?: () => string }> | undefined,
533
+ collection?: CollectionConfig
534
+ ): Record<string, false> | undefined => {
535
+ const excluded = excludedColumnNames(tableColumns, collection);
536
+ if (excluded.length === 0) return undefined;
537
+ return Object.fromEntries(excluded.map(name => [name, false as const]));
538
+ };
539
+
540
+ /**
541
+ * The columns to keep out of a response, by name.
542
+ *
543
+ * `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
544
+ * for anything that is not a real drizzle table — a stub in a test, a derived
545
+ * or nested path with no table behind it. Nothing to exclude is the right
546
+ * answer there, and it has to be an answer rather than a throw: this runs on
547
+ * the read path of every collection, opted in or not.
548
+ */
549
+ const excludedColumnNames = (
550
+ tableColumns: Record<string, { getSQLType?: () => string }> | undefined,
551
+ collection?: CollectionConfig
552
+ ): string[] => {
553
+ if (!tableColumns || typeof tableColumns !== "object") return [];
554
+ const byName = new Set(collection ? searchColumnNames(collection) : []);
555
+ return Object.keys(tableColumns).filter(
556
+ name => byName.has(name) || isSearchIndexColumn(tableColumns[name])
557
+ );
558
+ };
@@ -29,7 +29,9 @@ describe("warnOnAnonymousGrants", () => {
29
29
  it("reports both mistakes in the rule from the wild", () => {
30
30
  const warning = captureWarning(() => warnOnAnonymousGrants([collectionWith("talents", FROM_THE_WILD)]));
31
31
 
32
- expect(warning).toContain("auth.uid() IS NOT NULL");
32
+ // The rule is written in the pre-1.0 spelling and must still be
33
+ // detected; the advice is printed in the current one.
34
+ expect(warning).toContain("rebase.uid() IS NOT NULL");
33
35
  expect(warning).toContain("'anon' is a Supabase convention");
34
36
  expect(warning).toContain("policy.authenticated()");
35
37
  });
@@ -51,7 +53,7 @@ describe("warnOnAnonymousGrants", () => {
51
53
  it("catches the bare tautology, which carries no literal to notice", () => {
52
54
  const warning = captureWarning(() => warnOnAnonymousGrants([collectionWith("talents", "auth.uid() IS NOT NULL")]));
53
55
 
54
- expect(warning).toContain("auth.uid() IS NOT NULL");
56
+ expect(warning).toContain("rebase.uid() IS NOT NULL");
55
57
  expect(warning).not.toContain("Supabase convention");
56
58
  });
57
59