@abloatai/ablo 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +64 -35
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +1 -1
  5. package/dist/client/Ablo.d.ts +1 -0
  6. package/dist/client/Ablo.js +1 -0
  7. package/dist/client/createModelProxy.d.ts +26 -3
  8. package/dist/client/createModelProxy.js +4 -1
  9. package/dist/client/validateAbloOptions.js +2 -2
  10. package/dist/coordination/index.d.ts +6 -0
  11. package/dist/coordination/index.js +6 -0
  12. package/dist/coordination/schema.d.ts +329 -0
  13. package/dist/coordination/schema.js +209 -0
  14. package/dist/core/QueryView.d.ts +4 -1
  15. package/dist/core/QueryView.js +1 -1
  16. package/dist/core/query-utils.d.ts +7 -10
  17. package/dist/core/query-utils.js +2 -3
  18. package/dist/errorCodes.d.ts +264 -0
  19. package/dist/errorCodes.js +251 -0
  20. package/dist/errors.d.ts +51 -6
  21. package/dist/errors.js +56 -3
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +2 -2
  24. package/dist/policy/index.d.ts +1 -1
  25. package/dist/policy/index.js +1 -1
  26. package/dist/policy/types.d.ts +31 -0
  27. package/dist/policy/types.js +15 -0
  28. package/dist/react/AbloProvider.d.ts +12 -0
  29. package/dist/react/AbloProvider.js +11 -3
  30. package/dist/schema/ddl.d.ts +62 -0
  31. package/dist/schema/ddl.js +317 -0
  32. package/dist/schema/diff.d.ts +6 -0
  33. package/dist/schema/diff.js +21 -3
  34. package/dist/schema/field.d.ts +16 -19
  35. package/dist/schema/field.js +30 -17
  36. package/dist/schema/index.d.ts +7 -4
  37. package/dist/schema/index.js +9 -3
  38. package/dist/schema/model.d.ts +87 -25
  39. package/dist/schema/model.js +33 -3
  40. package/dist/schema/relation.d.ts +17 -0
  41. package/dist/schema/roles.d.ts +148 -0
  42. package/dist/schema/roles.js +149 -0
  43. package/dist/schema/schema.d.ts +2 -112
  44. package/dist/schema/schema.js +50 -62
  45. package/dist/schema/select.d.ts +25 -0
  46. package/dist/schema/select.js +55 -0
  47. package/dist/schema/serialize.d.ts +13 -9
  48. package/dist/schema/serialize.js +14 -10
  49. package/dist/schema/sugar.d.ts +20 -3
  50. package/dist/schema/sugar.js +5 -1
  51. package/dist/schema/tenancy.d.ts +66 -0
  52. package/dist/schema/tenancy.js +58 -0
  53. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  54. package/dist/sync/HydrationCoordinator.js +23 -17
  55. package/dist/sync/createIntentStream.d.ts +2 -1
  56. package/dist/sync/createIntentStream.js +46 -1
  57. package/dist/sync/participants.js +5 -14
  58. package/dist/types/streams.d.ts +53 -33
  59. package/docs/api-keys.md +44 -0
  60. package/docs/api.md +11 -22
  61. package/docs/cli.md +212 -0
  62. package/docs/client-behavior.md +1 -1
  63. package/docs/coordination.md +61 -12
  64. package/docs/data-sources.md +2 -2
  65. package/docs/examples/existing-python-backend.md +3 -3
  66. package/docs/examples/scoped-agent.md +78 -0
  67. package/docs/guarantees.md +5 -2
  68. package/docs/identity.md +139 -68
  69. package/docs/index.md +6 -0
  70. package/docs/integration-guide.md +31 -35
  71. package/docs/interaction-model.md +3 -0
  72. package/docs/react.md +3 -3
  73. package/docs/roadmap.md +14 -2
  74. package/package.json +8 -1
@@ -50,8 +50,14 @@ export interface IndexChange {
50
50
  readonly from: boolean;
51
51
  readonly to: boolean;
52
52
  }
53
+ /** Physical column-name transition for a stable logical field. */
54
+ export interface FieldColumnChange {
55
+ readonly from: string;
56
+ readonly to: string;
57
+ }
53
58
  /** The facets of a single column that changed (Atlas-style bitmask, as data). */
