@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
@@ -26,9 +26,13 @@ export interface PostgresDriverConfig {
26
26
  */
27
27
  introspectionSchema?: string;
28
28
  /**
29
- * Realtime options. Currently only channel retention, which is opt-in:
30
- * without rules here no channel keeps any history and broadcast stays
31
- * fire-and-forget. See {@link ChannelRetentionRule}.
29
+ * Realtime options, both opt-in:
30
+ *
31
+ * - `channels` — retention. Without rules no channel keeps any history and
32
+ * broadcast stays fire-and-forget. See {@link ChannelRetentionRule}.
33
+ * - `bus` — the cross-instance transport for channel broadcast and
34
+ * presence. Defaults to in-process only, which is correct for a single
35
+ * instance and wrong for two. See {@link ChannelBusConfig}.
32
36
  */
33
37
  realtime?: RealtimeChannelsConfig;
34
38
  }
@@ -0,0 +1,106 @@
1
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import type { CollectionConfig } from "@rebasepro/types";
3
+ /**
4
+ * The auth schema version this runtime expects to find in the database.
5
+ *
6
+ * Bump this whenever a migration in `ensureAuthTablesExist` makes the schema
7
+ * unreadable by the runtime that came before it — that is, whenever a *previous*
8
+ * version's auth queries would break against the migrated shape. Additive
9
+ * changes (a new nullable column nobody older references) do not need a bump.
10
+ *
11
+ * History. Note that 1 is a label for an era, not a value any database holds:
12
+ * stamping did not exist then, so an era-1 database reads as unstamped
13
+ * (`null`), and 2 is the first version ever actually written. The numbering
14
+ * starts at 2 only because two schema eras already existed when it was
15
+ * introduced; it could just as well have started at 1. It is not worth
16
+ * renumbering now — deployed databases already carry 2, and lowering the
17
+ * constant would make them look newer than the runtime and refuse the boot.
18
+ *
19
+ * 1 — Device-session refresh tokens. A row *was* a session, identified by
20
+ * `unique_device_session UNIQUE (uid, user_agent, ip_address)`, and
21
+ * `createToken` upserted with `ON CONFLICT (uid, user_agent, ip_address)`.
22
+ * 2 — Session-scoped, rotation-safe refresh tokens: `session_id`, `revoked`,
23
+ * `rotated_at`, `session_started_at`, and `unique_device_session`
24
+ * dropped because two live tokens of one session share all three columns.
25
+ *
26
+ * The 1 → 2 migration is why this file exists. Dropping the constraint is
27
+ * one-way: a version-1 runtime deployed afterwards boots perfectly, logs
28
+ * `✅ Auth tables ready` (its `CREATE TABLE IF NOT EXISTS` never revisits the
29
+ * existing table, so it cannot re-add the constraint), answers `/health` with
30
+ * 200 — and then fails every single login and refresh with SQLSTATE 42P10,
31
+ * because its `ON CONFLICT` names a constraint that no longer exists. A silent
32
+ * total auth outage behind a green health check. The stamp below turns that
33
+ * into a boot refusal.
34
+ */
35
+ export declare const AUTH_SCHEMA_VERSION = 2;
36
+ /**
37
+ * Thrown when the database was migrated by a runtime newer than this one.
38
+ *
39
+ * Distinct class rather than a bare `Error` because `ensureAuthTablesExist`
40
+ * wraps its migrations in a catch that deliberately swallows failures and
41
+ * continues — every other problem there is better survived than crashed on.
42
+ * This one is not, so the catch rethrows on this type specifically.
43
+ */
44
+ export declare class AuthSchemaVersionError extends Error {
45
+ readonly databaseVersion: number;
46
+ readonly runtimeVersion: number;
47
+ constructor(databaseVersion: number, runtimeVersion: number);
48
+ }
49
+ /**
50
+ * The schema the auth tables live in, derived exactly as `ensureAuthTablesExist`
51
+ * derives it. Shared so the two cannot drift: a stamp written to one schema and
52
+ * read from another would read as "never stamped" forever.
53
+ */
54
+ export declare function resolveAuthSchema(collection?: CollectionConfig): string;
55
+ /**
56
+ * Read the stamped version, or `null` when the database has never been stamped.
57
+ *
58
+ * `null` is not an error and must not be treated as one: every database
59
+ * provisioned before this file existed is unstamped, and so is every fresh one.
60
+ * Uses `to_regclass` rather than selecting straight from the table so a missing
61
+ * schema or table is a `null` rather than a thrown 42P01.
62
+ */
63
+ export declare function readAuthSchemaVersion(db: NodePgDatabase, authSchema: string): Promise<number | null>;
64
+ /**
65
+ * Refuse to run against a database a newer runtime has already migrated.
66
+ *
67
+ * Deliberately one-directional. A database *older* than this runtime is the
68
+ * normal upgrade path — the migrations in `ensureAuthTablesExist` are about to
69
+ * bring it forward, so it is not an error. Only the reverse is unrecoverable.
70
+ */
71
+ export declare function assertAuthSchemaCompatible(db: NodePgDatabase, authSchema: string): Promise<void>;
72
+ /**
73
+ * Record that this runtime's migrations have been applied.
74
+ *
75
+ * Called at the end of `ensureAuthTablesExist`, so a boot that failed partway
76
+ * through leaves the older stamp in place and the next boot migrates again.
77
+ */
78
+ export declare function stampAuthSchemaVersion(db: NodePgDatabase, authSchema: string): Promise<void>;
79
+ /** What {@link probeAuthSchema} found. */
80
+ export interface AuthSchemaProbeResult {
81
+ /** False when this runtime cannot be trusted to serve auth against this database. */
82
+ healthy: boolean;
83
+ /** The stamped version, or `null` on a database that predates stamping. */
84
+ databaseVersion: number | null;
85
+ /** {@link AUTH_SCHEMA_VERSION}. */
86
+ runtimeVersion: number;
87
+ /** Human-readable descriptions of each mismatch found. Empty when healthy. */
88
+ problems: string[];
89
+ }
90
+ /**
91
+ * Check that the auth schema is one this runtime can actually write to.
92
+ *
93
+ * Two independent checks, because either alone has a blind spot:
94
+ *
95
+ * - The **stamp** catches a runtime older than the database. It is the precise
96
+ * signal, but it is blind on every database provisioned before stamping
97
+ * existed — which today is all of them.
98
+ * - The **structure** catches a database older than the runtime, and works on
99
+ * unstamped databases. It is what makes this useful immediately rather than
100
+ * one upgrade cycle from now.
101
+ *
102
+ * Never throws: a probe that fails to run reports unhealthy with the reason, so
103
+ * a broken check surfaces as a degraded health response rather than a 500 from
104
+ * the health endpoint itself.
105
+ */
106
+ export declare function probeAuthSchema(db: NodePgDatabase, authSchema: string): Promise<AuthSchemaProbeResult>;
@@ -0,0 +1,40 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ import "process";
3
+ const require = __createRequire(import.meta.url);
4
+ //#region \0rolldown/runtime.js
5
+ var __create = Object.create;
6
+ var __defProp = Object.defineProperty;
7
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
+ var __getOwnPropNames = Object.getOwnPropertyNames;
9
+ var __getProtoOf = Object.getPrototypeOf;
10
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
11
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
12
+ var __exportAll = (all, no_symbols) => {
13
+ let target = {};
14
+ for (var name in all) __defProp(target, name, {
15
+ get: all[name],
16
+ enumerable: true
17
+ });
18
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
19
+ return target;
20
+ };
21
+ var __copyProps = (to, from, except, desc) => {
22
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
23
+ key = keys[i];
24
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
25
+ get: ((k) => from[k]).bind(null, key),
26
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
27
+ });
28
+ }
29
+ return to;
30
+ };
31
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
32
+ value: mod,
33
+ enumerable: true
34
+ }) : target, mod));
35
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
36
+ if (typeof require !== "undefined") return require.apply(this, arguments);
37
+ throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
38
+ });
39
+ //#endregion
40
+ export { __toESM as i, __exportAll as n, __require as r, __commonJSMin as t };
@@ -0,0 +1,53 @@
1
+ import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
2
+ import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
3
+ /**
4
+ * Check every relation against the schema it actually runs on, at boot.
5
+ *
6
+ * The tagged union made the *shape* of a relation impossible to get wrong: a
7
+ * `manyToMany` cannot carry a `foreignKeyOnTarget`, a to-many cannot carry a
8
+ * `localKey`. What it cannot know is whether any of the names are real —
9
+ * whether `posts_tags` is a table, whether `author_id` is a column, whether a
10
+ * `joinPath` connects the tables it claims to. Those are facts about the
11
+ * database, and the type system never sees them.
12
+ *
13
+ * Until now nothing checked them until a query ran, and the failures were the
14
+ * quiet kind. A missing junction table logged a warning and returned no rows,
15
+ * so `posts/1/tags` answered `[]` — indistinguishable from a post with no tags.
16
+ * The relation looked configured, the admin drew the tab, the tab was empty,
17
+ * and nothing anywhere said why.
18
+ *
19
+ * The junction default is the sharp edge this exists for. `through.table`
20
+ * defaults to the two table names sorted and joined, so renaming a table
21
+ * silently re-points the relation at a name that was never created. It is the
22
+ * one default whose output changes when you edit something that looks
23
+ * unrelated.
24
+ */
25
+ export interface RelationDefect {
26
+ /** Slug of the collection declaring the relation. */
27
+ collection: string;
28
+ relationName: string;
29
+ kind: ResolvedRelation["kind"];
30
+ /** What is wrong, in terms of the schema. */
31
+ problem: string;
32
+ /** The edit that fixes it. */
33
+ fix: string;
34
+ }
35
+ /**
36
+ * Relations whose names do not resolve against the registered schema.
37
+ *
38
+ * Fails open wherever it cannot see enough to be sure — an unregistered source
39
+ * table, a target belonging to another backend — because a false alarm here
40
+ * costs more than a missed one: it would block boot on a working app.
41
+ */
42
+ export declare function findRelationDefects(collections: CollectionConfig[], registry: PostgresCollectionRegistry): RelationDefect[];
43
+ /**
44
+ * Fail boot on any relation that cannot resolve, listing all of them at once.
45
+ *
46
+ * Deliberately fatal rather than a warning. Every one of these produces an
47
+ * empty result at query time and nothing else — an empty tab, an empty
48
+ * `include`, a subcollection that looks like it has no rows. A server that
49
+ * refuses to start is recoverable in a minute; a relation that quietly answers
50
+ * "nothing" is the kind of bug found in production, weeks later, by a user
51
+ * asking where their data went.
52
+ */
53
+ export declare function assertRelationsResolve(collections: CollectionConfig[], registry: PostgresCollectionRegistry): void;
@@ -1,5 +1,5 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { CollectionConfig, Properties, Property, Relation } from "@rebasepro/types";
2
+ import { CollectionConfig, Properties, Property, ResolvedRelation, type ResolvedVia } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
4
4
  /**
5
5
  * Data transformation utilities for converting between frontend and database formats.
@@ -23,13 +23,13 @@ export interface SerializedEntityData {
23
23
  */
