@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
@@ -26,8 +26,18 @@
26
26
  * no-op.
27
27
  */
28
28
  import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
29
- import { getTableName } from "@rebasepro/common";
29
+ import { getTableName, relationalCollections } from "@rebasepro/common";
30
30
  import { logger } from "@rebasepro/server";
31
+ import {
32
+ assertSearchIsPostgresOnly,
33
+ buildSearchColumnSpec,
34
+ searchExtensionStatements,
35
+ searchHelperFunctions,
36
+ searchIndexStatements,
37
+ SEARCH_TEXT_FN,
38
+ SEARCH_UNACCENT_FN,
39
+ type SearchColumnSpec
40
+ } from "./search-column";
31
41
  import {
32
42
  getSqlColumnType,
33
43
  resolveColumnName,
@@ -36,6 +46,12 @@ import {
36
46
  planJunctionTables,
37
47
  quoteSqlLiteral
38
48
  } from "./generate-postgres-ddl-logic";
49
+ import {
50
+ AUTH_USERS_COLUMNS,
51
+ authUsersColumnDefinition,
52
+ authUsersColumnSql,
53
+ isAuthCollection
54
+ } from "./auth-users-columns";
39
55
 
40
56
  /**
41
57
  * The subset of a database handle this needs: run a statement, get rows back.
@@ -77,7 +93,8 @@ export interface ExistingSchema {
77
93
  }
78
94
 
79
95
  export interface EnsureAction {
80
- kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column";
96
+ kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
97
+ | "create-extension" | "create-function" | "create-index";
81
98
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
82
99
  target: string;
83
100
  sql: string;
@@ -167,9 +184,19 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
167
184
  * table may be the target of a relation), and nothing is emitted twice.
168
185
  */
169
186
  export function planCollectionSchemaEnsure(
170
- collections: CollectionConfig[],
187
+ allCollections: CollectionConfig[],
171
188
  existing: ExistingSchema
172
189
  ): EnsurePlan {
190
+ // Boot receives every collection the bundle declares, including the ones
191
+ // served by another engine entirely. Creating a Postgres table for a
192
+ // Firestore collection is not a harmless extra: the app keeps reading
193
+ // documents from Firestore while an empty table with the same name accretes
194
+ // policies and shows up in every drift report.
195
+ // Before the filter, deliberately: a `search` block on a collection this
196
+ // engine does not store would otherwise be dropped here without a word.
197
+ assertSearchIsPostgresOnly(allCollections);
198
+
199
+ const collections = relationalCollections(allCollections);
173
200
  const actions: EnsureAction[] = [];
174
201
  const plannedEnums = new Set<string>();
175
202
 
@@ -188,6 +215,34 @@ export function planCollectionSchemaEnsure(
188
215
  }
189
216
  }
190
217
 
218
+ // 1b. Search support, for collections that declared a `search` block.
219
+ //
220
+ // Before the tables, because a generated column's expression is
221
+ // resolved when the column is created: a table whose search column
222
+ // calls `rebase_search_text` cannot be added before that function
223
+ // exists. Both forms are idempotent, so a boot against a database that
224
+ // already has them plans nothing.
225
+ const searchSpecs = collections
226
+ .map(c => buildSearchColumnSpec(c))
227
+ .filter((spec): spec is SearchColumnSpec => spec !== undefined);
228
+
229
+ const plannedExtensions = new Set<string>();
230
+ for (const spec of searchSpecs) {
231
+ for (const statement of searchExtensionStatements(spec)) {
232
+ if (plannedExtensions.has(statement)) continue;
233
+ plannedExtensions.add(statement);
234
+ actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
235
+ }
236
+ }
237
+ const plannedFunctions = new Set<string>();
238
+ for (const spec of searchSpecs) {
239
+ for (const statement of searchHelperFunctions(spec)) {
240
+ if (plannedFunctions.has(statement)) continue;
241
+ plannedFunctions.add(statement);
242
+ actions.push({ kind: "create-function", target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN, sql: statement });
243
+ }
244
+ }
245
+
191
246
  // 2. Missing tables. Only the identity column is created here; every other
192
247
  // column is added by step 3, so a new table and an existing table that
193
248
  // gained a field travel the exact same code path. One way to build a
@@ -204,17 +259,22 @@ export function planCollectionSchemaEnsure(
204
259
  );
205
260
  const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
206
261
  const idProp = idEntry?.[1] as Property | undefined;
207
- let idDef: string;
208
- if (idProp?.type === "number") {
209
- idDef = `"${idName}" BIGSERIAL PRIMARY KEY`;
210
- } else if (
211
- idProp &&
212
- idProp.type === "string" &&
213
- (idProp as { isId?: unknown }).isId === "uuid"
214
- ) {
215
- idDef = `"${idName}" UUID PRIMARY KEY DEFAULT gen_random_uuid()`;
216
- } else {
217
- idDef = `"${idName}" TEXT PRIMARY KEY`;
262
+ // Derived through the generator's own type mapping, not re-decided here.
263
+ // This branch used to emit BIGSERIAL for a numeric id while `db push`
264
+ // emitted INTEGER GENERATED BY DEFAULT AS IDENTITY, so the same project
265
+ // got an int8 key when the runtime brought the schema up and an int4 key
266
+ // when a human pushed it. Two consequences, both real: node-postgres
267
+ // hands back int8 as a *string*, so a collection declaring
268
+ // `type: "number"` served `"1"` instead of `1` on the managed path only;
269
+ // and every foreign key and junction column pointing at it stayed
270
+ // INTEGER on both paths, which is a truncation waiting for the sequence
271
+ // to pass 2^31. The agreement test now pins this.
272
+ const idType = idProp
273
+ ? getSqlColumnType(idEntry![0], idProp, collection, collections)
274
+ : "TEXT";
275
+ let idDef = `"${idName}" ${idType} PRIMARY KEY`;
276
+ if (idProp?.type === "string" && (idProp as { isId?: unknown }).isId === "uuid") {
277
+ idDef += " DEFAULT gen_random_uuid()";
218
278
  }
219
279
  actions.push({
220
280
  kind: "create-table",
@@ -276,17 +336,14 @@ export function planCollectionSchemaEnsure(
276
336
  schema: string,
277
337
  table: string,
278
338
  column: string,
279
- type: string
339
+ definition: string
280
340
  ): void => {
281
341
  const present = existing.tables.get(key);
282
342
  if (present?.has(column)) return;
283
343
  actions.push({
284
344
  kind: "add-column",
285
345
  target: `${key}.${column}`,
286
- // Never NOT NULL: an existing table with rows cannot take a
287
- // non-null column without a default, and inventing one would be
288
- // guessing at the customer's data.
289
- sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
346
+ sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
290
347
  });
291
348
  };
292
349
 
@@ -294,6 +351,16 @@ export function planCollectionSchemaEnsure(
294
351
  const key = qualified(collection);
295
352
  const schema = schemaOf(collection);
296
353
  const table = getTableName(collection);
354
+ // A table this run is creating has no rows yet, so the constraints
355
+ // `db push` writes are free to apply. On a table that already exists
356
+ // they are not: `SET NOT NULL` is checked against live rows and a UNIQUE
357
+ // would fail on existing duplicates, and this module runs unattended
358
+ // against customer data with nobody reading a diff. So the constraints
359
+ // are emitted for the fresh case — which is the whole managed-runtime
360
+ // path, and the one that diverged from `db push` — and withheld for the
361
+ // adopted one. `rebase db push` remains how an existing table gets them.
362
+ const fresh = created.has(key);
363
+ const auth = isAuthCollection(collection);
297
364
  for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
298
365
  const p = prop as Property;
299
366
  if (isIdProperty(propName, p, collection)) continue;
@@ -302,7 +369,68 @@ export function planCollectionSchemaEnsure(
302
369
  // foreign key the same way `db push` does. Deriving them here as
303
370
  // plain columns is what once produced a column with no constraint.
304
371
  if (p.type === "reference" || p.type === "relation") continue;
305
- addColumn(key, schema, table, resolveColumnName(propName, p), getSqlColumnType(propName, p, collection, collections));
372
+
373
+ const column = resolveColumnName(propName, p);
374
+ // On an auth collection, the columns auth itself reads and writes
375
+ // have exactly one definition, wherever the table is created from —
376
+ // see `auth-users-columns`. Anything else on that collection is an
377
+ // ordinary user-declared field and is generated like any other.
378
+ const authDefinition = auth ? authUsersColumnDefinition(column) : undefined;
379
+ if (authDefinition) {
380
+ addColumn(key, schema, table, column, authDefinition);
381
+ continue;
382
+ }
383
+
384
+ // Assembled in the generator's order — type, UNIQUE, DEFAULT,
385
+ // NOT NULL — so the two produce byte-identical column definitions
386
+ // and the agreement test can compare them directly instead of
387
+ // checking that a column merely exists, which is how BIGSERIAL-vs-
388
+ // INTEGER and every missing constraint went unnoticed.
389
+ let definition = getSqlColumnType(propName, p, collection, collections);
390
+ if (fresh && p.validation?.unique) definition += " UNIQUE";
391
+ // Not gated on `fresh`: a default binds future writes only, so it is
392
+ // safe on a live table, and a column added without it would take the
393
+ // value the application forgot to send rather than `now()`.
394
+ const autoValue = (p as { autoValue?: string }).autoValue;
395
+ if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
396
+ definition += " DEFAULT now()";
397
+ }
398
+ if (fresh && p.validation?.required) definition += " NOT NULL";
399
+ addColumn(key, schema, table, column, definition);
400
+ }
401
+
402
+ // The auth columns the collection never mentions. The scaffold's users
403
+ // collection describes 12 of the 14 auth reads and writes, so planning
404
+ // only from properties left `is_anonymous` and `tokens_valid_after` to
405
+ // `ensureAuthTablesExist` — which does create them, but only because
406
+ // that function happens to run later in the same boot. Planning them
407
+ // here makes this path self-contained and identical to `db push`, so
408
+ // neither depends on the other having run.
409
+ if (auth) {
410
+ const declared = new Set(
411
+ Object.entries(collection.properties ?? {})
412
+ .map(([name, prop]) => resolveColumnName(name, prop as Property))
413
+ );
414
+ for (const spec of AUTH_USERS_COLUMNS) {
415
+ if (declared.has(spec.column)) continue;
416
+ addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
417
+ }
418
+ }
419
+ }
420
+
421
+ // 3aa. The generated search columns.
422
+ //
423
+ // Adding a STORED generated column rewrites the table, which on a large
424
+ // one is not free — but it is the same additive shape as every other
425
+ // column here, and the alternative (leaving it out until someone runs a
426
+ // migration) is a declared `search` block that silently does nothing.
427
+ for (const spec of searchSpecs) {
428
+ const key = `${spec.schema}.${spec.table}`;
429
+ addColumn(key, spec.schema, spec.table, spec.column,
430
+ `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`);
431
+ if (spec.fuzzy) {
432
+ addColumn(key, spec.schema, spec.table, spec.fuzzy.column,
433
+ `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`);
306
434
  }
307
435
  }
308
436
 
@@ -353,6 +481,23 @@ export function planCollectionSchemaEnsure(
353
481
  });
354
482
  }
355
483
 
484
+ // 5. Search indexes, after everything — the column has to exist, and this is
485
+ // the one step that runs against a populated table for real work.
486
+ //
487
+ // CONCURRENTLY: a plain CREATE INDEX takes a lock that blocks writes for
488
+ // the duration of the build, which on a live table is an outage. Each
489
+ // statement here is issued on its own, outside any transaction, which is
490
+ // the condition CONCURRENTLY requires.
491
+ for (const spec of searchSpecs) {
492
+ for (const statement of searchIndexStatements(spec)) {
493
+ actions.push({
494
+ kind: "create-index",
495
+ target: `${spec.schema}.${spec.table}`,
496
+ sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
497
+ });
498
+ }
499
+ }
500
+
356
501
  return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
357
502
  }
