@rebasepro/server-postgres 0.10.1-canary.6f89f77 → 0.10.1-canary.7801eed

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 (64) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/schema-version.d.ts +106 -0
  3. package/dist/chunk-DSJWtz9O.js +40 -0
  4. package/dist/collections/validate-relations.d.ts +53 -0
  5. package/dist/data-transformer.d.ts +3 -3
  6. package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
  7. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.es.js +1853 -4900
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  12. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  13. package/dist/services/FetchService.d.ts +21 -8
  14. package/dist/services/PersistService.d.ts +12 -0
  15. package/dist/services/RelationService.d.ts +39 -8
  16. package/dist/services/cdc/CdcListener.d.ts +7 -14
  17. package/dist/services/cdc/junction-tables.d.ts +38 -0
  18. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  19. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  20. package/dist/services/channel-bus/index.d.ts +55 -0
  21. package/dist/services/channel-history.d.ts +11 -0
  22. package/dist/services/channel-presence.d.ts +66 -0
  23. package/dist/services/nested-path.d.ts +59 -0
  24. package/dist/services/pg-notify-listener.d.ts +47 -0
  25. package/dist/services/realtimeService.d.ts +133 -6
  26. package/dist/services/row-pipeline.d.ts +2 -2
  27. package/dist/src-3VmUJ8Xn.js +3994 -0
  28. package/dist/src-3VmUJ8Xn.js.map +1 -0
  29. package/dist/src-D5xBTl32.js +346 -0
  30. package/dist/src-D5xBTl32.js.map +1 -0
  31. package/dist/utils/drizzle-conditions.d.ts +71 -18
  32. package/package.json +8 -9
  33. package/src/PostgresBootstrapper.ts +87 -5
  34. package/src/auth/ensure-tables.ts +23 -0
  35. package/src/auth/schema-version.ts +260 -0
  36. package/src/cli-errors.ts +1 -1
  37. package/src/cli-helpers.ts +4 -3
  38. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  39. package/src/collections/buildRegistry.ts +7 -0
  40. package/src/collections/validate-relations.ts +280 -0
  41. package/src/data-transformer.ts +28 -38
  42. package/src/index.ts +4 -0
  43. package/src/schema/doctor.ts +14 -14
  44. package/src/schema/ensure-collection-tables.test.ts +156 -0
  45. package/src/schema/ensure-collection-tables.ts +297 -0
  46. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  47. package/src/schema/generate-postgres-ddl-logic.ts +31 -24
  48. package/src/schema/introspect-db-inference.ts +13 -13
  49. package/src/schema/introspect-db-logic.ts +25 -29
  50. package/src/services/FetchService.ts +116 -126
  51. package/src/services/PersistService.ts +126 -88
  52. package/src/services/RelationService.ts +157 -86
  53. package/src/services/cdc/CdcListener.ts +27 -91
  54. package/src/services/cdc/junction-tables.ts +91 -0
  55. package/src/services/channel-bus/ChannelBus.ts +44 -0
  56. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  57. package/src/services/channel-bus/index.ts +123 -0
  58. package/src/services/channel-history.ts +35 -0
  59. package/src/services/channel-presence.ts +148 -0
  60. package/src/services/nested-path.ts +145 -0
  61. package/src/services/pg-notify-listener.ts +137 -0
  62. package/src/services/realtimeService.ts +430 -11
  63. package/src/services/row-pipeline.ts +5 -6
  64. package/src/utils/drizzle-conditions.ts +268 -330