54
59
  export interface FieldChanges {
60
+ readonly column?: FieldColumnChange;
55
61
  readonly type?: FieldTypeChange;
56
62
  readonly nullability?: NullabilityChange;
57
63
  readonly enumValues?: EnumValuesChange;
@@ -65,8 +65,19 @@ function diffEnumValues(from, to) {
65
65
  return undefined;
66
66
  return { added, removed };
67
67
  }
68
- function diffField(prev, next) {
68
+ function camelToSnake(identifier) {
69
+ return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
70
+ }
71
+ function columnNameOf(fieldName, meta) {
72
+ return meta.column ?? camelToSnake(fieldName);
73
+ }
74
+ function diffField(prevFieldName, nextFieldName, prev, next) {
69
75
  const changes = {};
76
+ const prevColumn = columnNameOf(prevFieldName, prev);
77
+ const nextColumn = columnNameOf(nextFieldName, next);
78
+ if (prevColumn !== nextColumn) {
79
+ changes.column = { from: prevColumn, to: nextColumn };
80
+ }
70
81
  if (prev.type !== next.type) {
71
82
  changes.type = { from: prev.type, to: next.type, cast: classifyCast(prev.type, next.type) };
72
83
  }
@@ -112,9 +123,16 @@ function diffModelFields(model, prev, next, fieldRenames) {
112
123
  const prevMeta = prev.fields[prevName];
113
124
  if (!prevMeta)
114
125
  continue;
115
- const changes = diffField(prevMeta, nextMeta);
116
- if (changes)
126
+ const changes = diffField(prevName, name, prevMeta, nextMeta);
127
+ if (changes?.column && renameByNewName.has(name)) {
128
+ // A hinted logical field rename already emits `rename_field`, whose
129
+ // lowering renames the physical column when needed. Do not emit a
130
+ // second `alter_field.column` for the same transition.
131
+ delete changes.column;
132
+ }
133
+ if (changes && Object.keys(changes).length > 0) {
117
134
  steps.push({ kind: 'alter_field', model, field: name, changes });
135
+ }
118
136
  }
119
137
  // Dropped (present in prev, not in next, and not renamed away).
120
138
  for (const name of Object.keys(prev.fields)) {
@@ -31,6 +31,11 @@ export interface FieldMeta {
31
31
  isOptional: boolean;
32
32
  /** Whether the field was marked indexed via `.indexed()`. */
33
33
  isIndexed: boolean;
34
+ /**
35
+ * Physical database column name override. When absent, SQL layers derive
36
+ * the column from the field name using the active casing convention.
37
+ */
38
+ column?: string;
34
39
  /** For enums: the allowed values. */
35
40
  enumValues?: readonly string[];
36
41
  }
@@ -73,27 +78,21 @@ export declare function inferFieldMetaFromZod(schema: z.ZodType): FieldMeta;
73
78
  * `model.ts:inferMetaFromZod`.
74
79
  */
75
80
  export declare function resolveFieldMeta(schema: z.ZodType): FieldMeta;
81
+ export type FieldBuilder<T extends z.ZodType> = T & {
82
+ indexed(): FieldBuilder<T>;
83
+ from(column: string): FieldBuilder<T>;
84
+ };
76
85
  export declare const field: {
77
86
  /** String field */
78
- readonly string: () => z.ZodString & {
79
- indexed(): z.ZodString;
80
- };
87
+ readonly string: () => FieldBuilder<z.ZodString>;
81
88
  /** Number field */
82
- readonly number: () => z.ZodNumber & {
83
- indexed(): z.ZodNumber;
84
- };
89
+ readonly number: () => FieldBuilder<z.ZodNumber>;
85
90
  /** Boolean field */
86
- readonly boolean: () => z.ZodBoolean & {
87
- indexed(): z.ZodBoolean;
88
- };
91
+ readonly boolean: () => FieldBuilder<z.ZodBoolean>;
89
92
  /** Date field */
90
- readonly date: () => z.ZodDate & {
91
- indexed(): z.ZodDate;
92
- };
93
+ readonly date: () => FieldBuilder<z.ZodDate>;
93
94
  /** Enum field with constrained string values */
94
- readonly enum: <const T extends readonly [string, ...string[]]>(values: T) => z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never> & {
95
- indexed(): z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_2 ? { [k in keyof T_2]: T_2[k]; } : never>;
96
- };
95
+ readonly enum: <const T extends readonly [string, ...string[]]>(values: T) => FieldBuilder<z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never>>;
97
96
  /**
98
97
  * JSON field. Three call shapes:
99
98
  *
@@ -124,11 +123,9 @@ export declare const field: {
124
123
  * deck.metadataJson.icon // 'presentation' (typed, with default)
125
124
  * ```
126
125
  */
127
- readonly json: <T extends z.ZodType = z.ZodUnknown>(schemaOrShape?: T | z.ZodRawShape) => z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>> & {
128
- indexed(): z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
129
- };
126
+ readonly json: <T extends z.ZodType = z.ZodUnknown>(schemaOrShape?: T | z.ZodRawShape) => FieldBuilder<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
130
127
  /** Indexed string field (shorthand for `field.string().indexed()`). */
131
- readonly id: () => z.ZodString;
128
+ readonly id: () => FieldBuilder<z.ZodString>;
132
129
  };
133
130
  /** Mark a Zod schema as indexed for fast lookups (function form). */
134
131
  export declare function indexed<T extends z.ZodType>(schema: T): T;
@@ -142,7 +142,15 @@ export function inferFieldMetaFromZod(schema) {
142
142
  current instanceof z.ZodArray ||
143
143
  current instanceof z.ZodRecord ||
144
144
  current instanceof z.ZodUnion ||
145
- current instanceof z.ZodUnknown) {
145
+ current instanceof z.ZodUnknown ||
146
+ // `z.custom<T>()` is an opaque, structurally-uninspectable blob —
147
+ // by construction the engine can't see its shape, which is exactly
148
+ // the JSON-blob pattern (ProseMirror docs, LayerData/LayerStyle maps,
149
+ // chart specs). Classifying it as `'string'` (the fallthrough default)
150
+ // gave these fields deep MobX observability instead of the intended
151
+ // `observable.ref` (see Ablo.ts registerProperty), producing the
152
+ // microtask-storm + nested-reactivity-drift bug on live updates.
153
+ current instanceof z.ZodCustom) {
146
154
  type = 'json';
147
155
  }
148
156
  return { type, isOptional, isIndexed: false, enumValues };
@@ -169,39 +177,44 @@ export function resolveFieldMeta(schema) {
169
177
  return attached;
170
178
  return inferFieldMetaFromZod(schema);
171
179
  }
172
- // ── Chainable field builders ──────────────────────────────────────────────
173
- //
174
- // Each builder returns the underlying Zod schema (so `z.object(shape)` still
175
- // works) with `.indexed()` added as a chainable method. `.optional()` and
176
- // `.nullable()` still come from Zod itself and preserve the description.
177
- /** Add `.indexed()` to a Zod schema without disturbing its type. */
178
- function withIndexed(schema, baseMeta) {
179
- const described = schema.describe(encodeMeta({ ...baseMeta, isIndexed: false }));
180
- described.indexed = () => {
181
- return schema.describe(encodeMeta({ ...baseMeta, isIndexed: true }));
180
+ function assertColumnName(column) {
181
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(column)) {
182
+ throw new Error(`field.from(): invalid column identifier ${JSON.stringify(column)}`);
183
+ }
184
+ }
185
+ /** Add sync-engine chain methods to a Zod schema without disturbing its type. */
186
+ function withFieldMethods(schema, meta) {
187
+ const described = schema.describe(encodeMeta(meta));
188
+ described.indexed = () => withFieldMethods(schema, { ...meta, isIndexed: true });
189
+ described.from = (column) => {
190
+ assertColumnName(column);
191
+ return withFieldMethods(schema, { ...meta, column });
182
192
  };
183
193
  return described;
184
194
  }
195
+ function buildField(schema, baseMeta) {
196
+ return withFieldMethods(schema, { ...baseMeta, isIndexed: false });
197
+ }
185
198
  export const field = {
186
199
  /** String field */
187
200
  string() {
188
- return withIndexed(z.string(), { type: 'string' });
201
+ return buildField(z.string(), { type: 'string' });
189
202
  },
190
203
  /** Number field */
191
204
  number() {
192
- return withIndexed(z.number(), { type: 'number' });
205
+ return buildField(z.number(), { type: 'number' });
193
206
  },
194
207
  /** Boolean field */
195
208
  boolean() {
196
- return withIndexed(z.boolean(), { type: 'boolean' });
209
+ return buildField(z.boolean(), { type: 'boolean' });
197
210
  },
198
211
  /** Date field */
199
212
  date() {
200
- return withIndexed(z.date(), { type: 'date' });
213
+ return buildField(z.date(), { type: 'date' });
201
214
  },
202
215
  /** Enum field with constrained string values */
203
216
  enum(values) {
204
- return withIndexed(z.enum(values), { type: 'enum', enumValues: values });
217
+ return buildField(z.enum(values), { type: 'enum', enumValues: values });
205
218
  },
206
219
  /**
207
220
  * JSON field. Three call shapes:
@@ -245,7 +258,7 @@ export const field = {
245
258
  // Plain object shape → wrap in z.object() for the sub-property pattern
246
259
  inner = z.object(schemaOrShape);
247
260
  }
248
- return withIndexed(inner, { type: 'json' });
261
+ return buildField(inner, { type: 'json' });
249
262
  },
250
263
  /** Indexed string field (shorthand for `field.string().indexed()`). */
251
264
  id() {
@@ -21,12 +21,15 @@
21
21
  * ```
22
22
  */
23
23
  export { z } from 'zod';
24
- export { field, indexed, getFieldMeta, type FieldMeta } from './field.js';
24
+ export { field, indexed, getFieldMeta, type FieldBuilder, type FieldMeta } from './field.js';
25
25
  export { relation, type RelationDef, type RelationType } from './relation.js';
26
- export { model, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, } from './model.js';
26
+ export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type TenancyInput, } from './tenancy.js';
27
+ export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, } from './model.js';
27
28
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
28
- export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, type IdentityRole, type IdentityContext, identityRole, extractIdentityIds, type IdentityRoleSource, } from './schema.js';
29
+ export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
29
30
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
30
- export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
31
+ export { selectModels } from './select.js';
32
+ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
33
+ export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldColumnChange, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
31
34
  export { generateTypes } from './generate.js';
32
35
  export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
@@ -26,17 +26,23 @@ export { z } from 'zod';
26
26
  export { field, indexed, getFieldMeta } from './field.js';
27
27
  // Relation builders
28
28
  export { relation } from './relation.js';
29
+ // Tenancy — the single source of truth for how a model's rows are tenant-scoped.
30
+ export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
29
31
  // Model builder
30
- export { model, } from './model.js';
32
+ export { model, scopeKindOf, } from './model.js';
31
33
  // Intent-first shorthand: `mutable.lazy({...})` and friends. Read the
32
34
  // safety posture and load shape off the verb tokens; everything else
33
35
  // falls back to sensible defaults. See sugar.ts for the full pattern.
34
36
  export { mutable, readOnly } from './sugar.js';
35
37
  // Schema definition + type inference
36
- export { defineSchema, composeIdentitySyncGroups, identityRole, extractIdentityIds, } from './schema.js';
38
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
37
39
  // Schema ⇄ JSON (control-plane transport for hosted multi-tenant)
38
40
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
39
- // Schema diff + migration planning (pure; SQL emission is server-side)
41
+ // Schema projection derive an app's subset from one canonical schema.
42
+ export { selectModels } from './select.js';
43
+ // Schema → Postgres DDL (pure; shared by the hosted server and the CLI)
44
+ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, q, sqlType, } from './ddl.js';
45
+ // Schema diff + migration planning (pure; SQL emission lowered by ddl.ts)
40
46
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
41
47
  // Schema → TypeScript type emission (the `generate` half; pure)
42
48
  export { generateTypes } from './generate.js';
@@ -18,7 +18,10 @@
18
18
  */
19
19
  import { z } from 'zod';
20
20
  import type { RelationDef } from './relation.js';
21
+ import type { EntityRole } from './roles.js';
21
22
  import { type FieldMeta } from './field.js';
23
+ import { type Tenancy, type ScopedViaRef } from './tenancy.js';
24
+ export type { ScopedViaRef, Tenancy } from './tenancy.js';
22
25
  /**
23
26
  * Controls when model data is loaded from the server.
24
27
  *
@@ -46,18 +49,15 @@ export interface PersistOptions {
46
49
  store?: string;
47
50
  }
48
51
  /**
49
- * Describes how to scope a table's rows via a parent table. See
50
- * {@link ModelOptions.scopedVia} for semantics and code emitted.
52
+ * Declares a membership edge on a join model. See {@link ModelOptions.grants}
53
+ * for semantics and how the server membership resolver reads it. Both fields
54
+ * name `belongsTo` relations declared on the same model.
51
55
  */
52
- export interface ScopedViaRef {
53
- /** Column on THIS table that points at `parentKey` (e.g. `'team_id'`). */
54
- localKey: string;
55
- /** Parent table name (e.g. `'team'`, `'member'`). */
56
- parentTable: string;
57
- /** Column on the parent that `localKey` references. Default: `'id'`. */
58
- parentKey?: string;
59
- /** Column on the parent holding the tenant id. Default: `'organization_id'`. */
60
- parentOrgColumn?: string;
56
+ export interface GrantsRef {
57
+ /** Relation name pointing at the identity that gains access (e.g. `'user'`). */
58
+ subject: string;
59
+ /** Relation name pointing at the scope-root entity (e.g. `'dataroom'`). */
60
+ scope: string;
61
61
  }
62
62
  /** Options for model() */
63
63
  export interface ModelOptions {
@@ -120,18 +120,64 @@ export interface ModelOptions {
120
120
  */
121
121
  scopedVia?: ScopedViaRef;
122
122
  /**
123
- * Template for the sync group this entity lives in. When set, the
124
- * participant APIs can derive a transport scope from the entity id.
125
- * The single `{id}` placeholder is substituted with the scope id.
123
+ * Override the physical tenancy column name (default `organization_id`).
124
+ * Authoring sugar normalized into the canonical {@link tenancy} descriptor.
125
+ */
126
+ orgColumn?: string;
127
+ /**
128
+ * Canonical tenancy descriptor. You normally don't set this — `orgScoped`,
129
+ * `scopedVia`, and `orgColumn` are normalized into it at build time. Set it
130
+ * directly only to author the union form explicitly.
131
+ */
132
+ tenancy?: Tenancy;
133
+ /**
134
+ * Marks this model as a **scope root** — a model that forms a sync group of
135
+ * its own. A scope-root record lives in the group `<kind>:<id>`, where `kind`
136
+ * defaults to the lowercased `typename` (so a `Deck` → `deck:<id>`). Pass a
137
+ * string to override the kind explicitly (`scope: 'matter'`).
138
+ *
139
+ * Replaces the old `syncGroupFormat` template string: there is no `{id}`
140
+ * placeholder to author — the engine mints the branded {@link SyncGroup} from
141
+ * `(kind, id)`. Child models inherit a root's group via their `belongsTo`
142
+ * relations (a `document` with `dataroomId` fans into `dataroom:<id>`), so
143
+ * only the root declares anything.
144
+ */
145
+ scope?: boolean | string;
146
+ /**
147
+ * Declares this model as a **membership edge** that grants an identity access
148
+ * to a scope root — the relation-driven equivalent of "this user can see that
149
+ * dataroom." Both values are *relation names* already declared on this model:
150
+ * `subject` is the `belongsTo` to the identity (e.g. a `user`), `scope` is the
151
+ * `belongsTo` to the scope-root entity (e.g. a `dataroom`).
152
+ *
153
+ * The server's membership resolver reads this at connect time — for identity
154
+ * `U`, it queries `WHERE <subject FK> = U` and adds `<scopeKind>:<scope FK>`
155
+ * to the identity's subscribed groups (Linear's `/sync/user_sync_groups`).
126
156
  *
127
- * Example: `syncGroupFormat: 'matter:{id}'` + `scope: { matters: 'acme-q3' }`
128
- * yields a capability restricted to `sync_group: ['matter:acme-q3']`.
157
+ * ```ts
158
+ * // dataroomMember: { userId, dataroomId }
159
+ * grants: { subject: 'user', scope: 'dataroom' } // both are relation names
160
+ * ```
129
161
  *
130
- * Leave unset for entities that aren't directly scopable (nested
131
- * children whose access derives from their parent e.g. a `redline`
132
- * inside a `document` inside a `matter`).
162
+ * Not needed for org-level access a `dataroom.organizationId` already routes
163
+ * via the `org:<id>` group. `grants` is only for sub-org membership.
133
164
  */
134
- syncGroupFormat?: string;
165
+ grants?: GrantsRef;
166
+ /**
167
+ * Explicit record→group roles for routing that isn't relational — a group
168
+ * keyed on a plain field rather than the record's own id or a `belongsTo`
169
+ * scope root. The escape hatch for cases like per-recipient inbox fan-out:
170
+ *
171
+ * ```ts
172
+ * // a message routes to its addressee's inbox, keyed on `toId`
173
+ * entityRoles: [entityRole({ kind: 'inbox', source: 'toId' })]
174
+ * ```
175
+ *
176
+ * Prefer {@link scope} (self group) + `belongsTo` relations (parent groups)
177
+ * when the routing follows the relation graph — reach for `entityRoles` only
178
+ * when it genuinely doesn't.
179
+ */
180
+ entityRoles?: EntityRole | readonly EntityRole[];
135
181
  /**
136
182
  * Whether clients may issue CREATE/UPDATE/DELETE mutations for this
137
183
  * model via the `commit` wire protocol. Default: false.
@@ -272,11 +318,15 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
272
318
  /** The actual database table name from Prisma @@map. See {@link ModelOptions.tableName}. */
273
319
  readonly tableName?: string;
274
320
  /** Whether the table has organization_id. See {@link ModelOptions.orgScoped}. */
275
- readonly orgScoped?: boolean;
276
- /** Parent-table scoping for rows with no organization_id. See {@link ModelOptions.scopedVia}. */
277
- readonly scopedVia?: ScopedViaRef;
278
- /** Template for participant scope derivation. See {@link ModelOptions.syncGroupFormat}. */
279
- readonly syncGroupFormat?: string;
321
+ /** Canonical tenancy descriptor — the single source of truth, normalized from
322
+ * the `orgScoped`/`scopedVia`/`orgColumn` authoring sugar at build. */
323
+ readonly tenancy: Tenancy;
324
+ /** Scope-root marker. See {@link ModelOptions.scope}. */
325
+ readonly scope?: boolean | string;
326
+ /** Membership edge granting identity → scope-root access. See {@link ModelOptions.grants}. */
327
+ readonly grants?: GrantsRef;
328
+ /** Explicit non-relational record→group roles (normalized to an array). See {@link ModelOptions.entityRoles}. */
329
+ readonly entityRoles?: readonly EntityRole[];
280
330
  /** Whether wire-level CREATE/UPDATE/DELETE is allowed. See {@link ModelOptions.mutable}. */
281
331
  readonly mutable?: boolean;
282
332
  /** Defer MobX setup until first observer access. See {@link ModelOptions.lazyObservable}. */
@@ -324,3 +374,15 @@ export interface ModelDef<Shape extends z.ZodRawShape = z.ZodRawShape, R extends
324
374
  export declare function model<Shape extends z.ZodRawShape, R extends RelationRecord = Record<string, never>, C extends ComputedRecord = Record<string, never>>(shape: Shape, relations?: R, options?: ModelOptions & {
325
375
  computed?: C;
326
376
  }): ModelDef<Shape, R, C>;
377
+ /**
378
+ * The sync-group kind a scope-root model mints, or `undefined` when the model
379
+ * isn't a scope root. `scope: true` derives the kind from the lowercased
380
+ * typename (`SlideDeck` → `slidedeck`); `scope: 'deck'` sets it explicitly
381
+ * (the form to use when the wire kind must differ from the typename). One place
382
+ * so the commit path, the membership resolver, and the participant join-side
383
+ * all agree on what a record's own group is.
384
+ */
385
+ export declare function scopeKindOf(def: {
386
+ scope?: boolean | string;
387
+ typename?: string;
388
+ }, fallbackKey: string): string | undefined;
@@ -18,6 +18,16 @@
18
18
  */
19
19
  import { z } from 'zod';
20
20
  import { getFieldMeta, inferFieldMetaFromZod } from './field.js';
21
+ // Tenancy is owned by `tenancy.ts` (single source of truth). `ScopedViaRef` is
22
+ // re-exported so existing `import { ScopedViaRef } from './model'` call sites
23
+ // keep resolving while consumers migrate.
24
+ import { resolveTenancy } from './tenancy.js';
25
+ /** Normalize the `entityRoles` option (single | array | undefined) to an array. */
26
+ function normalizeEntityRoles(input) {
27
+ if (!input)
28
+ return undefined;
29
+ return Array.isArray(input) ? input : [input];
30
+ }
21
31
  // ── Model factory ─────────────────────────────────────────────────────────
22
32
  /**
23
33
  * Define a model with a Zod shape and optional relations.
@@ -77,9 +87,16 @@ export function model(shape, relations, options) {
77
87
  typename: options?.typename,
78
88
  persist: options?.persist,
79
89
  tableName: options?.tableName,
80
- orgScoped: options?.orgScoped,
81
- scopedVia: options?.scopedVia,
82
- syncGroupFormat: options?.syncGroupFormat,
90
+ // Normalize all tenancy authoring sugar into the one canonical descriptor.
91
+ tenancy: resolveTenancy({
92
+ tenancy: options?.tenancy,
93
+ orgScoped: options?.orgScoped,
94
+ scopedVia: options?.scopedVia,
95
+ orgColumn: options?.orgColumn,
96
+ }),
97
+ scope: options?.scope,
98
+ grants: options?.grants,
99
+ entityRoles: normalizeEntityRoles(options?.entityRoles),
83
100
  mutable: options?.mutable,
84
101
  lazyObservable: options?.lazyObservable,
85
102
  computed: options?.computed,
@@ -87,3 +104,16 @@ export function model(shape, relations, options) {
87
104
  requiredFields: options?.requiredFields,
88
105
  };
89
106
  }
107
+ /**
108
+ * The sync-group kind a scope-root model mints, or `undefined` when the model
109
+ * isn't a scope root. `scope: true` derives the kind from the lowercased
110
+ * typename (`SlideDeck` → `slidedeck`); `scope: 'deck'` sets it explicitly
111
+ * (the form to use when the wire kind must differ from the typename). One place
112
+ * so the commit path, the membership resolver, and the participant join-side
113
+ * all agree on what a record's own group is.
114
+ */
115
+ export function scopeKindOf(def, fallbackKey) {
116
+ if (!def.scope)
117
+ return undefined;
118
+ return (typeof def.scope === 'string' ? def.scope : (def.typename ?? fallbackKey)).toLowerCase();
119
+ }
@@ -62,6 +62,23 @@ export interface BelongsToOptions {
62
62
  readonly index?: boolean;
63
63
  readonly enrich?: boolean;
64
64
  readonly defer?: boolean;
65
+ /**
66
+ * Marks the relation's target as this record's **parent** in the Zanzibar/
67
+ * ReBAC sense — the entity it lives in, that scope inherits *from*. Sync-group
68
+ * fan-out routes a record into its parent's group (directly, or transitively
69
+ * up a chain of `parent` edges), so a write reaches everyone subscribed to the
70
+ * owning entity — the same "access inherits from parent" rule OpenFGA/Zanzibar
71
+ * and filesystems use.
72
+ *
73
+ * A reference (provenance/template pointer like `sourceSlideId`, `templateId`)
74
+ * must NOT set this, or the record would leak into an unrelated scope.
75
+ * Optionality is NOT a proxy — many parent FKs are optional (a root folder, an
76
+ * inbox task) — so the parent edge must be declared, not inferred.
77
+ *
78
+ * Reads on the relation: `belongsTo('deck', 'deckId', { parent: true })` —
79
+ * "the deck is the parent."
80
+ */
81
+ readonly parent?: boolean;
65
82
  }
66
83
  declare const __relationType: unique symbol;
67
84
  declare const __relationTarget: unique symbol;
@@ -0,0 +1,148 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Sync-group roles — the fan-out vocabulary, as typed data.
4
+ *
5
+ * A *sync group* is the unit the broadcast layer routes deltas on. On the wire
6
+ * it's a `kind:id` string, but that serialization is owned entirely by this
7
+ * module — callers never hand-write `'org:abc'`. Instead they declare a typed
8
+ * {@link Role} (`kind` + which field supplies the id) and the engine mints the
9
+ * branded {@link SyncGroup} string via {@link syncGroup}.
10
+ *
11
+ * Two reading directions, one shape:
12
+ *
13
+ * • {@link IdentityRole} — "which groups may this *participant* subscribe to?"
14
+ * Reads fields off an identity context (`organizationId`, `teamIds`).
15
+ *
16
+ * • {@link EntityRole} — "which groups does this *record* live in?" Reads
17
+ * fields off the record itself (`id`, `deckId`), so the server can fan a
18
+ * committed delta to the right entity streams regardless of what the
19
+ * committer was subscribed to.
20
+ *
21
+ * Roles are pure data (no closures) so a `Schema` round-trips through the
22
+ * control plane and the reconstructed copy behaves identically.
23
+ */
24
+ /**
25
+ * The branded wire form of a sync group: `${kind}:${id}`. Branded so a raw
26
+ * string can't masquerade as one — the only way to produce a `SyncGroup` is
27
+ * {@link syncGroup}. Because the brand is an intersection it's still assignable
28
+ * *to* `string`, so existing `string[]` plumbing keeps working unchanged.
29
+ */
30
+ export declare const syncGroupSchema: z.core.$ZodBranded<z.ZodTemplateLiteral<`${string}:${string}`>, "SyncGroup", "out">;
31
+ export type SyncGroup = z.infer<typeof syncGroupSchema>;
32
+ /**
33
+ * Mint a sync-group string. The single place the `kind:id` convention lives —
34
+ * if the wire format ever changes (structured columns, a different separator),
35
+ * it changes here and nowhere else.
36
+ */
37
+ export declare function syncGroup(kind: string, id: string): SyncGroup;
38
+ /** Validates how a role pulls ids out of a context (identity or record). */
39
+ export declare const roleSourceSchema: z.ZodObject<{
40
+ field: z.ZodString;
41
+ multi: z.ZodBoolean;
42
+ }, z.core.$strip>;
43
+ export type RoleSource = z.infer<typeof roleSourceSchema>;
44
+ /** Back-compat alias — historical name for {@link RoleSource}. */
45
+ export type IdentityRoleSource = RoleSource;
46
+ /** Record-side name for {@link RoleSource}. */
47
+ export type EntityRoleSource = RoleSource;
48
+ /** Free-form context a role reads from. */
49
+ export type RoleContext = Record<string, unknown>;
50
+ /** The identity shape an {@link IdentityRole} reads from. */
51
+ export type IdentityContext = RoleContext;
52
+ /** The record shape an {@link EntityRole} reads from. */
53
+ export type EntityContext = RoleContext;
54
+ /**
55
+ * A sync-group role: a typed `kind` plus the field that supplies the id. The
56
+ * wire string is `${kind}:${id}`, built by the engine — there is deliberately
57
+ * no template/placeholder for the author to get wrong.
58
+ */
59
+ export declare const roleSchema: z.ZodObject<{
60
+ kind: z.ZodString;
61
+ source: z.ZodObject<{
62
+ field: z.ZodString;
63
+ multi: z.ZodBoolean;
64
+ }, z.core.$strip>;
65
+ }, z.core.$strip>;
66
+ export type Role = z.infer<typeof roleSchema>;
67
+ /**
68
+ * Identity-anchored role. Reads an identity field; `kind` names the group.
69
+ *
70
+ * ```ts
71
+ * identityRole({ kind: 'org', source: 'organizationId' })
72
+ * identityRole({ kind: 'team', source: 'teamIds', multi: true })
73
+ * ```
74
+ */
75
+ export type IdentityRole = Role;
76
+ /**
77
+ * Record-anchored role. Reads a record field; `kind` names the group. A record
78
+ * can route to a group keyed by its own `id` *or* a foreign key like `deckId`.
79
+ *
80
+ * ```ts
81
+ * entityRole({ kind: 'deck', source: 'id' }) // a deck → deck:<id>
82
+ * entityRole({ kind: 'deck', source: 'deckId' }) // a layer → its parent deck
83
+ * ```
84
+ */
85
+ export type EntityRole = Role;
86
+ /** Validates an {@link IdentityRole}. */
87
+ export declare const identityRoleSchema: z.ZodType<IdentityRole>;
88
+ /** Validates an {@link EntityRole}. */
89
+ export declare const entityRoleSchema: z.ZodType<EntityRole>;
90
+ /**
91
+ * Validates a model's `scope` declaration: `true` (kind = typename) or an
92
+ * explicit lowercase kind string. The same vocabulary the roles use, so the
93
+ * whole sync-group declaration surface is Zod-validated, not hand-checked.
94
+ */
95
+ export declare const scopeSchema: z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>;
96
+ /**
97
+ * Validates a model's `grants` membership edge. Both values are relation names
98
+ * declared on the same model (`subject` → identity, `scope` → scope root); that
99
+ * the relations actually exist + are `belongsTo` is a cross-field check done in
100
+ * `defineSchema` where the relation map is in scope.
101
+ */
102
+ export declare const grantsRefSchema: z.ZodObject<{
103
+ subject: z.ZodString;
104
+ scope: z.ZodString;
105
+ }, z.core.$strip>;
106
+ /** Build an identity-anchored role. `multi` defaults to `false`. */
107
+ export declare function identityRole(spec: {
108
+ readonly kind: string;
109
+ /** Identity-context field to read. See {@link RoleSource.field}. */
110
+ readonly source: string;
111
+ /** Treat the field as an array of ids. See {@link RoleSource.multi}. */
112
+ readonly multi?: boolean;
113
+ }): IdentityRole;
114
+ /** Build a record-anchored role. `multi` defaults to `false`. */
115
+ export declare function entityRole(spec: {
116
+ readonly kind: string;
117
+ /** Record field to read. See {@link RoleSource.field}. */
118
+ readonly source: string;
119
+ /** Treat the field as an array of ids. See {@link RoleSource.multi}. */
120
+ readonly multi?: boolean;
121
+ }): EntityRole;
122
+ /**
123
+ * Evaluate a {@link RoleSource} against a context. Absent or falsy fields yield
124
+ * `[]`, so a role whose field isn't present (a user with no `teamIds`, a record
125
+ * with no `deckId`) is a silent no-op.
126
+ */
127
+ export declare function extractRoleIds(context: RoleContext, source: RoleSource): readonly string[];
128
+ /** Identity-side name for {@link extractRoleIds}. */
129
+ export declare const extractIdentityIds: typeof extractRoleIds;
130
+ /** Record-side name for {@link extractRoleIds}. */
131
+ export declare const extractEntityIds: typeof extractRoleIds;
132
+ /**
133
+ * Compose the sync groups an identity may subscribe to, from the schema's
134
+ * registered {@link IdentityRole}s. Returns `[]` when no role produces an id;
135
+ * the caller treats `[]` as "no scope", not "match everything".
136
+ */
137
+ export declare function composeIdentitySyncGroups(identity: IdentityContext, schema: {
138
+ readonly identityRoles: readonly IdentityRole[];
139
+ }): readonly SyncGroup[];
140
+ /**
141
+ * Compose the sync groups a record belongs to, from the model's registered
142
+ * {@link EntityRole}s. Mirror of {@link composeIdentitySyncGroups}, reading the
143
+ * record instead of an identity. Returns `[]` when the model has no entity
144
+ * roles (the delta then fans on its base `org:`/`user:` groups only).
145
+ */
146
+ export declare function composeEntitySyncGroups(record: EntityContext, def: {
147
+ readonly entityRoles?: readonly EntityRole[];
148
+ }): readonly SyncGroup[];