358
503
 
@@ -1,6 +1,7 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
3
+ import { buildSearchColumnSpec } from "./search-column";
4
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
4
5
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
5
6
  import { logger } from "@rebasepro/server";
6
7
  // --- Helper Functions ---
@@ -451,7 +452,13 @@ const computeSharedRelationName = (
451
452
  };
452
453
 
453
454
  // --- Main Schema Generation Logic ---
454
- export const generateSchema = async (collections: CollectionConfig[], stripPolicies = false): Promise<string> => {
455
+ export const generateSchema = async (allCollections: CollectionConfig[], stripPolicies = false): Promise<string> => {
456
+ // A Firestore or MongoDB collection has no table to generate, and generating
457
+ // one for it is not merely wasted output: `db push` would create it, and
458
+ // `rebase doctor` would then report the store the collection actually reads
459
+ // from as drift. Non-SQL collections leave the toolchain here, once, rather
460
+ // than being filtered again in each stage below.
461
+ const collections = relationalCollections(allCollections);
455
462
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
456
463
 
457
464
 
@@ -473,11 +480,15 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
473
480
  )
474
481
  );
475
482
 
483
+ // drizzle ships no `tsvector` builder, so an opted-in search column reaches
484
+ // the schema the same way `bytea` does — through `customType`.
485
+ const hasSearch = collections.some(c => buildSearchColumnSpec(c) !== undefined);
486
+
476
487
  // Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
477
488
  const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
478
489
  if (hasUuid) pgCoreImports.push("uuid");
479
490
  if (hasVector) pgCoreImports.push("vector");
480
- if (hasBinary) pgCoreImports.push("customType");
491
+ if (hasBinary || hasSearch) pgCoreImports.push("customType");
481
492
 
482
493
  const uniqueSchemas = Array.from(new Set(
483
494
  collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
@@ -626,6 +637,25 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
626
637
 
627
638
  });
628
639
 
640
+ // The opt-in search column. Declared with its real generation
641
+ // expression rather than as a bare custom type: `schema.generated.ts`
642
+ // is what a developer running drizzle-kit themselves diffs against,
643
+ // and a column declared without its expression reads to drizzle-kit
644
+ // as one it should alter.
645
+ const searchSpec = buildSearchColumnSpec(collection);
646
+ if (searchSpec) {
647
+ columns.add(
648
+ ` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}")` +
649
+ `.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
650
+ );
651
+ if (searchSpec.fuzzy) {
652
+ columns.add(
653
+ ` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}")` +
654
+ `.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
655
+ );
656
+ }
657
+ }
658
+
629
659
  // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
630
660
  // We should generate a basic id column if one was completely omitted.
631
661
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));