@abloatai/ablo 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +248 -124
- package/dist/BaseSyncedStore.d.ts +3 -3
- package/dist/BaseSyncedStore.js +3 -3
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +91 -93
- package/dist/client/Ablo.js +122 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +116 -90
- package/dist/client/createModelProxy.js +128 -128
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +5 -5
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +59 -14
- package/dist/errors.js +73 -12
- package/dist/index.d.ts +11 -9
- package/dist/index.js +8 -12
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +14 -6
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +167 -0
- package/dist/schema/diff.js +280 -0
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +9 -3
- package/dist/schema/index.js +14 -2
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +10 -69
- package/dist/schema/schema.js +58 -24
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +96 -0
- package/dist/schema/serialize.js +231 -0
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +89 -5
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +9 -18
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +90 -42
- package/docs/api-keys.md +44 -0
- package/docs/api.md +72 -173
- package/docs/audit.md +5 -5
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +42 -43
- package/docs/coordination.md +343 -0
- package/docs/data-sources.md +16 -16
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +38 -36
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +34 -56
- package/docs/identity.md +529 -0
- package/docs/index.md +18 -24
- package/docs/integration-guide.md +130 -144
- package/docs/interaction-model.md +32 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +74 -24
- package/docs/roadmap.md +17 -7
- package/llms.txt +34 -39
- package/package.json +8 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
package/dist/schema/schema.js
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import { AbloValidationError } from '../errors.js';
|
|
24
|
+
import { scopeSchema, grantsRefSchema } from './roles.js';
|
|
25
|
+
// Sync-group roles (identity + entity) live in `./roles.js`. Re-exported here
|
|
26
|
+
// so the long-standing `@ablo/schema` / `./schema.js` import paths keep working
|
|
27
|
+
// after the rehome — see roles.ts for the full vocabulary.
|
|
28
|
+
export { identityRole, entityRole, extractIdentityIds, extractEntityIds, composeIdentitySyncGroups, composeEntitySyncGroups, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './roles.js';
|
|
24
29
|
function resolveCasing(fn) {
|
|
25
30
|
if (fn === undefined)
|
|
26
31
|
return (x) => x;
|
|
@@ -39,8 +44,14 @@ function resolveCasing(fn) {
|
|
|
39
44
|
function camelToSnake(identifier) {
|
|
40
45
|
return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
41
46
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Base fields every synced model gets automatically.
|
|
49
|
+
*
|
|
50
|
+
* Exported (internal) so `parseSchema` can rebuild a model's validator the
|
|
51
|
+
* same way `defineSchema` does — `baseFieldsSchema.merge(modelSchema)` — when
|
|
52
|
+
* reconstructing a `Schema` from its JSON form.
|
|
53
|
+
*/
|
|
54
|
+
export const baseFieldsSchema = z.object({
|
|
44
55
|
id: z.string(),
|
|
45
56
|
createdAt: z.date(),
|
|
46
57
|
updatedAt: z.date(),
|
|
@@ -142,11 +153,15 @@ export function defineSchema(models, options) {
|
|
|
142
153
|
// (identity) so this is a no-op when `casing` is unset — existing
|
|
143
154
|
// consumers get the same behavior they had before the option landed.
|
|
144
155
|
// When `casing: 'snake_case'` is set, every FK flips to its
|
|
145
|
-
// snake_case DB column name here and nowhere else.
|
|
146
|
-
//
|
|
156
|
+
// snake_case DB column name here and nowhere else. A field-level
|
|
157
|
+
// `.from(column)` override wins over the convention, so legacy
|
|
158
|
+
// columns stay declared in the artifact instead of rediscovered by
|
|
159
|
+
// SQL compilers. Server-side SQL compilers read the resolved value
|
|
160
|
+
// directly.
|
|
147
161
|
for (const relName of Object.keys(def.relations)) {
|
|
148
162
|
const rel = def.relations[relName];
|
|
149
|
-
|
|
163
|
+
const fieldColumn = def.fields[rel.foreignKey]?.column;
|
|
164
|
+
rel.foreignKeyColumn = fieldColumn ?? casing(rel.foreignKey);
|
|
150
165
|
}
|
|
151
166
|
const typename = def.typename ?? name;
|
|
152
167
|
const persist = def.persist
|
|
@@ -154,6 +169,7 @@ export function defineSchema(models, options) {
|
|
|
154
169
|
: undefined;
|
|
155
170
|
resolvedModels[name] = { ...def, typename, persist };
|
|
156
171
|
}
|
|
172
|
+
validateSyncGroupSchema(resolvedModels);
|
|
157
173
|
return {
|
|
158
174
|
// Cast back to S: we only added values to optional fields that were
|
|
159
175
|
// already part of ModelDef, so the shape is structurally unchanged.
|
|
@@ -163,26 +179,44 @@ export function defineSchema(models, options) {
|
|
|
163
179
|
};
|
|
164
180
|
}
|
|
165
181
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `template`. Output is stable, deduped, and never includes a literal
|
|
171
|
-
* convention string from this function itself — the convention lives
|
|
172
|
-
* 100% in the consumer's schema declaration.
|
|
173
|
-
*
|
|
174
|
-
* Returns `[]` when the schema has no identity roles registered or
|
|
175
|
-
* when no role's extractor produces an id. Caller decides what to do
|
|
176
|
-
* with `[]`; the server's intersect-with-requested logic treats it as
|
|
177
|
-
* "no scope" rather than "match everything."
|
|
182
|
+
* Validate the relation-driven sync-group declarations (`scope` / `grants`)
|
|
183
|
+
* at schema-build time, so a mistyped membership edge fails *here* — with a
|
|
184
|
+
* Stripe-shaped error (`code` + `param` + `doc_url`) pointing at the exact
|
|
185
|
+
* declaration — instead of silently mis-routing deltas at runtime.
|
|
178
186
|
*/
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
187
|
+
function validateSyncGroupSchema(models) {
|
|
188
|
+
for (const [name, def] of Object.entries(models)) {
|
|
189
|
+
// Shape-validate the `scope` declaration via the shared Zod schema.
|
|
190
|
+
if (def.scope !== undefined && !scopeSchema.safeParse(def.scope).success) {
|
|
191
|
+
throw new AbloValidationError(`Model "${name}": scope kind "${String(def.scope)}" must be a lowercase identifier (e.g. 'dataroom').`, { code: 'schema_scope_kind_invalid', param: `${name}.scope` });
|
|
192
|
+
}
|
|
193
|
+
if (!def.grants)
|
|
194
|
+
continue;
|
|
195
|
+
// Shape-validate the `grants` edge via the shared Zod schema before the
|
|
196
|
+
// cross-field (relation-exists / belongsTo) checks below.
|
|
197
|
+
if (!grantsRefSchema.safeParse(def.grants).success) {
|
|
198
|
+
throw new AbloValidationError(`Model "${name}": grants must be { subject, scope } naming two relations on this model.`, { code: 'schema_grants_shape_invalid', param: `${name}.grants` });
|
|
199
|
+
}
|
|
200
|
+
const relations = def.relations;
|
|
201
|
+
for (const role of ['subject', 'scope']) {
|
|
202
|
+
const relName = def.grants[role];
|
|
203
|
+
const rel = relations?.[relName];
|
|
204
|
+
if (!rel) {
|
|
205
|
+
throw new AbloValidationError(`Model "${name}": grants.${role} "${relName}" is not a relation on this model. ` +
|
|
206
|
+
`Declare a \`belongsTo\` relation named "${relName}" first.`, { code: 'schema_grants_relation_missing', param: `${name}.grants.${role}` });
|
|
207
|
+
}
|
|
208
|
+
if (rel.type !== 'belongsTo') {
|
|
209
|
+
throw new AbloValidationError(`Model "${name}": grants.${role} "${relName}" must be a \`belongsTo\` relation ` +
|
|
210
|
+
`(got "${rel.type}"). A membership edge points at a single subject/scope row.`, { code: 'schema_grants_relation_kind', param: `${name}.grants.${role}` });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// The scope edge must target a model that is actually a scope root —
|
|
214
|
+
// otherwise the resolved `<kind>:<id>` group is one nothing fans into.
|
|
215
|
+
const scopeRel = relations[def.grants.scope];
|
|
216
|
+
const target = models[scopeRel.target];
|
|
217
|
+
if (target && !target.scope) {
|
|
218
|
+
throw new AbloValidationError(`Model "${name}": grants.scope "${def.grants.scope}" targets "${scopeRel.target}", ` +
|
|
219
|
+
`which is not a scope root. Add \`scope: true\` to the "${scopeRel.target}" model.`, { code: 'schema_grants_target_not_scope_root', param: `${scopeRel.target}.scope` });
|
|
185
220
|
}
|
|
186
221
|
}
|
|
187
|
-
return Array.from(out);
|
|
188
222
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `selectModels` — project a schema down to a subset of its models.
|
|
3
|
+
*
|
|
4
|
+
* The Prisma-style "one canonical schema, each app selects what it needs"
|
|
5
|
+
* primitive. Instead of re-declaring a model's fields in a second schema (which
|
|
6
|
+
* must then be kept shape-identical by hand), an app picks the models it
|
|
7
|
+
* subscribes to from the canonical schema. Field shapes, resolved FK columns,
|
|
8
|
+
* computeds, typenames, and identity roles all come from the source — so a
|
|
9
|
+
* subset is structurally incapable of drifting from the canonical definition.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { schema as full } from '@ablo/schema';
|
|
13
|
+
* import { selectModels } from '@abloatai/ablo/schema';
|
|
14
|
+
*
|
|
15
|
+
* // Vault subscribes to identity + dataroom content only.
|
|
16
|
+
* export const schema = selectModels(full, ['users', 'organizations', 'datarooms', 'folders', 'files']);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Relations whose target falls outside the selected set are dropped — the
|
|
20
|
+
* subset only sees its own models. A dropped relation that carries `parent`
|
|
21
|
+
* scope-inheritance throws instead: silently losing it would mis-route a
|
|
22
|
+
* record's fan-out, so the selected set must be closed under `parent` edges.
|
|
23
|
+
*/
|
|
24
|
+
import type { Schema, SchemaRecord } from './schema.js';
|
|
25
|
+
export declare function selectModels<S extends SchemaRecord, K extends keyof S & string>(schema: Schema<S>, keys: readonly K[]): Schema<Pick<S, K>>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `selectModels` — project a schema down to a subset of its models.
|
|
3
|
+
*
|
|
4
|
+
* The Prisma-style "one canonical schema, each app selects what it needs"
|
|
5
|
+
* primitive. Instead of re-declaring a model's fields in a second schema (which
|
|
6
|
+
* must then be kept shape-identical by hand), an app picks the models it
|
|
7
|
+
* subscribes to from the canonical schema. Field shapes, resolved FK columns,
|
|
8
|
+
* computeds, typenames, and identity roles all come from the source — so a
|
|
9
|
+
* subset is structurally incapable of drifting from the canonical definition.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { schema as full } from '@ablo/schema';
|
|
13
|
+
* import { selectModels } from '@abloatai/ablo/schema';
|
|
14
|
+
*
|
|
15
|
+
* // Vault subscribes to identity + dataroom content only.
|
|
16
|
+
* export const schema = selectModels(full, ['users', 'organizations', 'datarooms', 'folders', 'files']);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Relations whose target falls outside the selected set are dropped — the
|
|
20
|
+
* subset only sees its own models. A dropped relation that carries `parent`
|
|
21
|
+
* scope-inheritance throws instead: silently losing it would mis-route a
|
|
22
|
+
* record's fan-out, so the selected set must be closed under `parent` edges.
|
|
23
|
+
*/
|
|
24
|
+
import { AbloValidationError } from '../errors.js';
|
|
25
|
+
export function selectModels(schema, keys) {
|
|
26
|
+
const keep = new Set(keys);
|
|
27
|
+
const models = {};
|
|
28
|
+
const validators = {};
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const def = schema.models[key];
|
|
31
|
+
if (!def) {
|
|
32
|
+
throw new AbloValidationError(`selectModels: "${String(key)}" is not a model in the source schema`, { code: 'invalid_schema', param: String(key) });
|
|
33
|
+
}
|
|
34
|
+
// Prune relations whose target isn't in the selected set. A pruned
|
|
35
|
+
// `parent` edge is a routing error, not a silent drop.
|
|
36
|
+
const relations = {};
|
|
37
|
+
for (const [relName, rel] of Object.entries(def.relations)) {
|
|
38
|
+
if (keep.has(rel.target)) {
|
|
39
|
+
relations[relName] = rel;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (rel.options?.parent) {
|
|
43
|
+
throw new AbloValidationError(`selectModels: model "${String(key)}" has a parent relation "${relName}" → "${rel.target}", ` +
|
|
44
|
+
`which is not in the selected set. Include "${rel.target}" so scope inheritance still routes.`, { code: 'invalid_schema', param: `${String(key)}.${relName}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
models[key] = { ...def, relations };
|
|
48
|
+
validators[key] = schema.validators[key];
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
models: models,
|
|
52
|
+
validators: validators,
|
|
53
|
+
identityRoles: schema.identityRoles,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema ⇄ JSON
|
|
3
|
+
*
|
|
4
|
+
* A `Schema` is serializable except for client-only closures (Zod
|
|
5
|
+
* validators + computed getters). `serializeSchema` emits the plain-data
|
|
6
|
+
* JSON form; `parseSchema` reconstructs a working `Schema` from it.
|
|
7
|
+
*
|
|
8
|
+
* This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
|
|
9
|
+
* type, two representations. A hosted multi-tenant server obtains a tenant's
|
|
10
|
+
* `Schema` by `parseSchema(json)` instead of an in-process import — the JSON
|
|
11
|
+
* is what travels over the control plane (`ablo schema push`) and is stored
|
|
12
|
+
* per `(tenant, version)`.
|
|
13
|
+
*
|
|
14
|
+
* What round-trips:
|
|
15
|
+
* - all model routing/scoping metadata (typename, tableName, load,
|
|
16
|
+
* mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
|
|
17
|
+
* entityRoles, persist, autoFill, requiredFields, lazyObservable)
|
|
18
|
+
* - relations (incl. resolved `foreignKeyColumn`)
|
|
19
|
+
* - field metadata (names + type tags), from which validators are rebuilt
|
|
20
|
+
* - identity roles (already pure data)
|
|
21
|
+
*
|
|
22
|
+
* What does NOT round-trip (client-only, server never needs it):
|
|
23
|
+
* - `computed` getters (closures) — dropped
|
|
24
|
+
* - exact Zod refinements — rebuilt as permissive validators from
|
|
25
|
+
* `FieldMeta` (the server does no field-shape validation anyway)
|
|
26
|
+
*/
|
|
27
|
+
import type { FieldMeta } from './field.js';
|
|
28
|
+
import type { Tenancy } from './tenancy.js';
|
|
29
|
+
import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
|
|
30
|
+
import type { RelationType } from './relation.js';
|
|
31
|
+
import { type Schema, type SchemaRecord, type IdentityRole, type EntityRole } from './schema.js';
|
|
32
|
+
/** Current schema-JSON envelope version. Bump on a breaking change to the
|
|
33
|
+
* JSON shape itself (not the user's schema). v2 replaced the per-model
|
|
34
|
+
* `syncGroupFormat` template string with structured `scope`/`grants`/
|
|
35
|
+
* `entityRoles` (relation-driven sync groups). */
|
|
36
|
+
declare const SCHEMA_JSON_VERSION: 3;
|
|
37
|
+
/** A relation in JSON form. Mirrors the serializable members of {@link RelationDef}. */
|
|
38
|
+
export interface RelationJSON {
|
|
39
|
+
readonly type: RelationType;
|
|
40
|
+
readonly target: string;
|
|
41
|
+
readonly foreignKey: string;
|
|
42
|
+
readonly foreignKeyColumn: string;
|
|
43
|
+
readonly options?: Record<string, boolean>;
|
|
44
|
+
readonly orderBy?: string;
|
|
45
|
+
}
|
|
46
|
+
/** A model in JSON form. Everything on {@link ModelDef} except closures. */
|
|
47
|
+
export interface ModelJSON {
|
|
48
|
+
readonly fields: Record<string, FieldMeta>;
|
|
49
|
+
readonly relations: Record<string, RelationJSON>;
|
|
50
|
+
readonly load: LoadStrategy;
|
|
51
|
+
readonly typename: string;
|
|
52
|
+
readonly tableName?: string;
|
|
53
|
+
readonly tenancy: Tenancy;
|
|
54
|
+
readonly scope?: boolean | string;
|
|
55
|
+
readonly grants?: GrantsRef;
|
|
56
|
+
readonly entityRoles?: readonly EntityRole[];
|
|
57
|
+
readonly bootstrapLimit?: number;
|
|
58
|
+
readonly bootstrapOrderBy?: string;
|
|
59
|
+
readonly mutable?: boolean;
|
|
60
|
+
readonly lazyObservable?: boolean;
|
|
61
|
+
readonly persist?: PersistOptions;
|
|
62
|
+
readonly autoFill?: readonly AutoFillRule[];
|
|
63
|
+
readonly requiredFields?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
/** The JSON form of a {@link Schema}. The `@ablo schema push` payload. */
|
|
66
|
+
export interface SchemaJSON {
|
|
67
|
+
readonly v: typeof SCHEMA_JSON_VERSION;
|
|
68
|
+
readonly models: Record<string, ModelJSON>;
|
|
69
|
+
readonly identityRoles: readonly IdentityRole[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Project a `Schema` to its JSON form. Drops the client-only closures
|
|
73
|
+
* (validators, `computed`); keeps everything the server and a faithful
|
|
74
|
+
* rebuild need. The result is plain data — `JSON.stringify`-safe.
|
|
75
|
+
*/
|
|
76
|
+
export declare function toSchemaJSON(schema: Schema<SchemaRecord>): SchemaJSON;
|
|
77
|
+
/** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
|
|
78
|
+
export declare function serializeSchema(schema: Schema<SchemaRecord>): string;
|
|
79
|
+
/**
|
|
80
|
+
* Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
|
|
81
|
+
* permissively from field metadata (the server never validates field shapes);
|
|
82
|
+
* `computed` getters are absent. Everything the server reads — routing,
|
|
83
|
+
* scoping, relations, identity roles — is restored exactly.
|
|
84
|
+
*/
|
|
85
|
+
export declare function fromSchemaJSON(json: SchemaJSON): Schema<SchemaRecord>;
|
|
86
|
+
/** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
|
|
87
|
+
export declare function parseSchema(json: string): Schema<SchemaRecord>;
|
|
88
|
+
/**
|
|
89
|
+
* Stable content hash of a `Schema`'s JSON form. FNV-1a over a canonical
|
|
90
|
+
* (sorted-key) encoding — deterministic across runs and order-invariant, no
|
|
91
|
+
* `crypto` dependency. Used for connect-time version gating: the client sends
|
|
92
|
+
* the hash it was built against, the server compares it to the tenant's
|
|
93
|
+
* active schema hash. Not a security primitive.
|
|
94
|
+
*/
|
|
95
|
+
export declare function schemaHash(schema: Schema<SchemaRecord>): string;
|
|
96
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema ⇄ JSON
|
|
3
|
+
*
|
|
4
|
+
* A `Schema` is serializable except for client-only closures (Zod
|
|
5
|
+
* validators + computed getters). `serializeSchema` emits the plain-data
|
|
6
|
+
* JSON form; `parseSchema` reconstructs a working `Schema` from it.
|
|
7
|
+
*
|
|
8
|
+
* This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
|
|
9
|
+
* type, two representations. A hosted multi-tenant server obtains a tenant's
|
|
10
|
+
* `Schema` by `parseSchema(json)` instead of an in-process import — the JSON
|
|
11
|
+
* is what travels over the control plane (`ablo schema push`) and is stored
|
|
12
|
+
* per `(tenant, version)`.
|
|
13
|
+
*
|
|
14
|
+
* What round-trips:
|
|
15
|
+
* - all model routing/scoping metadata (typename, tableName, load,
|
|
16
|
+
* mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
|
|
17
|
+
* entityRoles, persist, autoFill, requiredFields, lazyObservable)
|
|
18
|
+
* - relations (incl. resolved `foreignKeyColumn`)
|
|
19
|
+
* - field metadata (names + type tags), from which validators are rebuilt
|
|
20
|
+
* - identity roles (already pure data)
|
|
21
|
+
*
|
|
22
|
+
* What does NOT round-trip (client-only, server never needs it):
|
|
23
|
+
* - `computed` getters (closures) — dropped
|
|
24
|
+
* - exact Zod refinements — rebuilt as permissive validators from
|
|
25
|
+
* `FieldMeta` (the server does no field-shape validation anyway)
|
|
26
|
+
*/
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { baseFieldsSchema, } from './schema.js';
|
|
29
|
+
/** Current schema-JSON envelope version. Bump on a breaking change to the
|
|
30
|
+
* JSON shape itself (not the user's schema). v2 replaced the per-model
|
|
31
|
+
* `syncGroupFormat` template string with structured `scope`/`grants`/
|
|
32
|
+
* `entityRoles` (relation-driven sync groups). */
|
|
33
|
+
const SCHEMA_JSON_VERSION = 3;
|
|
34
|
+
// ── Serialize ────────────────────────────────────────────────────────────────
|
|
35
|
+
function relationToJSON(rel) {
|
|
36
|
+
const options = rel.options;
|
|
37
|
+
return {
|
|
38
|
+
type: rel.type,
|
|
39
|
+
target: rel.target,
|
|
40
|
+
foreignKey: rel.foreignKey,
|
|
41
|
+
foreignKeyColumn: rel.foreignKeyColumn,
|
|
42
|
+
options: options && Object.keys(options).length > 0 ? { ...options } : undefined,
|
|
43
|
+
orderBy: rel._orderBy,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function modelToJSON(def) {
|
|
47
|
+
const relations = {};
|
|
48
|
+
for (const [name, rel] of Object.entries(def.relations)) {
|
|
49
|
+
relations[name] = relationToJSON(rel);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
fields: def.fields,
|
|
53
|
+
relations,
|
|
54
|
+
load: def.load,
|
|
55
|
+
// `defineSchema` always resolves `typename` to the schema key when unset,
|
|
56
|
+
// so it is present on a built ModelDef; fall back defensively anyway.
|
|
57
|
+
typename: def.typename ?? '',
|
|
58
|
+
tableName: def.tableName,
|
|
59
|
+
tenancy: def.tenancy,
|
|
60
|
+
scope: def.scope,
|
|
61
|
+
grants: def.grants,
|
|
62
|
+
entityRoles: def.entityRoles,
|
|
63
|
+
bootstrapLimit: def.bootstrapLimit,
|
|
64
|
+
bootstrapOrderBy: def.bootstrapOrderBy,
|
|
65
|
+
mutable: def.mutable,
|
|
66
|
+
lazyObservable: def.lazyObservable,
|
|
67
|
+
persist: def.persist,
|
|
68
|
+
autoFill: def.autoFill,
|
|
69
|
+
requiredFields: def.requiredFields,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Project a `Schema` to its JSON form. Drops the client-only closures
|
|
74
|
+
* (validators, `computed`); keeps everything the server and a faithful
|
|
75
|
+
* rebuild need. The result is plain data — `JSON.stringify`-safe.
|
|
76
|
+
*/
|
|
77
|
+
export function toSchemaJSON(schema) {
|
|
78
|
+
const models = {};
|
|
79
|
+
for (const [key, def] of Object.entries(schema.models)) {
|
|
80
|
+
if (def.typename === '' || def.typename === undefined) {
|
|
81
|
+
// typename '' only happens for a malformed def; surface it loudly
|
|
82
|
+
// rather than ship a model the server can't route.
|
|
83
|
+
models[key] = { ...modelToJSON(def), typename: key };
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
models[key] = modelToJSON(def);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { v: SCHEMA_JSON_VERSION, models, identityRoles: schema.identityRoles };
|
|
90
|
+
}
|
|
91
|
+
/** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
|
|
92
|
+
export function serializeSchema(schema) {
|
|
93
|
+
return JSON.stringify(toSchemaJSON(schema));
|
|
94
|
+
}
|
|
95
|
+
// ── Parse ──────────────────────────────────────────────────────────────────
|
|
96
|
+
/** Rebuild a Zod validator for a field from its metadata. Permissive by
|
|
97
|
+
* design — the server does no field-shape validation; this exists so a
|
|
98
|
+
* parsed `Schema` is structurally a real `Schema`. */
|
|
99
|
+
function zodForField(meta) {
|
|
100
|
+
let base;
|
|
101
|
+
switch (meta.type) {
|
|
102
|
+
case 'string':
|
|
103
|
+
base = z.string();
|
|
104
|
+
break;
|
|
105
|
+
case 'number':
|
|
106
|
+
base = z.number();
|
|
107
|
+
break;
|
|
108
|
+
case 'boolean':
|
|
109
|
+
base = z.boolean();
|
|
110
|
+
break;
|
|
111
|
+
case 'date':
|
|
112
|
+
base = z.date();
|
|
113
|
+
break;
|
|
114
|
+
case 'enum':
|
|
115
|
+
base =
|
|
116
|
+
meta.enumValues && meta.enumValues.length > 0
|
|
117
|
+
? z.enum(meta.enumValues)
|
|
118
|
+
: z.string();
|
|
119
|
+
break;
|
|
120
|
+
case 'json':
|
|
121
|
+
default:
|
|
122
|
+
base = z.unknown();
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return meta.isOptional ? base.optional() : base;
|
|
126
|
+
}
|
|
127
|
+
function relationFromJSON(rel) {
|
|
128
|
+
// The brand symbols on RelationDef are declare-only (no runtime
|
|
129
|
+
// presence), so a plain object with the runtime members satisfies every
|
|
130
|
+
// server-side reader. Cast through unknown to attach the nominal type.
|
|
131
|
+
return {
|
|
132
|
+
type: rel.type,
|
|
133
|
+
target: rel.target,
|
|
134
|
+
foreignKey: rel.foreignKey,
|
|
135
|
+
foreignKeyColumn: rel.foreignKeyColumn,
|
|
136
|
+
options: rel.options ?? {},
|
|
137
|
+
_orderBy: rel.orderBy,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function modelFromJSON(json) {
|
|
141
|
+
// `z.ZodRawShape` is a readonly index signature in Zod v4, so build a
|
|
142
|
+
// mutable record and cast once when handing it to `z.object`/`ModelDef`.
|
|
143
|
+
const shapeMut = {};
|
|
144
|
+
for (const [name, meta] of Object.entries(json.fields)) {
|
|
145
|
+
shapeMut[name] = zodForField(meta);
|
|
146
|
+
}
|
|
147
|
+
const shape = shapeMut;
|
|
148
|
+
const relations = {};
|
|
149
|
+
for (const [name, rel] of Object.entries(json.relations)) {
|
|
150
|
+
relations[name] = relationFromJSON(rel);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
schema: z.object(shape),
|
|
154
|
+
shape,
|
|
155
|
+
fields: json.fields,
|
|
156
|
+
relations,
|
|
157
|
+
load: json.load,
|
|
158
|
+
bootstrapLimit: json.bootstrapLimit,
|
|
159
|
+
bootstrapOrderBy: json.bootstrapOrderBy,
|
|
160
|
+
typename: json.typename,
|
|
161
|
+
persist: json.persist,
|
|
162
|
+
tableName: json.tableName,
|
|
163
|
+
tenancy: json.tenancy,
|
|
164
|
+
scope: json.scope,
|
|
165
|
+
grants: json.grants,
|
|
166
|
+
entityRoles: json.entityRoles,
|
|
167
|
+
mutable: json.mutable,
|
|
168
|
+
lazyObservable: json.lazyObservable,
|
|
169
|
+
// computed getters are closures and intentionally not serialized; a
|
|
170
|
+
// parsed schema (server-side) has none.
|
|
171
|
+
computed: undefined,
|
|
172
|
+
autoFill: json.autoFill,
|
|
173
|
+
requiredFields: json.requiredFields,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
|
|
178
|
+
* permissively from field metadata (the server never validates field shapes);
|
|
179
|
+
* `computed` getters are absent. Everything the server reads — routing,
|
|
180
|
+
* scoping, relations, identity roles — is restored exactly.
|
|
181
|
+
*/
|
|
182
|
+
export function fromSchemaJSON(json) {
|
|
183
|
+
const models = {};
|
|
184
|
+
const validators = {};
|
|
185
|
+
for (const [key, modelJson] of Object.entries(json.models)) {
|
|
186
|
+
const def = modelFromJSON(modelJson);
|
|
187
|
+
models[key] = def;
|
|
188
|
+
validators[key] = baseFieldsSchema.merge(def.schema);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
models: models,
|
|
192
|
+
validators: validators,
|
|
193
|
+
identityRoles: json.identityRoles,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
|
|
197
|
+
export function parseSchema(json) {
|
|
198
|
+
const parsed = JSON.parse(json);
|
|
199
|
+
if (parsed.v !== SCHEMA_JSON_VERSION) {
|
|
200
|
+
throw new Error(`parseSchema: unsupported schema-JSON version ${parsed.v} (expected ${SCHEMA_JSON_VERSION})`);
|
|
201
|
+
}
|
|
202
|
+
return fromSchemaJSON(parsed);
|
|
203
|
+
}
|
|
204
|
+
// ── Hash ─────────────────────────────────────────────────────────────────────
|
|
205
|
+
/**
|
|
206
|
+
* Stable content hash of a `Schema`'s JSON form. FNV-1a over a canonical
|
|
207
|
+
* (sorted-key) encoding — deterministic across runs and order-invariant, no
|
|
208
|
+
* `crypto` dependency. Used for connect-time version gating: the client sends
|
|
209
|
+
* the hash it was built against, the server compares it to the tenant's
|
|
210
|
+
* active schema hash. Not a security primitive.
|
|
211
|
+
*/
|
|
212
|
+
export function schemaHash(schema) {
|
|
213
|
+
const canonical = canonicalJson(toSchemaJSON(schema));
|
|
214
|
+
let h = 0x811c9dc5;
|
|
215
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
216
|
+
h ^= canonical.charCodeAt(i);
|
|
217
|
+
h = Math.imul(h, 0x01000193);
|
|
218
|
+
}
|
|
219
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
220
|
+
}
|
|
221
|
+
/** Stable JSON: object keys sorted recursively, `undefined` dropped. */
|
|
222
|
+
function canonicalJson(value) {
|
|
223
|
+
if (value === null || typeof value !== 'object')
|
|
224
|
+
return JSON.stringify(value) ?? 'null';
|
|
225
|
+
if (Array.isArray(value))
|
|
226
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
227
|
+
const entries = Object.entries(value)
|
|
228
|
+
.filter(([, v]) => v !== undefined)
|
|
229
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
230
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
|
|
231
|
+
}
|
package/dist/schema/sugar.d.ts
CHANGED
|
@@ -73,10 +73,27 @@ export interface SugarOptions<R extends RelationRecord = RelationRecord, C exten
|
|
|
73
73
|
*/
|
|
74
74
|
scopedVia?: ModelOptions['scopedVia'];
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* Override the row-local tenancy column name. See
|
|
77
|
+
* {@link ModelOptions.orgColumn}.
|
|
78
78
|
*/
|
|
79
|
-
|
|
79
|
+
orgColumn?: ModelOptions['orgColumn'];
|
|
80
|
+
/** Canonical tenancy descriptor. See {@link ModelOptions.tenancy}. */
|
|
81
|
+
tenancy?: ModelOptions['tenancy'];
|
|
82
|
+
/**
|
|
83
|
+
* Mark this model a scope root — its records form the group `<kind>:<id>`
|
|
84
|
+
* (kind defaults from typename). See {@link ModelOptions.scope}.
|
|
85
|
+
*/
|
|
86
|
+
scope?: ModelOptions['scope'];
|
|
87
|
+
/**
|
|
88
|
+
* Membership edge granting identity → scope-root access. Both fields are
|
|
89
|
+
* relation names on this model. See {@link ModelOptions.grants}.
|
|
90
|
+
*/
|
|
91
|
+
grants?: ModelOptions['grants'];
|
|
92
|
+
/**
|
|
93
|
+
* Explicit non-relational record→group roles (e.g. inbox fan-out keyed on a
|
|
94
|
+
* field). See {@link ModelOptions.entityRoles}.
|
|
95
|
+
*/
|
|
96
|
+
entityRoles?: ModelOptions['entityRoles'];
|
|
80
97
|
/** Max rows loaded during bootstrap. Only applies to `.instant`. */
|
|
81
98
|
bootstrapLimit?: number;
|
|
82
99
|
/** Bootstrap sort order (e.g. `'created_at DESC'`). */
|
package/dist/schema/sugar.js
CHANGED
|
@@ -48,7 +48,11 @@ function build(shape, opts, baseline) {
|
|
|
48
48
|
tableName: opts?.tableName,
|
|
49
49
|
orgScoped: opts?.orgScoped,
|
|
50
50
|
scopedVia: opts?.scopedVia,
|
|
51
|
-
|
|
51
|
+
orgColumn: opts?.orgColumn,
|
|
52
|
+
tenancy: opts?.tenancy,
|
|
53
|
+
scope: opts?.scope,
|
|
54
|
+
grants: opts?.grants,
|
|
55
|
+
entityRoles: opts?.entityRoles,
|
|
52
56
|
bootstrapLimit: opts?.bootstrapLimit,
|
|
53
57
|
bootstrapOrderBy: opts?.bootstrapOrderBy,
|
|
54
58
|
persist: opts?.persist,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy — the single source of truth for how a model's rows are scoped to a
|
|
3
|
+
* tenant. This replaces three scattered mechanisms (a hardcoded
|
|
4
|
+
* `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
|
|
5
|
+
* one Zod discriminated union, resolved in one place and consumed everywhere
|
|
6
|
+
* (provision/RLS, introspection, runtime, CLI).
|
|
7
|
+
*
|
|
8
|
+
* Why a union: every consumer used to re-derive "how is this table scoped?" from
|
|
9
|
+
* a flag plus a literal — a missed branch was a silent cross-tenant scoping bug.
|
|
10
|
+
* A discriminated union makes the `switch` exhaustive, so the type system holds
|
|
11
|
+
* the isolation boundary, and the physical column name lives in exactly one
|
|
12
|
+
* place (the `column` variant) instead of being hardcoded across the codebase.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/** Default physical tenancy column. The ONLY place this literal is canonical. */
|
|
16
|
+
export declare const DEFAULT_ORG_COLUMN = "organization_id";
|
|
17
|
+
/**
|
|
18
|
+
* Scope a table's rows through a parent table (for rows that carry no tenancy
|
|
19
|
+
* column of their own — e.g. `slide_layers` → slide → deck → org).
|
|
20
|
+
*/
|
|
21
|
+
export declare const scopedViaRefSchema: z.ZodObject<{
|
|
22
|
+
localKey: z.ZodString;
|
|
23
|
+
parentTable: z.ZodString;
|
|
24
|
+
parentKey: z.ZodOptional<z.ZodString>;
|
|
25
|
+
parentOrgColumn: z.ZodOptional<z.ZodString>;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export type ScopedViaRef = z.infer<typeof scopedViaRefSchema>;
|
|
28
|
+
/** How a model's rows are scoped to a tenant. */
|
|
29
|
+
export declare const tenancySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
30
|
+
kind: z.ZodLiteral<"column">;
|
|
31
|
+
column: z.ZodString;
|
|
32
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
33
|
+
kind: z.ZodLiteral<"parent">;
|
|
34
|
+
via: z.ZodObject<{
|
|
35
|
+
localKey: z.ZodString;
|
|
36
|
+
parentTable: z.ZodString;
|
|
37
|
+
parentKey: z.ZodOptional<z.ZodString>;
|
|
38
|
+
parentOrgColumn: z.ZodOptional<z.ZodString>;
|
|
39
|
+
}, z.core.$strip>;
|
|
40
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
41
|
+
kind: z.ZodLiteral<"none">;
|
|
42
|
+
}, z.core.$strip>], "kind">;
|
|
43
|
+
export type Tenancy = z.infer<typeof tenancySchema>;
|
|
44
|
+
/**
|
|
45
|
+
* Ergonomic authoring shortcuts accepted on a model. These are *input only* —
|
|
46
|
+
* `resolveTenancy` normalizes them into the canonical {@link Tenancy} at
|
|
47
|
+
* model-build time, so they never reach the serialized JSON or any consumer.
|
|
48
|
+
*/
|
|
49
|
+
export interface TenancyInput {
|
|
50
|
+
tenancy?: Tenancy;
|
|
51
|
+
/** `false` → not tenant-scoped. */
|
|
52
|
+
orgScoped?: boolean;
|
|
53
|
+
/** Scope through a parent table. */
|
|
54
|
+
scopedVia?: ScopedViaRef;
|
|
55
|
+
/** Override the column name for a column-scoped model. */
|
|
56
|
+
orgColumn?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
|
|
60
|
+
* at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
|
|
61
|
+
* `tenancy`. Precedence: explicit `tenancy` → `scopedVia` → `orgScoped:false` →
|
|
62
|
+
* column (default or `orgColumn`).
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveTenancy(input: TenancyInput): Tenancy;
|
|
65
|
+
/** The physical tenancy column for a column-scoped model, else `null`. */
|
|
66
|
+
export declare function tenancyColumn(t: Tenancy): string | null;
|