@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +248 -124
  3. package/dist/BaseSyncedStore.d.ts +3 -3
  4. package/dist/BaseSyncedStore.js +3 -3
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +91 -93
  8. package/dist/client/Ablo.js +122 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +116 -90
  14. package/dist/client/createModelProxy.js +128 -128
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +5 -5
  18. package/dist/coordination/index.d.ts +6 -0
  19. package/dist/coordination/index.js +6 -0
  20. package/dist/coordination/schema.d.ts +329 -0
  21. package/dist/coordination/schema.js +209 -0
  22. package/dist/core/QueryView.d.ts +4 -1
  23. package/dist/core/QueryView.js +1 -1
  24. package/dist/core/index.d.ts +2 -0
  25. package/dist/core/index.js +7 -0
  26. package/dist/core/query-utils.d.ts +7 -10
  27. package/dist/core/query-utils.js +2 -3
  28. package/dist/errorCodes.d.ts +264 -0
  29. package/dist/errorCodes.js +251 -0
  30. package/dist/errors.d.ts +59 -14
  31. package/dist/errors.js +73 -12
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +8 -12
  34. package/dist/interfaces/index.d.ts +2 -10
  35. package/dist/mutators/Transaction.d.ts +2 -2
  36. package/dist/mutators/Transaction.js +2 -2
  37. package/dist/mutators/mutateActions.d.ts +44 -0
  38. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  39. package/dist/mutators/readerActions.d.ts +32 -0
  40. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  41. package/dist/policy/index.d.ts +1 -1
  42. package/dist/policy/index.js +1 -1
  43. package/dist/policy/types.d.ts +31 -0
  44. package/dist/policy/types.js +15 -0
  45. package/dist/query/types.d.ts +1 -1
  46. package/dist/react/AbloProvider.d.ts +13 -1
  47. package/dist/react/AbloProvider.js +14 -6
  48. package/dist/react/context.d.ts +4 -4
  49. package/dist/react/index.d.ts +4 -5
  50. package/dist/react/index.js +3 -7
  51. package/dist/react/useAblo.d.ts +14 -14
  52. package/dist/react/useAblo.js +26 -26
  53. package/dist/react/useIntent.d.ts +2 -2
  54. package/dist/react/useIntent.js +2 -2
  55. package/dist/react/useMutators.d.ts +1 -1
  56. package/dist/react/usePresence.d.ts +3 -3
  57. package/dist/react/usePresence.js +4 -4
  58. package/dist/react/useUndoScope.d.ts +1 -1
  59. package/dist/schema/ddl.d.ts +62 -0
  60. package/dist/schema/ddl.js +317 -0
  61. package/dist/schema/diff.d.ts +167 -0
  62. package/dist/schema/diff.js +280 -0
  63. package/dist/schema/field.d.ts +16 -19
  64. package/dist/schema/field.js +30 -17
  65. package/dist/schema/generate.d.ts +19 -0
  66. package/dist/schema/generate.js +87 -0
  67. package/dist/schema/index.d.ts +9 -3
  68. package/dist/schema/index.js +14 -2
  69. package/dist/schema/model.d.ts +87 -25
  70. package/dist/schema/model.js +33 -3
  71. package/dist/schema/relation.d.ts +17 -0
  72. package/dist/schema/roles.d.ts +148 -0
  73. package/dist/schema/roles.js +149 -0
  74. package/dist/schema/schema.d.ts +10 -69
  75. package/dist/schema/schema.js +58 -24
  76. package/dist/schema/select.d.ts +25 -0
  77. package/dist/schema/select.js +55 -0
  78. package/dist/schema/serialize.d.ts +96 -0
  79. package/dist/schema/serialize.js +231 -0
  80. package/dist/schema/sugar.d.ts +20 -3
  81. package/dist/schema/sugar.js +5 -1
  82. package/dist/schema/tenancy.d.ts +66 -0
  83. package/dist/schema/tenancy.js +58 -0
  84. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  85. package/dist/sync/HydrationCoordinator.js +23 -17
  86. package/dist/sync/SyncWebSocket.d.ts +17 -0
  87. package/dist/sync/SyncWebSocket.js +46 -1
  88. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  89. package/dist/sync/awaitIntentGrant.js +60 -0
  90. package/dist/sync/createIntentStream.d.ts +2 -1
  91. package/dist/sync/createIntentStream.js +89 -5
  92. package/dist/sync/createPresenceStream.js +1 -1
  93. package/dist/sync/participants.d.ts +2 -2
  94. package/dist/sync/participants.js +9 -18
  95. package/dist/types/global.d.ts +43 -52
  96. package/dist/types/global.js +16 -18
  97. package/dist/types/streams.d.ts +90 -42
  98. package/docs/api-keys.md +44 -0
  99. package/docs/api.md +72 -173
  100. package/docs/audit.md +5 -5
  101. package/docs/cli.md +212 -0
  102. package/docs/client-behavior.md +42 -43
  103. package/docs/coordination.md +343 -0
  104. package/docs/data-sources.md +16 -16
  105. package/docs/examples/agent-human.md +30 -32
  106. package/docs/examples/ai-sdk-tool.md +32 -33
  107. package/docs/examples/existing-python-backend.md +38 -36
  108. package/docs/examples/nextjs.md +24 -25
  109. package/docs/examples/scoped-agent.md +78 -0
  110. package/docs/examples/server-agent.md +20 -61
  111. package/docs/guarantees.md +34 -56
  112. package/docs/identity.md +529 -0
  113. package/docs/index.md +18 -24
  114. package/docs/integration-guide.md +130 -144
  115. package/docs/interaction-model.md +32 -95
  116. package/docs/mcp/claude-code.md +3 -3
  117. package/docs/mcp/cursor.md +1 -1
  118. package/docs/mcp/windsurf.md +1 -1
  119. package/docs/mcp.md +11 -26
  120. package/docs/quickstart.md +43 -49
  121. package/docs/react.md +74 -24
  122. package/docs/roadmap.md +17 -7
  123. package/llms.txt +34 -39
  124. package/package.json +8 -1
  125. package/dist/react/useMutate.d.ts +0 -83
  126. package/dist/react/useQuery.d.ts +0 -123
  127. package/dist/react/useQuery.js +0 -145
  128. package/dist/react/useReader.d.ts +0 -69
  129. package/docs/capabilities.md +0 -163
