@abloatai/ablo 0.6.0 → 0.8.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.
Files changed (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -0,0 +1,62 @@
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 type { SchemaJSON, ModelJSON } from './serialize.js';
21
+ import type { MigrationStep, BackfillValue } from './diff.js';
22
+ export interface ProvisionPlan {
23
+ /** The Postgres schema the tables live in (`app_<id>` or `public`). */
24
+ readonly appSchema: string;
25
+ /** Ordered, idempotent DDL statements. Safe to run repeatedly. */
26
+ readonly statements: readonly string[];
27
+ }
28
+ export interface MigrationPlan {
29
+ /** The app Postgres schema the DDL targets (`app_<id>` or `public`). */
30
+ readonly appSchema: string;
31
+ /** Ordered DDL statements (expand → contract). */
32
+ readonly statements: readonly string[];
33
+ }
34
+ /** Per-app schema name for an app (organization) id. */
35
+ export declare function appSchemaName(organizationId: string): string;
36
+ export declare function camelToSnake(identifier: string): string;
37
+ /** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
38
+ export declare function q(identifier: string): string;
39
+ export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
40
+ /**
41
+ * Build the additive, idempotent provisioning plan for an app. Pure — no DB
42
+ * access.
43
+ *
44
+ * `targetSchema` is where the tables live: the app's schema `app_<id>` on the
45
+ * shared tier, or `public` on a dedicated tenant's own database (where the DB
46
+ * itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
47
+ * skipped (it always exists).
48
+ */
49
+ export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string): ProvisionPlan;
50
+ /**
51
+ * Lower an ordered migration step list to DDL. `next` is the schema being pushed
52
+ * (the target column shapes are read from it), `prev` the active one (used to
53
+ * resolve the *old* table name on a model rename).
54
+ */
55
+ export declare function generateMigrationPlan(steps: readonly MigrationStep[], opts: {
56
+ readonly prev: SchemaJSON | null;
57
+ readonly next: SchemaJSON;
58
+ readonly targetSchema: string;
59
+ /** Constant seed values that let a required-field add / made-required step
60
+ * set NOT NULL on a non-empty table. Keyed by (model, field). */
61
+ readonly backfills?: readonly BackfillValue[];
62
+ }): MigrationPlan;
@@ -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
+ }
@@ -50,8 +50,14 @@ export interface IndexChange {
50
50
  readonly from: boolean;
51
51
  readonly to: boolean;
52
52
  }
53
+ /** Physical column-name transition for a stable logical field. */
54
+ export interface FieldColumnChange {
55
+ readonly from: string;
56
+ readonly to: string;
57
+ }
53
58
  /** The facets of a single column that changed (Atlas-style bitmask, as data). */
