@schemic/postgres 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +103 -0
- package/lib/index.d.ts +240 -0
- package/lib/index.js +1161 -0
- package/lib/index.js.map +1 -0
- package/package.json +61 -0
- package/src/authoring.ts +356 -0
- package/src/emit.ts +253 -0
- package/src/index.ts +732 -0
- package/src/kinds.ts +390 -0
- package/src/lower.ts +171 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
// The POSTGRES driver — driver #2, the spike's proof the seam holds for a very different database
|
|
2
|
+
// (see docs/MULTI-DB-SPIKE.md, Milestone 3). It produces/consumes the PORTABLE IR natively: `emit`
|
|
3
|
+
// translates portable types to `CREATE TABLE`, `introspect` reads `information_schema` back into the
|
|
4
|
+
// portable IR, and `normalize` PROJECTS the portable IR onto what Postgres can actually represent.
|
|
5
|
+
//
|
|
6
|
+
// The execution engine is PGlite (embedded Postgres in WASM) — a real engine, so the round-trip is
|
|
7
|
+
// genuine, and it doubles as the driver's `shadow` capability (the "embedded engine" option the
|
|
8
|
+
// design calls out for DBs that can't spin an in-process throwaway the way SurrealDB can).
|
|
9
|
+
//
|
|
10
|
+
// KNOWN CAPABILITY GAPS (deliberate, documented — not silent loss):
|
|
11
|
+
// - `option<T>` and `T | null` BOTH collapse to a nullable column: Postgres has no column-level
|
|
12
|
+
// notion of "absent" distinct from NULL, so `normalize` folds option -> nullable. This is the
|
|
13
|
+
// portable model working as designed — a rich superset projected down per dialect.
|
|
14
|
+
// - Nested objects map to a single `jsonb` column; the dotted sub-fields are folded in (dropped by
|
|
15
|
+
// `normalize`), mirroring how the Surreal IR auto-creates `x.*` elements.
|
|
16
|
+
// - Surreal-only constructs (events, access, db functions, relations, changefeed, permissions) have
|
|
17
|
+
// no Postgres analogue and are dropped by `normalize` with no DDL emitted.
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
ApplyOptions,
|
|
21
|
+
ConnectionConfigBase,
|
|
22
|
+
ConnectionEntry,
|
|
23
|
+
ConnectionInput,
|
|
24
|
+
ConnectionOverrides,
|
|
25
|
+
Definable,
|
|
26
|
+
Diff,
|
|
27
|
+
Driver,
|
|
28
|
+
MigrationDirection,
|
|
29
|
+
MigrationRecord,
|
|
30
|
+
MigrationStore,
|
|
31
|
+
PortableField,
|
|
32
|
+
PortableObject,
|
|
33
|
+
PortableType,
|
|
34
|
+
ResolveContext,
|
|
35
|
+
ResolvedConfig,
|
|
36
|
+
ScalarName,
|
|
37
|
+
ShadowCapability,
|
|
38
|
+
} from "@schemic/core/driver";
|
|
39
|
+
import {
|
|
40
|
+
connectionEntry,
|
|
41
|
+
nullable,
|
|
42
|
+
registerDriver,
|
|
43
|
+
} from "@schemic/core/driver";
|
|
44
|
+
import type { PgTableDef } from "./authoring";
|
|
45
|
+
import { escId, type PgIndexInfo, type PgTable } from "./emit";
|
|
46
|
+
import { registry, splitTables } from "./kinds";
|
|
47
|
+
import { pgLower } from "./lower";
|
|
48
|
+
|
|
49
|
+
// The pg-native authoring surface (`s.*`, defineTable, PgField, $postgres escape hatch, …).
|
|
50
|
+
export * from "./authoring";
|
|
51
|
+
|
|
52
|
+
// A minimal structural view of a PGlite/node-postgres connection (so core needs no hard pg dep).
|
|
53
|
+
export interface PgConn {
|
|
54
|
+
query<T = Record<string, unknown>>(
|
|
55
|
+
sql: string,
|
|
56
|
+
params?: unknown[],
|
|
57
|
+
): Promise<{ rows: T[] }>;
|
|
58
|
+
exec(sql: string): Promise<unknown>;
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- introspect: information_schema -> portable IR ----------------------------------------------
|
|
63
|
+
|
|
64
|
+
interface ColRow {
|
|
65
|
+
table_name: string;
|
|
66
|
+
column_name: string;
|
|
67
|
+
data_type: string;
|
|
68
|
+
udt_name: string;
|
|
69
|
+
is_nullable: string;
|
|
70
|
+
is_identity: string;
|
|
71
|
+
identity_generation: string | null;
|
|
72
|
+
character_maximum_length: number | null;
|
|
73
|
+
numeric_precision: number | null;
|
|
74
|
+
numeric_scale: number | null;
|
|
75
|
+
}
|
|
76
|
+
interface FkRow {
|
|
77
|
+
table_name: string;
|
|
78
|
+
column_name: string;
|
|
79
|
+
foreign_table_name: string;
|
|
80
|
+
delete_rule: string;
|
|
81
|
+
update_rule: string;
|
|
82
|
+
}
|
|
83
|
+
interface PkRow {
|
|
84
|
+
table_name: string;
|
|
85
|
+
column_name: string;
|
|
86
|
+
}
|
|
87
|
+
interface IdxRow {
|
|
88
|
+
table_name: string;
|
|
89
|
+
index_name: string;
|
|
90
|
+
column_name: string;
|
|
91
|
+
is_unique: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const nativeT = (name: string, params?: (string | number)[]): PortableType =>
|
|
95
|
+
params && params.length > 0
|
|
96
|
+
? { t: "native", db: "postgres", name, params }
|
|
97
|
+
: { t: "native", db: "postgres", name };
|
|
98
|
+
const scalarT = (name: ScalarName): PortableType => ({ t: "scalar", name });
|
|
99
|
+
|
|
100
|
+
/** information_schema data_type -> the CANONICAL portable scalar (mirrors lower's CANON, reversed). */
|
|
101
|
+
const DATATYPE_TO_SCALAR: Record<string, ScalarName> = {
|
|
102
|
+
text: "string",
|
|
103
|
+
integer: "int",
|
|
104
|
+
"double precision": "float",
|
|
105
|
+
boolean: "bool",
|
|
106
|
+
uuid: "uuid",
|
|
107
|
+
bytea: "bytes",
|
|
108
|
+
interval: "duration",
|
|
109
|
+
};
|
|
110
|
+
/** data_type spellings that differ from our pg-type token (so a native node matches what lower made). */
|
|
111
|
+
const DATATYPE_TO_NATIVE: Record<string, string> = {
|
|
112
|
+
"time without time zone": "time",
|
|
113
|
+
"time with time zone": "timetz",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** A column row -> portable type, INVERSE of lower's token->portable (canonical -> scalar, else native+params). */
|
|
117
|
+
function introspectType(c: ColRow): PortableType {
|
|
118
|
+
const dt = c.data_type;
|
|
119
|
+
if (dt === "ARRAY") {
|
|
120
|
+
// udt_name is the element type prefixed with `_` (e.g. `_int4`, `_text`).
|
|
121
|
+
return { t: "array", elem: pgScalarFromUdt(c.udt_name.replace(/^_/, "")) };
|
|
122
|
+
}
|
|
123
|
+
if (dt === "jsonb") return { t: "object", fields: {} };
|
|
124
|
+
if (dt === "json") return nativeT("json");
|
|
125
|
+
if (dt === "character varying")
|
|
126
|
+
return c.character_maximum_length != null
|
|
127
|
+
? nativeT("varchar", [c.character_maximum_length])
|
|
128
|
+
: nativeT("varchar");
|
|
129
|
+
if (dt === "character")
|
|
130
|
+
return c.character_maximum_length != null
|
|
131
|
+
? nativeT("char", [c.character_maximum_length])
|
|
132
|
+
: nativeT("char");
|
|
133
|
+
if (dt === "numeric")
|
|
134
|
+
return c.numeric_precision != null
|
|
135
|
+
? nativeT("numeric", [c.numeric_precision, c.numeric_scale ?? 0])
|
|
136
|
+
: scalarT("decimal");
|
|
137
|
+
if (dt === "timestamp without time zone") return nativeT("timestamp");
|
|
138
|
+
if (dt === "timestamp with time zone") return scalarT("datetime");
|
|
139
|
+
const sc = DATATYPE_TO_SCALAR[dt];
|
|
140
|
+
if (sc) return scalarT(sc);
|
|
141
|
+
return nativeT(DATATYPE_TO_NATIVE[dt] ?? dt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Array element udt -> portable scalar (CANONICAL only; non-canonical elements ride as native, which
|
|
145
|
+
// may not match lower's type name -> arrays of native types are a documented round-trip gap).
|
|
146
|
+
const UDT_TO_SCALAR: Record<string, ScalarName> = {
|
|
147
|
+
text: "string",
|
|
148
|
+
int4: "int",
|
|
149
|
+
float8: "float",
|
|
150
|
+
numeric: "decimal",
|
|
151
|
+
bool: "bool",
|
|
152
|
+
timestamptz: "datetime",
|
|
153
|
+
uuid: "uuid",
|
|
154
|
+
bytea: "bytes",
|
|
155
|
+
interval: "duration",
|
|
156
|
+
};
|
|
157
|
+
function pgScalarFromUdt(udt: string): PortableType {
|
|
158
|
+
const name = UDT_TO_SCALAR[udt];
|
|
159
|
+
return name
|
|
160
|
+
? { t: "scalar", name }
|
|
161
|
+
: { t: "native", db: "postgres", name: udt };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function pgIntrospect(
|
|
165
|
+
conn: PgConn,
|
|
166
|
+
exclude: Set<string> = new Set(),
|
|
167
|
+
): Promise<PgTable[]> {
|
|
168
|
+
// Also skip the companion lock table this driver creates for any excluded (bookkeeping) table.
|
|
169
|
+
const skip = new Set(exclude);
|
|
170
|
+
for (const t of exclude) skip.add(`${t}_lock`);
|
|
171
|
+
const { rows: cols } = await conn.query<ColRow>(
|
|
172
|
+
`SELECT table_name, column_name, data_type, udt_name, is_nullable,
|
|
173
|
+
is_identity, identity_generation,
|
|
174
|
+
character_maximum_length, numeric_precision, numeric_scale
|
|
175
|
+
FROM information_schema.columns
|
|
176
|
+
WHERE table_schema = 'public'
|
|
177
|
+
ORDER BY table_name, ordinal_position`,
|
|
178
|
+
);
|
|
179
|
+
const { rows: fks } = await conn.query<FkRow>(
|
|
180
|
+
`SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table_name,
|
|
181
|
+
rc.delete_rule, rc.update_rule
|
|
182
|
+
FROM information_schema.table_constraints tc
|
|
183
|
+
JOIN information_schema.key_column_usage kcu
|
|
184
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
185
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
186
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
187
|
+
JOIN information_schema.referential_constraints rc
|
|
188
|
+
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
|
|
189
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`,
|
|
190
|
+
);
|
|
191
|
+
const { rows: pks } = await conn.query<PkRow>(
|
|
192
|
+
`SELECT tc.table_name, kcu.column_name
|
|
193
|
+
FROM information_schema.table_constraints tc
|
|
194
|
+
JOIN information_schema.key_column_usage kcu
|
|
195
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
196
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
|
|
197
|
+
ORDER BY kcu.ordinal_position`,
|
|
198
|
+
);
|
|
199
|
+
// Secondary indexes this driver can author — plain btree over real columns, UNIQUE or not (the ones
|
|
200
|
+
// emitted via $unique / .index([...])). Excludes the PK's implicit index, partial indexes
|
|
201
|
+
// (indpred), expression indexes (indexprs), and non-btree methods (gin/gist/…), which the driver
|
|
202
|
+
// can't emit — reading those back would phantom-REMOVE. `is_unique` distinguishes the two forms.
|
|
203
|
+
// Required so the `index` kind ROUND-TRIPS: the registry diffs by presence, so an un-introspected
|
|
204
|
+
// index phantom-adds and a non-emittable one phantom-removes.
|
|
205
|
+
const { rows: idxs } = await conn.query<IdxRow>(
|
|
206
|
+
`SELECT t.relname AS table_name, i.relname AS index_name, a.attname AS column_name,
|
|
207
|
+
ix.indisunique AS is_unique
|
|
208
|
+
FROM pg_class t
|
|
209
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace AND n.nspname = 'public'
|
|
210
|
+
JOIN pg_index ix ON ix.indrelid = t.oid AND NOT ix.indisprimary
|
|
211
|
+
AND ix.indpred IS NULL AND ix.indexprs IS NULL
|
|
212
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
213
|
+
JOIN pg_am am ON am.oid = i.relam AND am.amname = 'btree'
|
|
214
|
+
JOIN LATERAL unnest(string_to_array(ix.indkey::text, ' ')::int[])
|
|
215
|
+
WITH ORDINALITY AS k(attnum, ord) ON true
|
|
216
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
217
|
+
WHERE t.relkind = 'r'
|
|
218
|
+
ORDER BY t.relname, i.relname, k.ord`,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const fkBy = new Map<string, FkRow>();
|
|
222
|
+
for (const f of fks) fkBy.set(`${f.table_name}.${f.column_name}`, f);
|
|
223
|
+
const pkBy = new Map<string, string[]>();
|
|
224
|
+
for (const p of pks) {
|
|
225
|
+
const list = pkBy.get(p.table_name) ?? [];
|
|
226
|
+
list.push(p.column_name);
|
|
227
|
+
pkBy.set(p.table_name, list);
|
|
228
|
+
}
|
|
229
|
+
// The `id` column per table (to tell the IMPLICIT id apart from an overridden one).
|
|
230
|
+
const idColBy = new Map<string, ColRow>();
|
|
231
|
+
for (const c of cols)
|
|
232
|
+
if (c.column_name === "id") idColBy.set(c.table_name, c);
|
|
233
|
+
// The implicit key is the EXACT `id text` PK pgEmit adds (no PK authored). A lone `id` PK whose
|
|
234
|
+
// column was overridden — uuid / serial (identity) / bigint / etc. — is a real authored column that
|
|
235
|
+
// must round-trip, so it is NOT implicit (kept as a column + recorded as the table's primaryKey).
|
|
236
|
+
const isImplicit = (table: string) => {
|
|
237
|
+
const pk = pkBy.get(table);
|
|
238
|
+
if (!(pk?.length === 1 && pk[0] === "id")) return false;
|
|
239
|
+
const id = idColBy.get(table);
|
|
240
|
+
return !!id && id.data_type === "text" && id.is_identity !== "YES";
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const seen = new Set<string>();
|
|
244
|
+
const byTable = new Map<string, PortableField[]>();
|
|
245
|
+
for (const c of cols) {
|
|
246
|
+
if (skip.has(c.table_name)) continue;
|
|
247
|
+
seen.add(c.table_name);
|
|
248
|
+
if (c.column_name === "id" && isImplicit(c.table_name)) continue;
|
|
249
|
+
const fk = fkBy.get(`${c.table_name}.${c.column_name}`);
|
|
250
|
+
let type: PortableType = fk
|
|
251
|
+
? { t: "record", tables: [fk.foreign_table_name] }
|
|
252
|
+
: introspectType(c);
|
|
253
|
+
if (c.is_nullable === "YES") type = nullable(type);
|
|
254
|
+
const pf: PortableField = {
|
|
255
|
+
name: c.column_name,
|
|
256
|
+
table: c.table_name,
|
|
257
|
+
type,
|
|
258
|
+
};
|
|
259
|
+
if (c.is_identity === "YES")
|
|
260
|
+
pf.identity =
|
|
261
|
+
c.identity_generation === "ALWAYS" ? "always" : "by-default";
|
|
262
|
+
if (fk) {
|
|
263
|
+
const ref: { on_delete?: string; on_update?: string } = {};
|
|
264
|
+
if (fk.delete_rule && fk.delete_rule !== "NO ACTION")
|
|
265
|
+
ref.on_delete = fk.delete_rule;
|
|
266
|
+
if (fk.update_rule && fk.update_rule !== "NO ACTION")
|
|
267
|
+
ref.on_update = fk.update_rule;
|
|
268
|
+
if (ref.on_delete !== undefined || ref.on_update !== undefined)
|
|
269
|
+
pf.reference = ref;
|
|
270
|
+
}
|
|
271
|
+
const list = byTable.get(c.table_name) ?? [];
|
|
272
|
+
list.push(pf);
|
|
273
|
+
byTable.set(c.table_name, list);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Group index rows -> PgIndexInfo[] per table (columns in index order, dedup by index name).
|
|
277
|
+
const idxBy = new Map<string, Map<string, PgIndexInfo>>();
|
|
278
|
+
for (const r of idxs) {
|
|
279
|
+
if (skip.has(r.table_name)) continue;
|
|
280
|
+
const byName = idxBy.get(r.table_name) ?? new Map<string, PgIndexInfo>();
|
|
281
|
+
const ix = byName.get(r.index_name) ?? {
|
|
282
|
+
name: r.index_name,
|
|
283
|
+
cols: [],
|
|
284
|
+
unique: r.is_unique,
|
|
285
|
+
};
|
|
286
|
+
ix.cols.push(r.column_name);
|
|
287
|
+
byName.set(r.index_name, ix);
|
|
288
|
+
idxBy.set(r.table_name, byName);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return [...seen].map((name) => {
|
|
292
|
+
const t: PgTable = {
|
|
293
|
+
name,
|
|
294
|
+
fields: byTable.get(name) ?? [],
|
|
295
|
+
indexes: [...(idxBy.get(name)?.values() ?? [])],
|
|
296
|
+
};
|
|
297
|
+
if (!isImplicit(name)) {
|
|
298
|
+
const pk = pkBy.get(name);
|
|
299
|
+
if (pk && pk.length > 0) t.primaryKey = pk;
|
|
300
|
+
}
|
|
301
|
+
return t;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- connection (PGlite, embedded) --------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
async function newPglite(dataDir?: string): Promise<PgConn> {
|
|
308
|
+
const pkg: string = "@electric-sql/pglite"; // non-literal so it stays an optional dep.
|
|
309
|
+
let PGlite: (new (dir?: string) => PgConn) | undefined;
|
|
310
|
+
try {
|
|
311
|
+
const mod = (await import(pkg)) as {
|
|
312
|
+
PGlite?: new (dir?: string) => PgConn;
|
|
313
|
+
};
|
|
314
|
+
PGlite = mod.PGlite;
|
|
315
|
+
} catch {
|
|
316
|
+
PGlite = undefined;
|
|
317
|
+
}
|
|
318
|
+
if (!PGlite) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
"postgres driver needs `@electric-sql/pglite` (embedded) — install it, or wire a node-postgres client.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return new PGlite(dataDir);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const shadow: ShadowCapability<PgConn> = {
|
|
327
|
+
// A throwaway in-memory PGlite IS the shadow: apply the DDL, read it back as kind objects, done (no
|
|
328
|
+
// drop needed — the instance is discarded). This is the "embedded engine" canonicalization path.
|
|
329
|
+
async roundTrip(_conn, _config, ddl): Promise<PortableObject[]> {
|
|
330
|
+
const scratch = await newPglite();
|
|
331
|
+
try {
|
|
332
|
+
if (ddl.trim()) await scratch.exec(ddl);
|
|
333
|
+
return splitTables(await pgIntrospect(scratch));
|
|
334
|
+
} finally {
|
|
335
|
+
await scratch.close();
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
async ephemeral() {
|
|
339
|
+
const conn = await newPglite();
|
|
340
|
+
return { conn, stop: () => conn.close() };
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// --- pgSql: a safe tagged-template query builder (the Postgres analogue of `surql`) -------------
|
|
345
|
+
|
|
346
|
+
/** A bound Postgres query: text with positional `$1..$n` placeholders + the values bound to them. */
|
|
347
|
+
export interface BoundPgQuery {
|
|
348
|
+
query: string;
|
|
349
|
+
params: unknown[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A raw SQL fragment spliced VERBATIM into a `pgSql` template (NOT parameterized — caller-trusted). */
|
|
353
|
+
interface PgFragment {
|
|
354
|
+
readonly __pgRaw: string;
|
|
355
|
+
}
|
|
356
|
+
const isFragment = (v: unknown): v is PgFragment =>
|
|
357
|
+
typeof v === "object" && v !== null && "__pgRaw" in v;
|
|
358
|
+
const isBound = (v: unknown): v is BoundPgQuery =>
|
|
359
|
+
typeof v === "object" &&
|
|
360
|
+
v !== null &&
|
|
361
|
+
typeof (v as BoundPgQuery).query === "string" &&
|
|
362
|
+
Array.isArray((v as BoundPgQuery).params);
|
|
363
|
+
|
|
364
|
+
/** Splice a raw SQL string verbatim (NOT parameterized — only for caller-trusted SQL). */
|
|
365
|
+
export function raw(sql: string): PgFragment {
|
|
366
|
+
return { __pgRaw: sql };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** A safely double-quoted identifier (table/column) to splice into a `pgSql` template. */
|
|
370
|
+
export function identifier(name: string): PgFragment {
|
|
371
|
+
return { __pgRaw: escId(name) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Tagged-template SQL builder — the Postgres analogue of SurrealDB's `surql`. Interpolated values
|
|
376
|
+
* become positional bind params (`$1..$n`), so values are never string-interpolated (injection-safe).
|
|
377
|
+
* Wrap a value in {@link raw} / {@link identifier} to splice SQL STRUCTURE instead of a param, and a
|
|
378
|
+
* nested `pgSql` composes (its placeholders renumber, its params merge). Returns a {@link BoundPgQuery}
|
|
379
|
+
* — it does NOT execute; pass it to `postgresDriver.query` / `conn.query`, or nest it in another `pgSql`.
|
|
380
|
+
*
|
|
381
|
+
* pgSql`SELECT * FROM ${identifier("user")} WHERE id = ${id}`
|
|
382
|
+
* // -> { query: 'SELECT * FROM "user" WHERE id = $1', params: [id] }
|
|
383
|
+
*/
|
|
384
|
+
export function pgSql(
|
|
385
|
+
strings: TemplateStringsArray,
|
|
386
|
+
...values: unknown[]
|
|
387
|
+
): BoundPgQuery {
|
|
388
|
+
let query = "";
|
|
389
|
+
const params: unknown[] = [];
|
|
390
|
+
strings.forEach((str, i) => {
|
|
391
|
+
query += str;
|
|
392
|
+
if (i >= values.length) return;
|
|
393
|
+
const v = values[i];
|
|
394
|
+
if (isFragment(v)) {
|
|
395
|
+
query += v.__pgRaw;
|
|
396
|
+
} else if (isBound(v)) {
|
|
397
|
+
// Compose: renumber the nested query's $n by the params already collected, then merge.
|
|
398
|
+
query += v.query.replace(
|
|
399
|
+
/\$(\d+)/g,
|
|
400
|
+
(_m, n) => `$${params.length + Number(n)}`,
|
|
401
|
+
);
|
|
402
|
+
params.push(...v.params);
|
|
403
|
+
} else {
|
|
404
|
+
params.push(v);
|
|
405
|
+
query += `$${params.length}`;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
return { query, params };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- postgresConnection: the multi-connection authoring factory ---------------------------------
|
|
412
|
+
|
|
413
|
+
/** Postgres connection params, on top of the dialect-neutral base ({schema, key?, migrations?}). */
|
|
414
|
+
export interface PostgresConnectionConfig extends ConnectionConfigBase {
|
|
415
|
+
/**
|
|
416
|
+
* Where to connect. `file:<dir>` (or a bare path) -> embedded PGlite data dir; empty/omitted ->
|
|
417
|
+
* in-memory PGlite. A `postgres://` URL is reserved for a future node-postgres client.
|
|
418
|
+
*/
|
|
419
|
+
url?: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Typed `postgresConnection(...)` factory — the only thing a config's `connections` map accepts for
|
|
424
|
+
* this driver. Wraps {@link connectionEntry} with the Postgres connection shape. Pass a static config,
|
|
425
|
+
* a resolver yielding one config, or a resolver yielding a keyed COLLECTION (each entry needs `key`).
|
|
426
|
+
*/
|
|
427
|
+
export function postgresConnection(
|
|
428
|
+
config: PostgresConnectionConfig,
|
|
429
|
+
): ConnectionEntry;
|
|
430
|
+
export function postgresConnection(
|
|
431
|
+
resolver: (
|
|
432
|
+
ctx: ResolveContext,
|
|
433
|
+
) => PostgresConnectionConfig | Promise<PostgresConnectionConfig>,
|
|
434
|
+
): ConnectionEntry;
|
|
435
|
+
export function postgresConnection(
|
|
436
|
+
resolver: (
|
|
437
|
+
ctx: ResolveContext,
|
|
438
|
+
) =>
|
|
439
|
+
| (PostgresConnectionConfig & { key: string })[]
|
|
440
|
+
| Promise<(PostgresConnectionConfig & { key: string })[]>,
|
|
441
|
+
): ConnectionEntry;
|
|
442
|
+
export function postgresConnection(
|
|
443
|
+
input: ConnectionInput<PostgresConnectionConfig>,
|
|
444
|
+
): ConnectionEntry {
|
|
445
|
+
return connectionEntry("postgres", input);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --- migration bookkeeping (apply-time SQL behind migrate/rollback/status) ----------------------
|
|
449
|
+
|
|
450
|
+
const MIG_UP = "-- schemic:up";
|
|
451
|
+
const MIG_DOWN = "-- schemic:down";
|
|
452
|
+
|
|
453
|
+
/** Render a diff to a Postgres migration file: marker-delimited `up` and `down` DDL sections. */
|
|
454
|
+
function renderMigration(_tag: string, diff: Diff): string {
|
|
455
|
+
return `${MIG_UP}\n${diff.up.join("\n")}\n\n${MIG_DOWN}\n${diff.down.join("\n")}\n`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Extract the `up` or `down` DDL section from a migration file body. */
|
|
459
|
+
function migSection(content: string, direction: MigrationDirection): string {
|
|
460
|
+
const up = content.indexOf(MIG_UP);
|
|
461
|
+
const down = content.indexOf(MIG_DOWN);
|
|
462
|
+
if (up === -1 || down === -1) return direction === "up" ? content : "";
|
|
463
|
+
return direction === "up"
|
|
464
|
+
? content.slice(up + MIG_UP.length, down)
|
|
465
|
+
: content.slice(down + MIG_DOWN.length);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const sqlStr = (v: string) => `'${v.replace(/'/g, "''")}'`;
|
|
469
|
+
const lockTableOf = (table: string) => `${table}_lock`;
|
|
470
|
+
|
|
471
|
+
async function ensureMigTable(conn: PgConn, table: string): Promise<void> {
|
|
472
|
+
await conn.exec(
|
|
473
|
+
`CREATE TABLE IF NOT EXISTS ${escId(table)} (
|
|
474
|
+
${escId("tag")} text PRIMARY KEY,
|
|
475
|
+
${escId("file")} text NOT NULL,
|
|
476
|
+
${escId("checksum")} text NOT NULL,
|
|
477
|
+
${escId("applied_at")} timestamptz NOT NULL DEFAULT now()
|
|
478
|
+
);`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const recordInsert = (table: string, r: MigrationRecord) =>
|
|
483
|
+
`INSERT INTO ${escId(table)} (${escId("tag")}, ${escId("file")}, ${escId("checksum")}) VALUES (${sqlStr(r.tag)}, ${sqlStr(r.file)}, ${sqlStr(r.checksum)});`;
|
|
484
|
+
|
|
485
|
+
const migrations: MigrationStore<PgConn> = {
|
|
486
|
+
extension: ".sql",
|
|
487
|
+
render: renderMigration,
|
|
488
|
+
ensure: ensureMigTable,
|
|
489
|
+
|
|
490
|
+
async applied(conn, table) {
|
|
491
|
+
const { rows } = await conn.query<{ tag: string; checksum: string }>(
|
|
492
|
+
`SELECT ${escId("tag")}, ${escId("checksum")} FROM ${escId(table)};`,
|
|
493
|
+
);
|
|
494
|
+
return new Map(rows.map((r) => [r.tag, r.checksum]));
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
// Postgres runs DDL inside a transaction, so the migration's section + its bookkeeping write commit
|
|
498
|
+
// atomically — the record lands iff the DDL applied.
|
|
499
|
+
async apply(conn, table, { content, direction, record }) {
|
|
500
|
+
const ddl = migSection(content, direction).trim();
|
|
501
|
+
const book =
|
|
502
|
+
direction === "up"
|
|
503
|
+
? recordInsert(table, record)
|
|
504
|
+
: `DELETE FROM ${escId(table)} WHERE ${escId("tag")} = ${sqlStr(record.tag)};`;
|
|
505
|
+
await conn.exec(`BEGIN;\n${ddl ? `${ddl}\n` : ""}${book}\nCOMMIT;`);
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async record(conn, table, record) {
|
|
509
|
+
await ensureMigTable(conn, table);
|
|
510
|
+
await conn.exec(recordInsert(table, record));
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
async clear(conn, table) {
|
|
514
|
+
await conn.exec(`DELETE FROM ${escId(table)};`);
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
// A persisted lock ROW (survives across separate CLI runs on a file-based PGlite, unlike a session
|
|
518
|
+
// advisory lock). The PK collision on a held lock is the "already locked" signal.
|
|
519
|
+
async lock(conn, table) {
|
|
520
|
+
const lt = lockTableOf(table);
|
|
521
|
+
await conn.exec(
|
|
522
|
+
`CREATE TABLE IF NOT EXISTS ${escId(lt)} (${escId("id")} int PRIMARY KEY);`,
|
|
523
|
+
);
|
|
524
|
+
try {
|
|
525
|
+
await conn.exec(`INSERT INTO ${escId(lt)} (${escId("id")}) VALUES (1);`);
|
|
526
|
+
} catch {
|
|
527
|
+
throw new Error(
|
|
528
|
+
"Migrations are locked — another run is in progress. If it's stale, run `schemic unlock`.",
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
async unlock(conn, table) {
|
|
534
|
+
const lt = lockTableOf(table);
|
|
535
|
+
await conn.exec(
|
|
536
|
+
`CREATE TABLE IF NOT EXISTS ${escId(lt)} (${escId("id")} int PRIMARY KEY);\nDELETE FROM ${escId(lt)} WHERE ${escId("id")} = 1;`,
|
|
537
|
+
);
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
export const postgresDriver: Driver<PgConn> = {
|
|
542
|
+
name: "postgres",
|
|
543
|
+
|
|
544
|
+
// The kind registry (table/index/constraint) — core runs lower/diff/emit/order generically over it.
|
|
545
|
+
registry,
|
|
546
|
+
|
|
547
|
+
// Authoring (pg-native `defineTable` -> PgTableDef) -> kinded Definables: lower each table to the
|
|
548
|
+
// driver's `PgTable` IR (./lower.ts), then split it into [table, ...index, ...constraint] objects
|
|
549
|
+
// (./kinds.ts splitTable). Core then runs lowerSchema(registry, explode(...)). pg has no standalone
|
|
550
|
+
// defs, so `defs` is unused.
|
|
551
|
+
explode: (tables): Definable[] =>
|
|
552
|
+
splitTables(pgLower(tables as unknown as PgTableDef[])),
|
|
553
|
+
|
|
554
|
+
// One information_schema/pg_catalog read -> ALL kind objects, canonicalized identically to lowering
|
|
555
|
+
// (a clean apply round-trips to a zero diff) and complete (table + index + FK), so no phantom diffs.
|
|
556
|
+
introspectAll: async (conn, exclude) =>
|
|
557
|
+
splitTables(await pgIntrospect(conn, exclude)),
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Raw READ query for connection RESOLVERS + seed (returns rows opaquely). Postgres binds
|
|
561
|
+
* POSITIONALLY, so the uniform `vars` record is mapped onto `$1..$n`: a string with NAMED `$name`
|
|
562
|
+
* placeholders + `vars` is rewritten to positional `$1..$n` with `vars` bound by name (never
|
|
563
|
+
* string-interpolated); native numeric `$1` is left untouched; no `vars` -> run as-is. To build a
|
|
564
|
+
* query safely from interpolated values, use {@link pgSql} (positional) and run it via the raw
|
|
565
|
+
* connection: `conn.query(q.query, q.params)` — that also avoids rewriting `$` inside string literals.
|
|
566
|
+
*/
|
|
567
|
+
async query<T = unknown>(
|
|
568
|
+
conn: PgConn,
|
|
569
|
+
sql: string,
|
|
570
|
+
vars?: Record<string, unknown>,
|
|
571
|
+
): Promise<T[]> {
|
|
572
|
+
if (!vars || Object.keys(vars).length === 0) {
|
|
573
|
+
return (await conn.query<T>(sql)).rows;
|
|
574
|
+
}
|
|
575
|
+
const params: unknown[] = [];
|
|
576
|
+
const text = sql.replace(
|
|
577
|
+
/\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
578
|
+
(_m, name: string) => {
|
|
579
|
+
if (!(name in vars)) {
|
|
580
|
+
throw new Error(`postgres query: no binding for $${name}`);
|
|
581
|
+
}
|
|
582
|
+
params.push(vars[name]);
|
|
583
|
+
return `$${params.length}`;
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
return (await conn.query<T>(text, params)).rows;
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
connect(
|
|
590
|
+
config: ResolvedConfig,
|
|
591
|
+
_over?: ConnectionOverrides,
|
|
592
|
+
): Promise<PgConn> {
|
|
593
|
+
// The neutral ResolvedConfig carries the driver-specific connection bag in `params` (our
|
|
594
|
+
// PostgresConnectionConfig minus the neutral schema/migrations/key). PGlite is embedded; treat a
|
|
595
|
+
// `file:`/path url as a data dir, else in-memory.
|
|
596
|
+
const url = typeof config.params.url === "string" ? config.params.url : "";
|
|
597
|
+
const dir = url.startsWith("file:") ? url.slice("file:".length) : undefined;
|
|
598
|
+
return newPglite(dir);
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
async apply(
|
|
602
|
+
conn: PgConn,
|
|
603
|
+
statements: string[],
|
|
604
|
+
opts?: ApplyOptions,
|
|
605
|
+
): Promise<void> {
|
|
606
|
+
if (!statements.length) return;
|
|
607
|
+
const body = statements.join("\n");
|
|
608
|
+
if (opts?.transactional === false) {
|
|
609
|
+
await conn.exec(body);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
await conn.exec(`BEGIN;\n${body}\nCOMMIT;`);
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
close(conn: PgConn): Promise<void> {
|
|
616
|
+
return conn.close();
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
// Apply-time migration bookkeeping (the `_migrations` table SQL behind migrate/rollback/status).
|
|
620
|
+
migrations,
|
|
621
|
+
|
|
622
|
+
// `schemic init --driver postgres` scaffolds a real connections-only pg project from these files
|
|
623
|
+
// (the CLI adds the neutral migration snapshot).
|
|
624
|
+
initScaffold: () => ({
|
|
625
|
+
"schemic.config.ts": INIT_CONFIG_TS,
|
|
626
|
+
"database/schema/tables.ts": INIT_SCHEMA_TS,
|
|
627
|
+
"database/seed.ts": INIT_SEED_TS,
|
|
628
|
+
".env.example": INIT_ENV,
|
|
629
|
+
}),
|
|
630
|
+
|
|
631
|
+
// `schemic new <kind> <name>` -> the starter authoring module for a new entity. pg's only
|
|
632
|
+
// standalone definable is the `table`; indexes/FKs are authored INSIDE a table, so those kinds
|
|
633
|
+
// throw with guidance. The CLI writes the returned text under registry.display(kind).folder.
|
|
634
|
+
scaffoldEntity: (kind, name) => scaffoldPgEntity(kind, name),
|
|
635
|
+
|
|
636
|
+
shadow,
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// --- `schemic init` scaffold templates ----------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
const INIT_CONFIG_TS = `import { defineConfig } from "@schemic/core/config";
|
|
642
|
+
import { postgresConnection } from "@schemic/postgres";
|
|
643
|
+
|
|
644
|
+
// Connections-only config: a map of named connections, each from a driver factory. Values are
|
|
645
|
+
// explicit — read env yourself (no magic env vars).
|
|
646
|
+
export default defineConfig({
|
|
647
|
+
connections: {
|
|
648
|
+
default: postgresConnection({
|
|
649
|
+
schema: "./database/schema",
|
|
650
|
+
// PGlite (embedded): \`file:<dir>\` is a persistent data dir; "" is in-memory. Point
|
|
651
|
+
// DATABASE_URL at a real server (\`postgres://…\`) once the node-postgres client lands.
|
|
652
|
+
url: process.env.DATABASE_URL ?? "file:./.pgdata",
|
|
653
|
+
}),
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
`;
|
|
657
|
+
|
|
658
|
+
const INIT_SCHEMA_TS = `import { defineTable, s, sqlExpr } from "@schemic/postgres";
|
|
659
|
+
|
|
660
|
+
export const user = defineTable("user", {
|
|
661
|
+
email: s.varchar(255).$unique(),
|
|
662
|
+
name: s.text(),
|
|
663
|
+
age: s.smallint().optional(),
|
|
664
|
+
createdAt: s.timestamptz().$default(sqlExpr("now()")),
|
|
665
|
+
});
|
|
666
|
+
`;
|
|
667
|
+
|
|
668
|
+
const INIT_SEED_TS = `// Seed script — run with \`schemic seed\`. Receives the live connection(s).
|
|
669
|
+
export default async function seed() {
|
|
670
|
+
// await conn.query("INSERT INTO ...");
|
|
671
|
+
}
|
|
672
|
+
`;
|
|
673
|
+
|
|
674
|
+
const INIT_ENV = `# A real Postgres server (uncomment to use instead of embedded PGlite):
|
|
675
|
+
# DATABASE_URL=postgres://user:pass@localhost:5432/app
|
|
676
|
+
`;
|
|
677
|
+
|
|
678
|
+
// --- `schemic new <kind> <name>` entity scaffolding ---------------------------------------------
|
|
679
|
+
|
|
680
|
+
/** A valid JS identifier from an entity name (snake_case kept; other separators -> camelCase; digit-led -> `_`). */
|
|
681
|
+
function toIdentifier(name: string): string {
|
|
682
|
+
const camel = name.replace(
|
|
683
|
+
/[^a-zA-Z0-9_$]+([a-zA-Z0-9])?/g,
|
|
684
|
+
(_m, c?: string) => (c ? c.toUpperCase() : ""),
|
|
685
|
+
);
|
|
686
|
+
return /^[0-9]/.test(camel) ? `_${camel}` : camel || "entity";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** A starter `defineTable` module for a new table — full templating: commented examples of every clause. */
|
|
690
|
+
function scaffoldTable(name: string): string {
|
|
691
|
+
const ident = toIdentifier(name);
|
|
692
|
+
return `import { defineTable, s, sqlExpr } from "@schemic/postgres";
|
|
693
|
+
|
|
694
|
+
// \`sc new table ${name}\` scaffolded this. Author your columns, then \`sc gen\`.
|
|
695
|
+
export const ${ident} = defineTable(${JSON.stringify(name)}, {
|
|
696
|
+
// An implicit \`"id" text PRIMARY KEY\` is added unless you declare a PK below.
|
|
697
|
+
name: s.text(),
|
|
698
|
+
// email: s.varchar(255).$unique(), // -> UNIQUE INDEX
|
|
699
|
+
// age: s.smallint().optional(), // -> nullable column
|
|
700
|
+
// status: s.text().$check("status in ('active', 'archived')"), // -> CHECK constraint
|
|
701
|
+
// owner: s.references("other_table", { onDelete: "cascade" }), // -> FOREIGN KEY
|
|
702
|
+
createdAt: s.timestamptz().$default(sqlExpr("now()")),
|
|
703
|
+
});
|
|
704
|
+
// Table-level options (chain onto defineTable(...) above):
|
|
705
|
+
// .primaryKey("a", "b") composite PK (drops the implicit id)
|
|
706
|
+
// .check("age >= 0") table-level CHECK
|
|
707
|
+
// .index(["name"]) secondary index (add { unique: true } for UNIQUE)
|
|
708
|
+
`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Author a new entity module for `kind`. Postgres' only standalone definable is the `table`; indexes
|
|
713
|
+
* and foreign keys are authored INSIDE a table (`.index([...])` / `s.references(...)`), so those kinds
|
|
714
|
+
* throw with guidance rather than scaffolding a meaningless standalone file. Unknown kinds throw too.
|
|
715
|
+
*/
|
|
716
|
+
function scaffoldPgEntity(kind: string, name: string): string {
|
|
717
|
+
switch (kind) {
|
|
718
|
+
case "table":
|
|
719
|
+
return scaffoldTable(name);
|
|
720
|
+
case "index":
|
|
721
|
+
case "constraint":
|
|
722
|
+
throw new Error(
|
|
723
|
+
`postgres: "${kind}" isn't a standalone entity — indexes and foreign keys are authored inside a table (defineTable(...).index([...]) / s.references(...)). Run \`sc new table <name>\`.`,
|
|
724
|
+
);
|
|
725
|
+
default:
|
|
726
|
+
throw new Error(
|
|
727
|
+
`postgres: unknown entity kind "${kind}" — pg scaffolds: table.`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
registerDriver(postgresDriver as Driver<unknown>);
|