@everystack/model 0.4.23 → 0.4.25
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/CHANGELOG.md +65 -0
- package/README.md +237 -57
- package/package.json +1 -1
- package/src/can.ts +8 -0
- package/src/dependents.ts +172 -25
- package/src/derive.ts +3 -0
- package/src/derived.ts +5 -0
- package/src/destroy-log.ts +150 -0
- package/src/index.ts +7 -4
- package/src/lifecycle-hooks.ts +70 -0
- package/src/model.ts +112 -28
- package/src/pg-type.ts +66 -0
- package/src/wire.ts +9 -1
package/src/model.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dependentWhereRefusal } from './dependents';
|
|
1
2
|
/**
|
|
2
3
|
* `defineModel` — Layer 2. One call colocates everything about a table: its
|
|
3
4
|
* fields, its authorization (`abilities`), its mutation hooks, and its relations.
|
|
@@ -22,34 +23,34 @@ import type { RawSql } from './raw-sql';
|
|
|
22
23
|
import { validateTriggersFor, type TriggerSpec, type DependsOnRef } from './derived';
|
|
23
24
|
import { TABLE_PRIVILEGES, type TablePrivilege } from './privileges';
|
|
24
25
|
|
|
26
|
+
import { assertDeleteHookNames, assertDestroyHooks, type AfterLifecycleHook, type BeforeLifecycleHook, type DestroyHook } from './lifecycle-hooks';
|
|
27
|
+
export type { AfterLifecycleHook, BeforeLifecycleHook, DestroyContext, DestroyHook, HookVeto, LifecycleContext } from './lifecycle-hooks';
|
|
28
|
+
|
|
25
29
|
/**
|
|
26
30
|
* Per-table mutation hooks. Imperative function bodies the framework cannot
|
|
27
|
-
* absorb (
|
|
31
|
+
* absorb (remove a stored object after destroy, set a column on create) — held
|
|
28
32
|
* verbatim and passed through to the handler's `hooks`.
|
|
29
33
|
*/
|
|
30
|
-
/** Context for a real removal. Hooks must be idempotent: retries can repeat external effects. */
|
|
31
|
-
export interface DestroyContext {
|
|
32
|
-
source: 'purge' | 'api';
|
|
33
|
-
user: Record<string, unknown> | null;
|
|
34
|
-
client: Record<string, unknown> | null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
|
|
38
|
-
|
|
39
34
|
export interface ModelHooks {
|
|
40
35
|
beforeCreate?: (...args: any[]) => any;
|
|
41
36
|
afterCreate?: (...args: any[]) => any;
|
|
42
37
|
beforeUpdate?: (...args: any[]) => any;
|
|
43
38
|
afterUpdate?: (...args: any[]) => any;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
/** Before commit, once per matched root. Cascaded children run no hook; use deleteWhen. */
|
|
40
|
+
beforeDelete?: BeforeLifecycleHook;
|
|
41
|
+
/** After commit, once per request with the root rows. Best-effort. */
|
|
42
|
+
afterDelete?: AfterLifecycleHook;
|
|
43
|
+
/** Before an SQL destroy attempt, per candidate root. A veto or throw holds that root.
|
|
44
|
+
* The row may survive the attempt; do nothing irreversible here. No transaction is passed.
|
|
45
|
+
* Hooks may repeat after a failed commit. Treat the database row as read-only. */
|
|
49
46
|
beforeDestroy?: DestroyHook;
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
47
|
+
/** After commit, only for removed roots; FK cascade children run no hook. Best-effort:
|
|
48
|
+
* a crash can lose the call. Cannot undo removal. Treat the database row as read-only. */
|
|
52
49
|
afterDestroy?: DestroyHook;
|
|
50
|
+
/** Lifecycle tables: after `POST {table}/restore` commits, with `[{ <key>: id }]`, the batch and the counts. Best-effort. */
|
|
51
|
+
afterRestore?: AfterLifecycleHook;
|
|
52
|
+
/** Reserved for a future release; createHandler refuses it. */
|
|
53
|
+
beforeRestore?: never;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
|
@@ -119,7 +120,7 @@ export interface ModelDefinition {
|
|
|
119
120
|
*
|
|
120
121
|
* The guard used to ask only about the public read, which is the visibility axis. That let a
|
|
121
122
|
* model with an owner-scoped read and a `delete` ability default to `false` and begin
|
|
122
|
-
* destroying rows — with an S3
|
|
123
|
+
* destroying rows — with an S3 cleanup job keyed on `deleted_at IS NOT NULL`, that orphaned
|
|
123
124
|
* every file it should have cleaned up. On those models the flag adds no policy at all, so
|
|
124
125
|
* visibility was never the axis that mattered.
|
|
125
126
|
*
|
|
@@ -137,9 +138,13 @@ export interface ModelDefinition {
|
|
|
137
138
|
* ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
|
|
138
139
|
* `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
|
|
139
140
|
* lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
|
|
140
|
-
*
|
|
141
|
+
*
|
|
142
|
+
* `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be marked
|
|
143
|
+
* deleted only when the predicate is TRUE for it (FALSE or NULL refuses, SQLSTATE 23514), whether
|
|
144
|
+
* the delete is the row's own or a parent's cascade. Bare column names, like a policy predicate.
|
|
145
|
+
* Destroy does not re-check this predicate after deletion. `{}` is `true`.
|
|
141
146
|
*/
|
|
142
|
-
lifecycle?: true;
|
|
147
|
+
lifecycle?: true | LifecycleOptions;
|
|
143
148
|
/**
|
|
144
149
|
* The Postgres schema the table lives in. Default `'public'`. Package-owned
|
|
145
150
|
* tables declare their own (`'dist'`, `'ops'`, `'auth'`) so one db:generate run
|
|
@@ -220,8 +225,11 @@ export interface ModelDescriptor {
|
|
|
220
225
|
privileges: Record<string, TablePrivilege[]>;
|
|
221
226
|
/** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
|
|
222
227
|
softDelete: boolean;
|
|
223
|
-
/** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}.
|
|
228
|
+
/** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. Always
|
|
229
|
+
* `true` on the descriptor, whichever form the definition used. */
|
|
224
230
|
lifecycle?: true;
|
|
231
|
+
/** `lifecycle: { deleteWhen }`'s predicate text. Absent when there is no guard. */
|
|
232
|
+
deleteWhen?: string;
|
|
225
233
|
/** The resolved Postgres schema (default `'public'`). Package tables live in their own. */
|
|
226
234
|
schema: string;
|
|
227
235
|
/** `true` removes the table from the generic data API (not in `exposedTables`). Default `false`. */
|
|
@@ -307,14 +315,40 @@ function lifecycleFields(): Record<(typeof LIFECYCLE_COLUMNS)[number], FieldBuil
|
|
|
307
315
|
};
|
|
308
316
|
}
|
|
309
317
|
|
|
318
|
+
/** `lifecycle: { … }` options. */
|
|
319
|
+
export interface LifecycleOptions {
|
|
320
|
+
/** A row may be marked deleted only when this is TRUE for it; FALSE or NULL refuses. */
|
|
321
|
+
deleteWhen?: RawSql;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** True for `lifecycle: true` and for `lifecycle: { … }` (validated by assertLifecycle). */
|
|
325
|
+
function isLifecycle(def: ModelDefinition): boolean {
|
|
326
|
+
return def.lifecycle === true || (typeof def.lifecycle === 'object' && def.lifecycle !== null);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The guard predicate text, or undefined. Call after assertLifecycle. */
|
|
330
|
+
function deleteWhenOf(def: ModelDefinition): string | undefined {
|
|
331
|
+
const value = def.lifecycle;
|
|
332
|
+
return typeof value === 'object' && value !== null && value.deleteWhen !== undefined ? value.deleteWhen.sql : undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
310
335
|
function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
311
336
|
const value: unknown = def.lifecycle;
|
|
312
337
|
if (value === undefined) return;
|
|
313
338
|
if (value === false) {
|
|
314
339
|
throw new Error(`model '${table}': lifecycle: false is not supported yet. Omit the key for a model without lifecycle.`);
|
|
315
340
|
}
|
|
316
|
-
if (value !==
|
|
317
|
-
|
|
341
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
342
|
+
const unknown = Object.keys(value).filter((k) => k !== 'deleteWhen');
|
|
343
|
+
if (unknown.length > 0) {
|
|
344
|
+
throw new Error(`model '${table}': lifecycle: { ${unknown.join(', ')} } — the only key is deleteWhen (lifecycle: { deleteWhen: sql\`…\` }).`);
|
|
345
|
+
}
|
|
346
|
+
const when = (value as { deleteWhen?: unknown }).deleteWhen;
|
|
347
|
+
if (when !== undefined && !(typeof when === 'object' && when !== null && typeof (when as RawSql).sql === 'string' && (when as RawSql).sql.trim() !== '')) {
|
|
348
|
+
throw new Error(`model '${table}': lifecycle: { deleteWhen } — deleteWhen must be sql\`…\`, a non-empty predicate over this table's columns.`);
|
|
349
|
+
}
|
|
350
|
+
} else if (value !== true) {
|
|
351
|
+
throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only values are lifecycle: true and lifecycle: { deleteWhen: sql\`…\` }.`);
|
|
318
352
|
}
|
|
319
353
|
if (def.softDelete !== undefined) {
|
|
320
354
|
throw new Error(
|
|
@@ -340,7 +374,7 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
|
340
374
|
}
|
|
341
375
|
// A cascade into this table, declared on this table's own foreign key. A parent's
|
|
342
376
|
// hasMany { dependent: 'cascade' } is checked in relationDeleteActions (allowed from a
|
|
343
|
-
// lifecycle parent
|
|
377
|
+
// lifecycle parent: the triggers soft-delete the children, and destroy follows the real CASCADE).
|
|
344
378
|
const tableName = def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table;
|
|
345
379
|
for (const [name, f] of Object.entries(def.fields)) {
|
|
346
380
|
if (f.spec.onDelete !== 'cascade') continue;
|
|
@@ -358,6 +392,10 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
|
358
392
|
`Mark exactly one field .primaryKey().`,
|
|
359
393
|
);
|
|
360
394
|
}
|
|
395
|
+
const [key, keyField] = Object.entries(def.fields).find(([, f]) => f.spec.isPrimaryKey)!;
|
|
396
|
+
if (keyField.spec.isArray) {
|
|
397
|
+
throw new Error(`model '${tableName}': lifecycle needs a primary key PostgreSQL can list in a key array; ${key} is an array type`);
|
|
398
|
+
}
|
|
361
399
|
// The generated trigger and function names are `<table>_lifecycle_transition` and `_guard`.
|
|
362
400
|
// PostgreSQL silently truncates an identifier past 63 bytes, so the declared name would not be the live one.
|
|
363
401
|
// UTF-8 bytes, as PostgreSQL counts them. TextEncoder rather than Buffer: a model also loads outside Node.
|
|
@@ -375,10 +413,12 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
|
|
|
375
413
|
* Restore runs the table's delete rule inside a SECURITY DEFINER function as the lifecycle role.
|
|
376
414
|
* Refuse a delete rule whose meaning would change there: a subquery (`via`, or SELECT in
|
|
377
415
|
* where/sql) runs with that role's privileges, and current_user/session_user name that role or
|
|
378
|
-
* the login, not the caller. TRUNCATE is refused because it skips the delete triggers
|
|
416
|
+
* the login, not the caller. TRUNCATE is refused because it skips the delete triggers; TRIGGER
|
|
417
|
+
* because its holder can create triggers on the table; REFERENCES because its holder can
|
|
418
|
+
* add a foreign key that blocks or cascades a destroy.
|
|
379
419
|
*/
|
|
380
420
|
function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
|
|
381
|
-
if (def
|
|
421
|
+
if (!isLifecycle(def)) return;
|
|
382
422
|
const why = 'restore runs the delete rule inside a SECURITY DEFINER function as the lifecycle owner';
|
|
383
423
|
const abilities = def.abilities ?? [];
|
|
384
424
|
// A role-less (or authenticated) delete on a model scoped only through `via` compiles to the
|
|
@@ -427,14 +467,31 @@ function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
|
|
|
427
467
|
`triggers, so it would erase rows past the soft delete and restore. Remove TRUNCATE from the entry.`,
|
|
428
468
|
);
|
|
429
469
|
}
|
|
470
|
+
const beyondDelete: Record<string, string> = {
|
|
471
|
+
TRIGGER: 'TRIGGER lets the holder create triggers on the table, so its own code would run inside every delete and destroy',
|
|
472
|
+
REFERENCES: 'REFERENCES lets the holder add a foreign key that blocks or cascades a destroy',
|
|
473
|
+
};
|
|
474
|
+
for (const [role, privs] of Object.entries(def.privileges ?? {})) {
|
|
475
|
+
if (!Array.isArray(privs)) continue;
|
|
476
|
+
for (const priv of ['TRIGGER', 'REFERENCES'] as const) {
|
|
477
|
+
if (!privs.includes(priv)) continue;
|
|
478
|
+
throw new Error(
|
|
479
|
+
`model '${table}': privileges['${role}'] grants ${priv} on a lifecycle: true model — ${beyondDelete[priv]}. ` +
|
|
480
|
+
`Remove ${priv} from the entry.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
430
484
|
}
|
|
431
485
|
|
|
432
486
|
export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
|
|
487
|
+
assertDeleteHookNames(table, def.hooks);
|
|
488
|
+
assertDestroyHooks(table, def.hooks, isLifecycle(def));
|
|
433
489
|
// Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
|
|
434
490
|
assertRoleNotReserved(table, def);
|
|
435
491
|
assertLifecycle(table, def);
|
|
436
492
|
assertLifecycleRestoreSafe(table, def);
|
|
437
|
-
const lifecycle = def
|
|
493
|
+
const lifecycle = isLifecycle(def);
|
|
494
|
+
const deleteWhen = deleteWhenOf(def);
|
|
438
495
|
const fields: Record<string, FieldBuilder> = lifecycle ? { ...def.fields, ...lifecycleFields() } : def.fields;
|
|
439
496
|
|
|
440
497
|
for (const [name, field] of Object.entries(def.fields)) {
|
|
@@ -443,6 +500,8 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
443
500
|
for (const [name, relation] of Object.entries(def.relations ?? {})) {
|
|
444
501
|
assertRelationShape(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation);
|
|
445
502
|
referenceLocation(relation.target, `${table}.${name}`);
|
|
503
|
+
const filtered = lifecycle ? dependentWhereRefusal(def.schema && def.schema !== 'public' ? `${def.schema}.${table}` : table, name, relation) : undefined;
|
|
504
|
+
if (filtered) throw new Error(filtered);
|
|
446
505
|
}
|
|
447
506
|
const primaryKey = Object.entries(def.fields)
|
|
448
507
|
.filter(([, f]) => f.spec.isPrimaryKey)
|
|
@@ -453,6 +512,28 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
453
512
|
throw new Error(`model '${table}': can('execute') is a function ability — declare it on the defineFunction it gates.`);
|
|
454
513
|
}
|
|
455
514
|
|
|
515
|
+
if (a.condition.deleted !== undefined) {
|
|
516
|
+
if (a.action !== 'read') {
|
|
517
|
+
throw new Error(`model '${table}': deleted applies only to can('read'), not can('${a.action}').`);
|
|
518
|
+
}
|
|
519
|
+
if (a.condition.deleted !== 'include' && a.condition.deleted !== 'only') {
|
|
520
|
+
throw new Error(`model '${table}': can('read', { deleted: ${JSON.stringify(a.condition.deleted)} }) — deleted must be 'include' or 'only'.`);
|
|
521
|
+
}
|
|
522
|
+
if (!lifecycle) {
|
|
523
|
+
throw new Error(`model '${table}': can('read', { deleted }) — deleted applies only to a lifecycle model; this model has no deleted rows to include.`);
|
|
524
|
+
}
|
|
525
|
+
if (!a.condition.role) {
|
|
526
|
+
throw new Error(`model '${table}': can('read', { deleted }) — deleted needs a role: it changes that role's own read policy. Name the role that may see deleted rows.`);
|
|
527
|
+
}
|
|
528
|
+
// A public tier is every caller of the API, and db:pull could not fold the table back.
|
|
529
|
+
if (['anon', 'authenticated', 'public'].includes(a.condition.role.toLowerCase())) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`model '${table}': can('read', { role: '${a.condition.role}', deleted }) — deleted rows are for a named internal role, ` +
|
|
532
|
+
`never a public tier (anon, authenticated, public). Name the role that searches or restores, such as 'support_role'.`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
456
537
|
// A column list is authorization, not a selection hint. If it reaches a table-grant path
|
|
457
538
|
// unnoticed, Postgres receives a whole-table privilege. Admit only the shapes the column
|
|
458
539
|
// compiler understands, and reject an empty list because it is indistinguishable from no
|
|
@@ -484,7 +565,9 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
484
565
|
);
|
|
485
566
|
}
|
|
486
567
|
for (const column of a.condition.columns) {
|
|
487
|
-
|
|
568
|
+
// deletedAt is visible to every reader of a lifecycle model, so a column read may list it.
|
|
569
|
+
// deletedBy, deletedBatch and deletedXid are hidden by design and stay refused.
|
|
570
|
+
if (!(column in def.fields) && !(lifecycle && a.action === 'read' && column === 'deletedAt')) {
|
|
488
571
|
throw new Error(
|
|
489
572
|
`model '${table}': columns names '${column}', but no such field exists. Column abilities use model field keys.`,
|
|
490
573
|
);
|
|
@@ -686,6 +769,7 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
|
|
|
686
769
|
// Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
|
|
687
770
|
softDelete: def.softDelete ?? false,
|
|
688
771
|
...(lifecycle ? { lifecycle: true as const } : {}),
|
|
772
|
+
...(deleteWhen !== undefined ? { deleteWhen } : {}),
|
|
689
773
|
schema: def.schema ?? 'public',
|
|
690
774
|
private: def.private ?? false,
|
|
691
775
|
rls: def.rls ?? true,
|
package/src/pg-type.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FieldSpec } from './field';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* pg-type — the ONE definition of a column type's spelling.
|
|
3
5
|
*
|
|
@@ -95,3 +97,67 @@ export function pgTypeRefusal(typeName: string): string | null {
|
|
|
95
97
|
}
|
|
96
98
|
return null;
|
|
97
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Postgres type for a field, with the precision drizzle-kit would emit — in
|
|
103
|
+
* `format_type`'s vocabulary, because this feeds the IR that diffs against
|
|
104
|
+
* introspection. A serial field's *column type* is integer/bigint (`serial` is
|
|
105
|
+
* DDL shorthand, not a type — `format_type` never reports it); the sequence
|
|
106
|
+
* default is carried separately (see compileTableSchema), and the DDL renderers
|
|
107
|
+
* spell the shorthand (see SERIAL_DDL).
|
|
108
|
+
*/
|
|
109
|
+
const PG_TYPE: Record<string, string> = {
|
|
110
|
+
text: 'text',
|
|
111
|
+
uuid: 'uuid',
|
|
112
|
+
integer: 'integer',
|
|
113
|
+
boolean: 'boolean',
|
|
114
|
+
json: 'json',
|
|
115
|
+
jsonb: 'jsonb',
|
|
116
|
+
bigint: 'bigint',
|
|
117
|
+
serial: 'integer',
|
|
118
|
+
bigserial: 'bigint',
|
|
119
|
+
real: 'real',
|
|
120
|
+
doublePrecision: 'double precision',
|
|
121
|
+
timestamptz: 'timestamp with time zone',
|
|
122
|
+
// `format_type` canonicalizes a bare `timestamp` column to this spelling, and
|
|
123
|
+
// introspection compares against it — so the compiler must emit the same, or an
|
|
124
|
+
// unchanged `timestamp` column round-trips to a spurious ALTER COLUMN TYPE.
|
|
125
|
+
timestamp: 'timestamp without time zone',
|
|
126
|
+
date: 'date',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function pgType(spec: FieldSpec): string {
|
|
130
|
+
// An array is the base type with `[]` appended — `text` → `text[]`, exactly as
|
|
131
|
+
// `format_type` spells it.
|
|
132
|
+
return spec.isArray ? `${scalarPgType(spec)}[]` : scalarPgType(spec);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scalarPgType(spec: FieldSpec): string {
|
|
136
|
+
// Parameterized types build their own canonical string — exactly as `format_type`
|
|
137
|
+
// spells it (no space after the comma; `varchar` is `character varying`; a
|
|
138
|
+
// precision-only numeric carries scale 0), so an unchanged column round-trips clean.
|
|
139
|
+
if (spec.type === 'numeric') {
|
|
140
|
+
if (spec.precision != null) return `numeric(${spec.precision},${spec.scale ?? 0})`;
|
|
141
|
+
return 'numeric';
|
|
142
|
+
}
|
|
143
|
+
if (spec.type === 'varchar') {
|
|
144
|
+
return spec.length != null ? `character varying(${spec.length})` : 'character varying';
|
|
145
|
+
}
|
|
146
|
+
if (spec.type === 'enum') {
|
|
147
|
+
// The column's type is the enum's own type name; `format_type` reports the same bare
|
|
148
|
+
// name, so it round-trips. The `CREATE TYPE` is emitted separately, before the table.
|
|
149
|
+
if (!spec.enumName) throw new Error('field bridge: an enum field needs a type name — field.enum(name, values)');
|
|
150
|
+
return spec.enumName;
|
|
151
|
+
}
|
|
152
|
+
if (spec.type === 'pgType') {
|
|
153
|
+
// A verbatim type: the declared string IS the canonical `format_type` spelling
|
|
154
|
+
// (the factory refused anything else), so it round-trips byte-for-byte.
|
|
155
|
+
if (!spec.pgTypeName) throw new Error('field bridge: a pgType field needs its type name — field.pgType(name)');
|
|
156
|
+
return spec.pgTypeName;
|
|
157
|
+
}
|
|
158
|
+
const t = PG_TYPE[spec.type];
|
|
159
|
+
if (!t) {
|
|
160
|
+
throw new Error(`field bridge: no DDL mapping for field type '${spec.type}' yet`);
|
|
161
|
+
}
|
|
162
|
+
return t;
|
|
163
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -46,8 +46,10 @@
|
|
|
46
46
|
*
|
|
47
47
|
* v1 — the first version. Shapes as they stood when the guard was introduced.
|
|
48
48
|
* v2 — ProvisionWirePayload gains `lifecycleRole` (provision the reserved lifecycle role).
|
|
49
|
+
* v3 — ReconcileWirePayload gains `applicationRoles` (the lifecycle catalog check's roles). A v2
|
|
50
|
+
* CLI's payload carries none, and a reconcile of lifecycle-owned objects refuses without them.
|
|
49
51
|
*/
|
|
50
|
-
export const MODEL_WIRE_CONTRACT =
|
|
52
|
+
export const MODEL_WIRE_CONTRACT = 3;
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
55
|
* Every Model-derived wire payload carries this. The server refuses a payload whose `modelContract`
|
|
@@ -237,6 +239,12 @@ export interface ReconcileWirePayload extends ModelWireEnvelope {
|
|
|
237
239
|
rebuild?: boolean;
|
|
238
240
|
overwriteDrift?: boolean;
|
|
239
241
|
only?: string[];
|
|
242
|
+
/**
|
|
243
|
+
* The application roles the Models' compiled contracts name (catalogInputsFromModels in the
|
|
244
|
+
* CLI): the lifecycle catalog check's one input the declared layer cannot supply. Absent while a
|
|
245
|
+
* declared object is lifecycle-owned = the reconcile refuses (fail closed).
|
|
246
|
+
*/
|
|
247
|
+
applicationRoles?: string[];
|
|
240
248
|
actor?: string | null;
|
|
241
249
|
gitRef?: string | null;
|
|
242
250
|
}
|