54
59
  export interface FieldChanges {
60
+ readonly column?: FieldColumnChange;
55
61
  readonly type?: FieldTypeChange;
56
62
  readonly nullability?: NullabilityChange;
57
63
  readonly enumValues?: EnumValuesChange;
@@ -65,8 +65,19 @@ function diffEnumValues(from, to) {
65
65
  return undefined;
66
66
  return { added, removed };
67
67
  }
68
- function diffField(prev, next) {
68
+ function camelToSnake(identifier) {
69
+ return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
70
+ }
71
+ function columnNameOf(fieldName, meta) {
72
+ return meta.column ?? camelToSnake(fieldName);
73
+ }
74
+ function diffField(prevFieldName, nextFieldName, prev, next) {
69
75
  const changes = {};
76
+ const prevColumn = columnNameOf(prevFieldName, prev);
77
+ const nextColumn = columnNameOf(nextFieldName, next);
78
+ if (prevColumn !== nextColumn) {
79
+ changes.column = { from: prevColumn, to: nextColumn };
80
+ }
70
81
  if (prev.type !== next.type) {
71
82
  changes.type = { from: prev.type, to: next.type, cast: classifyCast(prev.type, next.type) };
72
83
  }
@@ -112,9 +123,16 @@ function diffModelFields(model, prev, next, fieldRenames) {
112
123
  const prevMeta = prev.fields[prevName];
113
124
  if (!prevMeta)
114
125
  continue;
115
- const changes = diffField(prevMeta, nextMeta);
116
- if (changes)
126
+ const changes = diffField(prevName, name, prevMeta, nextMeta);
127
+ if (changes?.column && renameByNewName.has(name)) {
128
+ // A hinted logical field rename already emits `rename_field`, whose
129
+ // lowering renames the physical column when needed. Do not emit a
130
+ // second `alter_field.column` for the same transition.
131
+ delete changes.column;
132
+ }
133
+ if (changes && Object.keys(changes).length > 0) {
117
134
  steps.push({ kind: 'alter_field', model, field: name, changes });
135
+ }
118
136
  }
119
137
  // Dropped (present in prev, not in next, and not renamed away).
120
138
  for (const name of Object.keys(prev.fields)) {
@@ -31,6 +31,11 @@ export interface FieldMeta {
31
31
  isOptional: boolean;
32
32
  /** Whether the field was marked indexed via `.indexed()`. */
33
33
  isIndexed: boolean;
34
+ /**
35
+ * Physical database column name override. When absent, SQL layers derive
36
+ * the column from the field name using the active casing convention.
37
+ */
38
+ column?: string;
34
39
  /** For enums: the allowed values. */
35
40
  enumValues?: readonly string[];
36
41
  }
@@ -73,27 +78,21 @@ export declare function inferFieldMetaFromZod(schema: z.ZodType): FieldMeta;
73
78
  * `model.ts:inferMetaFromZod`.
74
79
  */
75
80
  export declare function resolveFieldMeta(schema: z.ZodType): FieldMeta;
81
+ export type FieldBuilder<T extends z.ZodType> = T & {
82
+ indexed(): FieldBuilder<T>;
83
+ from(column: string): FieldBuilder<T>;
84
+ };
76
85
  export declare const field: {
77
86
  /** String field */
78
- readonly string: () => z.ZodString & {
79
- indexed(): z.ZodString;
80
- };
87
+ readonly string: () => FieldBuilder<z.ZodString>;
81
88
  /** Number field */
82
- readonly number: () => z.ZodNumber & {
83
- indexed(): z.ZodNumber;
84
- };
89
+ readonly number: () => FieldBuilder<z.ZodNumber>;
85
90
  /** Boolean field */
86
- readonly boolean: () => z.ZodBoolean & {
87
- indexed(): z.ZodBoolean;
88
- };
91
+ readonly boolean: () => FieldBuilder<z.ZodBoolean>;
89
92
  /** Date field */
90
- readonly date: () => z.ZodDate & {
91
- indexed(): z.ZodDate;
92
- };
93
+ readonly date: () => FieldBuilder<z.ZodDate>;
93
94
  /** Enum field with constrained string values */
94
- readonly enum: <const T extends readonly [string, ...string[]]>(values: T) => z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never> & {
95
- indexed(): z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_2 ? { [k in keyof T_2]: T_2[k]; } : never>;
96
- };
95
+ readonly enum: <const T extends readonly [string, ...string[]]>(values: T) => FieldBuilder<z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never>>;
97
96
  /**
98
97
  * JSON field. Three call shapes:
99
98
  *
@@ -124,11 +123,9 @@ export declare const field: {
124
123
  * deck.metadataJson.icon // 'presentation' (typed, with default)
125
124
  * ```
126
125
  */
127
- readonly json: <T extends z.ZodType = z.ZodUnknown>(schemaOrShape?: T | z.ZodRawShape) => z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>> & {
128
- indexed(): z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
129
- };
126
+ readonly json: <T extends z.ZodType = z.ZodUnknown>(schemaOrShape?: T | z.ZodRawShape) => FieldBuilder<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
130
127
  /** Indexed string field (shorthand for `field.string().indexed()`). */
131
- readonly id: () => z.ZodString;
128
+ readonly id: () => FieldBuilder<z.ZodString>;
132
129
  };
133
130
  /** Mark a Zod schema as indexed for fast lookups (function form). */
134
131
  export declare function indexed<T extends z.ZodType>(schema: T): T;
@@ -142,7 +142,15 @@ export function inferFieldMetaFromZod(schema) {
142
142
  current instanceof z.ZodArray ||
143
143
  current instanceof z.ZodRecord ||
144
144
  current instanceof z.ZodUnion ||
145
- current instanceof z.ZodUnknown) {
145
+ current instanceof z.ZodUnknown ||
146
+ // `z.custom<T>()` is an opaque, structurally-uninspectable blob —
147
+ // by construction the engine can't see its shape, which is exactly
148
+ // the JSON-blob pattern (ProseMirror docs, LayerData/LayerStyle maps,
149
+ // chart specs). Classifying it as `'string'` (the fallthrough default)
150
+ // gave these fields deep MobX observability instead of the intended
151
+ // `observable.ref` (see Ablo.ts registerProperty), producing the
152
+ // microtask-storm + nested-reactivity-drift bug on live updates.
153
+ current instanceof z.ZodCustom) {
146
154
  type = 'json';
147
155
  }
148
156
  return { type, isOptional, isIndexed: false, enumValues };
@@ -169,39 +177,44 @@ export function resolveFieldMeta(schema) {
169
177
  return attached;
170
178
  return inferFieldMetaFromZod(schema);
171
179
  }
172
- // ── Chainable field builders ──────────────────────────────────────────────
173
- //
174
- // Each builder returns the underlying Zod schema (so `z.object(shape)` still
175
- // works) with `.indexed()` added as a chainable method. `.optional()` and
176
- // `.nullable()` still come from Zod itself and preserve the description.
177
- /** Add `.indexed()` to a Zod schema without disturbing its type. */
178
- function withIndexed(schema, baseMeta) {
179
- const described = schema.describe(encodeMeta({ ...baseMeta, isIndexed: false }));
180
- described.indexed = () => {
181
- return schema.describe(encodeMeta({ ...baseMeta, isIndexed: true }));
180
+ function assertColumnName(column) {
181
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(column)) {
182
+ throw new Error(`field.from(): invalid column identifier ${JSON.stringify(column)}`);
183
+ }
184
+ }
185
+ /** Add sync-engine chain methods to a Zod schema without disturbing its type. */
186
+ function withFieldMethods(schema, meta) {
187
+ const described = schema.describe(encodeMeta(meta));
188
+ described.indexed = () => withFieldMethods(schema, { ...meta, isIndexed: true });
189
+ described.from = (column) => {
190
+ assertColumnName(column);
191
+ return withFieldMethods(schema, { ...meta, column });
182
192
  };
183
193
  return described;
184
194
  }
195
+ function buildField(schema, baseMeta) {
196
+ return withFieldMethods(schema, { ...baseMeta, isIndexed: false });
197
+ }
185
198
  export const field = {
186
199
  /** String field */
187
200
  string() {
188
- return withIndexed(z.string(), { type: 'string' });
201
+ return buildField(z.string(), { type: 'string' });
189
202
  },
190
203
  /** Number field */
191
204
  number() {
192
- return withIndexed(z.number(), { type: 'number' });
205
+ return buildField(z.number(), { type: 'number' });
193
206
  },
194
207
  /** Boolean field */
195
208
  boolean() {
196
- return withIndexed(z.boolean(), { type: 'boolean' });
209
+ return buildField(z.boolean(), { type: 'boolean' });
197
210
  },
198
211
  /** Date field */
199
212
  date() {
200
- return withIndexed(z.date(), { type: 'date' });
213
+ return buildField(z.date(), { type: 'date' });
201
214
  },
202
215
  /** Enum field with constrained string values */
203
216
  enum(values) {
204
- return withIndexed(z.enum(values), { type: 'enum', enumValues: values });
217
+ return buildField(z.enum(values), { type: 'enum', enumValues: values });
205
218
  },
206
219
  /**
207
220
  * JSON field. Three call shapes:
@@ -245,7 +258,7 @@ export const field = {
245
258
  // Plain object shape → wrap in z.object() for the sub-property pattern
246
259
  inner = z.object(schemaOrShape);
247
260
  }
248
- return withIndexed(inner, { type: 'json' });
261
+ return buildField(inner, { type: 'json' });
249
262
  },
250
263
  /** Indexed string field (shorthand for `field.string().indexed()`). */
251
264
  id() {
@@ -21,12 +21,15 @@
21
21
  * ```
22
22
  */
23
23
  export { z } from 'zod';
24
- export { field, indexed, getFieldMeta, type FieldMeta } from './field.js';
24
+ export { field, indexed, getFieldMeta, type FieldBuilder, type FieldMeta } from './field.js';
25
25
  export { relation, type RelationDef, type RelationType } from './relation.js';
26
- export { model, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, } from './model.js';
26
+ export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type TenancyInput, } from './tenancy.js';
27
+ export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, } from './model.js';
27
28
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
28
- export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, type IdentityRole, type IdentityContext, identityRole, extractIdentityIds, type IdentityRoleSource, } from './schema.js';
29
+ export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
29
30
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
30
- export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
31
+ export { selectModels } from './select.js';
32
+ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
33
+ export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldColumnChange, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
31
34
  export { generateTypes } from './generate.js';
32
35
  export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
@@ -26,17 +26,23 @@ export { z } from 'zod';
26
26
  export { field, indexed, getFieldMeta } from './field.js';
27
27
  // Relation builders
28
28
  export { relation } from './relation.js';
29
+ // Tenancy — the single source of truth for how a model's rows are tenant-scoped.
30
+ export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
29
31
  // Model builder
30
- export { model, } from './model.js';
32
+ export { model, scopeKindOf, } from './model.js';
31
33
  // Intent-first shorthand: `mutable.lazy({...})` and friends. Read the
32
34
  // safety posture and load shape off the verb tokens; everything else
33
35
  // falls back to sensible defaults. See sugar.ts for the full pattern.
34
36
  export { mutable, readOnly } from './sugar.js';
35
37
  // Schema definition + type inference
36
- export { defineSchema, composeIdentitySyncGroups, identityRole, extractIdentityIds, } from './schema.js';
38
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
37
39
  // Schema ⇄ JSON (control-plane transport for hosted multi-tenant)
38
40
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
39
- // Schema diff + migration planning (pure; SQL emission is server-side)
41
+ // Schema projection derive an app's subset from one canonical schema.
42
+ export { selectModels } from './select.js';
43
+ // Schema → Postgres DDL (pure; shared by the hosted server and the CLI)
44
+ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, q, sqlType, } from './ddl.js';
45
+ // Schema diff + migration planning (pure; SQL emission lowered by ddl.ts)
40
46
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
41
47
  // Schema → TypeScript type emission (the `generate` half; pure)
42
48
  export { generateTypes } from './generate.js';