@abloatai/ablo 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -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,42 +44,6 @@ function resolveCasing(fn) {
39
44
  function camelToSnake(identifier) {
40
45
  return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
41
46
  }
42
- /**
43
- * Evaluate an {@link IdentityRoleSource} against an identity context.
44
- * The whole runtime behaviour of a role lives here, as data-driven logic —
45
- * `composeIdentitySyncGroups` calls this once per role. Absent or falsy
46
- * fields yield `[]`, so a role whose field isn't present (e.g. a user with
47
- * no `teamIds`) is a silent no-op.
48
- */
49
- export function extractIdentityIds(identity, source) {
50
- const raw = identity[source.field];
51
- if (source.multi) {
52
- return Array.isArray(raw)
53
- ? raw.filter((t) => typeof t === 'string' && t.length > 0)
54
- : [];
55
- }
56
- return raw ? [String(raw)] : [];
57
- }
58
- /**
59
- * Build an identity-anchored sync-group role. A thin, validated factory
60
- * over the {@link IdentityRole} data shape — `multi` defaults to `false`.
61
- *
62
- * ```ts
63
- * defineSchema({ ... }, {
64
- * identityRoles: [
65
- * identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
66
- * identityRole({ kind: 'member', template: 'team:{id}', source: 'teamIds', multi: true }),
67
- * ],
68
- * });
69
- * ```
70
- */
71
- export function identityRole(spec) {
72
- return {
73
- kind: spec.kind,
74
- template: spec.template,
75
- source: { field: spec.source, multi: spec.multi ?? false },
76
- };
77
- }
78
47
  /**
79
48
  * Base fields every synced model gets automatically.
80
49
  *
@@ -184,11 +153,15 @@ export function defineSchema(models, options) {
184
153
  // (identity) so this is a no-op when `casing` is unset — existing
185
154
  // consumers get the same behavior they had before the option landed.
186
155
  // When `casing: 'snake_case'` is set, every FK flips to its
187
- // snake_case DB column name here and nowhere else. Server-side SQL
188
- // compilers read the resolved value directly.
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.
189
161
  for (const relName of Object.keys(def.relations)) {
190
162
  const rel = def.relations[relName];
191
- rel.foreignKeyColumn = casing(rel.foreignKey);
163
+ const fieldColumn = def.fields[rel.foreignKey]?.column;
164
+ rel.foreignKeyColumn = fieldColumn ?? casing(rel.foreignKey);
192
165
  }
193
166
  const typename = def.typename ?? name;
194
167
  const persist = def.persist
@@ -196,6 +169,7 @@ export function defineSchema(models, options) {
196
169
  : undefined;
197
170
  resolvedModels[name] = { ...def, typename, persist };
198
171
  }
172
+ validateSyncGroupSchema(resolvedModels);
199
173
  return {
200
174
  // Cast back to S: we only added values to optional fields that were
201
175
  // already part of ModelDef, so the shape is structurally unchanged.
@@ -205,30 +179,44 @@ export function defineSchema(models, options) {
205
179
  };
206
180
  }
207
181
  /**
208
- * Compose the canonical sync-group set this identity is allowed to
209
- * subscribe to, derived purely from the schema's registered
210
- * {@link IdentityRole}s. Walks every role, evaluates its `source` against
211
- * the identity context via {@link extractIdentityIds}, and substitutes each
212
- * id into the role's `template`. Output is stable, deduped, and never
213
- * includes a literal convention string from this function itself — the
214
- * convention lives 100% in the consumer's schema declaration.
215
- *
216
- * Reads only data off the schema (`identityRoles` is pure data), so it works
217
- * identically on an in-process `Schema` and one reconstructed from JSON on a
218
- * hosted server.
219
- *
220
- * Returns `[]` when the schema has no identity roles registered or when no
221
- * role's source produces an id. Caller decides what to do with `[]`; the
222
- * server's intersect-with-requested logic treats it as "no scope" rather
223
- * 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.
224
186
  */
225
- export function composeIdentitySyncGroups(identity, schema) {
226
- const out = new Set();
227
- for (const role of schema.identityRoles) {
228
- for (const id of extractIdentityIds(identity, role.source)) {
229
- if (id)
230
- out.add(role.template.replace('{id}', id));
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` });
231
220
  }
232
221
  }
233
- return Array.from(out);
234
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
+ }
@@ -8,13 +8,13 @@
8
8
  * This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
9
9
  * type, two representations. A hosted multi-tenant server obtains a tenant's
10
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
11
+ * is what travels over the control plane (`ablo push`) and is stored
12
12
  * per `(tenant, version)`.
13
13
  *
14
14
  * What round-trips:
15
15
  * - all model routing/scoping metadata (typename, tableName, load,
16
- * mutable, orgScoped, scopedVia, bootstrap hints, syncGroupFormat,
17
- * persist, autoFill, requiredFields, lazyObservable)
16
+ * mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
17
+ * entityRoles, persist, autoFill, requiredFields, lazyObservable)
18
18
  * - relations (incl. resolved `foreignKeyColumn`)
19
19
  * - field metadata (names + type tags), from which validators are rebuilt
20
20
  * - identity roles (already pure data)
@@ -25,12 +25,15 @@
25
25
  * `FieldMeta` (the server does no field-shape validation anyway)
26
26
  */
27
27
  import type { FieldMeta } from './field.js';
28
- import type { ScopedViaRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
28
+ import type { Tenancy } from './tenancy.js';
29
+ import type { GrantsRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
29
30
  import type { RelationType } from './relation.js';
30
- import { type Schema, type SchemaRecord, type IdentityRole } from './schema.js';
31
+ import { type Schema, type SchemaRecord, type IdentityRole, type EntityRole } from './schema.js';
31
32
  /** Current schema-JSON envelope version. Bump on a breaking change to the
32
- * JSON shape itself (not the user's schema). */
33
- declare const SCHEMA_JSON_VERSION: 1;
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;
34
37
  /** A relation in JSON form. Mirrors the serializable members of {@link RelationDef}. */
35
38
  export interface RelationJSON {
36
39
  readonly type: RelationType;
@@ -47,18 +50,19 @@ export interface ModelJSON {
47
50
  readonly load: LoadStrategy;
48
51
  readonly typename: string;
49
52
  readonly tableName?: string;
50
- readonly orgScoped?: boolean;
51
- readonly scopedVia?: ScopedViaRef;
53
+ readonly tenancy: Tenancy;
54
+ readonly scope?: boolean | string;
55
+ readonly grants?: GrantsRef;
56
+ readonly entityRoles?: readonly EntityRole[];
52
57
  readonly bootstrapLimit?: number;
53
58
  readonly bootstrapOrderBy?: string;
54
- readonly syncGroupFormat?: string;
55
59
  readonly mutable?: boolean;
56
60
  readonly lazyObservable?: boolean;
57
61
  readonly persist?: PersistOptions;
58
62
  readonly autoFill?: readonly AutoFillRule[];
59
63
  readonly requiredFields?: readonly string[];
60
64
  }
61
- /** The JSON form of a {@link Schema}. The `@ablo schema push` payload. */
65
+ /** The JSON form of a {@link Schema}. The `ablo push` payload. */
62
66
  export interface SchemaJSON {
63
67
  readonly v: typeof SCHEMA_JSON_VERSION;
64
68
  readonly models: Record<string, ModelJSON>;
@@ -70,7 +74,7 @@ export interface SchemaJSON {
70
74
  * rebuild need. The result is plain data — `JSON.stringify`-safe.
71
75
  */
72
76
  export declare function toSchemaJSON(schema: Schema<SchemaRecord>): SchemaJSON;
73
- /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
77
+ /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
74
78
  export declare function serializeSchema(schema: Schema<SchemaRecord>): string;
75
79
  /**
76
80
  * Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
@@ -8,13 +8,13 @@
8
8
  * This is the GraphQL `printSchema` / `buildSchema` model: one `Schema`
9
9
  * type, two representations. A hosted multi-tenant server obtains a tenant's
10
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
11
+ * is what travels over the control plane (`ablo push`) and is stored
12
12
  * per `(tenant, version)`.
13
13
  *
14
14
  * What round-trips:
15
15
  * - all model routing/scoping metadata (typename, tableName, load,
16
- * mutable, orgScoped, scopedVia, bootstrap hints, syncGroupFormat,
17
- * persist, autoFill, requiredFields, lazyObservable)
16
+ * mutable, orgScoped, scopedVia, bootstrap hints, scope, grants,
17
+ * entityRoles, persist, autoFill, requiredFields, lazyObservable)
18
18
  * - relations (incl. resolved `foreignKeyColumn`)
19
19
  * - field metadata (names + type tags), from which validators are rebuilt
20
20
  * - identity roles (already pure data)
@@ -27,8 +27,10 @@
27
27
  import { z } from 'zod';
28
28
  import { baseFieldsSchema, } from './schema.js';
29
29
  /** Current schema-JSON envelope version. Bump on a breaking change to the
30
- * JSON shape itself (not the user's schema). */
31
- const SCHEMA_JSON_VERSION = 1;
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;
32
34
  // ── Serialize ────────────────────────────────────────────────────────────────
33
35
  function relationToJSON(rel) {
34
36
  const options = rel.options;
@@ -54,11 +56,12 @@ function modelToJSON(def) {
54
56
  // so it is present on a built ModelDef; fall back defensively anyway.
55
57
  typename: def.typename ?? '',
56
58
  tableName: def.tableName,
57
- orgScoped: def.orgScoped,
58
- scopedVia: def.scopedVia,
59
+ tenancy: def.tenancy,
60
+ scope: def.scope,
61
+ grants: def.grants,
62
+ entityRoles: def.entityRoles,
59
63
  bootstrapLimit: def.bootstrapLimit,
60
64
  bootstrapOrderBy: def.bootstrapOrderBy,
61
- syncGroupFormat: def.syncGroupFormat,
62
65
  mutable: def.mutable,
63
66
  lazyObservable: def.lazyObservable,
64
67
  persist: def.persist,
@@ -85,7 +88,7 @@ export function toSchemaJSON(schema) {
85
88
  }
86
89
  return { v: SCHEMA_JSON_VERSION, models, identityRoles: schema.identityRoles };
87
90
  }
88
- /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
91
+ /** Serialize a `Schema` to a JSON string (the `ablo push` payload). */
89
92
  export function serializeSchema(schema) {
90
93
  return JSON.stringify(toSchemaJSON(schema));
91
94
  }
@@ -157,9 +160,10 @@ function modelFromJSON(json) {
157
160
  typename: json.typename,
158
161
  persist: json.persist,
159
162
  tableName: json.tableName,
160
- orgScoped: json.orgScoped,
161
- scopedVia: json.scopedVia,
162
- syncGroupFormat: json.syncGroupFormat,
163
+ tenancy: json.tenancy,
164
+ scope: json.scope,
165
+ grants: json.grants,
166
+ entityRoles: json.entityRoles,
163
167
  mutable: json.mutable,
164
168
  lazyObservable: json.lazyObservable,
165
169
  // computed getters are closures and intentionally not serialized; a
@@ -73,10 +73,27 @@ export interface SugarOptions<R extends RelationRecord = RelationRecord, C exten
73
73
  */
74
74
  scopedVia?: ModelOptions['scopedVia'];
75
75
  /**
76
- * Template for participant scope derivation (e.g. `'matter:{id}'`).
77
- * Omit for nested children whose access cascades from a parent.
76
+ * Override the row-local tenancy column name. See
77
+ * {@link ModelOptions.orgColumn}.
78
78
  */
79
- syncGroupFormat?: string;
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'`). */
@@ -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
- syncGroupFormat: opts?.syncGroupFormat,
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;
@@ -0,0 +1,58 @@
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 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 const scopedViaRefSchema = z.object({
22
+ /** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
23
+ localKey: z.string().min(1),
24
+ /** Parent table name (e.g. `'team'`). */
25
+ parentTable: z.string().min(1),
26
+ /** Column on the parent that `localKey` references. Default `'id'`. */
27
+ parentKey: z.string().min(1).optional(),
28
+ /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
29
+ parentOrgColumn: z.string().min(1).optional(),
30
+ });
31
+ /** How a model's rows are scoped to a tenant. */
32
+ export const tenancySchema = z.discriminatedUnion('kind', [
33
+ /** Row-local tenancy column (default name `organization_id`, overridable). */
34
+ z.object({ kind: z.literal('column'), column: z.string().min(1) }),
35
+ /** Scoped through a parent table's tenancy. */
36
+ z.object({ kind: z.literal('parent'), via: scopedViaRefSchema }),
37
+ /** Not tenant-scoped (global / reference data). */
38
+ z.object({ kind: z.literal('none') }),
39
+ ]);
40
+ /**
41
+ * Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
42
+ * at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
43
+ * `tenancy`. Precedence: explicit `tenancy` → `scopedVia` → `orgScoped:false` →
44
+ * column (default or `orgColumn`).
45
+ */
46
+ export function resolveTenancy(input) {
47
+ if (input.tenancy)
48
+ return input.tenancy;
49
+ if (input.scopedVia)
50
+ return { kind: 'parent', via: input.scopedVia };
51
+ if (input.orgScoped === false)
52
+ return { kind: 'none' };
53
+ return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
54
+ }
55
+ /** The physical tenancy column for a column-scoped model, else `null`. */
56
+ export function tenancyColumn(t) {
57
+ return t.kind === 'column' ? t.column : null;
58
+ }