@@ -0,0 +1,280 @@
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
+ // ── Cast safety matrix ────────────────────────────────────────────────────────
29
+ // Keyed `${from}->${to}` over the 6 sync field types. Targets that map to TEXT
30
+ // (`string`) accept any scalar losslessly; tightening into an `enum` adds a CHECK
31
+ // that existing rows may violate (risky); narrowing into number/bool/date/json
32
+ // is risky (USING cast can fail per-row) or impossible (notCastable).
33
+ const CAST = {
34
+ // → string (TEXT): always safe
35
+ 'number->string': 'safe', 'boolean->string': 'safe', 'date->string': 'safe',
36
+ 'enum->string': 'safe', 'json->string': 'safe',
37
+ // → enum (TEXT + CHECK): constraint over existing data is risky
38
+ 'string->enum': 'risky', 'number->enum': 'risky', 'boolean->enum': 'risky',
39
+ 'date->enum': 'risky', 'json->enum': 'notCastable',
40
+ // → number (DOUBLE PRECISION)
41
+ 'string->number': 'risky', 'enum->number': 'risky', 'boolean->number': 'notCastable',
42
+ 'date->number': 'notCastable', 'json->number': 'notCastable',
43
+ // → boolean
44
+ 'string->boolean': 'risky', 'enum->boolean': 'risky', 'number->boolean': 'risky',
45
+ 'date->boolean': 'notCastable', 'json->boolean': 'notCastable',
46
+ // → date (TIMESTAMPTZ)
47
+ 'string->date': 'risky', 'enum->date': 'risky', 'number->date': 'notCastable',
48
+ 'boolean->date': 'notCastable', 'json->date': 'notCastable',
49
+ // → json (JSONB)
50
+ 'string->json': 'risky', 'enum->json': 'risky', 'number->json': 'notCastable',
51
+ 'boolean->json': 'notCastable', 'date->json': 'notCastable',
52
+ };
53
+ export function classifyCast(from, to) {
54
+ if (from === to)
55
+ return 'safe';
56
+ return CAST[`${from}->${to}`] ?? 'notCastable';
57
+ }
58
+ // ── Diff ──────────────────────────────────────────────────────────────────────
59
+ function diffEnumValues(from, to) {
60
+ const a = new Set(from ?? []);
61
+ const b = new Set(to ?? []);
62
+ const added = [...b].filter((v) => !a.has(v));
63
+ const removed = [...a].filter((v) => !b.has(v));
64
+ if (added.length === 0 && removed.length === 0)
65
+ return undefined;
66
+ return { added, removed };
67
+ }
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) {
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
+ }
81
+ if (prev.type !== next.type) {
82
+ changes.type = { from: prev.type, to: next.type, cast: classifyCast(prev.type, next.type) };
83
+ }
84
+ if (prev.isOptional !== next.isOptional) {
85
+ changes.nullability = { fromOptional: prev.isOptional, toOptional: next.isOptional };
86
+ }
87
+ // Enum value drift only matters while the field is (still) an enum; a type
88
+ // change away from enum is already captured by `type`.
89
+ if (prev.type === 'enum' && next.type === 'enum') {
90
+ const ev = diffEnumValues(prev.enumValues, next.enumValues);
91
+ if (ev)
92
+ changes.enumValues = ev;
93
+ }
94
+ if (prev.isIndexed !== next.isIndexed) {
95
+ changes.indexed = { from: prev.isIndexed, to: next.isIndexed };
96
+ }
97
+ return Object.keys(changes).length === 0 ? null : changes;
98
+ }
99
+ function tableNameOf(model, key) {
100
+ return model.tableName ?? key;
101
+ }
102
+ function diffModelFields(model, prev, next, fieldRenames) {
103
+ const steps = [];
104
+ const renameByNewName = new Map(fieldRenames.map((r) => [r.to, r.from]));
105
+ const renamedFromNames = new Set(fieldRenames.map((r) => r.from));
106
+ // Renames first (so subsequent alter steps reference the new name).
107
+ for (const { from, to } of fieldRenames) {
108
+ if (from in prev.fields && to in next.fields) {
109
+ steps.push({ kind: 'rename_field', model, from, to });
110
+ }
111
+ }
112
+ // Added (present in next, not in prev, and not the target of a rename).
113
+ for (const [name, meta] of Object.entries(next.fields)) {
114
+ if (name in prev.fields)
115
+ continue;
116
+ if (renameByNewName.has(name))
117
+ continue;
118
+ steps.push({ kind: 'add_field', model, field: name, meta });
119
+ }
120
+ // Altered: every field present in both (directly or via rename).
121
+ for (const [name, nextMeta] of Object.entries(next.fields)) {
122
+ const prevName = renameByNewName.get(name) ?? name;
123
+ const prevMeta = prev.fields[prevName];
124
+ if (!prevMeta)
125
+ continue;
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) {
134
+ steps.push({ kind: 'alter_field', model, field: name, changes });
135
+ }
136
+ }
137
+ // Dropped (present in prev, not in next, and not renamed away).
138
+ for (const name of Object.keys(prev.fields)) {
139
+ if (name in next.fields)
140
+ continue;
141
+ if (renamedFromNames.has(name))
142
+ continue;
143
+ steps.push({ kind: 'drop_field', model, field: name });
144
+ }
145
+ return steps;
146
+ }
147
+ /**
148
+ * Diff two serialized schemas into an ordered, expand→contract migration plan.
149
+ * `prev` is the active schema (`null` for a first push → all creates). Rename
150
+ * decisions are supplied via {@link RenameHints}; anything not hinted reads as
151
+ * drop+add.
152
+ */
153
+ export function diffSchema(prev, next, hints = {}) {
154
+ if (!prev) {
155
+ // First push: every model is created, with its fields carried in the
156
+ // create (no per-field add steps — the table is born with them).
157
+ return Object.entries(next.models).map(([model, def]) => ({
158
+ kind: 'create_model',
159
+ model,
160
+ tableName: tableNameOf(def, model),
161
+ }));
162
+ }
163
+ const modelRenames = hints.models ?? [];
164
+ const renameByNewModel = new Map(modelRenames.map((r) => [r.to, r.from]));
165
+ const renamedFromModels = new Set(modelRenames.map((r) => r.from));
166
+ const fieldHints = hints.fields ?? [];
167
+ const creates = [];
168
+ const renames = [];
169
+ const fieldSteps = [];
170
+ const drops = [];
171
+ // New + renamed models, and per-model field diffs.
172
+ for (const [model, nextDef] of Object.entries(next.models)) {
173
+ const prevModelKey = renameByNewModel.get(model) ?? model;
174
+ const prevDef = prev.models[prevModelKey];
175
+ if (!prevDef) {
176
+ creates.push({ kind: 'create_model', model, tableName: tableNameOf(nextDef, model) });
177
+ continue;
178
+ }
179
+ if (renameByNewModel.has(model)) {
180
+ renames.push({ kind: 'rename_model', from: prevModelKey, to: model });
181
+ }
182
+ const myFieldRenames = fieldHints
183
+ .filter((f) => f.model === model)
184
+ .map((f) => ({ from: f.from, to: f.to }));
185
+ fieldSteps.push(...diffModelFields(model, prevDef, nextDef, myFieldRenames));
186
+ }
187
+ // Dropped models (in prev, not in next, not renamed away).
188
+ for (const [model, prevDef] of Object.entries(prev.models)) {
189
+ if (model in next.models)
190
+ continue;
191
+ if (renamedFromModels.has(model))
192
+ continue;
193
+ drops.push({ kind: 'drop_model', model, tableName: tableNameOf(prevDef, model) });
194
+ }
195
+ // Expand → contract ordering. Within fieldSteps the per-model helper already
196
+ // emits rename → add → alter → drop_field, which preserves the same invariant.
197
+ return [...creates, ...renames, ...fieldSteps, ...drops];
198
+ }
199
+ /**
200
+ * Classify a plan's steps into Prisma-style warnings vs unexecutable. The IR
201
+ * carries no per-field default, so a non-optional `add_field` is conservatively
202
+ * unexecutable (a backfill or default resolves it) — we cannot prove a default
203
+ * exists. Classification is rule-based (schema-derived); the runtime layer can
204
+ * downgrade a signal to a no-op when the target table is empty.
205
+ */
206
+ export function classifyMigration(steps) {
207
+ const warnings = [];
208
+ const unexecutable = [];
209
+ for (const step of steps) {
210
+ switch (step.kind) {
211
+ case 'drop_model':
212
+ warnings.push({ code: 'drop_model', model: step.model, detail: `drops table for "${step.model}" (data loss)` });
213
+ break;
214
+ case 'drop_field':
215
+ warnings.push({ code: 'drop_field', model: step.model, field: step.field, detail: `drops column "${step.field}" (data loss)` });
216
+ break;
217
+ case 'add_field':
218
+ if (!step.meta.isOptional) {
219
+ unexecutable.push({
220
+ code: 'required_field_added',
221
+ model: step.model,
222
+ field: step.field,
223
+ detail: `adds required column "${step.field}" — needs a default or backfill on a non-empty table`,
224
+ });
225
+ }
226
+ break;
227
+ case 'alter_field': {
228
+ const { changes } = step;
229
+ if (changes.nullability && changes.nullability.fromOptional && !changes.nullability.toOptional) {
230
+ unexecutable.push({
231
+ code: 'made_required',
232
+ model: step.model,
233
+ field: step.field,
234
+ detail: `makes "${step.field}" required — fails if existing rows are NULL`,
235
+ });
236
+ }
237
+ if (changes.type) {
238
+ if (changes.type.cast === 'risky') {
239
+ warnings.push({ code: 'risky_cast', model: step.model, field: step.field, detail: `${changes.type.from} → ${changes.type.to} may fail per-row` });
240
+ }
241
+ else if (changes.type.cast === 'notCastable') {
242
+ warnings.push({ code: 'lossy_recreate', model: step.model, field: step.field, detail: `${changes.type.from} → ${changes.type.to} requires drop-and-recreate (data loss)` });
243
+ }
244
+ }
245
+ if (changes.enumValues && changes.enumValues.removed.length > 0) {
246
+ warnings.push({
247
+ code: 'enum_value_removed',
248
+ model: step.model,
249
+ field: step.field,
250
+ detail: `removes enum value(s) ${changes.enumValues.removed.join(', ')} — rows using them violate the new CHECK`,
251
+ });
252
+ }
253
+ break;
254
+ }
255
+ // create_model, rename_model, rename_field, add optional field: non-destructive.
256
+ default:
257
+ break;
258
+ }
259
+ }
260
+ return { warnings, unexecutable };
261
+ }
262
+ /** Convenience: a plan is safe to auto-apply iff it has no unexecutable steps. */
263
+ export function isAutoApplicable(classification) {
264
+ return classification.unexecutable.length === 0;
265
+ }
266
+ /**
267
+ * Does a provided backfill resolve this blocker? Only the two row-dependent
268
+ * blockers (`required_field_added`, `made_required`) are backfill-resolvable; a
269
+ * data-loss *warning* is not — that always needs `force`.
270
+ */
271
+ export function isBlockerResolved(signal, backfills) {
272
+ if (signal.code !== 'required_field_added' && signal.code !== 'made_required')
273
+ return false;
274
+ return backfills.some((b) => b.model === signal.model && b.field === signal.field);
275
+ }
276
+ /** The unexecutable signals NOT covered by a supplied backfill. Empty → the push
277
+ * can proceed (modulo the separate `warnings`/`force` gate). */
278
+ export function unresolvedBlockers(classification, backfills) {
279
+ return classification.unexecutable.filter((s) => !isBlockerResolved(s, backfills));
280
+ }
@@ -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() {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Schema → TypeScript type emission — the `generate` half of the loop (the
3
+ * counterpart to `diff`/migrate). Lowers a serialized schema to a `.ts` file of
4
+ * row interfaces + an `AbloSchema` map, so a consumer's app is typed against the
5
+ * exact schema that the database and sync layer enforce. Pure (returns source
6
+ * text); the CLI writes it to disk.
7
+ *
8
+ * This is the SDK's front door: the developer writes ONE `defineSchema`, pushes
9
+ * it (which migrates the DB), and generates types FROM the same schema — so the
10
+ * types they code against, the rows the DB stores, and the entities sync moves
11
+ * are provably the same thing.
12
+ *
13
+ * v1 emits the row shape (base columns + declared fields, enums as literal
14
+ * unions). Relations are resolved by the runtime SDK's typed accessors and are
15
+ * not expanded here.
16
+ */
17
+ import type { SchemaJSON } from './serialize.js';
18
+ /** Emit a TypeScript module of row interfaces + the `AbloSchema` map. */
19
+ export declare function generateTypes(schema: SchemaJSON): string;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Schema → TypeScript type emission — the `generate` half of the loop (the
3
+ * counterpart to `diff`/migrate). Lowers a serialized schema to a `.ts` file of
4
+ * row interfaces + an `AbloSchema` map, so a consumer's app is typed against the
5
+ * exact schema that the database and sync layer enforce. Pure (returns source
6
+ * text); the CLI writes it to disk.
7
+ *
8
+ * This is the SDK's front door: the developer writes ONE `defineSchema`, pushes
9
+ * it (which migrates the DB), and generates types FROM the same schema — so the
10
+ * types they code against, the rows the DB stores, and the entities sync moves
11
+ * are provably the same thing.
12
+ *
13
+ * v1 emits the row shape (base columns + declared fields, enums as literal
14
+ * unions). Relations are resolved by the runtime SDK's typed accessors and are
15
+ * not expanded here.
16
+ */
17
+ /** The base columns every model carries (mirrors `baseFieldsSchema`). */
18
+ const BASE_FIELDS = ['id', 'createdAt', 'updatedAt', 'organizationId', 'createdBy'];
19
+ function tsType(meta) {
20
+ switch (meta.type) {
21
+ case 'string':
22
+ return 'string';
23
+ case 'number':
24
+ return 'number';
25
+ case 'boolean':
26
+ return 'boolean';
27
+ case 'date':
28
+ // `baseFieldsSchema` uses `z.date()` and the driver hydrates timestamptz
29
+ // to Date — declared date fields match.
30
+ return 'Date';
31
+ case 'json':
32
+ return 'unknown';
33
+ case 'enum':
34
+ return meta.enumValues && meta.enumValues.length > 0
35
+ ? meta.enumValues.map((v) => `'${v.replace(/'/g, "\\'")}'`).join(' | ')
36
+ : 'string';
37
+ default:
38
+ return 'unknown';
39
+ }
40
+ }
41
+ /** A valid PascalCase TS identifier from a model's typename (or its key). */
42
+ function interfaceName(model, key) {
43
+ const raw = model.typename && model.typename.trim() ? model.typename : key;
44
+ const id = raw
45
+ .replace(/[^A-Za-z0-9]+/g, ' ')
46
+ .trim()
47
+ .split(/\s+/)
48
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
49
+ .join('');
50
+ return /^[A-Za-z_]/.test(id) ? id : `Model${id}`;
51
+ }
52
+ /** Emit a TypeScript module of row interfaces + the `AbloSchema` map. */
53
+ export function generateTypes(schema) {
54
+ const lines = [
55
+ '// Generated by `ablo generate` — do not edit by hand.',
56
+ '// Re-run `ablo generate` after pushing a schema change.',
57
+ '',
58
+ ];
59
+ const nameByKey = new Map();
60
+ for (const [key, model] of Object.entries(schema.models)) {
61
+ nameByKey.set(key, interfaceName(model, key));
62
+ }
63
+ for (const [key, model] of Object.entries(schema.models)) {
64
+ lines.push(`export interface ${nameByKey.get(key)} {`);
65
+ lines.push(' id: string;');
66
+ lines.push(' createdAt: Date;');
67
+ lines.push(' updatedAt: Date;');
68
+ lines.push(' organizationId?: string;');
69
+ lines.push(' createdBy?: string;');
70
+ for (const [fieldName, meta] of Object.entries(model.fields)) {
71
+ // A model that redeclares a base column doesn't double-emit it.
72
+ if (BASE_FIELDS.includes(fieldName))
73
+ continue;
74
+ lines.push(` ${fieldName}${meta.isOptional ? '?' : ''}: ${tsType(meta)};`);
75
+ }
76
+ lines.push('}');
77
+ lines.push('');
78
+ }
79
+ // The model map — parameterize the Ablo client with this for typed access.
80
+ lines.push('export interface AbloSchema {');
81
+ for (const key of Object.keys(schema.models)) {
82
+ lines.push(` ${JSON.stringify(key)}: ${nameByKey.get(key)};`);
83
+ }
84
+ lines.push('}');
85
+ lines.push('');
86
+ return lines.join('\n');
87
+ }
@@ -21,9 +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, } 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';
30
+ export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.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';
34
+ export { generateTypes } from './generate.js';
29
35
  export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
@@ -26,13 +26,25 @@ 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, } from './schema.js';
38
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
39
+ // Schema ⇄ JSON (control-plane transport for hosted multi-tenant)
40
+ export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
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)
46
+ export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
47
+ // Schema → TypeScript type emission (the `generate` half; pure)
48
+ export { generateTypes } from './generate.js';
37
49
  // Query definition DSL + type inference
38
50
  export { query, defineQueries, } from './queries.js';