@@ -1,6 +1,6 @@
1
1
  import { SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { FilterValues, WhereFilterOp, Relation, LogicalCondition, FilterCondition } from "@rebasepro/types";
3
+ import { FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation } from "@rebasepro/types";
4
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
5
5
  /** Drizzle dynamic query builder — accepts innerJoin + where chaining */
6
6
  export interface DrizzleDynamicQuery {
@@ -18,6 +18,47 @@ export interface DrizzleDynamicQuery {
18
18
  * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
19
19
  */
20
20
  export declare class DrizzleConditionBuilder {
21
+ /**
22
+ * Express "reachable from this parent through this relation" as a plain
23
+ * `WHERE` condition on the target table.
24
+ *
25
+ * This is the primitive that lets a relation be a *filter* rather than an
26
+ * addressing scheme. A nested listing used to be served by its own query
27
+ * builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
28
+ * did not have and lost the options the root pipeline did have (offset,
29
+ * filter, orderBy, include). Reduced to a condition, the same listing runs
30
+ * through the ordinary collection query, so it inherits all of them and
31
+ * there is one read path instead of two.
32
+ *
33
+ * The shapes:
34
+ * - inverse FK → `target.<fk> = :parentId`, a column comparison.
35
+ * - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
36
+ * target's key, so the junction never multiplies rows the
37
+ * way an `INNER JOIN` would.
38
+ * - `joinPath` → the same `EXISTS`, with the path's steps joined inside
39
+ * it and the final step correlating to the outer row.
40
+ */
41
+ static buildRelationScopeCondition(relation: ResolvedRelation,
42
+ /**
43
+ * Lazy: only `via` and `belongsTo` need the parent's own table. A
44
+ * foreign key on the target and a junction are both expressible from
45
+ * the parent's *id* alone, and requiring the table for them would make
46
+ * a child listing fail on a parent whose table isn't registered.
47
+ */
48
+ parent: () => {
49
+ table: PgTable<any>;
50
+ idColumn: AnyPgColumn;
51
+ }, parentId: string | number, targetTable: PgTable<any>, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): SQL;
52
+ /**
53
+ * `EXISTS` for an explicit `joinPath`.
54
+ *
55
+ * The path is declared source → target. The subquery replays every step but
56
+ * the last from inside, and turns the last one into the correlation with the
57
+ * outer target row — so the target table is never named twice and needs no
58
+ * alias. Each intermediate table is aliased positionally, which keeps a path
59
+ * that revisits a table (a self-referencing many-to-many) unambiguous.
60
+ */
61
+ private static buildJoinPathScopeCondition;
21
62
  /**
22
63
  * Build filter conditions from FilterValues
23
64
  */
@@ -33,7 +74,21 @@ export declare class DrizzleConditionBuilder {
33
74
  /**
34
75
  * Build relation-based conditions for different relation types
35
76
  */
36
- static buildRelationConditions(relation: Relation, parentId: string | number | (string | number)[], targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): {
77
+ /**
78
+ * Joins and where-conditions that reach a relation's target rows.
79
+ *
80
+ * One case per kind. This used to be a chain of six `else if`s over
81
+ * `cardinality`/`direction`/`through`, ending in
82
+ * `findCorrespondingJunctionTable` — a search through the *target's* own
83
+ * relations to work out whether an "inverse many" was a one-to-many or the
84
+ * far side of a junction. That search is gone: the kind says which it is.
85
+ *
86
+ * The owning/inverse split for junctions is gone too. Both variants built
87
+ * the identical condition — `through` is always written from the declaring
88
+ * side's point of view — so the second was a distinction without a
89
+ * difference and one of the places the two could drift apart.
90
+ */
91
+ static buildRelationConditions(relation: ResolvedRelation, parentId: string | number | (string | number)[], targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): {
37
92
  joinConditions: {
38
93
  table: PgTable<any>;
39
94
  condition: SQL;
@@ -57,11 +112,13 @@ export declare class DrizzleConditionBuilder {
57
112
  */
58
113
  private static buildJunctionTableConditions;
59
114
  /**
60
- * Build conditions for inverse junction table (many-to-many) relations
61
- */
62
- private static buildInverseJunctionTableConditions;
63
- /**
64
- * Build conditions for simple relations (owning/inverse without join paths)
115
+ * The condition for a relation whose link is a single column.
116
+ *
117
+ * Two cases. It had five: two of them existed only to throw ("should not be
118
+ * called directly", "lacks proper configuration"), and one guessed a column
119
+ * name by appending `_id` to `inverseRelationName` when no foreign key had
120
+ * been resolved. All three were reachable only because the old type let a
121
+ * relation arrive here under-specified. It cannot now.
65
122
  */
66
123
  private static buildSimpleRelationCondition;
67
124
  /**
@@ -83,11 +140,15 @@ export declare class DrizzleConditionBuilder {
83
140
  /**
84
141
  * Build relation-based query with joins and conditions
85
142
  */
86
- static buildRelationQuery<T extends DrizzleDynamicQuery>(baseQuery: T, relation: Relation, parentId: string | number | (string | number)[], targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T;
143
+ static buildRelationQuery<T extends DrizzleDynamicQuery>(baseQuery: T, relation: ResolvedRelation, parentId: string | number | (string | number)[], targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T;
87
144
  /**
88
- * Build count query for relations with proper joins and conditions
145
+ * A count over a relation's target rows.
146
+ *
147
+ * The junction case counts `distinct` because the caller's query joins the
148
+ * junction; the owning/inverse pair that used to sit here built the same
149
+ * query twice.
89
150
  */
90
- static buildRelationCountQuery<T extends DrizzleDynamicQuery>(baseCountQuery: T, relation: Relation, parentId: string | number, targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T;
151
+ static buildRelationCountQuery<T extends DrizzleDynamicQuery>(baseCountQuery: T, relation: ResolvedRelation, parentId: string | number, targetTable: PgTable<any>, parentTable: PgTable<any>, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T;
91
152
  /**
92
153
  * Build join path conditions for count queries
93
154
  */
@@ -96,10 +157,6 @@ export declare class DrizzleConditionBuilder {
96
157
  * Build junction table conditions for count queries
97
158
  */
98
159
  private static buildJunctionCountQuery;
99
- /**
100
- * Build inverse junction table conditions for count queries
101
- */
102
- private static buildInverseJunctionCountQuery;
103
160
  /**
104
161
  * Helper method to extract table names from columns
105
162
  */
@@ -108,10 +165,6 @@ export declare class DrizzleConditionBuilder {
108
165
  * Helper method to extract column names from columns
109
166
  */
110
167
  static getColumnNamesFromColumns(columns: string | string[]): string[];
111
- /**
112
- * Find the corresponding junction table for an inverse many-to-many relation
113
- */
114
- private static findCorrespondingJunctionTable;
115
168
  /**
116
169
  * Build vector similarity search expressions for pgvector.
117
170
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.10.1-canary.6f89f77",
4
+ "version": "0.10.1-canary.7801eed",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,21 +47,20 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.21.0",
49
49
  "ws": "^8.21.0",
50
- "@rebasepro/codegen": "0.10.1-canary.6f89f77",
51
- "@rebasepro/server": "0.10.1-canary.6f89f77",
52
- "@rebasepro/common": "0.10.1-canary.6f89f77",
53
- "@rebasepro/utils": "0.10.1-canary.6f89f77",
54
- "@rebasepro/types": "0.10.1-canary.6f89f77"
50
+ "@rebasepro/codegen": "0.10.1-canary.7801eed",
51
+ "@rebasepro/common": "0.10.1-canary.7801eed",
52
+ "@rebasepro/server": "0.10.1-canary.7801eed",
53
+ "@rebasepro/types": "0.10.1-canary.7801eed",
54
+ "@rebasepro/utils": "0.10.1-canary.7801eed"
55
55
  },
56
56
  "devDependencies": {
57
- "@hono/node-server": "^2.0.9",
57
+ "@hono/node-server": "^2.0.11",
58
58
  "@jest/globals": "^30.4.1",
59
59
  "@types/jest": "^30.0.0",
60
60
  "@types/node": "^25.9.3",
61
61
  "@types/pg": "^8.20.0",
62
62
  "@types/ws": "^8.18.1",
63
- "@vitejs/plugin-react": "^6.0.2",
64
- "hono": "^4.12.25",
63
+ "hono": "^4.12.27",
65
64
  "jest": "^30.4.2",
66
65
  "ts-jest": "^29.4.11",
67
66
  "typescript": "^6.0.3",
@@ -28,6 +28,7 @@ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegi
28
28
  import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server";
29
29
  import { getTableName as getCollectionTableName } from "@rebasepro/common";
30
30
  import { ensureAuthTablesExist } from "./auth/ensure-tables";
31
+ import { probeAuthSchema, resolveAuthSchema } from "./auth/schema-version";
31
32
  import { AuthSchemaTables, PostgresAuthRepository, UserService } from "./auth/services";
32
33
  import { createAuthSchema } from "./schema/auth-schema";
33
34
  import { HistoryService } from "./history/HistoryService";
@@ -37,6 +38,9 @@ import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./s
37
38
  import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
38
39
  import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
39
40
  import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
41
+ import { collectJunctionLinks } from "./services/cdc/junction-tables";
42
+ import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
43
+ import { isChannelBusInstance } from "@rebasepro/types";
40
44
 
41
45
  export interface PostgresDriverConfig {
42
46
  connectionString?: string;
@@ -54,9 +58,13 @@ export interface PostgresDriverConfig {
54
58
  */
55
59
  introspectionSchema?: string;
56
60
  /**
57
- * Realtime options. Currently only channel retention, which is opt-in:
58
- * without rules here no channel keeps any history and broadcast stays
59
- * fire-and-forget. See {@link ChannelRetentionRule}.
61
+ * Realtime options, both opt-in:
62
+ *
63
+ * - `channels` — retention. Without rules no channel keeps any history and
64
+ * broadcast stays fire-and-forget. See {@link ChannelRetentionRule}.
65
+ * - `bus` — the cross-instance transport for channel broadcast and
66
+ * presence. Defaults to in-process only, which is correct for a single
67
+ * instance and wrong for two. See {@link ChannelBusConfig}.
60
68
  */
61
69
  realtime?: RealtimeChannelsConfig;
62
70
  }
@@ -227,8 +235,8 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
227
235
  ` The database server is not running or is not accepting\n` +
228
236
  ` connections. Common fixes:\n` +
229
237
  `\n` +
238
+ ` • docker compose up -d db (the service a Rebase scaffold ships)\n` +
230
239
  ` • brew services start postgresql@18\n` +
231
- ` • docker compose up -d postgres\n` +
232
240
  ` • Verify DATABASE_URL in your .env file\n` +
233
241
  `\n` +
234
242
  `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
@@ -337,6 +345,29 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
337
345
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
338
346
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
339
347
 
348
+ // ── Cross-instance channel bus ───────────────────────────────────
349
+ // Entity changes already span instances (CDC / LISTEN below).
350
+ // Channel broadcast and presence did not — they lived in per-process
351
+ // maps, so behind two replicas the clients of one were invisible to
352
+ // the other. Opt-in, and a no-op when left at "memory".
353
+ try {
354
+ const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
355
+ // A supplied instance is always installed; a named built-in only
356
+ // when it is not the in-process default, so the common case
357
+ // touches none of this machinery.
358
+ const wantsBus = isChannelBusInstance(busSetting) || busSetting.type !== "memory";
359
+ if (wantsBus) {
360
+ await realtimeService.configureChannelBus(
361
+ createChannelBus(busSetting, {
362
+ db: schemaAwareDb as unknown as NodePgDatabase<Record<string, unknown>>,
363
+ directUrl
364
+ })
365
+ );
366
+ }
367
+ } catch (err) {
368
+ logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
369
+ }
370
+
340
371
  // Database-level Change Data Capture. When active, realtime events are
341
372
  // emitted for EVERY committed write — including ones that bypass the
342
373
  // Rebase API (psql, another service's cron, raw SQL, the Studio SQL
@@ -388,6 +419,14 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
388
419
  table: getCollectionTableName(c)
389
420
  }))
390
421
  .filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
422
+ // Junction tables back no collection, so the list above misses
423
+ // them — and a link or unlink is a write nobody would hear
424
+ // about. Their rows are the contents of a child list, which is
425
+ // as much a change as a write to the rows themselves.
426
+ for (const link of collectJunctionLinks(registry)) {
427
+ cdcTables.push({ schema: link.schema,
428
+ table: link.table });
429
+ }
391
430
  // Provisioning throws only when the connection can't create the
392
431
  // trigger function (insufficient privilege); enableCdc throws when
393
432
  // the LISTEN connection can't be established. Either → fall back.
@@ -593,7 +632,10 @@ table: fullCheckName });
593
632
  return { userService,
594
633
  roleService: userService,
595
634
  emailService,
596
- authRepository };
635
+ authRepository,
636
+ // Bound to the same schema `ensureAuthTablesExist` just migrated, so the
637
+ // health endpoint reports on the tables auth actually reads.
638
+ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection)) };
597
639
  },
598
640
 
599
641
  async initializeHistory(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: HistoryService } | undefined> {
@@ -615,6 +657,46 @@ authRepository };
615
657
  return internals.realtimeService;
616
658
  },
617
659
 
660
+ /**
661
+ * Create any collection tables, columns and enum types the database is
662
+ * missing — additively, never destructively.
663
+ *
664
+ * This is what lets the managed runtime boot a project against a fresh
665
+ * database and actually serve it. Before this, only auth tables were
666
+ * ensured, so a managed tenant came up with working sign-in and a 500 on
667
+ * every data route.
668
+ *
669
+ * Runs through the drizzle handle's underlying session so it uses the
670
+ * same connection (and therefore the same privileges) the driver already
671
+ * proved it can bootstrap with.
672
+ */
673
+ async ensureCollectionSchema(
674
+ collections: unknown[],
675
+ driverResult: InitializedDriver,
676
+ log?: (message: string) => void
677
+ ): Promise<{ applied: number }> {
678
+ const internals = driverResult.internals as PostgresDriverInternals;
679
+ const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
680
+ // Runs through the drizzle handle the driver already bootstrapped
681
+ // with, so it uses exactly the connection and privileges that were
682
+ // proven to work. Every statement is DDL or a catalogue read with no
683
+ // bindable values (schema names are identifiers), and the module
684
+ // validates them before they reach a string.
685
+ const queryable = {
686
+ async query<T>(text: string): Promise<{ rows: T[] }> {
687
+ const result = await internals.db.execute(sql.raw(text));
688
+ const rows = (result as unknown as { rows?: T[] }).rows;
689
+ return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
690
+ }
691
+ };
692
+ const plan = await ensureCollectionTables(
693
+ queryable,
694
+ collections as Parameters<typeof ensureCollectionTables>[1],
695
+ log
696
+ );
697
+ return { applied: plan.actions.length };
698
+ },
699
+
618
700
  getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
619
701
  const internals = driverResult.internals as PostgresDriverInternals;
620
702
  return internals.driver.admin;
@@ -2,6 +2,12 @@ import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
4
  import type { CollectionConfig } from "@rebasepro/types";
5
+ import {
6
+ AuthSchemaVersionError,
7
+ assertAuthSchemaCompatible,
8
+ resolveAuthSchema,
9
+ stampAuthSchemaVersion
10
+ } from "./schema-version";
5
11
 
6
12
 
7
13
  /**
@@ -14,6 +20,13 @@ import type { CollectionConfig } from "@rebasepro/types";
14
20
  export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: CollectionConfig): Promise<void> {
15
21
  logger.info("🔍 Checking auth tables...");
16
22
 
23
+ // Before anything else, and deliberately outside the catch below: refuse to
24
+ // run against a database that a newer framework version has already
25
+ // migrated. Everything past this point is best-effort by design, which is
26
+ // exactly the wrong posture for an incompatibility that would otherwise
27
+ // surface as a fully booted server failing every login.
28
+ await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
29
+
17
30
  try {
18
31
  // Resolve dynamic user table name and ID type from the collection
19
32
  let usersTableName = '"rebase"."users"';
@@ -588,8 +601,18 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
588
601
  );
589
602
  }
590
603
 
604
+ // Stamped last, so a boot that died partway through the migrations above
605
+ // leaves the older stamp in place and the next boot runs them again.
606
+ await stampAuthSchemaVersion(db, authSchema);
607
+
591
608
  logger.info("✅ Auth tables ready");
592
609
  } catch (error) {
610
+ // The one failure that must not be survived. Continuing here is what
611
+ // produced a server that answered /health with 200 while every login
612
+ // returned 500 — the incompatibility is total, so crashing is the
613
+ // kinder outcome: an orchestrator will not route traffic to a pod that
614
+ // never came up.
615
+ if (error instanceof AuthSchemaVersionError) throw error;
593
616
  logger.error("❌ Failed to create auth tables", { error });
594
617
  logger.warn("⚠️ Continuing without creating auth tables.");
595
618
  }
@@ -0,0 +1,260 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import type { CollectionConfig } from "@rebasepro/types";
4
+
5
+ /**
6
+ * The auth schema version this runtime expects to find in the database.
7
+ *
8
+ * Bump this whenever a migration in `ensureAuthTablesExist` makes the schema
9
+ * unreadable by the runtime that came before it — that is, whenever a *previous*
10
+ * version's auth queries would break against the migrated shape. Additive
11
+ * changes (a new nullable column nobody older references) do not need a bump.
12
+ *
13
+ * History. Note that 1 is a label for an era, not a value any database holds:
14
+ * stamping did not exist then, so an era-1 database reads as unstamped
15
+ * (`null`), and 2 is the first version ever actually written. The numbering
16
+ * starts at 2 only because two schema eras already existed when it was
17
+ * introduced; it could just as well have started at 1. It is not worth
18
+ * renumbering now — deployed databases already carry 2, and lowering the
19
+ * constant would make them look newer than the runtime and refuse the boot.
20
+ *
21
+ * 1 — Device-session refresh tokens. A row *was* a session, identified by
22
+ * `unique_device_session UNIQUE (uid, user_agent, ip_address)`, and
23
+ * `createToken` upserted with `ON CONFLICT (uid, user_agent, ip_address)`.
24
+ * 2 — Session-scoped, rotation-safe refresh tokens: `session_id`, `revoked`,
25
+ * `rotated_at`, `session_started_at`, and `unique_device_session`
26
+ * dropped because two live tokens of one session share all three columns.
27
+ *
28
+ * The 1 → 2 migration is why this file exists. Dropping the constraint is
29
+ * one-way: a version-1 runtime deployed afterwards boots perfectly, logs
30
+ * `✅ Auth tables ready` (its `CREATE TABLE IF NOT EXISTS` never revisits the
31
+ * existing table, so it cannot re-add the constraint), answers `/health` with
32
+ * 200 — and then fails every single login and refresh with SQLSTATE 42P10,
33
+ * because its `ON CONFLICT` names a constraint that no longer exists. A silent
34
+ * total auth outage behind a green health check. The stamp below turns that
35
+ * into a boot refusal.
36
+ */
37
+ export const AUTH_SCHEMA_VERSION = 2;
38
+
39
+ /** Key under which the version is stored in the auth schema's meta table. */
40
+ const VERSION_KEY = "auth_schema_version";
41
+
42
+ /**
43
+ * Columns `refresh_tokens` must have for the current runtime's auth write path
44
+ * to work. Checked by the health probe so a database that drifted *below* this
45
+ * runtime is reported as unhealthy rather than discovered one failed login at a
46
+ * time. Kept in step with the migration in `ensureAuthTablesExist`.
47
+ */
48
+ const REQUIRED_REFRESH_TOKEN_COLUMNS = ["session_id", "revoked", "rotated_at", "session_started_at"];
49
+
50
+ /**
51
+ * A constraint whose *presence* means the database is still at version 1, in a
52
+ * shape this runtime's rotation logic cannot write to: it makes two live tokens
53
+ * of one rotating session collide.
54
+ */
55
+ const RETIRED_REFRESH_TOKEN_CONSTRAINT = "unique_device_session";
56
+
57
+ /**
58
+ * Thrown when the database was migrated by a runtime newer than this one.
59
+ *
60
+ * Distinct class rather than a bare `Error` because `ensureAuthTablesExist`
61
+ * wraps its migrations in a catch that deliberately swallows failures and
62
+ * continues — every other problem there is better survived than crashed on.
63
+ * This one is not, so the catch rethrows on this type specifically.
64
+ */
65
+ export class AuthSchemaVersionError extends Error {
66
+ readonly databaseVersion: number;
67
+ readonly runtimeVersion: number;
68
+
69
+ constructor(databaseVersion: number, runtimeVersion: number) {
70
+ super(
71
+ `Auth schema version mismatch: the database is at version ${databaseVersion}, ` +
72
+ `but this runtime understands version ${runtimeVersion}.\n\n` +
73
+ "A newer version of the framework has already migrated this database. Running this " +
74
+ "older runtime against it would boot cleanly and then fail every login and token " +
75
+ "refresh, because the auth schema it expects no longer exists.\n\n" +
76
+ "Refusing to start. Deploy a framework version at or above the one that migrated " +
77
+ "this database, or restore the database from a backup taken before the upgrade."
78
+ );
79
+ this.name = "AuthSchemaVersionError";
80
+ this.databaseVersion = databaseVersion;
81
+ this.runtimeVersion = runtimeVersion;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`
87
+ * derives it. Shared so the two cannot drift: a stamp written to one schema and
88
+ * read from another would read as "never stamped" forever.
89
+ */
90
+ export function resolveAuthSchema(collection?: CollectionConfig): string {
91
+ if (!collection) return "rebase";
92
+ const usersSchema = ("schema" in collection && typeof collection.schema === "string")
93
+ ? collection.schema
94
+ : "public";
95
+ return usersSchema === "public" ? "rebase" : usersSchema;
96
+ }
97
+
98
+ /**
99
+ * Read the stamped version, or `null` when the database has never been stamped.
100
+ *
101
+ * `null` is not an error and must not be treated as one: every database
102
+ * provisioned before this file existed is unstamped, and so is every fresh one.
103
+ * Uses `to_regclass` rather than selecting straight from the table so a missing
104
+ * schema or table is a `null` rather than a thrown 42P01.
105
+ */
106
+ export async function readAuthSchemaVersion(
107
+ db: NodePgDatabase,
108
+ authSchema: string
109
+ ): Promise<number | null> {
110
+ const qualified = `"${authSchema}"."schema_meta"`;
111
+ const exists = await db.execute(sql`SELECT to_regclass(${qualified}) IS NOT NULL AS present`);
112
+ if (!(exists.rows[0] as { present: boolean } | undefined)?.present) return null;
113
+
114
+ const result = await db.execute(sql`
115
+ SELECT value FROM ${sql.raw(qualified)} WHERE key = ${VERSION_KEY}
116
+ `);
117
+ const raw = (result.rows[0] as { value: string } | undefined)?.value;
118
+ if (raw === undefined) return null;
119
+
120
+ const parsed = Number.parseInt(raw, 10);
121
+ // A meta row we cannot parse is treated as unstamped rather than as version
122
+ // 0: refusing to boot over a garbled string would be a worse failure than
123
+ // the drift it is meant to catch.
124
+ return Number.isFinite(parsed) ? parsed : null;
125
+ }
126
+
127
+ /**
128
+ * Refuse to run against a database a newer runtime has already migrated.
129
+ *
130
+ * Deliberately one-directional. A database *older* than this runtime is the
131
+ * normal upgrade path — the migrations in `ensureAuthTablesExist` are about to
132
+ * bring it forward, so it is not an error. Only the reverse is unrecoverable.
133
+ */
134
+ export async function assertAuthSchemaCompatible(
135
+ db: NodePgDatabase,
136
+ authSchema: string
137
+ ): Promise<void> {
138
+ const databaseVersion = await readAuthSchemaVersion(db, authSchema);
139
+ if (databaseVersion !== null && databaseVersion > AUTH_SCHEMA_VERSION) {
140
+ throw new AuthSchemaVersionError(databaseVersion, AUTH_SCHEMA_VERSION);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Record that this runtime's migrations have been applied.
146
+ *
147
+ * Called at the end of `ensureAuthTablesExist`, so a boot that failed partway
148
+ * through leaves the older stamp in place and the next boot migrates again.
149
+ */
150
+ export async function stampAuthSchemaVersion(
151
+ db: NodePgDatabase,
152
+ authSchema: string
153
+ ): Promise<void> {
154
+ const qualified = `"${authSchema}"."schema_meta"`;
155
+ await db.execute(sql`
156
+ CREATE TABLE IF NOT EXISTS ${sql.raw(qualified)} (
157
+ key TEXT PRIMARY KEY,
158
+ value TEXT NOT NULL,
159
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
160
+ )
161
+ `);
162
+ await db.execute(sql`
163
+ INSERT INTO ${sql.raw(qualified)} (key, value)
164
+ VALUES (${VERSION_KEY}, ${String(AUTH_SCHEMA_VERSION)})
165
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
166
+ `);
167
+ }
168
+
169
+ /** What {@link probeAuthSchema} found. */
170
+ export interface AuthSchemaProbeResult {
171
+ /** False when this runtime cannot be trusted to serve auth against this database. */
172
+ healthy: boolean;
173
+ /** The stamped version, or `null` on a database that predates stamping. */
174
+ databaseVersion: number | null;
175
+ /** {@link AUTH_SCHEMA_VERSION}. */
176
+ runtimeVersion: number;
177
+ /** Human-readable descriptions of each mismatch found. Empty when healthy. */
178
+ problems: string[];
179
+ }
180
+
181
+ /**
182
+ * Check that the auth schema is one this runtime can actually write to.
183
+ *
184
+ * Two independent checks, because either alone has a blind spot:
185
+ *
186
+ * - The **stamp** catches a runtime older than the database. It is the precise
187
+ * signal, but it is blind on every database provisioned before stamping
188
+ * existed — which today is all of them.
189
+ * - The **structure** catches a database older than the runtime, and works on
190
+ * unstamped databases. It is what makes this useful immediately rather than
191
+ * one upgrade cycle from now.
192
+ *
193
+ * Never throws: a probe that fails to run reports unhealthy with the reason, so
194
+ * a broken check surfaces as a degraded health response rather than a 500 from
195
+ * the health endpoint itself.
196
+ */
197
+ export async function probeAuthSchema(
198
+ db: NodePgDatabase,
199
+ authSchema: string
200
+ ): Promise<AuthSchemaProbeResult> {
201
+ const problems: string[] = [];
202
+ let databaseVersion: number | null = null;
203
+
204
+ try {
205
+ databaseVersion = await readAuthSchemaVersion(db, authSchema);
206
+ if (databaseVersion !== null && databaseVersion > AUTH_SCHEMA_VERSION) {
207
+ problems.push(
208
+ `database is at auth schema version ${databaseVersion}, this runtime understands ` +
209
+ `${AUTH_SCHEMA_VERSION} — it was migrated by a newer framework version`
210
+ );
211
+ }
212
+
213
+ const refreshTokens = `"${authSchema}"."refresh_tokens"`;
214
+ const present = await db.execute(sql`SELECT to_regclass(${refreshTokens}) IS NOT NULL AS present`);
215
+ if (!(present.rows[0] as { present: boolean } | undefined)?.present) {
216
+ // Not a problem in itself: auth may simply not be configured on this
217
+ // deployment, and the table is created on demand at boot when it is.
218
+ return { healthy: problems.length === 0, databaseVersion, runtimeVersion: AUTH_SCHEMA_VERSION, problems };
219
+ }
220
+
221
+ const columns = await db.execute(sql`
222
+ SELECT column_name FROM information_schema.columns
223
+ WHERE table_schema = ${authSchema} AND table_name = 'refresh_tokens'
224
+ `);
225
+ const found = new Set((columns.rows as { column_name: string }[]).map(row => row.column_name));
226
+ const missing = REQUIRED_REFRESH_TOKEN_COLUMNS.filter(column => !found.has(column));
227
+ if (missing.length > 0) {
228
+ problems.push(
229
+ `refresh_tokens is missing ${missing.join(", ")} — the auth migrations have not been ` +
230
+ "applied to this database, so token rotation will fail"
231
+ );
232
+ }
233
+
234
+ const retired = await db.execute(sql`
235
+ SELECT 1 FROM pg_constraint c
236
+ JOIN pg_class t ON t.oid = c.conrelid
237
+ JOIN pg_namespace n ON n.oid = t.relnamespace
238
+ WHERE n.nspname = ${authSchema}
239
+ AND t.relname = 'refresh_tokens'
240
+ AND c.conname = ${RETIRED_REFRESH_TOKEN_CONSTRAINT}
241
+ `);
242
+ if (retired.rows.length > 0) {
243
+ problems.push(
244
+ `refresh_tokens still carries ${RETIRED_REFRESH_TOKEN_CONSTRAINT} — concurrent token ` +
245
+ "rotation for one session will fail on it"
246
+ );
247
+ }
248
+ } catch (error: unknown) {
249
+ problems.push(
250
+ `auth schema probe failed: ${error instanceof Error ? error.message : String(error)}`
251
+ );
252
+ }
253
+
254
+ return {
255
+ healthy: problems.length === 0,
256
+ databaseVersion,
257
+ runtimeVersion: AUTH_SCHEMA_VERSION,
258
+ problems
259
+ };
260
+ }
package/src/cli-errors.ts CHANGED
@@ -113,8 +113,8 @@ function formatConnectionRefusedBanner(databaseUrl: string): string {
113
113
  ` The database server is not running or is not accepting\n` +
114
114
  ` connections. Common fixes:\n` +
115
115
  `\n` +
116
+ ` • docker compose up -d db (the service a Rebase scaffold ships)\n` +
116
117
  ` • brew services start postgresql@18\n` +
117
- ` • docker compose up -d postgres\n` +
118
118
  ` • Verify DATABASE_URL in your .env file\n` +
119
119
  `\n` +
120
120
  `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
@@ -1,10 +1,11 @@
1
+ import { isManyToMany } from "@rebasepro/types";
1
2
  import path from "path";
2
3
  import fs from "fs";
3
4
  import { execSync } from "child_process";
4
5
  import { pathToFileURL } from "url";
5
6
  import chalk from "chalk";
6
7
  import { logger } from "@rebasepro/server";
7
- import type { CollectionConfig, Relation } from "@rebasepro/types";
8
+ import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
8
9
  import { moduleDir as __helpersDirname } from "./module-dir";
9
10
 
10
11
 
@@ -53,8 +54,8 @@ export async function getTableIncludesFromCollections(collections: CollectionCon
53
54
  }
54
55
 
55
56
  const resolvedRelations = resolveCollectionRelations(col);
56
- for (const relation of Object.values(resolvedRelations) as Relation[]) {
57
- if (relation.through) {
57
+ for (const relation of Object.values(resolvedRelations) as ResolvedRelation[]) {
58
+ if (isManyToMany(relation)) {
58
59
  const junctionTableName = relation.through.table;
59
60
  const targetCollection = relation.target();
60
61
  const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";