@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/authoring.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// The POSTGRES native authoring surface — `s.*` in pg vocabulary, built on the neutral core base
|
|
2
|
+
// (@schemic/core/authoring). A pg project authors with THESE types (pg lingo: text/varchar/numeric/
|
|
3
|
+
// timestamptz/jsonb/serial/...) and the driver lowers them to the portable IR (see ./lower.ts), then
|
|
4
|
+
// emits pg DDL. Per the multi-DB decision (every driver owns its own `s.*`), this mirrors
|
|
5
|
+
// @schemic/surrealdb's surface but in Postgres terms.
|
|
6
|
+
//
|
|
7
|
+
// Design constraints (Manuel):
|
|
8
|
+
// - LAYER ON ZOD: every field IS a Zod schema (`PgField extends SFieldBase`); pg-native metadata
|
|
9
|
+
// rides the field's opaque `native` slot (PgMeta), never patching Zod internals.
|
|
10
|
+
// - ESCAPE HATCH: `s.$postgres(pgType, codec)` for any type not representable on the wire — a Zod
|
|
11
|
+
// codec (encode/decode) App-side, stored as the given pg type (mirrors surreal's `$surreal`).
|
|
12
|
+
// - DX FIRST: native types + `$`-methods ($default/$check/$generated/$identity/$unique/$primaryKey/
|
|
13
|
+
// $references/$comment) + the full Zod wrapper/passthrough chain, all type-preserving.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type AnyField,
|
|
17
|
+
type SchemaOf,
|
|
18
|
+
SFieldBase,
|
|
19
|
+
toZod,
|
|
20
|
+
} from "@schemic/core/authoring";
|
|
21
|
+
import * as z from "zod";
|
|
22
|
+
|
|
23
|
+
// --- PgMeta: the pg-native metadata bag carried on every field ----------------------------------
|
|
24
|
+
|
|
25
|
+
/** A pg column type token + optional params (`varchar`/[255], `numeric`/[10,2]); the leaf factory sets it. */
|
|
26
|
+
export interface PgTypeRef {
|
|
27
|
+
type: string;
|
|
28
|
+
params?: (string | number)[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Postgres-native field metadata (the `native` slot of {@link PgField}). All optional; merged by `$`-methods. */
|
|
32
|
+
export interface PgMeta {
|
|
33
|
+
/** The pg base type (sans option/nullable/array wrappers, which live on the Zod schema). */
|
|
34
|
+
pg?: PgTypeRef;
|
|
35
|
+
/** `DEFAULT <expr>` (verbatim SQL). */
|
|
36
|
+
default?: string;
|
|
37
|
+
/** Field-level `CHECK (<expr>)` (verbatim SQL boolean expr). */
|
|
38
|
+
check?: string;
|
|
39
|
+
/** `GENERATED ALWAYS AS (<expr>) STORED` (verbatim SQL expr). */
|
|
40
|
+
generated?: string;
|
|
41
|
+
/** `GENERATED {ALWAYS|BY DEFAULT} AS IDENTITY` (also how `serial` is modeled). */
|
|
42
|
+
identity?: "always" | "by-default";
|
|
43
|
+
/** Column-level `UNIQUE`. */
|
|
44
|
+
unique?: boolean;
|
|
45
|
+
/** Column is (part of) the PRIMARY KEY. */
|
|
46
|
+
primaryKey?: boolean;
|
|
47
|
+
/** A foreign key to `table(id)` with optional referential actions. */
|
|
48
|
+
references?: { table: string; onDelete?: string; onUpdate?: string };
|
|
49
|
+
/** `COMMENT ON COLUMN`. */
|
|
50
|
+
comment?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const blankMeta = (): PgMeta => ({});
|
|
54
|
+
|
|
55
|
+
/** A raw SQL expression marker for DDL clauses (`$default`/`$check`/`$generated`) — spliced verbatim. */
|
|
56
|
+
export interface SqlExpr {
|
|
57
|
+
readonly __sql: string;
|
|
58
|
+
}
|
|
59
|
+
/** Mark a string as a raw SQL expression (vs a literal value) for a DDL clause: `s.timestamptz().$default(sqlExpr("now()"))`. */
|
|
60
|
+
export const sqlExpr = (sql: string): SqlExpr => ({ __sql: sql });
|
|
61
|
+
const isSqlExpr = (v: unknown): v is SqlExpr =>
|
|
62
|
+
typeof v === "object" && v !== null && "__sql" in v;
|
|
63
|
+
|
|
64
|
+
/** Render a JS literal as a Postgres literal (numbers/bools bare, strings single-quoted, null -> NULL). */
|
|
65
|
+
function pgLiteral(v: string | number | boolean | null): string {
|
|
66
|
+
if (v === null) return "NULL";
|
|
67
|
+
if (typeof v === "number") return String(v);
|
|
68
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
69
|
+
return `'${v.replace(/'/g, "''")}'`;
|
|
70
|
+
}
|
|
71
|
+
const toExpr = (v: string | number | boolean | null | SqlExpr): string =>
|
|
72
|
+
isSqlExpr(v) ? v.__sql : pgLiteral(v);
|
|
73
|
+
|
|
74
|
+
// --- PgField: the dialect subclass -------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A Postgres field: a Zod schema (App/Wire typing) + {@link PgMeta} (pg-native DDL metadata). Extends
|
|
78
|
+
* the neutral {@link SFieldBase}, which supplies the codecs, the Zod wrappers, and the `z.*` passthrough;
|
|
79
|
+
* this subclass re-types the wrappers so a chain stays a `PgField`, and adds the pg `$`-methods.
|
|
80
|
+
*/
|
|
81
|
+
export class PgField<
|
|
82
|
+
S extends z.ZodType = z.ZodType,
|
|
83
|
+
Flags extends string = never,
|
|
84
|
+
> extends SFieldBase<S, Flags, PgMeta> {
|
|
85
|
+
protected rebuild<S2 extends z.ZodType, F2 extends string>(
|
|
86
|
+
schema: S2,
|
|
87
|
+
native: PgMeta,
|
|
88
|
+
): PgField<S2, F2> {
|
|
89
|
+
return new PgField<S2, F2>(schema, native);
|
|
90
|
+
}
|
|
91
|
+
protected blank(): PgMeta {
|
|
92
|
+
return blankMeta();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Type-only narrowing of the inherited portable wrappers (runtime impl is the base's, which builds a
|
|
96
|
+
// PgField via rebuild) — so a mixed chain like `s.text().$default("x").optional()` stays a PgField.
|
|
97
|
+
declare optional: () => PgField<z.ZodOptional<S>, Flags>;
|
|
98
|
+
declare nullable: () => PgField<z.ZodNullable<S>, Flags>;
|
|
99
|
+
declare nullish: () => PgField<z.ZodOptional<z.ZodNullable<S>>, Flags>;
|
|
100
|
+
declare array: () => PgField<z.ZodArray<S>, Flags>;
|
|
101
|
+
declare default: (value: z.input<S>) => PgField<z.ZodDefault<S>, Flags>;
|
|
102
|
+
declare prefault: (value: z.input<S>) => PgField<z.ZodPrefault<S>, Flags>;
|
|
103
|
+
declare catch: (value: z.output<S>) => PgField<z.ZodCatch<S>, Flags>;
|
|
104
|
+
|
|
105
|
+
private with(meta: Partial<PgMeta>): PgField<S, Flags> {
|
|
106
|
+
return new PgField<S, Flags>(this.schema, { ...this.native, ...meta });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- pg-native `$`-methods (DDL authoring) ---
|
|
110
|
+
/** `DEFAULT <value>` — a JS literal, or `sqlExpr("now()")` for a raw SQL default. */
|
|
111
|
+
$default(value: z.input<S> | SqlExpr): PgField<S, Flags> {
|
|
112
|
+
return this.with({ default: toExpr(value as never) });
|
|
113
|
+
}
|
|
114
|
+
/** Field-level `CHECK (<expr>)`. */
|
|
115
|
+
$check(expr: string | SqlExpr): PgField<S, Flags> {
|
|
116
|
+
return this.with({ check: isSqlExpr(expr) ? expr.__sql : expr });
|
|
117
|
+
}
|
|
118
|
+
/** `GENERATED ALWAYS AS (<expr>) STORED` — a computed column. */
|
|
119
|
+
$generated(expr: string | SqlExpr): PgField<S, Flags> {
|
|
120
|
+
return this.with({ generated: isSqlExpr(expr) ? expr.__sql : expr });
|
|
121
|
+
}
|
|
122
|
+
/** `GENERATED {ALWAYS|BY DEFAULT} AS IDENTITY` (auto-increment). */
|
|
123
|
+
$identity(mode: "always" | "by-default" = "by-default"): PgField<S, Flags> {
|
|
124
|
+
return this.with({ identity: mode });
|
|
125
|
+
}
|
|
126
|
+
/** Column-level `UNIQUE`. */
|
|
127
|
+
$unique(): PgField<S, Flags> {
|
|
128
|
+
return this.with({ unique: true });
|
|
129
|
+
}
|
|
130
|
+
/** Mark this column (part of) the PRIMARY KEY. */
|
|
131
|
+
$primaryKey(): PgField<S, Flags> {
|
|
132
|
+
return this.with({ primaryKey: true });
|
|
133
|
+
}
|
|
134
|
+
/** Foreign key to `table(id)` with optional `ON DELETE`/`ON UPDATE` actions. */
|
|
135
|
+
$references(
|
|
136
|
+
table: string,
|
|
137
|
+
opts?: { onDelete?: string; onUpdate?: string },
|
|
138
|
+
): PgField<S, Flags> {
|
|
139
|
+
return this.with({ references: { table, ...(opts ?? {}) } });
|
|
140
|
+
}
|
|
141
|
+
/** `COMMENT ON COLUMN`. */
|
|
142
|
+
$comment(text: string): PgField<S, Flags> {
|
|
143
|
+
return this.with({ comment: text });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* ESCAPE HATCH (chainable form) — teach the driver how to STORE this field's value in Postgres:
|
|
148
|
+
* give the **wire type** as an `s.*`/Zod field (its pg column type is taken from it) plus a codec
|
|
149
|
+
* (`encode`: app -> wire, `decode`: wire -> app). This turns an otherwise-unmappable App value
|
|
150
|
+
* (e.g. `s.instanceof(Money)`) into a real pg column. Omit the codec for an identity mapping (the
|
|
151
|
+
* app value is stored as-is). Mirrors SurrealDB's `.$surreal(wire, codec)`; the standalone
|
|
152
|
+
* {@link s.$postgres} factory is the from-scratch equivalent. `$`-prefixed to avoid clashing with Zod.
|
|
153
|
+
*/
|
|
154
|
+
$postgres<WF extends AnyField | z.ZodType, A = z.output<S>>(
|
|
155
|
+
wire: WF,
|
|
156
|
+
codec?: {
|
|
157
|
+
encode: (app: A) => z.output<SchemaOf<WF>>;
|
|
158
|
+
decode: (wire: z.output<SchemaOf<WF>>) => A;
|
|
159
|
+
},
|
|
160
|
+
): PgField<z.ZodCodec<SchemaOf<WF>, S>, Flags> {
|
|
161
|
+
const wireSchema = toZod(wire) as SchemaOf<WF>;
|
|
162
|
+
const c = z.codec(wireSchema, this.schema, {
|
|
163
|
+
decode: (w) => (codec ? codec.decode(w as never) : w) as never,
|
|
164
|
+
encode: (a) => (codec ? codec.encode(a as A) : a) as never,
|
|
165
|
+
});
|
|
166
|
+
// The stored pg type comes from the WIRE field (its column type); App typing comes from `this`.
|
|
167
|
+
const wirePg = wire instanceof PgField ? wire.native.pg : undefined;
|
|
168
|
+
return new PgField<z.ZodCodec<SchemaOf<WF>, S>, Flags>(c, {
|
|
169
|
+
...this.native,
|
|
170
|
+
...(wirePg ? { pg: wirePg } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- the `s` vocabulary (pg lingo) -------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const mk = (
|
|
178
|
+
type: string,
|
|
179
|
+
schema: z.ZodType,
|
|
180
|
+
params?: (string | number)[],
|
|
181
|
+
): PgField => new PgField(schema, { pg: params ? { type, params } : { type } });
|
|
182
|
+
|
|
183
|
+
/** The Postgres authoring namespace. Zod drop-ins (string/number/…) + native pg types + `$postgres`. */
|
|
184
|
+
export const s = {
|
|
185
|
+
// Zod drop-ins (the canonical superset; each maps to a sensible pg default). Native aliases below
|
|
186
|
+
// (text/varchar/int/numeric/…) give precise control.
|
|
187
|
+
string: () => mk("text", z.string()),
|
|
188
|
+
number: () => mk("double precision", z.number()),
|
|
189
|
+
// text
|
|
190
|
+
text: () => mk("text", z.string()),
|
|
191
|
+
varchar: (n?: number) =>
|
|
192
|
+
n === undefined
|
|
193
|
+
? mk("varchar", z.string())
|
|
194
|
+
: mk("varchar", z.string().max(n), [n]),
|
|
195
|
+
char: (n?: number) =>
|
|
196
|
+
n === undefined ? mk("char", z.string()) : mk("char", z.string(), [n]),
|
|
197
|
+
citext: () => mk("citext", z.string()),
|
|
198
|
+
// numeric
|
|
199
|
+
smallint: () => mk("smallint", z.int().gte(-32768).lte(32767)),
|
|
200
|
+
integer: () => mk("integer", z.int()),
|
|
201
|
+
int: () => mk("integer", z.int()),
|
|
202
|
+
bigint: () => mk("bigint", z.int()),
|
|
203
|
+
serial: () => mk("integer", z.int()).$identity("by-default"),
|
|
204
|
+
bigserial: () => mk("bigint", z.int()).$identity("by-default"),
|
|
205
|
+
numeric: (precision?: number, scale?: number) =>
|
|
206
|
+
precision === undefined
|
|
207
|
+
? mk("numeric", z.number())
|
|
208
|
+
: // Postgres stores `numeric(p)` as `numeric(p,0)`; keep scale explicit so it round-trips.
|
|
209
|
+
mk("numeric", z.number(), [precision, scale ?? 0]),
|
|
210
|
+
decimal: (precision?: number, scale?: number) => s.numeric(precision, scale),
|
|
211
|
+
real: () => mk("real", z.number()),
|
|
212
|
+
doublePrecision: () => mk("double precision", z.number()),
|
|
213
|
+
float: () => mk("double precision", z.number()),
|
|
214
|
+
money: () => mk("money", z.string()),
|
|
215
|
+
// boolean
|
|
216
|
+
boolean: () => mk("boolean", z.boolean()),
|
|
217
|
+
bool: () => mk("boolean", z.boolean()),
|
|
218
|
+
// temporal
|
|
219
|
+
timestamptz: () => mk("timestamptz", z.date()),
|
|
220
|
+
timestamp: () => mk("timestamp", z.date()),
|
|
221
|
+
date: () => mk("date", z.date()),
|
|
222
|
+
time: () => mk("time", z.string()),
|
|
223
|
+
timetz: () => mk("timetz", z.string()),
|
|
224
|
+
interval: () => mk("interval", z.string()),
|
|
225
|
+
// identity / network / uuid / bytes
|
|
226
|
+
uuid: () => mk("uuid", z.uuid()),
|
|
227
|
+
bytea: () => mk("bytea", z.instanceof(Uint8Array)),
|
|
228
|
+
inet: () => mk("inet", z.string()),
|
|
229
|
+
cidr: () => mk("cidr", z.string()),
|
|
230
|
+
macaddr: () => mk("macaddr", z.string()),
|
|
231
|
+
// json
|
|
232
|
+
jsonb: <T extends z.ZodType = z.ZodUnknown>(shape?: T) =>
|
|
233
|
+
mk("jsonb", shape ?? z.unknown()),
|
|
234
|
+
json: <T extends z.ZodType = z.ZodUnknown>(shape?: T) =>
|
|
235
|
+
mk("json", shape ?? z.unknown()),
|
|
236
|
+
// enum (string-literal union -> text) and single literal
|
|
237
|
+
enum: <const T extends readonly [string, ...string[]]>(values: T) =>
|
|
238
|
+
mk("text", z.enum(values)),
|
|
239
|
+
literal: <const V extends string | number | boolean>(value: V) =>
|
|
240
|
+
mk(
|
|
241
|
+
typeof value === "number"
|
|
242
|
+
? "double precision"
|
|
243
|
+
: typeof value === "boolean"
|
|
244
|
+
? "boolean"
|
|
245
|
+
: "text",
|
|
246
|
+
z.literal(value),
|
|
247
|
+
),
|
|
248
|
+
// object -> jsonb (opaque on disk). Accepts field OR raw-Zod values (a Zod drop-in superset).
|
|
249
|
+
object: (shape: Record<string, AnyField | z.ZodType>) =>
|
|
250
|
+
mk(
|
|
251
|
+
"jsonb",
|
|
252
|
+
z.object(
|
|
253
|
+
Object.fromEntries(
|
|
254
|
+
Object.entries(shape).map(([k, v]) => [k, toZod(v)]),
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
// array(elem) -> `<elem>[]`; carries the element's pg metadata so it lowers to an array of that type.
|
|
259
|
+
array: (elem: AnyField | z.ZodType): PgField =>
|
|
260
|
+
new PgField(
|
|
261
|
+
z.array(toZod(elem)),
|
|
262
|
+
elem instanceof PgField ? elem.native : {},
|
|
263
|
+
),
|
|
264
|
+
// foreign key: `text` column + FK to `table(id)`
|
|
265
|
+
references: (
|
|
266
|
+
table: string,
|
|
267
|
+
opts?: { onDelete?: string; onUpdate?: string },
|
|
268
|
+
): PgField =>
|
|
269
|
+
new PgField(z.string(), {
|
|
270
|
+
pg: { type: "text" },
|
|
271
|
+
references: { table, ...(opts ?? {}) },
|
|
272
|
+
}),
|
|
273
|
+
/**
|
|
274
|
+
* ESCAPE HATCH — a pg type with no portable meaning, stored via a Zod codec (encode/decode). The
|
|
275
|
+
* column is emitted as `pgType`; App-side reads/writes go through `codec`. Mirrors surreal `$surreal`.
|
|
276
|
+
*/
|
|
277
|
+
$postgres: <C extends z.ZodType>(pgType: string, codec: C): PgField<C> =>
|
|
278
|
+
new PgField<C>(codec, { pg: { type: pgType } }),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// --- defineTable: a pg table builder producing an `Authored` object -----------------------------
|
|
282
|
+
|
|
283
|
+
/** Table-level pg config: composite PK, table CHECKs, and secondary indexes. */
|
|
284
|
+
export interface PgTableConfig {
|
|
285
|
+
primaryKey?: string[];
|
|
286
|
+
checks?: string[];
|
|
287
|
+
indexes?: { name?: string; cols: string[]; unique?: boolean }[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A Postgres table definition — the `Authored` object the driver's `lower` reads. Structurally a
|
|
292
|
+
* `{ name }` (the neutral `Authored` bound); also carries its `fields` (a `{ col: PgField }` map) and
|
|
293
|
+
* table-level config. Chainable: `.primaryKey(...)`, `.check(expr)`, `.index([...])`.
|
|
294
|
+
*/
|
|
295
|
+
export class PgTableDef<
|
|
296
|
+
Name extends string = string,
|
|
297
|
+
F extends Record<string, PgField> = Record<string, PgField>,
|
|
298
|
+
> {
|
|
299
|
+
constructor(
|
|
300
|
+
readonly name: Name,
|
|
301
|
+
readonly fields: F,
|
|
302
|
+
readonly config: PgTableConfig = {},
|
|
303
|
+
) {}
|
|
304
|
+
|
|
305
|
+
/** Composite / custom PRIMARY KEY (overrides the implicit `id`). */
|
|
306
|
+
primaryKey(...cols: (keyof F & string)[]): PgTableDef<Name, F> {
|
|
307
|
+
return new PgTableDef(this.name, this.fields, {
|
|
308
|
+
...this.config,
|
|
309
|
+
primaryKey: cols,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** A table-level `CHECK (<expr>)`. */
|
|
313
|
+
check(expr: string): PgTableDef<Name, F> {
|
|
314
|
+
return new PgTableDef(this.name, this.fields, {
|
|
315
|
+
...this.config,
|
|
316
|
+
checks: [...(this.config.checks ?? []), expr],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** A secondary index over `cols` (optionally `UNIQUE`). */
|
|
320
|
+
index(
|
|
321
|
+
cols: (keyof F & string)[],
|
|
322
|
+
opts?: { name?: string; unique?: boolean },
|
|
323
|
+
): PgTableDef<Name, F> {
|
|
324
|
+
return new PgTableDef(this.name, this.fields, {
|
|
325
|
+
...this.config,
|
|
326
|
+
indexes: [...(this.config.indexes ?? []), { cols, ...(opts ?? {}) }],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A foreign-key field referencing THIS table (for use in another table's shape):
|
|
332
|
+
* `author: user.record({ onDelete: "cascade" })`. Also satisfies the CLI's structural table check.
|
|
333
|
+
*/
|
|
334
|
+
record(opts?: { onDelete?: string; onUpdate?: string }): PgField {
|
|
335
|
+
return s.references(this.name, opts);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Declare a Postgres table: `export const user = defineTable("user", { name: s.text(), age: s.integer().optional() })`. */
|
|
340
|
+
export function defineTable<
|
|
341
|
+
Name extends string,
|
|
342
|
+
F extends Record<string, PgField>,
|
|
343
|
+
>(name: Name, fields: F, config?: PgTableConfig): PgTableDef<Name, F> {
|
|
344
|
+
return new PgTableDef(name, fields, config ?? {});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- App/Wire type inference (DX) --------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/** The decoded (App-land) row type of a table — `z.output` of each field's schema. */
|
|
350
|
+
export type App<T extends PgTableDef> = {
|
|
351
|
+
[K in keyof T["fields"]]: z.output<T["fields"][K]["schema"]>;
|
|
352
|
+
};
|
|
353
|
+
/** The encoded (wire) row type of a table — `z.input` of each field's schema. */
|
|
354
|
+
export type Wire<T extends PgTableDef> = {
|
|
355
|
+
[K in keyof T["fields"]]: z.input<T["fields"][K]["schema"]>;
|
|
356
|
+
};
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Pure Postgres DDL + type helpers — the shared emit primitives the kind engines (./kinds.ts) +
|
|
2
|
+
// authoring lower (./lower.ts) + introspection (./index.ts) build on, so a table's CREATE/ALTER DDL
|
|
3
|
+
// is produced by ONE set of functions (no drift). No Driver/connection state here: just IR -> pg DDL
|
|
4
|
+
// string transforms. The field/type SUBSTRATE (`PortableField`/`PortableType`) is core's; the table-
|
|
5
|
+
// level container is this driver's own (`PgTable`) — `PortableTable` retired at the kind-registry flip.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
PortableField,
|
|
9
|
+
PortableType,
|
|
10
|
+
ScalarName,
|
|
11
|
+
} from "@schemic/core/driver";
|
|
12
|
+
import { nullable } from "@schemic/core/driver";
|
|
13
|
+
|
|
14
|
+
/** A secondary index over one table's columns (this driver emits UNIQUE; others tracked for parity). */
|
|
15
|
+
export interface PgIndexInfo {
|
|
16
|
+
name: string;
|
|
17
|
+
cols: string[];
|
|
18
|
+
unique: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The driver-private table shape (replaces the retired `PortableTable`): columns + PK + CHECKs + idx. */
|
|
22
|
+
export interface PgTable {
|
|
23
|
+
name: string;
|
|
24
|
+
fields: PortableField[];
|
|
25
|
+
indexes: PgIndexInfo[];
|
|
26
|
+
primaryKey?: string[];
|
|
27
|
+
checks?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Just what `createTableDdl` needs (a `PgTable` is a structural superset). */
|
|
31
|
+
export type PgCreateInput = Omit<PgTable, "indexes">;
|
|
32
|
+
|
|
33
|
+
export const escId = (name: string) => `"${name.replace(/"/g, '""')}"`;
|
|
34
|
+
|
|
35
|
+
// --- Type mapping (portable <-> Postgres) -------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const SCALAR_TO_PG: Partial<Record<ScalarName, string>> = {
|
|
38
|
+
string: "text",
|
|
39
|
+
int: "integer",
|
|
40
|
+
float: "double precision",
|
|
41
|
+
decimal: "numeric",
|
|
42
|
+
number: "double precision",
|
|
43
|
+
bool: "boolean",
|
|
44
|
+
datetime: "timestamp with time zone",
|
|
45
|
+
uuid: "uuid",
|
|
46
|
+
bytes: "bytea",
|
|
47
|
+
duration: "interval",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A portable column type and how it lands in Postgres: base SQL type, nullability, and FK target. */
|
|
51
|
+
export interface PgColumn {
|
|
52
|
+
sql: string;
|
|
53
|
+
nullable: boolean;
|
|
54
|
+
/** A `record<table>` link -> a FK to that table (single-target only; spike scope). */
|
|
55
|
+
references?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pgColumn(type: PortableType): PgColumn {
|
|
59
|
+
// Peel option/nullable: Postgres represents both as a nullable column (the documented collapse).
|
|
60
|
+
if (type.t === "option" || type.t === "nullable") {
|
|
61
|
+
return { ...pgColumn(type.inner), nullable: true };
|
|
62
|
+
}
|
|
63
|
+
if (type.t === "scalar") {
|
|
64
|
+
const sql = SCALAR_TO_PG[type.name];
|
|
65
|
+
if (!sql) throw new Error(`postgres: unsupported scalar "${type.name}"`);
|
|
66
|
+
return { sql, nullable: false };
|
|
67
|
+
}
|
|
68
|
+
if (type.t === "literal") {
|
|
69
|
+
// A single literal -> its base scalar (PG has no singleton types). Enums (literal unions) below.
|
|
70
|
+
const base =
|
|
71
|
+
typeof type.value === "number"
|
|
72
|
+
? "double precision"
|
|
73
|
+
: typeof type.value === "boolean"
|
|
74
|
+
? "boolean"
|
|
75
|
+
: "text";
|
|
76
|
+
return { sql: base, nullable: false };
|
|
77
|
+
}
|
|
78
|
+
if (type.t === "union") {
|
|
79
|
+
// A union of string literals -> text (an enum-ish column; a CHECK could be added later).
|
|
80
|
+
if (
|
|
81
|
+
type.members.every(
|
|
82
|
+
(m) => m.t === "literal" && typeof m.value === "string",
|
|
83
|
+
)
|
|
84
|
+
) {
|
|
85
|
+
return { sql: "text", nullable: false };
|
|
86
|
+
}
|
|
87
|
+
throw new Error("postgres: non-enum unions are unsupported");
|
|
88
|
+
}
|
|
89
|
+
if (type.t === "array" || type.t === "set") {
|
|
90
|
+
const elem = pgColumn(type.elem);
|
|
91
|
+
return { sql: `${elem.sql}[]`, nullable: false };
|
|
92
|
+
}
|
|
93
|
+
if (type.t === "object") {
|
|
94
|
+
return { sql: "jsonb", nullable: false };
|
|
95
|
+
}
|
|
96
|
+
if (type.t === "record") {
|
|
97
|
+
if (type.tables.length !== 1) {
|
|
98
|
+
// Multi-target links would need a polymorphic FK; out of spike scope -> plain text id.
|
|
99
|
+
return { sql: "text", nullable: false };
|
|
100
|
+
}
|
|
101
|
+
return { sql: "text", nullable: false, references: type.tables[0] };
|
|
102
|
+
}
|
|
103
|
+
if (type.t === "geometry") {
|
|
104
|
+
// Would map to PostGIS `geometry`; without the extension, store GeoJSON as jsonb.
|
|
105
|
+
return { sql: "jsonb", nullable: false };
|
|
106
|
+
}
|
|
107
|
+
if (type.t === "native") {
|
|
108
|
+
if (type.db !== "postgres") {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`postgres: native type "${type.name}" belongs to driver "${type.db}"`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
// params are order-significant: varchar -> (n), numeric -> (p[, s]).
|
|
114
|
+
const params = type.params as (string | number)[] | undefined;
|
|
115
|
+
const sql =
|
|
116
|
+
params && params.length > 0
|
|
117
|
+
? `${type.name}(${params.join(", ")})`
|
|
118
|
+
: type.name;
|
|
119
|
+
return { sql, nullable: false };
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`postgres: cannot emit type ${JSON.stringify(type)}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- normalize: project the portable IR onto what Postgres represents ---------------------------
|
|
125
|
+
|
|
126
|
+
/** Collapse option -> nullable (Postgres can't distinguish absence from NULL). Idempotent. */
|
|
127
|
+
export function pgCanonType(type: PortableType): PortableType {
|
|
128
|
+
if (type.t === "option") return nullable(pgCanonType(type.inner));
|
|
129
|
+
if (type.t === "nullable") return nullable(pgCanonType(type.inner));
|
|
130
|
+
if (type.t === "array")
|
|
131
|
+
return {
|
|
132
|
+
t: "array",
|
|
133
|
+
elem: pgCanonType(type.elem),
|
|
134
|
+
...(type.size !== undefined ? { size: type.size } : {}),
|
|
135
|
+
};
|
|
136
|
+
if (type.t === "set")
|
|
137
|
+
return {
|
|
138
|
+
t: "set",
|
|
139
|
+
elem: pgCanonType(type.elem),
|
|
140
|
+
...(type.size !== undefined ? { size: type.size } : {}),
|
|
141
|
+
};
|
|
142
|
+
// A nested object becomes an opaque jsonb column -> canonical empty object (sub-keys not tracked).
|
|
143
|
+
if (type.t === "object") return { t: "object", fields: {} };
|
|
144
|
+
return type;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Uppercase a referential action so authored `cascade` matches introspected `CASCADE`; drop NO ACTION (default). */
|
|
148
|
+
export function normAction(a?: string): string | undefined {
|
|
149
|
+
const u = a?.toUpperCase();
|
|
150
|
+
return u && u !== "NO ACTION" ? u : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A field reduced to its EQUALITY-relevant shape: canonical type + the STRUCTURAL clauses Postgres
|
|
155
|
+
* round-trips (identity, FK referential actions). EXPRESSION clauses (`default`/`check`/`computed`/
|
|
156
|
+
* `comment`) are dropped here: Postgres rewrites them on read (`0` -> `0`, `'x'` -> `'x'::text`,
|
|
157
|
+
* `a>0` -> `(a > 0)`), so they emit faithfully but can't round-trip to an exact match — a documented
|
|
158
|
+
* capability gap, not an equality difference (see docs/COVERAGE.md).
|
|
159
|
+
*/
|
|
160
|
+
export function canonField(f: PortableField, table: string): PortableField {
|
|
161
|
+
const out: PortableField = { name: f.name, table, type: pgCanonType(f.type) };
|
|
162
|
+
if (f.identity !== undefined) out.identity = f.identity;
|
|
163
|
+
if (f.reference) {
|
|
164
|
+
const ref: { on_delete?: string; on_update?: string } = {};
|
|
165
|
+
const od = normAction(f.reference.on_delete);
|
|
166
|
+
const ou = normAction(f.reference.on_update);
|
|
167
|
+
if (od) ref.on_delete = od;
|
|
168
|
+
if (ou) ref.on_update = ou;
|
|
169
|
+
if (ref.on_delete !== undefined || ref.on_update !== undefined)
|
|
170
|
+
out.reference = ref;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Fields ready for emit: drop dotted sub-fields, canonicalize the type, KEEP all DDL clauses, sort. */
|
|
176
|
+
export function pgEmitFields(t: PgCreateInput): PortableField[] {
|
|
177
|
+
return t.fields
|
|
178
|
+
.filter((f) => !f.name.includes("."))
|
|
179
|
+
.map((f) => ({ ...f, table: t.name, type: pgCanonType(f.type) }))
|
|
180
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- column + table DDL -------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/** `ON DELETE …`/`ON UPDATE …` suffix for a FK constraint (empty when no actions). */
|
|
186
|
+
export function fkActions(ref?: {
|
|
187
|
+
on_delete?: string;
|
|
188
|
+
on_update?: string;
|
|
189
|
+
}): string {
|
|
190
|
+
let s = "";
|
|
191
|
+
if (ref?.on_delete) s += ` ON DELETE ${ref.on_delete}`;
|
|
192
|
+
if (ref?.on_update) s += ` ON UPDATE ${ref.on_update}`;
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A column body for CREATE TABLE: type + NOT NULL + identity/default/generated/check clauses. */
|
|
197
|
+
export function fieldColumnDdl(f: PortableField): string {
|
|
198
|
+
const col = pgColumn(f.type);
|
|
199
|
+
let s = `${escId(f.name)} ${col.sql}`;
|
|
200
|
+
if (!col.nullable) s += " NOT NULL";
|
|
201
|
+
if (f.identity)
|
|
202
|
+
s += ` GENERATED ${f.identity === "always" ? "ALWAYS" : "BY DEFAULT"} AS IDENTITY`;
|
|
203
|
+
if (f.default !== undefined) s += ` DEFAULT ${f.default}`;
|
|
204
|
+
if (f.computed !== undefined)
|
|
205
|
+
s += ` GENERATED ALWAYS AS (${f.computed}) STORED`;
|
|
206
|
+
if (f.check !== undefined) s += ` CHECK (${f.check})`;
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The `CREATE TABLE (...)` statement body for a table — the implicit `id` PK (or a custom/composite
|
|
212
|
+
* PRIMARY KEY), every column with its clauses, and table-level CHECKs. The single source for table
|
|
213
|
+
* creation DDL, used by the `table` kind's `emit`/`canonical`.
|
|
214
|
+
*/
|
|
215
|
+
export function createTableDdl(t: PgCreateInput): string {
|
|
216
|
+
const fields = pgEmitFields(t);
|
|
217
|
+
const custom = !!(t.primaryKey && t.primaryKey.length > 0);
|
|
218
|
+
const cols: string[] = [];
|
|
219
|
+
if (!custom) cols.push(`${escId("id")} text PRIMARY KEY`); // implicit id (mirrors Surreal).
|
|
220
|
+
for (const f of fields) cols.push(fieldColumnDdl(f));
|
|
221
|
+
if (custom) cols.push(`PRIMARY KEY (${t.primaryKey?.map(escId).join(", ")})`);
|
|
222
|
+
for (const c of t.checks ?? []) cols.push(`CHECK (${c})`);
|
|
223
|
+
return `CREATE TABLE ${escId(t.name)} (\n ${cols.join(",\n ")}\n);`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- column-level ALTER helpers (used by the field-level diff + the table kind's overwrite) ------
|
|
227
|
+
|
|
228
|
+
/** A column definition body (`"name" type [NOT NULL]`) for ADD COLUMN / CREATE TABLE. */
|
|
229
|
+
export function colDef(f: PortableField): string {
|
|
230
|
+
const c = pgColumn(f.type);
|
|
231
|
+
return `${escId(f.name)} ${c.sql}${c.nullable ? "" : " NOT NULL"}`;
|
|
232
|
+
}
|
|
233
|
+
export const fkName = (table: string, field: string) =>
|
|
234
|
+
`${table}_${field}_fkey`;
|
|
235
|
+
export const addColSql = (table: string, f: PortableField) =>
|
|
236
|
+
`ALTER TABLE ${escId(table)} ADD COLUMN ${colDef(f)};`;
|
|
237
|
+
export const dropColSql = (table: string, field: string) =>
|
|
238
|
+
`ALTER TABLE ${escId(table)} DROP COLUMN IF EXISTS ${escId(field)};`;
|
|
239
|
+
export const dropTableSql = (table: string) =>
|
|
240
|
+
`DROP TABLE IF EXISTS ${escId(table)} CASCADE;`;
|
|
241
|
+
|
|
242
|
+
/** `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …` for a single-column FK to `ref(id)`. */
|
|
243
|
+
export const addFkSql = (
|
|
244
|
+
table: string,
|
|
245
|
+
field: string,
|
|
246
|
+
ref: string,
|
|
247
|
+
actions = "",
|
|
248
|
+
) =>
|
|
249
|
+
`ALTER TABLE ${escId(table)} ADD CONSTRAINT ${escId(fkName(table, field))} FOREIGN KEY (${escId(field)}) REFERENCES ${escId(ref)} (${escId("id")})${actions};`;
|
|
250
|
+
|
|
251
|
+
/** Drop a FK constraint by its generated name. */
|
|
252
|
+
export const dropFkSql = (table: string, field: string) =>
|
|
253
|
+
`ALTER TABLE ${escId(table)} DROP CONSTRAINT IF EXISTS ${escId(fkName(table, field))};`;
|