@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.
- package/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1853 -4900
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +133 -6
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +87 -5
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/index.ts +4 -0
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +430 -11
- package/src/services/row-pipeline.ts +5 -6
- 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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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,
|
|
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:
|
|
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:
|
|
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
|