@abloatai/ablo 0.8.0 → 0.9.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 +40 -1
- package/README.md +32 -27
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +172 -2
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/agent/session.js +3 -3
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +4 -42
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +160 -42
- package/dist/client/Ablo.js +145 -75
- package/dist/client/ApiClient.d.ts +20 -4
- package/dist/client/ApiClient.js +166 -28
- package/dist/client/auth.d.ts +14 -5
- package/dist/client/auth.js +60 -7
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +130 -66
- package/dist/client/createModelProxy.js +152 -49
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +49 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +3 -3
- package/dist/client/registerDataSource.js +11 -9
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +70 -1
- package/dist/errorCodes.js +108 -9
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +72 -22
- package/dist/index.d.ts +17 -8
- package/dist/index.js +15 -6
- package/dist/keys/index.d.ts +16 -1
- package/dist/keys/index.js +26 -6
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +3 -6
- package/dist/react/AbloProvider.d.ts +23 -126
- package/dist/react/AbloProvider.js +62 -199
- package/dist/react/useAblo.d.ts +2 -2
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +4 -0
- package/dist/schema/serialize.js +4 -0
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +10 -15
- package/dist/sync/ConnectionManager.d.ts +55 -1
- package/dist/sync/ConnectionManager.js +155 -16
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +238 -39
- package/dist/sync/NetworkProbe.d.ts +58 -24
- package/dist/sync/NetworkProbe.js +118 -42
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +70 -36
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api.md +47 -44
- package/docs/cli.md +44 -44
- package/docs/client-behavior.md +30 -30
- package/docs/coordination.md +33 -36
- package/docs/data-sources.md +35 -15
- package/docs/examples/agent-human.md +45 -43
- package/docs/examples/ai-sdk-tool.md +20 -16
- package/docs/examples/existing-python-backend.md +16 -12
- package/docs/examples/nextjs.md +14 -12
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/examples/server-agent.md +24 -21
- package/docs/guarantees.md +15 -13
- package/docs/index.md +1 -1
- package/docs/integration-guide.md +30 -30
- package/docs/interaction-model.md +19 -23
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +2 -2
- package/docs/mcp.md +6 -6
- package/docs/quickstart.md +41 -31
- package/docs/react.md +13 -9
- package/docs/schema-contract.md +12 -10
- package/docs/the-loop.md +21 -0
- package/examples/data-source/README.md +4 -5
- package/examples/data-source/customer-server.ts +27 -25
- package/llms.txt +28 -5
- package/package.json +43 -3
package/dist/schema/ddl.d.ts
CHANGED
|
@@ -22,14 +22,34 @@ import type { MigrationStep, BackfillValue } from './diff.js';
|
|
|
22
22
|
export interface ProvisionPlan {
|
|
23
23
|
/** The Postgres schema the tables live in (`app_<id>` or `public`). */
|
|
24
24
|
readonly appSchema: string;
|
|
25
|
-
/** Ordered, idempotent DDL statements. Safe to run repeatedly.
|
|
25
|
+
/** Ordered, idempotent DDL statements. Safe to run repeatedly. Executors run
|
|
26
|
+
* these together in ONE transaction. */
|
|
26
27
|
readonly statements: readonly string[];
|
|
28
|
+
/** Post-commit, NON-transactional DDL (`VALIDATE CONSTRAINT`, `CREATE INDEX
|
|
29
|
+
* CONCURRENTLY`) — run AFTER {@link statements} commit, each outside any
|
|
30
|
+
* transaction, best-effort. Keeps the lock-heavy / scan-heavy work off the
|
|
31
|
+
* main transaction so adding a foreign key never freezes a large, live BYO
|
|
32
|
+
* table. Optional + back-compat: absent = nothing to run. */
|
|
33
|
+
readonly concurrent?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
export interface ProvisionOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Emit `DEFERRABLE INITIALLY DEFERRED` FOREIGN KEY constraints for every
|
|
38
|
+
* `parent: true` belongsTo relation (true ownership edges only — see
|
|
39
|
+
* {@link foreignKeyStatements}). Off by default: the soft-reference model keeps
|
|
40
|
+
* out-of-order sync robust on Ablo-managed tables. Turn on for a customer's own
|
|
41
|
+
* (BYO / dedicated) database, where a clean, navigable relational schema is
|
|
42
|
+
* wanted and the DB starts empty (nothing for the constraint to fail against).
|
|
43
|
+
*/
|
|
44
|
+
readonly foreignKeys?: boolean;
|
|
27
45
|
}
|
|
28
46
|
export interface MigrationPlan {
|
|
29
47
|
/** The app Postgres schema the DDL targets (`app_<id>` or `public`). */
|
|
30
48
|
readonly appSchema: string;
|
|
31
|
-
/** Ordered DDL statements (expand → contract). */
|
|
49
|
+
/** Ordered DDL statements (expand → contract). Run in ONE transaction. */
|
|
32
50
|
readonly statements: readonly string[];
|
|
51
|
+
/** Post-commit, non-transactional DDL — see {@link ProvisionPlan.concurrent}. */
|
|
52
|
+
readonly concurrent?: readonly string[];
|
|
33
53
|
}
|
|
34
54
|
/** Per-app schema name for an app (organization) id. */
|
|
35
55
|
export declare function appSchemaName(organizationId: string): string;
|
|
@@ -46,7 +66,7 @@ export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']):
|
|
|
46
66
|
* itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
|
|
47
67
|
* skipped (it always exists).
|
|
48
68
|
*/
|
|
49
|
-
export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string): ProvisionPlan;
|
|
69
|
+
export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string, opts?: ProvisionOptions): ProvisionPlan;
|
|
50
70
|
/**
|
|
51
71
|
* Lower an ordered migration step list to DDL. `next` is the schema being pushed
|
|
52
72
|
* (the target column shapes are read from it), `prev` the active one (used to
|
|
@@ -59,4 +79,7 @@ export declare function generateMigrationPlan(steps: readonly MigrationStep[], o
|
|
|
59
79
|
/** Constant seed values that let a required-field add / made-required step
|
|
60
80
|
* set NOT NULL on a non-empty table. Keyed by (model, field). */
|
|
61
81
|
readonly backfills?: readonly BackfillValue[];
|
|
82
|
+
/** Emit DEFERRABLE FK constraints for `parent: true` edges of newly-created
|
|
83
|
+
* models. Off by default — see {@link ProvisionOptions.foreignKeys}. */
|
|
84
|
+
readonly foreignKeys?: boolean;
|
|
62
85
|
}): MigrationPlan;
|
package/dist/schema/ddl.js
CHANGED
|
@@ -55,6 +55,118 @@ export function sqlType(fieldType) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at', 'updated_at']);
|
|
58
|
+
// ── Foreign keys (relation-driven, sync-safe) ────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* A Postgres-identifier-safe constraint name ≤63 bytes. When the natural
|
|
61
|
+
* `<table>_<col>_<suffix>` exceeds the limit, fall back to a deterministic
|
|
62
|
+
* hashed form so the name stays stable AND matches what Postgres actually stores
|
|
63
|
+
* — a silently-truncated name would never match the DO-block existence guard,
|
|
64
|
+
* breaking idempotency (re-adds every push) and risking prefix collisions.
|
|
65
|
+
*/
|
|
66
|
+
function constraintName(table, col, suffix) {
|
|
67
|
+
const full = `${table}_${col}_${suffix}`;
|
|
68
|
+
if (full.length <= 63)
|
|
69
|
+
return full;
|
|
70
|
+
let h = 5381;
|
|
71
|
+
for (let i = 0; i < full.length; i++)
|
|
72
|
+
h = ((h * 33) + full.charCodeAt(i)) >>> 0;
|
|
73
|
+
const hash = h.toString(36);
|
|
74
|
+
const prefix = full.slice(0, Math.max(1, 63 - suffix.length - hash.length - 2));
|
|
75
|
+
return `${prefix}_${hash}_${suffix}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Foreign-key constraints for a model's belongsTo relations marked `{ fk: true }`.
|
|
79
|
+
*
|
|
80
|
+
* Emission is driven by an explicit `fk` marker, DECOUPLED from `parent`
|
|
81
|
+
* (`parent` = sync-group fan-out / visibility, control plane; `fk` = physical
|
|
82
|
+
* referential integrity, data plane — orthogonal axes, per Drizzle's
|
|
83
|
+
* relations()-vs-references() split and the Zanzibar "parent is permission-only"
|
|
84
|
+
* rule). A relation sets `fk` only when its target is co-located in the same DB
|
|
85
|
+
* AND written in the same commit, and is a strong / contained entity. Soft
|
|
86
|
+
* references (provenance / template pointers, e.g. `sourceSlideId`, `templateId`)
|
|
87
|
+
* stay plain columns — a hard FK there would reject a write pointing cross-scope
|
|
88
|
+
* or at an absent row and break sync.
|
|
89
|
+
*
|
|
90
|
+
* LIVE / POPULATED tables: a plain ADD CONSTRAINT takes SHARE ROW EXCLUSIVE on
|
|
91
|
+
* both tables and scans the whole child table — freezing writes on a customer's
|
|
92
|
+
* production DB. So the constraint is added `NOT VALID` (instant, no scan, brief
|
|
93
|
+
* lock) INSIDE the transaction, and the existing-row check (`VALIDATE
|
|
94
|
+
* CONSTRAINT`, SHARE UPDATE EXCLUSIVE — allows writes) plus the child index
|
|
95
|
+
* (`CREATE INDEX CONCURRENTLY`) are returned SEPARATELY in {@link
|
|
96
|
+
* ForeignKeyDdl.concurrent}, run after commit, outside any transaction, and are
|
|
97
|
+
* best-effort: if existing data violates a freshly-added FK the VALIDATE is
|
|
98
|
+
* skipped (logged, never fatal), the constraint still enforces all new writes,
|
|
99
|
+
* and nothing is destroyed.
|
|
100
|
+
*
|
|
101
|
+
* The key is a pure `DEFERRABLE INITIALLY DEFERRED` **integrity guard** with
|
|
102
|
+
* `ON DELETE NO ACTION`: it NEVER mutates a child row itself. (SET NULL / CASCADE
|
|
103
|
+
* would change data server-side with NO sync_delta — invisible to other clients
|
|
104
|
+
* until re-bootstrap — and would override the app-layer ModelRegistry onDelete
|
|
105
|
+
* contract.) The app layer owns deletes + nullification and emits the deltas; the
|
|
106
|
+
* deferred check just verifies — at COMMIT, so same-batch child-before-parent and
|
|
107
|
+
* the app's own cascade both pass — that integrity holds, failing loudly only if
|
|
108
|
+
* the app left a dangling reference.
|
|
109
|
+
*
|
|
110
|
+
* Authoritative + idempotent: a same-named constraint that isn't deferrable or
|
|
111
|
+
* carries the wrong delete action (a hand-added or legacy FK) is dropped and
|
|
112
|
+
* recreated; an already-correct one is left untouched (no re-validation cost).
|
|
113
|
+
* Emitted in a final pass, after every referenced table exists.
|
|
114
|
+
*
|
|
115
|
+
* The FK column is resolved the SAME way the table loop names columns
|
|
116
|
+
* (`fieldMeta.column ?? camelToSnake(field)`), not from `rel.foreignKeyColumn` —
|
|
117
|
+
* the table loop ignores relation casing, so trusting `foreignKeyColumn` would
|
|
118
|
+
* mismatch the real column whenever `casing` is unset.
|
|
119
|
+
*/
|
|
120
|
+
function foreignKeyStatements(table, model, models, qs) {
|
|
121
|
+
const qt = `${qs}.${q(table)}`;
|
|
122
|
+
// The model's provisioned column set — guard so a relation whose FK field
|
|
123
|
+
// isn't actually declared (no column) never produces a broken ALTER.
|
|
124
|
+
const orgCol = tenancyColumn(resolveTenancy(model));
|
|
125
|
+
const columns = new Set(['id', 'created_by', 'created_at', 'updated_at']);
|
|
126
|
+
if (orgCol)
|
|
127
|
+
columns.add(orgCol);
|
|
128
|
+
for (const [fieldName, meta] of Object.entries(model.fields)) {
|
|
129
|
+
columns.add(meta.column ?? camelToSnake(fieldName));
|
|
130
|
+
}
|
|
131
|
+
const statements = [];
|
|
132
|
+
const concurrent = [];
|
|
133
|
+
for (const rel of Object.values(model.relations)) {
|
|
134
|
+
if (rel.type !== 'belongsTo')
|
|
135
|
+
continue; // only relations whose FK column lives on THIS table
|
|
136
|
+
if (rel.options?.fk !== true)
|
|
137
|
+
continue; // explicit `fk` marker — decoupled from `parent` (visibility)
|
|
138
|
+
const targetModel = models[rel.target];
|
|
139
|
+
if (!targetModel)
|
|
140
|
+
continue; // target not provisioned into this DB → can't reference it
|
|
141
|
+
if ((targetModel.plane ?? 'tenant') === 'control')
|
|
142
|
+
continue; // control-plane table absent in a tenant DB
|
|
143
|
+
const col = model.fields[rel.foreignKey]?.column ?? camelToSnake(rel.foreignKey);
|
|
144
|
+
if (!columns.has(col))
|
|
145
|
+
continue; // FK field isn't a provisioned column
|
|
146
|
+
const targetTable = targetModel.tableName ?? rel.target;
|
|
147
|
+
const cname = constraintName(table, col, 'fkey');
|
|
148
|
+
const lit = cname.replace(/'/g, "''");
|
|
149
|
+
const iname = constraintName(table, col, 'idx');
|
|
150
|
+
const targetQt = `${qs}.${q(targetTable)}`;
|
|
151
|
+
// In-tx: authoritative create as NOT VALID — instant, no child-table scan,
|
|
152
|
+
// only a brief lock. confdeltype 'a' = NO ACTION; recreate only when absent /
|
|
153
|
+
// not deferrable / wrong delete action, so a correct constraint is untouched.
|
|
154
|
+
statements.push(`DO $$ BEGIN\n` +
|
|
155
|
+
` IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${lit}' AND (NOT condeferrable OR confdeltype <> 'a')) THEN\n` +
|
|
156
|
+
` ALTER TABLE ${qt} DROP CONSTRAINT ${q(cname)};\n` +
|
|
157
|
+
` END IF;\n` +
|
|
158
|
+
` IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${lit}') THEN\n` +
|
|
159
|
+
` ALTER TABLE ${qt} ADD CONSTRAINT ${q(cname)} FOREIGN KEY (${q(col)}) ` +
|
|
160
|
+
`REFERENCES ${targetQt} (${q('id')}) ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED NOT VALID;\n` +
|
|
161
|
+
` END IF;\nEND $$;`);
|
|
162
|
+
// Post-commit, non-blocking: validate existing rows (SHARE UPDATE EXCLUSIVE,
|
|
163
|
+
// allows concurrent writes) then index the child column (Postgres does NOT
|
|
164
|
+
// auto-index the referencing column → parent deletes would seq-scan it).
|
|
165
|
+
concurrent.push(`ALTER TABLE ${qt} VALIDATE CONSTRAINT ${q(cname)};`);
|
|
166
|
+
concurrent.push(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${q(iname)} ON ${qt} (${q(col)});`);
|
|
167
|
+
}
|
|
168
|
+
return { statements, concurrent };
|
|
169
|
+
}
|
|
58
170
|
// ── Provisioning (additive, idempotent) ─────────────────────────────────────
|
|
59
171
|
/**
|
|
60
172
|
* Build the additive, idempotent provisioning plan for an app. Pure — no DB
|
|
@@ -65,11 +177,18 @@ const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at
|
|
|
65
177
|
* itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
|
|
66
178
|
* skipped (it always exists).
|
|
67
179
|
*/
|
|
68
|
-
export function generateProvisionPlan(schema, targetSchema) {
|
|
180
|
+
export function generateProvisionPlan(schema, targetSchema, opts = {}) {
|
|
69
181
|
const appSchema = targetSchema;
|
|
70
182
|
const qs = q(appSchema);
|
|
71
183
|
const statements = appSchema === 'public' ? [] : [`CREATE SCHEMA IF NOT EXISTS ${qs};`];
|
|
184
|
+
const concurrent = [];
|
|
72
185
|
for (const [key, model] of Object.entries(schema.models)) {
|
|
186
|
+
// Control-plane models (Ablo's own sync log / attribution / audit) are never
|
|
187
|
+
// emitted into a tenant database — only `tenant`-plane models are. Absent
|
|
188
|
+
// plane = `tenant` (back-compat). This declared boundary is what makes "what
|
|
189
|
+
// a BYO customer DB gets" derivable instead of hand-coded.
|
|
190
|
+
if ((model.plane ?? 'tenant') === 'control')
|
|
191
|
+
continue;
|
|
73
192
|
// Default the physical table to the model key when `tableName` is omitted —
|
|
74
193
|
// same fallback the migration path uses (`tableOfModel: m.tableName ?? key`).
|
|
75
194
|
// Without this, a schema that doesn't set `tableName` (e.g. the `ablo init`
|
|
@@ -121,7 +240,19 @@ export function generateProvisionPlan(schema, targetSchema) {
|
|
|
121
240
|
statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
|
|
122
241
|
}
|
|
123
242
|
}
|
|
124
|
-
|
|
243
|
+
// Foreign keys (opt-in) — a final pass so every referenced table already
|
|
244
|
+
// exists when its constraint is added.
|
|
245
|
+
if (opts.foreignKeys) {
|
|
246
|
+
for (const [key, m] of Object.entries(schema.models)) {
|
|
247
|
+
if ((m.plane ?? 'tenant') === 'control')
|
|
248
|
+
continue;
|
|
249
|
+
const t = m.tableName ?? key;
|
|
250
|
+
const fk = foreignKeyStatements(t, m, schema.models, qs);
|
|
251
|
+
statements.push(...fk.statements);
|
|
252
|
+
concurrent.push(...fk.concurrent);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { appSchema, statements, concurrent };
|
|
125
256
|
}
|
|
126
257
|
// ── Migration (destructive-aware, diff-driven) ──────────────────────────────
|
|
127
258
|
function enumCheckStatements(table, col, qt, values) {
|
|
@@ -169,9 +300,10 @@ function sqlLiteral(value, fieldType) {
|
|
|
169
300
|
* resolve the *old* table name on a model rename).
|
|
170
301
|
*/
|
|
171
302
|
export function generateMigrationPlan(steps, opts) {
|
|
172
|
-
const { prev, next, targetSchema, backfills = [] } = opts;
|
|
303
|
+
const { prev, next, targetSchema, backfills = [], foreignKeys = false } = opts;
|
|
173
304
|
const qs = q(targetSchema);
|
|
174
305
|
const statements = [];
|
|
306
|
+
const concurrent = [];
|
|
175
307
|
const qtFor = (table) => `${qs}.${q(table)}`;
|
|
176
308
|
const tableOfModel = (schema, key) => {
|
|
177
309
|
const m = schema?.models[key];
|
|
@@ -313,5 +445,21 @@ export function generateMigrationPlan(steps, opts) {
|
|
|
313
445
|
}
|
|
314
446
|
}
|
|
315
447
|
}
|
|
316
|
-
|
|
448
|
+
// Foreign keys (opt-in). Reconcile against the FULL `next` schema, not just
|
|
449
|
+
// create_model steps: a parent edge ADDED to an existing model surfaces only as
|
|
450
|
+
// an add_field (relation changes aren't diffed), so a create_model-only pass
|
|
451
|
+
// would never materialize its FK. The DO-block is authoritative + idempotent
|
|
452
|
+
// (a no-op when the constraint is already correct), so emitting the full set
|
|
453
|
+
// each push is cheap and self-healing. Appended after every table/column step.
|
|
454
|
+
if (foreignKeys) {
|
|
455
|
+
for (const [key, def] of Object.entries(next.models)) {
|
|
456
|
+
if ((def.plane ?? 'tenant') === 'control')
|
|
457
|
+
continue;
|
|
458
|
+
const table = def.tableName ?? key;
|
|
459
|
+
const fk = foreignKeyStatements(table, def, next.models, qs);
|
|
460
|
+
statements.push(...fk.statements);
|
|
461
|
+
concurrent.push(...fk.concurrent);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { appSchema: targetSchema, statements, concurrent };
|
|
317
465
|
}
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ export { z } from 'zod';
|
|
|
24
24
|
export { field, indexed, getFieldMeta, type FieldBuilder, type FieldMeta } from './field.js';
|
|
25
25
|
export { relation, type RelationDef, type RelationType } from './relation.js';
|
|
26
26
|
export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type TenancyInput, } from './tenancy.js';
|
|
27
|
+
export { planeSchema, DEFAULT_PLANE, type SchemaPlane } from './plane.js';
|
|
28
|
+
export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_PLANES, type SyncDeltaCore, type DeltaAttribution, type DeltaProvenance, type SyncDeltaRow, type ParticipantKind, type ConfirmationState, type BackfillProvenance, } from './sync-delta-row.js';
|
|
29
|
+
export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, type SyncDeltaAction, type WireDeltaData, type ParticipantRef, type SyncDeltaWireCore, type ClientSyncDelta, type ServerSyncDelta, } from './sync-delta-wire.js';
|
|
27
30
|
export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, } from './model.js';
|
|
28
31
|
export { mutable, readOnly, type SugarOptions } from './sugar.js';
|
|
29
32
|
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';
|
|
@@ -33,3 +36,4 @@ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSna
|
|
|
33
36
|
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
37
|
export { generateTypes } from './generate.js';
|
|
35
38
|
export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
|
|
39
|
+
export { schemaToOpenApi, type SchemaToOpenApiOptions } from './openapi.js';
|
package/dist/schema/index.js
CHANGED
|
@@ -28,6 +28,17 @@ export { field, indexed, getFieldMeta } from './field.js';
|
|
|
28
28
|
export { relation } from './relation.js';
|
|
29
29
|
// Tenancy — the single source of truth for how a model's rows are tenant-scoped.
|
|
30
30
|
export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
|
|
31
|
+
// Database plane — which DB a model's rows live in (`tenant` portable to a BYO
|
|
32
|
+
// customer DB, `control` = Ablo's own). Sibling axis to `tenancy`.
|
|
33
|
+
export { planeSchema, DEFAULT_PLANE } from './plane.js';
|
|
34
|
+
// Decomposed sync-delta storage row (P0 of the control/tenant plane split —
|
|
35
|
+
// see docs/plans/sync-delta-zod-decomposition.md). Describes the existing
|
|
36
|
+
// `sync_deltas` columns as Zod schemas grouped by subsystem + database plane.
|
|
37
|
+
export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_PLANES, } from './sync-delta-row.js';
|
|
38
|
+
// Canonical WIRE delta contract — the broadcast (server→client) projection of
|
|
39
|
+
// the stored row. The SDK client and the sync-server both derive their
|
|
40
|
+
// `SyncDelta` type from these via `z.infer` so the contract cannot drift.
|
|
41
|
+
export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, } from './sync-delta-wire.js';
|
|
31
42
|
// Model builder
|
|
32
43
|
export { model, scopeKindOf, } from './model.js';
|
|
33
44
|
// Intent-first shorthand: `mutable.lazy({...})` and friends. Read the
|
|
@@ -48,3 +59,4 @@ export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlocke
|
|
|
48
59
|
export { generateTypes } from './generate.js';
|
|
49
60
|
// Query definition DSL + type inference
|
|
50
61
|
export { query, defineQueries, } from './queries.js';
|
|
62
|
+
export { schemaToOpenApi } from './openapi.js';
|
package/dist/schema/model.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { EntityRole } from './roles.js';
|
|
|
22
22
|
import { type FieldMeta } from './field.js';
|
|
23
23
|
import { type Tenancy, type ScopedViaRef } from './tenancy.js';
|
|
24
24
|
export type { ScopedViaRef, Tenancy } from './tenancy.js';
|
|
25
|
+
import { type SchemaPlane } from './plane.js';
|
|
25
26
|
/**
|
|
26
27
|
* Controls when model data is loaded from the server.
|
|
27
28
|
*
|
|
@@ -130,6 +131,13 @@ export interface ModelOptions {
|
|
|
130
131
|
* directly only to author the union form explicitly.
|
|
131
132
|
*/
|
|
132
133
|
tenancy?: Tenancy;
|
|
134
|
+
/**
|
|
135
|
+
* Which database plane this model's rows live in. `tenant` (default) =
|
|
136
|
+
* the tenant data plane, emitted into a customer's BYO/dedicated DB by
|
|
137
|
+
* provisioning. `control` = Ablo's control plane (sync log, attribution,
|
|
138
|
+
* audit) — never emitted into a customer DB. See `./plane.ts`.
|
|
139
|
+
*/
|
|
140
|
+
plane?: SchemaPlane;
|
|
133
141
|
/**
|
|
134
142
|
* Marks this model as a **scope root** — a model that forms a sync group of
|
|
135
143
|
* its own. A scope-root record lives in the group `<kind>:<id>`, where `kind`
|
|
@@ -321,6 +329,9 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
|
|
|
321
329
|
/** Canonical tenancy descriptor — the single source of truth, normalized from
|
|
322
330
|
* the `orgScoped`/`scopedVia`/`orgColumn` authoring sugar at build. */
|
|
323
331
|
readonly tenancy: Tenancy;
|
|
332
|
+
/** Database plane — `tenant` (default) is portable to a customer DB; `control`
|
|
333
|
+
* is Ablo-only. See {@link ModelOptions.plane} and `./plane.ts`. */
|
|
334
|
+
readonly plane?: SchemaPlane;
|
|
324
335
|
/** Scope-root marker. See {@link ModelOptions.scope}. */
|
|
325
336
|
readonly scope?: boolean | string;
|
|
326
337
|
/** Membership edge granting identity → scope-root access. See {@link ModelOptions.grants}. */
|
package/dist/schema/model.js
CHANGED
|
@@ -22,6 +22,7 @@ import { getFieldMeta, inferFieldMetaFromZod } from './field.js';
|
|
|
22
22
|
// re-exported so existing `import { ScopedViaRef } from './model'` call sites
|
|
23
23
|
// keep resolving while consumers migrate.
|
|
24
24
|
import { resolveTenancy } from './tenancy.js';
|
|
25
|
+
import { DEFAULT_PLANE } from './plane.js';
|
|
25
26
|
/** Normalize the `entityRoles` option (single | array | undefined) to an array. */
|
|
26
27
|
function normalizeEntityRoles(input) {
|
|
27
28
|
if (!input)
|
|
@@ -94,6 +95,7 @@ export function model(shape, relations, options) {
|
|
|
94
95
|
scopedVia: options?.scopedVia,
|
|
95
96
|
orgColumn: options?.orgColumn,
|
|
96
97
|
}),
|
|
98
|
+
plane: options?.plane ?? DEFAULT_PLANE,
|
|
97
99
|
scope: options?.scope,
|
|
98
100
|
grants: options?.grants,
|
|
99
101
|
entityRoles: normalizeEntityRoles(options?.entityRoles),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `schemaToOpenApi(schema)` — generate an OpenAPI 3.1 spec FROM a pushed schema,
|
|
3
|
+
* so the API Reference reflects the customer's OWN models, not Ablo's.
|
|
4
|
+
*
|
|
5
|
+
* The API surface *is* the schema: a `task` model is what makes `/v1/models/task`
|
|
6
|
+
* exist. This walks `schema.models[*].fields` (the introspectable `FieldMeta`)
|
|
7
|
+
* and emits, per model, the CRUD + coordination routes the hosted API serves:
|
|
8
|
+
* GET/POST /v1/models/{model}
|
|
9
|
+
* GET/PATCH/DELETE /v1/models/{model}/{id}
|
|
10
|
+
* POST/DELETE /v1/models/{model}/{id}/claim
|
|
11
|
+
* POST /v1/models/{model}/{id}/claim/reorder
|
|
12
|
+
* plus POST /v1/commits. Auth is a single Bearer scheme (the API key).
|
|
13
|
+
*
|
|
14
|
+
* Wire it into `ablo` codegen (e.g. `ablo openapi > openapi.json`) or serve it
|
|
15
|
+
* per-org; the output is a plain JSON-able object.
|
|
16
|
+
*/
|
|
17
|
+
import type { Schema, SchemaRecord } from './schema.js';
|
|
18
|
+
export interface SchemaToOpenApiOptions {
|
|
19
|
+
/** Spec title. Default `"Ablo API"`. */
|
|
20
|
+
readonly title?: string;
|
|
21
|
+
/** Spec version. Default `"1.0.0"`. */
|
|
22
|
+
readonly version?: string;
|
|
23
|
+
/** API base URL. Default `"https://api.abloatai.com/api"`. */
|
|
24
|
+
readonly serverUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
type Json = Record<string, unknown>;
|
|
27
|
+
export declare function schemaToOpenApi<S extends SchemaRecord>(schema: Schema<S>, options?: SchemaToOpenApiOptions): Json;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
function fieldSchema(f) {
|
|
2
|
+
switch (f.type) {
|
|
3
|
+
case 'number':
|
|
4
|
+
return { type: 'number' };
|
|
5
|
+
case 'boolean':
|
|
6
|
+
return { type: 'boolean' };
|
|
7
|
+
case 'date':
|
|
8
|
+
return { type: 'string', format: 'date-time' };
|
|
9
|
+
case 'enum':
|
|
10
|
+
return f.enumValues ? { type: 'string', enum: [...f.enumValues] } : { type: 'string' };
|
|
11
|
+
case 'json':
|
|
12
|
+
return { type: 'object', additionalProperties: true };
|
|
13
|
+
case 'string':
|
|
14
|
+
default:
|
|
15
|
+
return { type: 'string' };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const pascal = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
19
|
+
const idParam = () => ({ name: 'id', in: 'path', required: true, schema: { type: 'string' } });
|
|
20
|
+
const jsonBody = (schema) => ({
|
|
21
|
+
required: true,
|
|
22
|
+
content: { 'application/json': { schema } },
|
|
23
|
+
});
|
|
24
|
+
const jsonResp = (description, schema) => ({
|
|
25
|
+
description,
|
|
26
|
+
content: { 'application/json': { schema } },
|
|
27
|
+
});
|
|
28
|
+
const commitReceipt = () => jsonResp('Commit receipt', {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
object: { type: 'string', enum: ['commit_receipt'] },
|
|
32
|
+
clientTxId: { type: 'string' },
|
|
33
|
+
serverTxId: { type: 'string' },
|
|
34
|
+
success: { type: 'boolean' },
|
|
35
|
+
lastSyncId: { type: 'integer' },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
export function schemaToOpenApi(schema, options = {}) {
|
|
39
|
+
const models = schema.models;
|
|
40
|
+
const paths = {};
|
|
41
|
+
const schemas = {};
|
|
42
|
+
for (const [key, def] of Object.entries(models)) {
|
|
43
|
+
const ref = { $ref: `#/components/schemas/${pascal(key)}` };
|
|
44
|
+
const properties = { id: { type: 'string' } };
|
|
45
|
+
const required = ['id'];
|
|
46
|
+
const createProps = {};
|
|
47
|
+
for (const [fname, fmeta] of Object.entries(def.fields)) {
|
|
48
|
+
const fs = fieldSchema(fmeta);
|
|
49
|
+
properties[fname] = fs;
|
|
50
|
+
createProps[fname] = fs;
|
|
51
|
+
if (!fmeta.isOptional)
|
|
52
|
+
required.push(fname);
|
|
53
|
+
}
|
|
54
|
+
schemas[pascal(key)] = { type: 'object', properties, required };
|
|
55
|
+
const createBody = jsonBody({ type: 'object', properties: createProps });
|
|
56
|
+
paths[`/v1/models/${key}`] = {
|
|
57
|
+
get: {
|
|
58
|
+
tags: [key],
|
|
59
|
+
summary: `List ${key}`,
|
|
60
|
+
parameters: [
|
|
61
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
|
62
|
+
{ name: 'order_by', in: 'query', schema: { type: 'string' } },
|
|
63
|
+
{ name: 'order', in: 'query', schema: { type: 'string', enum: ['asc', 'desc'] } },
|
|
64
|
+
],
|
|
65
|
+
responses: {
|
|
66
|
+
'200': jsonResp('List of rows', {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: { object: { type: 'string', enum: ['list'] }, data: { type: 'array', items: ref } },
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
post: { tags: [key], summary: `Create a ${key}`, requestBody: createBody, responses: { '200': commitReceipt() } },
|
|
73
|
+
};
|
|
74
|
+
paths[`/v1/models/${key}/{id}`] = {
|
|
75
|
+
get: {
|
|
76
|
+
tags: [key],
|
|
77
|
+
summary: `Retrieve a ${key}`,
|
|
78
|
+
parameters: [idParam()],
|
|
79
|
+
responses: {
|
|
80
|
+
'200': jsonResp('The row', {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: { data: ref, stamp: { type: 'integer' } },
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
patch: { tags: [key], summary: `Update a ${key}`, parameters: [idParam()], requestBody: createBody, responses: { '200': commitReceipt() } },
|
|
87
|
+
delete: { tags: [key], summary: `Delete a ${key}`, parameters: [idParam()], responses: { '200': commitReceipt() } },
|
|
88
|
+
};
|
|
89
|
+
paths[`/v1/models/${key}/{id}/claim`] = {
|
|
90
|
+
post: { tags: [key], summary: `Claim a ${key} (acquire lease)`, parameters: [idParam()], responses: { '200': jsonResp('Claim acquired', { type: 'object' }) } },
|
|
91
|
+
delete: { tags: [key], summary: `Release a ${key} claim`, parameters: [idParam()], responses: { '200': jsonResp('Released', { type: 'object' }) } },
|
|
92
|
+
};
|
|
93
|
+
paths[`/v1/models/${key}/{id}/claim/reorder`] = {
|
|
94
|
+
post: { tags: [key], summary: `Reorder the ${key} wait-line (privileged)`, parameters: [idParam()], responses: { '200': jsonResp('Reordered', { type: 'object' }) } },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
paths['/v1/commits'] = {
|
|
98
|
+
post: { tags: ['commits'], summary: 'Commit a batch of operations atomically', responses: { '200': commitReceipt() } },
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
openapi: '3.1.0',
|
|
102
|
+
info: {
|
|
103
|
+
title: options.title ?? 'Ablo API',
|
|
104
|
+
version: options.version ?? '1.0.0',
|
|
105
|
+
description: 'Generated from your pushed Ablo schema — these routes are your models. ' +
|
|
106
|
+
'Authenticate every request with your API key as a Bearer token.',
|
|
107
|
+
},
|
|
108
|
+
servers: [{ url: options.serverUrl ?? 'https://api.abloatai.com/api' }],
|
|
109
|
+
security: [{ bearerAuth: [] }],
|
|
110
|
+
components: {
|
|
111
|
+
securitySchemes: {
|
|
112
|
+
bearerAuth: { type: 'http', scheme: 'bearer', description: 'Your Ablo API key (sk_… / rk_…).' },
|
|
113
|
+
},
|
|
114
|
+
schemas,
|
|
115
|
+
},
|
|
116
|
+
paths,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database PLANE — which database a model's rows live in. A sibling axis to
|
|
3
|
+
* `tenancy` (which says how rows are isolated *within* a database):
|
|
4
|
+
*
|
|
5
|
+
* - `tenant` — the tenant data plane. For a BYO/dedicated customer this is
|
|
6
|
+
* THEIR database; provisioning emits these tables there.
|
|
7
|
+
* - `control` — Ablo's control plane (the sync log, attribution, audit, …).
|
|
8
|
+
* Never emitted into a customer DB; lives only in Ablo's own DB.
|
|
9
|
+
*
|
|
10
|
+
* P1 of the sync-delta decomposition (`docs/plans/sync-delta-zod-decomposition.md`):
|
|
11
|
+
* declaring the boundary lets BYO provisioning *derive* "what a customer DB gets"
|
|
12
|
+
* (`plane === 'tenant'`) instead of hand-coding it. Defaults to `tenant` —
|
|
13
|
+
* today every `defineSchema` model is the customer's own data; only Ablo's
|
|
14
|
+
* internal tables (once modeled in P2) declare `control`.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
export declare const planeSchema: z.ZodEnum<{
|
|
18
|
+
tenant: "tenant";
|
|
19
|
+
control: "control";
|
|
20
|
+
}>;
|
|
21
|
+
export type SchemaPlane = z.infer<typeof planeSchema>;
|
|
22
|
+
/** Default plane for a model that doesn't declare one — the tenant data plane. */
|
|
23
|
+
export declare const DEFAULT_PLANE: SchemaPlane;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database PLANE — which database a model's rows live in. A sibling axis to
|
|
3
|
+
* `tenancy` (which says how rows are isolated *within* a database):
|
|
4
|
+
*
|
|
5
|
+
* - `tenant` — the tenant data plane. For a BYO/dedicated customer this is
|
|
6
|
+
* THEIR database; provisioning emits these tables there.
|
|
7
|
+
* - `control` — Ablo's control plane (the sync log, attribution, audit, …).
|
|
8
|
+
* Never emitted into a customer DB; lives only in Ablo's own DB.
|
|
9
|
+
*
|
|
10
|
+
* P1 of the sync-delta decomposition (`docs/plans/sync-delta-zod-decomposition.md`):
|
|
11
|
+
* declaring the boundary lets BYO provisioning *derive* "what a customer DB gets"
|
|
12
|
+
* (`plane === 'tenant'`) instead of hand-coding it. Defaults to `tenant` —
|
|
13
|
+
* today every `defineSchema` model is the customer's own data; only Ablo's
|
|
14
|
+
* internal tables (once modeled in P2) declare `control`.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
export const planeSchema = z.enum(['tenant', 'control']);
|
|
18
|
+
/** Default plane for a model that doesn't declare one — the tenant data plane. */
|
|
19
|
+
export const DEFAULT_PLANE = 'tenant';
|
|
@@ -79,6 +79,26 @@ export interface BelongsToOptions {
|
|
|
79
79
|
* "the deck is the parent."
|
|
80
80
|
*/
|
|
81
81
|
readonly parent?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Emit a real Postgres FOREIGN KEY for this relation when provisioning a
|
|
84
|
+
* customer-owned (BYO / dedicated) database. **Independent of `parent`:**
|
|
85
|
+
* `parent` governs sync-group fan-out / visibility (control plane); `fk`
|
|
86
|
+
* governs physical referential integrity (data plane). The two are orthogonal
|
|
87
|
+
* — a relation may set either, both, or neither (mirrors Drizzle's
|
|
88
|
+
* `relations()` vs `references()` split; Zanzibar's `parent` is permission-only
|
|
89
|
+
* and "says nothing about data ownership or lifecycle").
|
|
90
|
+
*
|
|
91
|
+
* Set `fk: true` ONLY when the target row is co-located in the SAME database
|
|
92
|
+
* AND written in the SAME commit as this row, and the reference is to a
|
|
93
|
+
* strong / contained entity. Do NOT set it on provenance / template pointers
|
|
94
|
+
* (`sourceSlideId`, `templateId`), cross-tenant refs, or anything that may be
|
|
95
|
+
* written in a different transaction than its target — a hard FK there would
|
|
96
|
+
* reject the write and break out-of-order sync. Emitted as `DEFERRABLE
|
|
97
|
+
* INITIALLY DEFERRED, ON DELETE NO ACTION` — a pure integrity guard; cascade /
|
|
98
|
+
* nullify is owned by the app-layer mutation pipeline, not the DB. See
|
|
99
|
+
* `foreignKeyStatements` in `ddl.ts`.
|
|
100
|
+
*/
|
|
101
|
+
readonly fk?: boolean;
|
|
82
102
|
}
|
|
83
103
|
declare const __relationType: unique symbol;
|
|
84
104
|
declare const __relationTarget: unique symbol;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import type { FieldMeta } from './field.js';
|
|
28
28
|
import type { Tenancy } from './tenancy.js';
|
|
29
|
+
import type { SchemaPlane } from './plane.js';
|
|
29
30
|
import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
|
|
30
31
|
import type { RelationType } from './relation.js';
|
|
31
32
|
import { type Schema, type SchemaRecord, type IdentityRole, type EntityRole } from './schema.js';
|
|
@@ -51,6 +52,9 @@ export interface ModelJSON {
|
|
|
51
52
|
readonly typename: string;
|
|
52
53
|
readonly tableName?: string;
|
|
53
54
|
readonly tenancy: Tenancy;
|
|
55
|
+
/** Database plane. Optional for back-compat: absent in artifacts written before
|
|
56
|
+
* the plane axis → read as `tenant` (the default). See `./plane.ts`. */
|
|
57
|
+
readonly plane?: SchemaPlane;
|
|
54
58
|
readonly scope?: boolean | string;
|
|
55
59
|
readonly grants?: GrantsRef;
|
|
56
60
|
readonly entityRoles?: readonly EntityRole[];
|
package/dist/schema/serialize.js
CHANGED
|
@@ -57,6 +57,7 @@ function modelToJSON(def) {
|
|
|
57
57
|
typename: def.typename ?? '',
|
|
58
58
|
tableName: def.tableName,
|
|
59
59
|
tenancy: def.tenancy,
|
|
60
|
+
plane: def.plane,
|
|
60
61
|
scope: def.scope,
|
|
61
62
|
grants: def.grants,
|
|
62
63
|
entityRoles: def.entityRoles,
|
|
@@ -161,6 +162,9 @@ function modelFromJSON(json) {
|
|
|
161
162
|
persist: json.persist,
|
|
162
163
|
tableName: json.tableName,
|
|
163
164
|
tenancy: json.tenancy,
|
|
165
|
+
// Absent in pre-plane-axis artifacts → default `tenant` (matches the model
|
|
166
|
+
// builder default + the provisioning fallback), so the round-trip is stable.
|
|
167
|
+
plane: json.plane ?? 'tenant',
|
|
164
168
|
scope: json.scope,
|
|
165
169
|
grants: json.grants,
|
|
166
170
|
entityRoles: json.entityRoles,
|