@abloatai/ablo 0.5.1 → 0.7.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/CHANGELOG.md +61 -0
- package/README.md +248 -124
- package/dist/BaseSyncedStore.d.ts +3 -3
- package/dist/BaseSyncedStore.js +3 -3
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +91 -93
- package/dist/client/Ablo.js +122 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +116 -90
- package/dist/client/createModelProxy.js +128 -128
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +5 -5
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +59 -14
- package/dist/errors.js +73 -12
- package/dist/index.d.ts +11 -9
- package/dist/index.js +8 -12
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +14 -6
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +167 -0
- package/dist/schema/diff.js +280 -0
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +9 -3
- package/dist/schema/index.js +14 -2
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +10 -69
- package/dist/schema/schema.js +58 -24
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +96 -0
- package/dist/schema/serialize.js +231 -0
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +89 -5
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +9 -18
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +90 -42
- package/docs/api-keys.md +44 -0
- package/docs/api.md +72 -173
- package/docs/audit.md +5 -5
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +42 -43
- package/docs/coordination.md +343 -0
- package/docs/data-sources.md +16 -16
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +38 -36
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +34 -56
- package/docs/identity.md +529 -0
- package/docs/index.md +18 -24
- package/docs/integration-guide.md +130 -144
- package/docs/interaction-model.md +32 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +74 -24
- package/docs/roadmap.md +17 -7
- package/llms.txt +34 -39
- package/package.json +8 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema → Postgres DDL — the one pure SQL emitter shared by every consumer.
|
|
3
|
+
*
|
|
4
|
+
* `defineSchema(...)` (serialized to {@link SchemaJSON}) is the single source of
|
|
5
|
+
* truth; this module lowers it to ordered DDL strings. Both the hosted server
|
|
6
|
+
* (which applies it to Ablo-managed Postgres on `schema push`) and the
|
|
7
|
+
* `ablo migrate` CLI (which applies it to a customer's own Postgres) call these
|
|
8
|
+
* generators, so the SQL — column types, RLS, enum checks — is identical no
|
|
9
|
+
* matter who runs it. There is no second type map.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure (returns strings; no DB, no I/O); the execution side
|
|
12
|
+
* (transaction + advisory lock) lives with each consumer because it's coupled
|
|
13
|
+
* to that consumer's Postgres client and error type.
|
|
14
|
+
*
|
|
15
|
+
* - `generateProvisionPlan` — additive + idempotent (CREATE/ADD … IF NOT
|
|
16
|
+
* EXISTS + RLS). Never loses data. The "create my tables" primitive.
|
|
17
|
+
* - `generateMigrationPlan` — the destructive-aware counterpart driven by the
|
|
18
|
+
* {@link diffSchema} step list (drops, renames, type casts, backfills).
|
|
19
|
+
*/
|
|
20
|
+
import { resolveTenancy, tenancyColumn } from './tenancy.js';
|
|
21
|
+
// ── Identifier safety ────────────────────────────────────────────────────────
|
|
22
|
+
/** Postgres unquoted-identifier-safe slug: lowercase `[a-z0-9_]`, ≤50 chars. */
|
|
23
|
+
function slug(raw) {
|
|
24
|
+
const s = raw.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
25
|
+
return s.slice(0, 50) || 'x';
|
|
26
|
+
}
|
|
27
|
+
/** Per-app schema name for an app (organization) id. */
|
|
28
|
+
export function appSchemaName(organizationId) {
|
|
29
|
+
return `app_${slug(organizationId)}`;
|
|
30
|
+
}
|
|
31
|
+
export function camelToSnake(identifier) {
|
|
32
|
+
return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
33
|
+
}
|
|
34
|
+
/** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
|
|
35
|
+
export function q(identifier) {
|
|
36
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
37
|
+
}
|
|
38
|
+
// ── Field type mapping ───────────────────────────────────────────────────────
|
|
39
|
+
export function sqlType(fieldType) {
|
|
40
|
+
switch (fieldType) {
|
|
41
|
+
case 'string':
|
|
42
|
+
case 'enum':
|
|
43
|
+
return 'TEXT';
|
|
44
|
+
case 'number':
|
|
45
|
+
// DOUBLE PRECISION, not INTEGER — a Zod `number` may be fractional and
|
|
46
|
+
// truncating to INTEGER is silent data loss.
|
|
47
|
+
return 'DOUBLE PRECISION';
|
|
48
|
+
case 'boolean':
|
|
49
|
+
return 'BOOLEAN';
|
|
50
|
+
case 'date':
|
|
51
|
+
return 'TIMESTAMPTZ';
|
|
52
|
+
case 'json':
|
|
53
|
+
default:
|
|
54
|
+
return 'JSONB';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at', 'updated_at']);
|
|
58
|
+
// ── Provisioning (additive, idempotent) ─────────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* Build the additive, idempotent provisioning plan for an app. Pure — no DB
|
|
61
|
+
* access.
|
|
62
|
+
*
|
|
63
|
+
* `targetSchema` is where the tables live: the app's schema `app_<id>` on the
|
|
64
|
+
* shared tier, or `public` on a dedicated tenant's own database (where the DB
|
|
65
|
+
* itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
|
|
66
|
+
* skipped (it always exists).
|
|
67
|
+
*/
|
|
68
|
+
export function generateProvisionPlan(schema, targetSchema) {
|
|
69
|
+
const appSchema = targetSchema;
|
|
70
|
+
const qs = q(appSchema);
|
|
71
|
+
const statements = appSchema === 'public' ? [] : [`CREATE SCHEMA IF NOT EXISTS ${qs};`];
|
|
72
|
+
for (const [key, model] of Object.entries(schema.models)) {
|
|
73
|
+
// Default the physical table to the model key when `tableName` is omitted —
|
|
74
|
+
// same fallback the migration path uses (`tableOfModel: m.tableName ?? key`).
|
|
75
|
+
// Without this, a schema that doesn't set `tableName` (e.g. the `ablo init`
|
|
76
|
+
// starter) provisions zero tables.
|
|
77
|
+
const table = model.tableName ?? key;
|
|
78
|
+
const qt = `${qs}.${q(table)}`;
|
|
79
|
+
// Base columns are schema-driven, not blanket. `organization_id` (and its
|
|
80
|
+
// index + tenant-isolation RLS below) is emitted only for org-scoped models.
|
|
81
|
+
// A model that declares `orgScoped: false` (users, organizations, and other
|
|
82
|
+
// tables scoped via a FK / app layer) genuinely has no `organization_id`
|
|
83
|
+
// column — forcing one would add a NOT NULL column that fails on existing
|
|
84
|
+
// rows and contradicts the model's own declaration.
|
|
85
|
+
// Tenancy column: present only for column-scoped models, with the
|
|
86
|
+
// configured name (default `organization_id`). `parent`/`none` tenancy emit
|
|
87
|
+
// no tenancy column — they're scoped via a parent FK or not at all.
|
|
88
|
+
const orgCol = tenancyColumn(resolveTenancy(model));
|
|
89
|
+
const baseColumns = [
|
|
90
|
+
` ${q('id')} TEXT PRIMARY KEY,`,
|
|
91
|
+
...(orgCol ? [` ${q(orgCol)} TEXT NOT NULL,`] : []),
|
|
92
|
+
` ${q('created_by')} TEXT,`,
|
|
93
|
+
` ${q('created_at')} TIMESTAMPTZ NOT NULL DEFAULT NOW(),`,
|
|
94
|
+
` ${q('updated_at')} TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
|
95
|
+
];
|
|
96
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${qt} (\n${baseColumns.join('\n')}\n);`);
|
|
97
|
+
for (const [fieldName, meta] of Object.entries(model.fields)) {
|
|
98
|
+
const col = meta.column ?? camelToSnake(fieldName);
|
|
99
|
+
if (BASE_COLUMNS.has(col) || col === orgCol)
|
|
100
|
+
continue;
|
|
101
|
+
statements.push(`ALTER TABLE ${qt} ADD COLUMN IF NOT EXISTS ${q(col)} ${sqlType(meta.type)};`);
|
|
102
|
+
if (meta.type === 'enum' && meta.enumValues && meta.enumValues.length > 0) {
|
|
103
|
+
const cname = `${table}_${col}_enum`;
|
|
104
|
+
const allowed = meta.enumValues.map((v) => `'${v.replace(/'/g, "''")}'`).join(', ');
|
|
105
|
+
statements.push(`DO $$ BEGIN\n` +
|
|
106
|
+
` IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${cname}') THEN\n` +
|
|
107
|
+
` ALTER TABLE ${qt} ADD CONSTRAINT ${q(cname)} CHECK (${q(col)} IN (${allowed}));\n` +
|
|
108
|
+
` END IF;\n` +
|
|
109
|
+
`END $$;`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Org index + tenant-isolation RLS only where there's an `organization_id`
|
|
113
|
+
// to isolate on. Non-org-scoped tables rely on FK/app-layer scoping.
|
|
114
|
+
if (orgCol) {
|
|
115
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${q(`${table}_${orgCol}_idx`)} ON ${qt} (${q(orgCol)});`);
|
|
116
|
+
statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
|
|
117
|
+
statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
|
|
118
|
+
const policy = `${table}_tenant_isolation`;
|
|
119
|
+
const predicate = `${q(orgCol)} = current_setting('app.current_org_id', true)`;
|
|
120
|
+
statements.push(`DROP POLICY IF EXISTS ${q(policy)} ON ${qt};`);
|
|
121
|
+
statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { appSchema, statements };
|
|
125
|
+
}
|
|
126
|
+
// ── Migration (destructive-aware, diff-driven) ──────────────────────────────
|
|
127
|
+
function enumCheckStatements(table, col, qt, values) {
|
|
128
|
+
const cname = `${table}_${col}_enum`;
|
|
129
|
+
const stmts = [`ALTER TABLE ${qt} DROP CONSTRAINT IF EXISTS ${q(cname)};`];
|
|
130
|
+
if (values.length > 0) {
|
|
131
|
+
const allowed = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(', ');
|
|
132
|
+
stmts.push(`ALTER TABLE ${qt} ADD CONSTRAINT ${q(cname)} CHECK (${q(col)} IN (${allowed}));`);
|
|
133
|
+
}
|
|
134
|
+
return stmts;
|
|
135
|
+
}
|
|
136
|
+
function indexName(table, col) {
|
|
137
|
+
return `${table}_${col}_idx`;
|
|
138
|
+
}
|
|
139
|
+
function columnNameOf(fieldName, meta) {
|
|
140
|
+
return meta?.column ?? camelToSnake(fieldName);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Encode a constant backfill value as a typed SQL literal. Inputs are operator-
|
|
144
|
+
* supplied (via the authed push), but we still encode by the field's declared
|
|
145
|
+
* type and escape strings rather than interpolate raw — defense-in-depth.
|
|
146
|
+
*/
|
|
147
|
+
function sqlLiteral(value, fieldType) {
|
|
148
|
+
switch (fieldType) {
|
|
149
|
+
case 'number':
|
|
150
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
151
|
+
throw new Error(`backfill for a number field must be a finite number, got ${JSON.stringify(value)}`);
|
|
152
|
+
}
|
|
153
|
+
return String(value);
|
|
154
|
+
case 'boolean':
|
|
155
|
+
return value ? 'TRUE' : 'FALSE';
|
|
156
|
+
case 'date':
|
|
157
|
+
return `'${String(value).replace(/'/g, "''")}'::timestamptz`;
|
|
158
|
+
case 'json':
|
|
159
|
+
return `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`;
|
|
160
|
+
case 'string':
|
|
161
|
+
case 'enum':
|
|
162
|
+
default:
|
|
163
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Lower an ordered migration step list to DDL. `next` is the schema being pushed
|
|
168
|
+
* (the target column shapes are read from it), `prev` the active one (used to
|
|
169
|
+
* resolve the *old* table name on a model rename).
|
|
170
|
+
*/
|
|
171
|
+
export function generateMigrationPlan(steps, opts) {
|
|
172
|
+
const { prev, next, targetSchema, backfills = [] } = opts;
|
|
173
|
+
const qs = q(targetSchema);
|
|
174
|
+
const statements = [];
|
|
175
|
+
const qtFor = (table) => `${qs}.${q(table)}`;
|
|
176
|
+
const tableOfModel = (schema, key) => {
|
|
177
|
+
const m = schema?.models[key];
|
|
178
|
+
if (!m)
|
|
179
|
+
return null;
|
|
180
|
+
return m.tableName ?? key;
|
|
181
|
+
};
|
|
182
|
+
const backfillFor = (model, field) => backfills.find((b) => b.model === model && b.field === field);
|
|
183
|
+
for (const step of steps) {
|
|
184
|
+
switch (step.kind) {
|
|
185
|
+
case 'create_model': {
|
|
186
|
+
// Reuse the provisioner for the full table (base cols + fields + enum
|
|
187
|
+
// checks + RLS), minus its `CREATE SCHEMA` (the schema already exists
|
|
188
|
+
// mid-migration).
|
|
189
|
+
const def = next.models[step.model];
|
|
190
|
+
if (!def)
|
|
191
|
+
break;
|
|
192
|
+
const sub = { v: next.v, models: { [step.model]: def }, identityRoles: next.identityRoles };
|
|
193
|
+
for (const s of generateProvisionPlan(sub, targetSchema).statements) {
|
|
194
|
+
if (!s.startsWith('CREATE SCHEMA'))
|
|
195
|
+
statements.push(s);
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'drop_model':
|
|
200
|
+
statements.push(`DROP TABLE IF EXISTS ${qtFor(step.tableName)};`);
|
|
201
|
+
break;
|
|
202
|
+
case 'rename_model': {
|
|
203
|
+
const fromTable = tableOfModel(prev, step.from);
|
|
204
|
+
const toTable = tableOfModel(next, step.to);
|
|
205
|
+
// A logical model rename only needs SQL when the physical table name
|
|
206
|
+
// actually changes; if tableName is unchanged the rename is metadata.
|
|
207
|
+
if (fromTable && toTable && fromTable !== toTable) {
|
|
208
|
+
statements.push(`ALTER TABLE ${qtFor(fromTable)} RENAME TO ${q(toTable)};`);
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'add_field': {
|
|
213
|
+
const table = tableOfModel(next, step.model);
|
|
214
|
+
if (!table)
|
|
215
|
+
break;
|
|
216
|
+
const qt = qtFor(table);
|
|
217
|
+
const col = columnNameOf(step.field, step.meta);
|
|
218
|
+
// Added nullable first (the column is born NULL on every existing row).
|
|
219
|
+
statements.push(`ALTER TABLE ${qt} ADD COLUMN IF NOT EXISTS ${q(col)} ${sqlType(step.meta.type)};`);
|
|
220
|
+
if (step.meta.type === 'enum' && step.meta.enumValues?.length) {
|
|
221
|
+
statements.push(...enumCheckStatements(table, col, qt, step.meta.enumValues));
|
|
222
|
+
}
|
|
223
|
+
// Backfill + enforce NOT NULL only with a supplied seed value. Without
|
|
224
|
+
// one, a required field stays nullable (gated `unexecutable` upstream).
|
|
225
|
+
const addBf = backfillFor(step.model, step.field);
|
|
226
|
+
if (addBf !== undefined) {
|
|
227
|
+
statements.push(`UPDATE ${qt} SET ${q(col)} = ${sqlLiteral(addBf.value, step.meta.type)} WHERE ${q(col)} IS NULL;`);
|
|
228
|
+
if (!step.meta.isOptional) {
|
|
229
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} SET NOT NULL;`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (step.meta.isIndexed) {
|
|
233
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${q(indexName(table, col))} ON ${qt} (${q(col)});`);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case 'drop_field': {
|
|
238
|
+
const table = tableOfModel(next, step.model);
|
|
239
|
+
if (!table)
|
|
240
|
+
break;
|
|
241
|
+
const prevMeta = prev?.models[step.model]?.fields[step.field];
|
|
242
|
+
statements.push(`ALTER TABLE ${qtFor(table)} DROP COLUMN IF EXISTS ${q(columnNameOf(step.field, prevMeta))};`);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'rename_field': {
|
|
246
|
+
const table = tableOfModel(next, step.model);
|
|
247
|
+
if (!table)
|
|
248
|
+
break;
|
|
249
|
+
const prevMeta = prev?.models[step.model]?.fields[step.from];
|
|
250
|
+
const nextMeta = next.models[step.model]?.fields[step.to];
|
|
251
|
+
const fromCol = columnNameOf(step.from, prevMeta);
|
|
252
|
+
const toCol = columnNameOf(step.to, nextMeta);
|
|
253
|
+
if (fromCol === toCol)
|
|
254
|
+
break;
|
|
255
|
+
statements.push(`ALTER TABLE ${qtFor(table)} RENAME COLUMN ${q(fromCol)} TO ${q(toCol)};`);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'alter_field': {
|
|
259
|
+
const table = tableOfModel(next, step.model);
|
|
260
|
+
if (!table)
|
|
261
|
+
break;
|
|
262
|
+
const qt = qtFor(table);
|
|
263
|
+
const nextMeta = next.models[step.model]?.fields[step.field];
|
|
264
|
+
let col = columnNameOf(step.field, nextMeta);
|
|
265
|
+
const ch = step.changes;
|
|
266
|
+
// 0. Physical column rename. Subsequent alterations must address
|
|
267
|
+
// the new name.
|
|
268
|
+
if (ch.column) {
|
|
269
|
+
statements.push(`ALTER TABLE ${qt} RENAME COLUMN ${q(ch.column.from)} TO ${q(ch.column.to)};`);
|
|
270
|
+
col = ch.column.to;
|
|
271
|
+
}
|
|
272
|
+
// 1. Type — in-place cast or lossy drop-and-recreate.
|
|
273
|
+
if (ch.type) {
|
|
274
|
+
const target = sqlType(ch.type.to);
|
|
275
|
+
if (ch.type.cast === 'notCastable') {
|
|
276
|
+
statements.push(`ALTER TABLE ${qt} DROP COLUMN IF EXISTS ${q(col)};`);
|
|
277
|
+
statements.push(`ALTER TABLE ${qt} ADD COLUMN IF NOT EXISTS ${q(col)} ${target};`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} TYPE ${target} USING ${q(col)}::${target};`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 2. Enum CHECK — drop when leaving enum; (re)build when arriving at or
|
|
284
|
+
// re-valuing an enum. Reads the full target value set from `next`.
|
|
285
|
+
if (ch.type?.from === 'enum' && nextMeta?.type !== 'enum') {
|
|
286
|
+
statements.push(`ALTER TABLE ${qt} DROP CONSTRAINT IF EXISTS ${q(`${table}_${col}_enum`)};`);
|
|
287
|
+
}
|
|
288
|
+
else if (nextMeta?.type === 'enum' && (ch.enumValues || ch.type)) {
|
|
289
|
+
statements.push(...enumCheckStatements(table, col, qt, nextMeta.enumValues ?? []));
|
|
290
|
+
}
|
|
291
|
+
// 3. Nullability. DROP NOT NULL is always safe. SET NOT NULL is gated
|
|
292
|
+
// upstream (unexecutable on a table with NULLs); a supplied backfill
|
|
293
|
+
// seeds the existing NULLs first so the constraint can take.
|
|
294
|
+
if (ch.nullability) {
|
|
295
|
+
if (ch.nullability.toOptional) {
|
|
296
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} DROP NOT NULL;`);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const bf = backfillFor(step.model, step.field);
|
|
300
|
+
if (bf !== undefined && nextMeta) {
|
|
301
|
+
statements.push(`UPDATE ${qt} SET ${q(col)} = ${sqlLiteral(bf.value, nextMeta.type)} WHERE ${q(col)} IS NULL;`);
|
|
302
|
+
}
|
|
303
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} SET NOT NULL;`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// 4. Index.
|
|
307
|
+
if (ch.indexed) {
|
|
308
|
+
statements.push(ch.indexed.to
|
|
309
|
+
? `CREATE INDEX IF NOT EXISTS ${q(indexName(table, col))} ON ${qt} (${q(col)});`
|
|
310
|
+
: `DROP INDEX IF EXISTS ${qs}.${q(indexName(table, col))};`);
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { appSchema: targetSchema, statements };
|
|
317
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema diff + migration planning — the pure core of the managed-migration loop.
|
|
3
|
+
*
|
|
4
|
+
* Given two serialized schemas (the active one and the one being pushed), produce
|
|
5
|
+
* an ordered list of {@link MigrationStep}s describing how to evolve the database,
|
|
6
|
+
* and a {@link MigrationClassification} splitting the risky parts into *warnings*
|
|
7
|
+
* (execute but may lose/risk data) and *unexecutable* steps (fail on a non-empty
|
|
8
|
+
* table without a backfill/default). SQL emission and execution live elsewhere
|
|
9
|
+
* (server-side, where the type map + RLS live); this module is intentionally pure
|
|
10
|
+
* and DB-free so it is exhaustively unit-testable and reusable by the CLI.
|
|
11
|
+
*
|
|
12
|
+
* Design borrowed from mature tools:
|
|
13
|
+
* - **Drizzle Kit**: keep the differ pure and inject RENAME decisions as data
|
|
14
|
+
* (the {@link RenameHints} resolver seam) rather than guessing — the same
|
|
15
|
+
* engine is then headless-testable and drivable by an interactive prompt.
|
|
16
|
+
* - **Prisma migration engine**: a two-tier destructive classification
|
|
17
|
+
* (warning vs unexecutable) and a type-change sub-tier
|
|
18
|
+
* (safe / risky / not-castable) that decides in-place `ALTER TYPE` vs a
|
|
19
|
+
* lossy drop-and-recreate.
|
|
20
|
+
* - **Atlas**: a single `alter_field` step carrying *which* facets changed
|
|
21
|
+
* (type / nullability / enum / index) instead of N discrete alter steps.
|
|
22
|
+
*
|
|
23
|
+
* Step ordering is the expand→contract sequence (add before drop, widen before
|
|
24
|
+
* narrow): create models → rename → add columns (always nullable) → alter →
|
|
25
|
+
* drop columns → drop models. NOT NULL is never set on add — it is an
|
|
26
|
+
* `alter_field` nullability change that a backfill must precede.
|
|
27
|
+
*/
|
|
28
|
+
import type { FieldMeta } from './field.js';
|
|
29
|
+
import type { SchemaJSON } from './serialize.js';
|
|
30
|
+
export type FieldType = FieldMeta['type'];
|
|
31
|
+
/** Whether a Postgres `ALTER COLUMN … TYPE` can preserve the existing data. */
|
|
32
|
+
export type CastSafety = 'safe' | 'risky' | 'notCastable';
|
|
33
|
+
export interface FieldTypeChange {
|
|
34
|
+
readonly from: FieldType;
|
|
35
|
+
readonly to: FieldType;
|
|
36
|
+
/** `safe` → plain ALTER TYPE; `risky` → ALTER w/ USING (may fail per-row);
|
|
37
|
+
* `notCastable` → drop-and-recreate (data loss). */
|
|
38
|
+
readonly cast: CastSafety;
|
|
39
|
+
}
|
|
40
|
+
/** `isOptional` transition. `true → false` is the dangerous direction. */
|
|
41
|
+
export interface NullabilityChange {
|
|
42
|
+
readonly fromOptional: boolean;
|
|
43
|
+
readonly toOptional: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface EnumValuesChange {
|
|
46
|
+
readonly added: readonly string[];
|
|
47
|
+
readonly removed: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
export interface IndexChange {
|
|
50
|
+
readonly from: boolean;
|
|
51
|
+
readonly to: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** Physical column-name transition for a stable logical field. */
|
|
54
|
+
export interface FieldColumnChange {
|
|
55
|
+
readonly from: string;
|
|
56
|
+
readonly to: string;
|
|
57
|
+
}
|
|
58
|
+
/** The facets of a single column that changed (Atlas-style bitmask, as data). */
|
|
59
|
+
export interface FieldChanges {
|
|
60
|
+
readonly column?: FieldColumnChange;
|
|
61
|
+
readonly type?: FieldTypeChange;
|
|
62
|
+
readonly nullability?: NullabilityChange;
|
|
63
|
+
readonly enumValues?: EnumValuesChange;
|
|
64
|
+
readonly indexed?: IndexChange;
|
|
65
|
+
}
|
|
66
|
+
export type MigrationStep = {
|
|
67
|
+
readonly kind: 'create_model';
|
|
68
|
+
readonly model: string;
|
|
69
|
+
readonly tableName: string;
|
|
70
|
+
} | {
|
|
71
|
+
readonly kind: 'drop_model';
|
|
72
|
+
readonly model: string;
|
|
73
|
+
readonly tableName: string;
|
|
74
|
+
} | {
|
|
75
|
+
readonly kind: 'rename_model';
|
|
76
|
+
readonly from: string;
|
|
77
|
+
readonly to: string;
|
|
78
|
+
} | {
|
|
79
|
+
readonly kind: 'add_field';
|
|
80
|
+
readonly model: string;
|
|
81
|
+
readonly field: string;
|
|
82
|
+
readonly meta: FieldMeta;
|
|
83
|
+
} | {
|
|
84
|
+
readonly kind: 'drop_field';
|
|
85
|
+
readonly model: string;
|
|
86
|
+
readonly field: string;
|
|
87
|
+
} | {
|
|
88
|
+
readonly kind: 'rename_field';
|
|
89
|
+
readonly model: string;
|
|
90
|
+
readonly from: string;
|
|
91
|
+
readonly to: string;
|
|
92
|
+
} | {
|
|
93
|
+
readonly kind: 'alter_field';
|
|
94
|
+
readonly model: string;
|
|
95
|
+
readonly field: string;
|
|
96
|
+
readonly changes: FieldChanges;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Rename decisions, injected as data (Drizzle's resolver seam). Without a hint,
|
|
100
|
+
* a removed+added pair reads as drop+add (lossy) — the same safe default Prisma
|
|
101
|
+
* takes. `field.model` refers to the model key in the NEXT schema (post any
|
|
102
|
+
* model rename).
|
|
103
|
+
*/
|
|
104
|
+
export interface RenameHints {
|
|
105
|
+
readonly models?: readonly {
|
|
106
|
+
readonly from: string;
|
|
107
|
+
readonly to: string;
|
|
108
|
+
}[];
|
|
109
|
+
readonly fields?: readonly {
|
|
110
|
+
readonly model: string;
|
|
111
|
+
readonly from: string;
|
|
112
|
+
readonly to: string;
|
|
113
|
+
}[];
|
|
114
|
+
}
|
|
115
|
+
export declare function classifyCast(from: FieldType, to: FieldType): CastSafety;
|
|
116
|
+
/**
|
|
117
|
+
* Diff two serialized schemas into an ordered, expand→contract migration plan.
|
|
118
|
+
* `prev` is the active schema (`null` for a first push → all creates). Rename
|
|
119
|
+
* decisions are supplied via {@link RenameHints}; anything not hinted reads as
|
|
120
|
+
* drop+add.
|
|
121
|
+
*/
|
|
122
|
+
export declare function diffSchema(prev: SchemaJSON | null, next: SchemaJSON, hints?: RenameHints): MigrationStep[];
|
|
123
|
+
export type WarningCode = 'drop_model' | 'drop_field' | 'risky_cast' | 'lossy_recreate' | 'enum_value_removed';
|
|
124
|
+
export type BlockerCode = 'required_field_added' | 'made_required';
|
|
125
|
+
export interface MigrationSignal {
|
|
126
|
+
readonly code: WarningCode | BlockerCode;
|
|
127
|
+
readonly model: string;
|
|
128
|
+
readonly field?: string;
|
|
129
|
+
readonly detail: string;
|
|
130
|
+
}
|
|
131
|
+
export interface MigrationClassification {
|
|
132
|
+
/** Execute but may lose or risk data on a non-empty table. */
|
|
133
|
+
readonly warnings: readonly MigrationSignal[];
|
|
134
|
+
/** Will fail on a non-empty table unless a default/backfill is supplied. */
|
|
135
|
+
readonly unexecutable: readonly MigrationSignal[];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Classify a plan's steps into Prisma-style warnings vs unexecutable. The IR
|
|
139
|
+
* carries no per-field default, so a non-optional `add_field` is conservatively
|
|
140
|
+
* unexecutable (a backfill or default resolves it) — we cannot prove a default
|
|
141
|
+
* exists. Classification is rule-based (schema-derived); the runtime layer can
|
|
142
|
+
* downgrade a signal to a no-op when the target table is empty.
|
|
143
|
+
*/
|
|
144
|
+
export declare function classifyMigration(steps: readonly MigrationStep[]): MigrationClassification;
|
|
145
|
+
/** Convenience: a plan is safe to auto-apply iff it has no unexecutable steps. */
|
|
146
|
+
export declare function isAutoApplicable(classification: MigrationClassification): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* A constant value to seed into existing rows so an otherwise-`unexecutable`
|
|
149
|
+
* step becomes safe: a required field added to a non-empty table, or a field
|
|
150
|
+
* made required while NULLs exist. Deliberately a CONSTANT (not an SQL
|
|
151
|
+
* expression) — arbitrary backfill logic is out of scope; this serves the
|
|
152
|
+
* common "new column defaults to X" case only. `value` is typed to the field.
|
|
153
|
+
*/
|
|
154
|
+
export interface BackfillValue {
|
|
155
|
+
readonly model: string;
|
|
156
|
+
readonly field: string;
|
|
157
|
+
readonly value: string | number | boolean;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Does a provided backfill resolve this blocker? Only the two row-dependent
|
|
161
|
+
* blockers (`required_field_added`, `made_required`) are backfill-resolvable; a
|
|
162
|
+
* data-loss *warning* is not — that always needs `force`.
|
|
163
|
+
*/
|
|
164
|
+
export declare function isBlockerResolved(signal: MigrationSignal, backfills: readonly BackfillValue[]): boolean;
|
|
165
|
+
/** The unexecutable signals NOT covered by a supplied backfill. Empty → the push
|
|
166
|
+
* can proceed (modulo the separate `warnings`/`force` gate). */
|
|
167
|
+
export declare function unresolvedBlockers(classification: MigrationClassification, backfills: readonly BackfillValue[]): readonly MigrationSignal[];
|