24
24
  inverseRelationUpdates: Array<{
25
25
  relationKey: string;
26
- relation: Relation;
26
+ relation: ResolvedRelation;
27
27
  newValue: unknown;
28
28
  }>;
29
29
  /** JoinPath relation updates that require multi-hop writes. */
30
30
  joinPathRelationUpdates: Array<{
31
31
  relationKey: string;
32
- relation: Relation;
32
+ relation: ResolvedVia;
33
33
  newTargetId: string | number | null;
34
34
  }>;
35
35
  }
@@ -0,0 +1,304 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ import "process";
3
+ __createRequire(import.meta.url);
4
+ import { d as isPostgresCollectionConfig } from "./src-D5xBTl32.js";
5
+ import { g as getTableName, k as toSnakeCase, p as findRelation, v as resolveCollectionRelations } from "./src-3VmUJ8Xn.js";
6
+ //#region src/schema/generate-postgres-ddl-logic.ts
7
+ var resolveColumnName = (propName, prop) => {
8
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
9
+ return toSnakeCase(propName);
10
+ };
11
+ var getPrimaryKeyProp = (collection) => {
12
+ if (collection.properties) {
13
+ const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
14
+ if (idPropEntry) {
15
+ const prop = idPropEntry[1];
16
+ const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
17
+ return {
18
+ name: idPropEntry[0],
19
+ type: prop.type === "number" ? "number" : "string",
20
+ isUuid
21
+ };
22
+ }
23
+ }
24
+ const idProp = collection.properties?.["id"];
25
+ if (idProp?.type === "number") return {
26
+ name: "id",
27
+ type: "number",
28
+ isUuid: false
29
+ };
30
+ return {
31
+ name: "id",
32
+ type: "string",
33
+ isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
34
+ };
35
+ };
36
+ var isIdProperty = (propName, prop, collection) => {
37
+ if ("isId" in prop && Boolean(prop.isId)) return true;
38
+ return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
39
+ };
40
+ var getSqlColumnType = (propName, prop, collection, collections) => {
41
+ switch (prop.type) {
42
+ case "string": {
43
+ const stringProp = prop;
44
+ if (stringProp.enum) {
45
+ const tableName = getTableName(collection);
46
+ const colName = resolveColumnName(propName, prop);
47
+ return `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${tableName}_${colName}"`;
48
+ }
49
+ if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") return "UUID";
50
+ if (stringProp.columnType === "char") return "CHAR(255)";
51
+ if (stringProp.columnType === "varchar") return "VARCHAR(255)";
52
+ return "TEXT";
53
+ }
54
+ case "number": {
55
+ const numProp = prop;
56
+ const isId = isIdProperty(propName, prop, collection);
57
+ if ("isId" in numProp && numProp.isId === "increment") return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
58
+ if (numProp.columnType) {
59
+ if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
60
+ return numProp.columnType.toUpperCase();
61
+ }
62
+ return numProp.validation?.integer || isId ? "INTEGER" : "NUMERIC";
63
+ }
64
+ case "boolean": return "BOOLEAN";
65
+ case "date": {
66
+ const dateProp = prop;
67
+ if (dateProp.columnType === "date") return "DATE";
68
+ if (dateProp.columnType === "time") return "TIME";
69
+ return "TIMESTAMP WITH TIME ZONE";
70
+ }
71
+ case "map": return prop.columnType === "json" ? "JSON" : "JSONB";
72
+ case "array": {
73
+ const arrayProp = prop;
74
+ let colType = arrayProp.columnType;
75
+ if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
76
+ const ofProp = arrayProp.of;
77
+ if (ofProp.type === "string") colType = "text[]";
78
+ else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
79
+ else if (ofProp.type === "boolean") colType = "boolean[]";
80
+ }
81
+ if (colType === "json") return "JSON";
82
+ if (colType === "text[]") return "TEXT[]";
83
+ if (colType === "integer[]") return "INTEGER[]";
84
+ if (colType === "boolean[]") return "BOOLEAN[]";
85
+ if (colType === "numeric[]") return "NUMERIC[]";
86
+ return "JSONB";
87
+ }
88
+ case "vector": return `VECTOR(${prop.dimensions})`;
89
+ case "binary": return "BYTEA";
90
+ case "relation": {
91
+ const refProp = prop;
92
+ const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
93
+ if (relation?.kind !== "belongsTo") throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
94
+ let targetCollection;
95
+ try {
96
+ targetCollection = relation.target();
97
+ } catch {
98
+ return "TEXT";
99
+ }
100
+ const pkProp = getPrimaryKeyProp(targetCollection);
101
+ return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
102
+ }
103
+ case "reference": {
104
+ const refProp = prop;
105
+ const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
106
+ if (!targetCollection) return "TEXT";
107
+ const pkProp = getPrimaryKeyProp(targetCollection);
108
+ return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
109
+ }
110
+ default: return "TEXT";
111
+ }
112
+ };
113
+ //#endregion
114
+ //#region src/schema/ensure-collection-tables.ts
115
+ /**
116
+ * Bringing a database up to date with a bundle's collections, additively.
117
+ *
118
+ * ## Why this exists
119
+ *
120
+ * A managed runtime boots someone else's compiled project against a database it
121
+ * has never seen. Auth tables are ensured at boot already, but collection tables
122
+ * were not created by anything: the platform ran the app and every `/api/data/*`
123
+ * request answered 500 on a missing relation. `rebase db push` cannot help — it
124
+ * is an Atlas-driven CLI command, and the runtime image ships no CLI.
125
+ *
126
+ * ## Why additive-only, forever
127
+ *
128
+ * This runs unattended, against a database with customers' data in it, with no
129
+ * human reading a diff. So it may only ever do things that cannot lose data:
130
+ * create a missing table, add a missing column, create a missing enum type.
131
+ *
132
+ * It will **never** drop a table or a column, narrow a type, or alter a
133
+ * constraint. A removed field leaves its column behind; a renamed field looks
134
+ * like an addition and the old column stays. That is the correct trade for an
135
+ * automated path — the alternative is an unattended process that can silently
136
+ * destroy a column, which is precisely the failure `db push` was hardened
137
+ * against. Destructive changes stay a deliberate, human-reviewed migration.
138
+ *
139
+ * Because of that, this is safe to run on every boot, and re-running it is a
140
+ * no-op.
141
+ */
142
+ /** Postgres identifiers this module is willing to interpolate. */
143
+ var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
144
+ function assertSafeIdentifier(value, what) {
145
+ if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
146
+ return value;
147
+ }
148
+ function schemaOf(collection) {
149
+ return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
150
+ }
151
+ function qualified(collection) {
152
+ return `${schemaOf(collection)}.${getTableName(collection)}`;
153
+ }
154
+ /**
155
+ * Enum types a collection's properties require, as `schema.typename`.
156
+ *
157
+ * Named exactly as the DDL generator names them (`<table>_<column>`), because
158
+ * a column added here has to reference the same type the generator would have
159
+ * created — a second, differently-named type for the same field would be a
160
+ * silent schema fork.
161
+ */
162
+ function requiredEnums(collection) {
163
+ const table = getTableName(collection);
164
+ const schema = schemaOf(collection);
165
+ const out = [];
166
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
167
+ const p = prop;
168
+ if (!("enum" in p) || !p.enum) continue;
169
+ if (p.type !== "string" && p.type !== "number") continue;
170
+ const values = p.enum.map((entry) => entry && typeof entry === "object" && "id" in entry ? String(entry.id) : String(entry)).filter((v) => v.length > 0);
171
+ if (values.length === 0) continue;
172
+ out.push({
173
+ name: `${schema}.${table}_${resolveColumnName(propName, p)}`,
174
+ values
175
+ });
176
+ }
177
+ return out;
178
+ }
179
+ /** Single-quote escaping for an enum label. */
180
+ function quoteLiteral(value) {
181
+ return `'${value.replace(/'/g, "''")}'`;
182
+ }
183
+ /**
184
+ * Decide what to add. Pure — the caller supplies what exists and runs the result.
185
+ *
186
+ * Ordering matters and is deliberate: enum types before the tables and columns
187
+ * that reference them, tables before the columns added to other tables (a new
188
+ * table may be the target of a relation), and nothing is emitted twice.
189
+ */
190
+ function planCollectionSchemaEnsure(collections, existing) {
191
+ const actions = [];
192
+ const plannedEnums = /* @__PURE__ */ new Set();
193
+ for (const collection of collections) for (const { name, values } of requiredEnums(collection)) {
194
+ if (existing.enums.has(name) || plannedEnums.has(name)) continue;
195
+ plannedEnums.add(name);
196
+ const [schema, typeName] = name.split(".");
197
+ actions.push({
198
+ kind: "create-enum",
199
+ target: name,
200
+ sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteLiteral).join(", ")});`
201
+ });
202
+ }
203
+ const created = /* @__PURE__ */ new Set();
204
+ for (const collection of collections) {
205
+ const key = qualified(collection);
206
+ if (existing.tables.has(key) || created.has(key)) continue;
207
+ created.add(key);
208
+ const schema = schemaOf(collection);
209
+ const table = getTableName(collection);
210
+ const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) => isIdProperty(n, p, collection));
211
+ const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1]) : "id";
212
+ const idProp = idEntry?.[1];
213
+ let idDef;
214
+ if (idProp?.type === "number") idDef = `"${idName}" BIGSERIAL PRIMARY KEY`;
215
+ else if (idProp && idProp.type === "string" && idProp.isId === "uuid") idDef = `"${idName}" UUID PRIMARY KEY DEFAULT gen_random_uuid()`;
216
+ else idDef = `"${idName}" TEXT PRIMARY KEY`;
217
+ actions.push({
218
+ kind: "create-table",
219
+ target: key,
220
+ sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
221
+ });
222
+ }
223
+ for (const collection of collections) {
224
+ const key = qualified(collection);
225
+ const schema = schemaOf(collection);
226
+ const table = getTableName(collection);
227
+ const present = existing.tables.get(key) ?? /* @__PURE__ */ new Set();
228
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
229
+ const p = prop;
230
+ if (isIdProperty(propName, p, collection)) continue;
231
+ if (p.type === "reference" || p.type === "relation") continue;
232
+ const column = resolveColumnName(propName, p);
233
+ if (present.has(column)) continue;
234
+ const type = getSqlColumnType(propName, p, collection, collections);
235
+ actions.push({
236
+ kind: "add-column",
237
+ target: `${key}.${column}`,
238
+ sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
239
+ });
240
+ }
241
+ }
242
+ return {
243
+ actions,
244
+ statements: actions.map((a) => a.sql)
245
+ };
246
+ }
247
+ /** Read what the database has, for the schemas the collections live in. */
248
+ async function readExistingSchema(client, schemas) {
249
+ const tables = /* @__PURE__ */ new Map();
250
+ const enums = /* @__PURE__ */ new Set();
251
+ if (schemas.length === 0) return {
252
+ tables,
253
+ enums
254
+ };
255
+ const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
256
+ const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name
257
+ FROM information_schema.columns
258
+ WHERE table_schema IN (${inList})`);
259
+ for (const row of columns) {
260
+ const key = `${row.table_schema}.${row.table_name}`;
261
+ if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
262
+ tables.get(key).add(row.column_name);
263
+ }
264
+ const { rows: enumRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name
265
+ FROM pg_type t
266
+ JOIN pg_namespace n ON t.typnamespace = n.oid
267
+ WHERE t.typtype = 'e' AND n.nspname IN (${inList})`);
268
+ for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
269
+ return {
270
+ tables,
271
+ enums
272
+ };
273
+ }
274
+ /**
275
+ * Bring the database up to date. Returns what it did.
276
+ *
277
+ * Each statement runs on its own rather than in one transaction: they are all
278
+ * independently safe and idempotent, and a single failure (an enum label that
279
+ * cannot be added, say) should not roll back the tables that were created fine.
280
+ * The error is surfaced with the statement that caused it.
281
+ */
282
+ async function ensureCollectionTables(client, collections, log) {
283
+ const schemas = Array.from(new Set(collections.map(schemaOf)));
284
+ for (const schema of schemas) {
285
+ assertSafeIdentifier(schema, "schema name");
286
+ if (schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
287
+ }
288
+ const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas));
289
+ if (plan.actions.length === 0) {
290
+ log?.("Schema is up to date; nothing to create.");
291
+ return plan;
292
+ }
293
+ for (const action of plan.actions) try {
294
+ await client.query(action.sql);
295
+ log?.(`${action.kind}: ${action.target}`);
296
+ } catch (err) {
297
+ throw new Error(`Failed to ${action.kind} ${action.target}: ${err instanceof Error ? err.message : String(err)}\n ${action.sql}`);
298
+ }
299
+ return plan;
300
+ }
301
+ //#endregion
302
+ export { ensureCollectionTables };
303
+
304
+ //# sourceMappingURL=ensure-collection-tables-DGMYK0fr.js.map