@abloatai/ablo 0.5.1 → 0.6.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 (94) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +217 -122
  3. package/dist/BaseSyncedStore.d.ts +2 -2
  4. package/dist/BaseSyncedStore.js +2 -2
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +90 -93
  8. package/dist/client/Ablo.js +121 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +90 -87
  14. package/dist/client/createModelProxy.js +124 -127
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +3 -3
  18. package/dist/core/index.d.ts +2 -0
  19. package/dist/core/index.js +7 -0
  20. package/dist/errors.d.ts +8 -8
  21. package/dist/errors.js +18 -10
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +7 -11
  24. package/dist/interfaces/index.d.ts +2 -10
  25. package/dist/mutators/Transaction.d.ts +2 -2
  26. package/dist/mutators/Transaction.js +2 -2
  27. package/dist/mutators/mutateActions.d.ts +44 -0
  28. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  29. package/dist/mutators/readerActions.d.ts +32 -0
  30. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  31. package/dist/query/types.d.ts +1 -1
  32. package/dist/react/AbloProvider.d.ts +1 -1
  33. package/dist/react/AbloProvider.js +3 -3
  34. package/dist/react/context.d.ts +4 -4
  35. package/dist/react/index.d.ts +4 -5
  36. package/dist/react/index.js +3 -7
  37. package/dist/react/useAblo.d.ts +14 -14
  38. package/dist/react/useAblo.js +26 -26
  39. package/dist/react/useIntent.d.ts +2 -2
  40. package/dist/react/useIntent.js +2 -2
  41. package/dist/react/useMutators.d.ts +1 -1
  42. package/dist/react/usePresence.d.ts +3 -3
  43. package/dist/react/usePresence.js +4 -4
  44. package/dist/react/useUndoScope.d.ts +1 -1
  45. package/dist/schema/diff.d.ts +161 -0
  46. package/dist/schema/diff.js +262 -0
  47. package/dist/schema/generate.d.ts +19 -0
  48. package/dist/schema/generate.js +87 -0
  49. package/dist/schema/index.d.ts +4 -1
  50. package/dist/schema/index.js +7 -1
  51. package/dist/schema/schema.d.ts +83 -32
  52. package/dist/schema/schema.js +58 -12
  53. package/dist/schema/serialize.d.ts +92 -0
  54. package/dist/schema/serialize.js +227 -0
  55. package/dist/sync/SyncWebSocket.d.ts +17 -0
  56. package/dist/sync/SyncWebSocket.js +46 -1
  57. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  58. package/dist/sync/awaitIntentGrant.js +60 -0
  59. package/dist/sync/createIntentStream.js +43 -4
  60. package/dist/sync/createPresenceStream.js +1 -1
  61. package/dist/sync/participants.d.ts +2 -2
  62. package/dist/sync/participants.js +4 -4
  63. package/dist/types/global.d.ts +43 -52
  64. package/dist/types/global.js +16 -18
  65. package/dist/types/streams.d.ts +37 -9
  66. package/docs/api.md +68 -158
  67. package/docs/audit.md +5 -5
  68. package/docs/client-behavior.md +41 -42
  69. package/docs/coordination.md +294 -0
  70. package/docs/data-sources.md +14 -14
  71. package/docs/examples/agent-human.md +30 -32
  72. package/docs/examples/ai-sdk-tool.md +32 -33
  73. package/docs/examples/existing-python-backend.md +35 -33
  74. package/docs/examples/nextjs.md +24 -25
  75. package/docs/examples/server-agent.md +20 -61
  76. package/docs/guarantees.md +30 -55
  77. package/docs/identity.md +458 -0
  78. package/docs/index.md +12 -24
  79. package/docs/integration-guide.md +106 -116
  80. package/docs/interaction-model.md +29 -95
  81. package/docs/mcp/claude-code.md +3 -3
  82. package/docs/mcp/cursor.md +1 -1
  83. package/docs/mcp/windsurf.md +1 -1
  84. package/docs/mcp.md +11 -26
  85. package/docs/quickstart.md +43 -49
  86. package/docs/react.md +73 -23
  87. package/docs/roadmap.md +5 -7
  88. package/llms.txt +34 -39
  89. package/package.json +1 -1
  90. package/dist/react/useMutate.d.ts +0 -83
  91. package/dist/react/useQuery.d.ts +0 -123
  92. package/dist/react/useQuery.js +0 -145
  93. package/dist/react/useReader.d.ts +0 -69
  94. package/docs/capabilities.md +0 -163
@@ -29,20 +29,23 @@ export type CasingFn = (jsField: string) => string;
29
29
  /** `defineSchema`'s casing option. Identity when unset. */
30
30
  export type Casing = CasingConvention | CasingFn;
31
31
  /**
32
- * The identity shape passed to {@link IdentityRole.extract}. Free-form
32
+ * The identity shape an {@link IdentityRole} reads from. Free-form
33
33
  * `Record<string, unknown>` because each schema chooses what fields
34
34
  * its roles read — Ablo's roles read `organizationId`/`userId`/`teamIds`;
35
35
  * a tenant model with `regionId`/`customerId` is equally valid. The
36
36
  * server hands its resolved identity (whatever shape its AuthProvider
37
- * returns) straight to `extract` without translation.
37
+ * returns) straight to {@link extractIdentityIds} without translation.
38
38
  */
39
39
  export type IdentityContext = Record<string, unknown>;
40
40
  /**
41
- * Open registration of an identity-anchored sync-group. Each role
42
- * declares (a) a free-form `kind` label (purely for diagnostics), (b)
43
- * a `template` containing a single `{id}` placeholder, and (c) an
44
- * `extract` function that pulls the relevant ids out of an arbitrary
45
- * identity context.
41
+ * Open registration of an identity-anchored sync-group. Each role is
42
+ * pure data: (a) a free-form `kind` label (diagnostics only), (b) a
43
+ * `template` with a single `{id}` placeholder, and (c) a `source`
44
+ * declaring which identity field to read and how.
45
+ *
46
+ * Pure data on purpose — no closures. A `Schema` is then JSON-serializable
47
+ * end to end, so the same schema object works in-process AND after being
48
+ * sent to a hosted server over the control plane.
46
49
  *
47
50
  * No closed enum. A consumer whose identity shape is
48
51
  * `{ regionId, customerId }` registers:
@@ -50,35 +53,74 @@ export type IdentityContext = Record<string, unknown>;
50
53
  * ```ts
51
54
  * defineSchema({ ... }, {
52
55
  * identityRoles: [
53
- * { kind: 'region', template: 'region:{id}',
54
- * extract: (i) => i.regionId ? [String(i.regionId)] : [] },
55
- * { kind: 'customer', template: 'customer:{id}',
56
- * extract: (i) => i.customerId ? [String(i.customerId)] : [] },
56
+ * identityRole({ kind: 'region', template: 'region:{id}', source: 'regionId' }),
57
+ * identityRole({ kind: 'customer', template: 'customer:{id}', source: 'customerId' }),
57
58
  * ],
58
59
  * });
59
60
  * ```
60
61
  *
61
- * The server's `composeIdentitySyncGroups` walks every registered role
62
- * and produces the union — no hardcoded `org:` / `user:` / `team:`
63
- * anywhere in the engine.
62
+ * {@link composeIdentitySyncGroups} walks every registered role and
63
+ * produces the union — no hardcoded `org:` / `user:` / `team:` anywhere in
64
+ * the engine.
64
65
  */
65
66
  export interface IdentityRole {
66
67
  /** Free-form label for diagnostics/logging. Not parsed. */
67
68
  readonly kind: string;
68
69
  /**
69
70
  * Sync-group template with a single `{id}` placeholder. Substituted
70
- * once per id returned by `extract`. Example: `'org:{id}'` →
71
+ * once per id produced from `source`. Example: `'org:{id}'` →
71
72
  * `'org:abc-123'`.
72
73
  */
73
74
  readonly template: string;
75
+ /** Which identity field to read, and whether it's an array. */
76
+ readonly source: IdentityRoleSource;
77
+ }
78
+ /**
79
+ * Declarative, JSON-serializable description of how an {@link IdentityRole}
80
+ * pulls ids out of an identity context.
81
+ */
82
+ export interface IdentityRoleSource {
83
+ /**
84
+ * The identity-context field to read, e.g. `'organizationId'` or
85
+ * `'teamIds'`. The value is coerced per {@link multi}.
86
+ */
87
+ readonly field: string;
74
88
  /**
75
- * Extract zero or more ids from the resolved identity context. Pure
76
- * function must not allocate persistent state, must be safe to
77
- * call once per request. Return an empty array when the role
78
- * doesn't apply to this identity.
89
+ * When `true`, `field` holds an array; every non-empty string element
90
+ * yields one sync group. When `false` (default), `field` holds a single
91
+ * scalar; a truthy value yields exactly one group, falsy yields none.
79
92
  */
80
- readonly extract: (identity: IdentityContext) => readonly string[];
93
+ readonly multi: boolean;
81
94
  }
95
+ /**
96
+ * Evaluate an {@link IdentityRoleSource} against an identity context.
97
+ * The whole runtime behaviour of a role lives here, as data-driven logic —
98
+ * `composeIdentitySyncGroups` calls this once per role. Absent or falsy
99
+ * fields yield `[]`, so a role whose field isn't present (e.g. a user with
100
+ * no `teamIds`) is a silent no-op.
101
+ */
102
+ export declare function extractIdentityIds(identity: IdentityContext, source: IdentityRoleSource): readonly string[];
103
+ /**
104
+ * Build an identity-anchored sync-group role. A thin, validated factory
105
+ * over the {@link IdentityRole} data shape — `multi` defaults to `false`.
106
+ *
107
+ * ```ts
108
+ * defineSchema({ ... }, {
109
+ * identityRoles: [
110
+ * identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
111
+ * identityRole({ kind: 'member', template: 'team:{id}', source: 'teamIds', multi: true }),
112
+ * ],
113
+ * });
114
+ * ```
115
+ */
116
+ export declare function identityRole(spec: {
117
+ readonly kind: string;
118
+ readonly template: string;
119
+ /** Identity-context field to read. See {@link IdentityRoleSource.field}. */
120
+ readonly source: string;
121
+ /** Treat the field as an array of ids. See {@link IdentityRoleSource.multi}. */
122
+ readonly multi?: boolean;
123
+ }): IdentityRole;
82
124
  /** Options for `defineSchema`. */
83
125
  export interface DefineSchemaOptions {
84
126
  /**
@@ -113,8 +155,14 @@ export interface DefineSchemaOptions {
113
155
  }
114
156
  /** A record of model names → model definitions */
115
157
  export type SchemaRecord = Record<string, ModelDef>;
116
- /** Base fields every synced model gets automatically */
117
- declare const baseFieldsSchema: z.ZodObject<{
158
+ /**
159
+ * Base fields every synced model gets automatically.
160
+ *
161
+ * Exported (internal) so `parseSchema` can rebuild a model's validator the
162
+ * same way `defineSchema` does — `baseFieldsSchema.merge(modelSchema)` — when
163
+ * reconstructing a `Schema` from its JSON form.
164
+ */
165
+ export declare const baseFieldsSchema: z.ZodObject<{
118
166
  id: z.ZodString;
119
167
  createdAt: z.ZodDate;
120
168
  updatedAt: z.ZodDate;
@@ -244,16 +292,19 @@ export declare function defineSchema<const S extends SchemaRecord>(models: S, op
244
292
  /**
245
293
  * Compose the canonical sync-group set this identity is allowed to
246
294
  * subscribe to, derived purely from the schema's registered
247
- * {@link IdentityRole}s. Walks every role, calls its `extract` against
248
- * the identity context, and substitutes each id into the role's
249
- * `template`. Output is stable, deduped, and never includes a literal
250
- * convention string from this function itself — the convention lives
251
- * 100% in the consumer's schema declaration.
295
+ * {@link IdentityRole}s. Walks every role, evaluates its `source` against
296
+ * the identity context via {@link extractIdentityIds}, and substitutes each
297
+ * id into the role's `template`. Output is stable, deduped, and never
298
+ * includes a literal convention string from this function itself — the
299
+ * convention lives 100% in the consumer's schema declaration.
300
+ *
301
+ * Reads only data off the schema (`identityRoles` is pure data), so it works
302
+ * identically on an in-process `Schema` and one reconstructed from JSON on a
303
+ * hosted server.
252
304
  *
253
- * Returns `[]` when the schema has no identity roles registered or
254
- * when no role's extractor produces an id. Caller decides what to do
255
- * with `[]`; the server's intersect-with-requested logic treats it as
256
- * "no scope" rather than "match everything."
305
+ * Returns `[]` when the schema has no identity roles registered or when no
306
+ * role's source produces an id. Caller decides what to do with `[]`; the
307
+ * server's intersect-with-requested logic treats it as "no scope" rather
308
+ * than "match everything."
257
309
  */
258
310
  export declare function composeIdentitySyncGroups(identity: IdentityContext, schema: Pick<Schema, 'identityRoles'>): readonly string[];
259
- export {};
@@ -39,8 +39,50 @@ function resolveCasing(fn) {
39
39
  function camelToSnake(identifier) {
40
40
  return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
41
41
  }
42
- /** Base fields every synced model gets automatically */
43
- const baseFieldsSchema = z.object({
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
+ /**
79
+ * Base fields every synced model gets automatically.
80
+ *
81
+ * Exported (internal) so `parseSchema` can rebuild a model's validator the
82
+ * same way `defineSchema` does — `baseFieldsSchema.merge(modelSchema)` — when
83
+ * reconstructing a `Schema` from its JSON form.
84
+ */
85
+ export const baseFieldsSchema = z.object({
44
86
  id: z.string(),
45
87
  createdAt: z.date(),
46
88
  updatedAt: z.date(),
@@ -165,21 +207,25 @@ export function defineSchema(models, options) {
165
207
  /**
166
208
  * Compose the canonical sync-group set this identity is allowed to
167
209
  * subscribe to, derived purely from the schema's registered
168
- * {@link IdentityRole}s. Walks every role, calls its `extract` against
169
- * the identity context, and substitutes each id into the role's
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.
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.
173
219
  *
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."
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."
178
224
  */
179
225
  export function composeIdentitySyncGroups(identity, schema) {
180
226
  const out = new Set();
181
227
  for (const role of schema.identityRoles) {
182
- for (const id of role.extract(identity)) {
228
+ for (const id of extractIdentityIds(identity, role.source)) {
183
229
  if (id)
184
230
  out.add(role.template.replace('{id}', id));
185
231
  }
@@ -0,0 +1,92 @@
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, syncGroupFormat,
17
+ * 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 { ScopedViaRef, LoadStrategy, PersistOptions, AutoFillRule } from './model.js';
29
+ import type { RelationType } from './relation.js';
30
+ import { type Schema, type SchemaRecord, type IdentityRole } from './schema.js';
31
+ /** 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;
34
+ /** A relation in JSON form. Mirrors the serializable members of {@link RelationDef}. */
35
+ export interface RelationJSON {
36
+ readonly type: RelationType;
37
+ readonly target: string;
38
+ readonly foreignKey: string;
39
+ readonly foreignKeyColumn: string;
40
+ readonly options?: Record<string, boolean>;
41
+ readonly orderBy?: string;
42
+ }
43
+ /** A model in JSON form. Everything on {@link ModelDef} except closures. */
44
+ export interface ModelJSON {
45
+ readonly fields: Record<string, FieldMeta>;
46
+ readonly relations: Record<string, RelationJSON>;
47
+ readonly load: LoadStrategy;
48
+ readonly typename: string;
49
+ readonly tableName?: string;
50
+ readonly orgScoped?: boolean;
51
+ readonly scopedVia?: ScopedViaRef;
52
+ readonly bootstrapLimit?: number;
53
+ readonly bootstrapOrderBy?: string;
54
+ readonly syncGroupFormat?: string;
55
+ readonly mutable?: boolean;
56
+ readonly lazyObservable?: boolean;
57
+ readonly persist?: PersistOptions;
58
+ readonly autoFill?: readonly AutoFillRule[];
59
+ readonly requiredFields?: readonly string[];
60
+ }
61
+ /** The JSON form of a {@link Schema}. The `@ablo schema push` payload. */
62
+ export interface SchemaJSON {
63
+ readonly v: typeof SCHEMA_JSON_VERSION;
64
+ readonly models: Record<string, ModelJSON>;
65
+ readonly identityRoles: readonly IdentityRole[];
66
+ }
67
+ /**
68
+ * Project a `Schema` to its JSON form. Drops the client-only closures
69
+ * (validators, `computed`); keeps everything the server and a faithful
70
+ * rebuild need. The result is plain data — `JSON.stringify`-safe.
71
+ */
72
+ export declare function toSchemaJSON(schema: Schema<SchemaRecord>): SchemaJSON;
73
+ /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
74
+ export declare function serializeSchema(schema: Schema<SchemaRecord>): string;
75
+ /**
76
+ * Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
77
+ * permissively from field metadata (the server never validates field shapes);
78
+ * `computed` getters are absent. Everything the server reads — routing,
79
+ * scoping, relations, identity roles — is restored exactly.
80
+ */
81
+ export declare function fromSchemaJSON(json: SchemaJSON): Schema<SchemaRecord>;
82
+ /** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
83
+ export declare function parseSchema(json: string): Schema<SchemaRecord>;
84
+ /**
85
+ * Stable content hash of a `Schema`'s JSON form. FNV-1a over a canonical
86
+ * (sorted-key) encoding — deterministic across runs and order-invariant, no
87
+ * `crypto` dependency. Used for connect-time version gating: the client sends
88
+ * the hash it was built against, the server compares it to the tenant's
89
+ * active schema hash. Not a security primitive.
90
+ */
91
+ export declare function schemaHash(schema: Schema<SchemaRecord>): string;
92
+ export {};
@@ -0,0 +1,227 @@
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, syncGroupFormat,
17
+ * 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). */
31
+ const SCHEMA_JSON_VERSION = 1;
32
+ // ── Serialize ────────────────────────────────────────────────────────────────
33
+ function relationToJSON(rel) {
34
+ const options = rel.options;
35
+ return {
36
+ type: rel.type,
37
+ target: rel.target,
38
+ foreignKey: rel.foreignKey,
39
+ foreignKeyColumn: rel.foreignKeyColumn,
40
+ options: options && Object.keys(options).length > 0 ? { ...options } : undefined,
41
+ orderBy: rel._orderBy,
42
+ };
43
+ }
44
+ function modelToJSON(def) {
45
+ const relations = {};
46
+ for (const [name, rel] of Object.entries(def.relations)) {
47
+ relations[name] = relationToJSON(rel);
48
+ }
49
+ return {
50
+ fields: def.fields,
51
+ relations,
52
+ load: def.load,
53
+ // `defineSchema` always resolves `typename` to the schema key when unset,
54
+ // so it is present on a built ModelDef; fall back defensively anyway.
55
+ typename: def.typename ?? '',
56
+ tableName: def.tableName,
57
+ orgScoped: def.orgScoped,
58
+ scopedVia: def.scopedVia,
59
+ bootstrapLimit: def.bootstrapLimit,
60
+ bootstrapOrderBy: def.bootstrapOrderBy,
61
+ syncGroupFormat: def.syncGroupFormat,
62
+ mutable: def.mutable,
63
+ lazyObservable: def.lazyObservable,
64
+ persist: def.persist,
65
+ autoFill: def.autoFill,
66
+ requiredFields: def.requiredFields,
67
+ };
68
+ }
69
+ /**
70
+ * Project a `Schema` to its JSON form. Drops the client-only closures
71
+ * (validators, `computed`); keeps everything the server and a faithful
72
+ * rebuild need. The result is plain data — `JSON.stringify`-safe.
73
+ */
74
+ export function toSchemaJSON(schema) {
75
+ const models = {};
76
+ for (const [key, def] of Object.entries(schema.models)) {
77
+ if (def.typename === '' || def.typename === undefined) {
78
+ // typename '' only happens for a malformed def; surface it loudly
79
+ // rather than ship a model the server can't route.
80
+ models[key] = { ...modelToJSON(def), typename: key };
81
+ }
82
+ else {
83
+ models[key] = modelToJSON(def);
84
+ }
85
+ }
86
+ return { v: SCHEMA_JSON_VERSION, models, identityRoles: schema.identityRoles };
87
+ }
88
+ /** Serialize a `Schema` to a JSON string (the `ablo schema push` payload). */
89
+ export function serializeSchema(schema) {
90
+ return JSON.stringify(toSchemaJSON(schema));
91
+ }
92
+ // ── Parse ──────────────────────────────────────────────────────────────────
93
+ /** Rebuild a Zod validator for a field from its metadata. Permissive by
94
+ * design — the server does no field-shape validation; this exists so a
95
+ * parsed `Schema` is structurally a real `Schema`. */
96
+ function zodForField(meta) {
97
+ let base;
98
+ switch (meta.type) {
99
+ case 'string':
100
+ base = z.string();
101
+ break;
102
+ case 'number':
103
+ base = z.number();
104
+ break;
105
+ case 'boolean':
106
+ base = z.boolean();
107
+ break;
108
+ case 'date':
109
+ base = z.date();
110
+ break;
111
+ case 'enum':
112
+ base =
113
+ meta.enumValues && meta.enumValues.length > 0
114
+ ? z.enum(meta.enumValues)
115
+ : z.string();
116
+ break;
117
+ case 'json':
118
+ default:
119
+ base = z.unknown();
120
+ break;
121
+ }
122
+ return meta.isOptional ? base.optional() : base;
123
+ }
124
+ function relationFromJSON(rel) {
125
+ // The brand symbols on RelationDef are declare-only (no runtime
126
+ // presence), so a plain object with the runtime members satisfies every
127
+ // server-side reader. Cast through unknown to attach the nominal type.
128
+ return {
129
+ type: rel.type,
130
+ target: rel.target,
131
+ foreignKey: rel.foreignKey,
132
+ foreignKeyColumn: rel.foreignKeyColumn,
133
+ options: rel.options ?? {},
134
+ _orderBy: rel.orderBy,
135
+ };
136
+ }
137
+ function modelFromJSON(json) {
138
+ // `z.ZodRawShape` is a readonly index signature in Zod v4, so build a
139
+ // mutable record and cast once when handing it to `z.object`/`ModelDef`.
140
+ const shapeMut = {};
141
+ for (const [name, meta] of Object.entries(json.fields)) {
142
+ shapeMut[name] = zodForField(meta);
143
+ }
144
+ const shape = shapeMut;
145
+ const relations = {};
146
+ for (const [name, rel] of Object.entries(json.relations)) {
147
+ relations[name] = relationFromJSON(rel);
148
+ }
149
+ return {
150
+ schema: z.object(shape),
151
+ shape,
152
+ fields: json.fields,
153
+ relations,
154
+ load: json.load,
155
+ bootstrapLimit: json.bootstrapLimit,
156
+ bootstrapOrderBy: json.bootstrapOrderBy,
157
+ typename: json.typename,
158
+ persist: json.persist,
159
+ tableName: json.tableName,
160
+ orgScoped: json.orgScoped,
161
+ scopedVia: json.scopedVia,
162
+ syncGroupFormat: json.syncGroupFormat,
163
+ mutable: json.mutable,
164
+ lazyObservable: json.lazyObservable,
165
+ // computed getters are closures and intentionally not serialized; a
166
+ // parsed schema (server-side) has none.
167
+ computed: undefined,
168
+ autoFill: json.autoFill,
169
+ requiredFields: json.requiredFields,
170
+ };
171
+ }
172
+ /**
173
+ * Reconstruct a working `Schema` from its JSON form. Validators are rebuilt
174
+ * permissively from field metadata (the server never validates field shapes);
175
+ * `computed` getters are absent. Everything the server reads — routing,
176
+ * scoping, relations, identity roles — is restored exactly.
177
+ */
178
+ export function fromSchemaJSON(json) {
179
+ const models = {};
180
+ const validators = {};
181
+ for (const [key, modelJson] of Object.entries(json.models)) {
182
+ const def = modelFromJSON(modelJson);
183
+ models[key] = def;
184
+ validators[key] = baseFieldsSchema.merge(def.schema);
185
+ }
186
+ return {
187
+ models: models,
188
+ validators: validators,
189
+ identityRoles: json.identityRoles,
190
+ };
191
+ }
192
+ /** Parse a `Schema` from a JSON string (inverse of {@link serializeSchema}). */
193
+ export function parseSchema(json) {
194
+ const parsed = JSON.parse(json);
195
+ if (parsed.v !== SCHEMA_JSON_VERSION) {
196
+ throw new Error(`parseSchema: unsupported schema-JSON version ${parsed.v} (expected ${SCHEMA_JSON_VERSION})`);
197
+ }
198
+ return fromSchemaJSON(parsed);
199
+ }
200
+ // ── Hash ─────────────────────────────────────────────────────────────────────
201
+ /**
202
+ * Stable content hash of a `Schema`'s JSON form. FNV-1a over a canonical
203
+ * (sorted-key) encoding — deterministic across runs and order-invariant, no
204
+ * `crypto` dependency. Used for connect-time version gating: the client sends
205
+ * the hash it was built against, the server compares it to the tenant's
206
+ * active schema hash. Not a security primitive.
207
+ */
208
+ export function schemaHash(schema) {
209
+ const canonical = canonicalJson(toSchemaJSON(schema));
210
+ let h = 0x811c9dc5;
211
+ for (let i = 0; i < canonical.length; i++) {
212
+ h ^= canonical.charCodeAt(i);
213
+ h = Math.imul(h, 0x01000193);
214
+ }
215
+ return (h >>> 0).toString(16).padStart(8, '0');
216
+ }
217
+ /** Stable JSON: object keys sorted recursively, `undefined` dropped. */
218
+ function canonicalJson(value) {
219
+ if (value === null || typeof value !== 'object')
220
+ return JSON.stringify(value) ?? 'null';
221
+ if (Array.isArray(value))
222
+ return `[${value.map(canonicalJson).join(',')}]`;
223
+ const entries = Object.entries(value)
224
+ .filter(([, v]) => v !== undefined)
225
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
226
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
227
+ }
@@ -261,6 +261,23 @@ export interface CoreSyncEventMap {
261
261
  * Payload mirrors the wire frame's `payload`.
262
262
  */
263
263
  intent_rejected: [Record<string, unknown>];
264
+ /**
265
+ * Fair-queue frames (opt-in `queue: true` on `intent_begin`). `intent_acquired`
266
+ * means the target was free and the lease is ours immediately; `intent_queued`
267
+ * means the claim is waiting in line (carries `position`); `intent_granted`
268
+ * means it reached the head and the lease is now ours; `intent_lost` means a
269
+ * held/granted claim was taken away (TTL lapse on disconnect, revoke).
270
+ */
271
+ /**
272
+ * Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
273
+ * entry `status: 'queued'` + `position`. Broadcast to entity peers on every
274
+ * queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
275
+ */
276
+ intent_queue: [Record<string, unknown>];
277
+ intent_acquired: [Record<string, unknown>];
278
+ intent_queued: [Record<string, unknown>];
279
+ intent_granted: [Record<string, unknown>];
280
+ intent_lost: [Record<string, unknown>];
264
281
  }
265
282
  /**
266
283
  * Collaboration event — app-specific real-time events (selection, cursors, etc.)
@@ -10,7 +10,7 @@
10
10
  import { EventEmitter } from 'events';
11
11
  import { getContext } from '../context.js';
12
12
  import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
- import { CapabilityError, SyncSessionError } from '../errors.js';
13
+ import { AbloClaimedError, CapabilityError, SyncSessionError, } from '../errors.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
16
16
  // Consumers pass their own event types as TCollaboration generic parameter.
@@ -368,6 +368,24 @@ export class SyncWebSocket extends EventEmitter {
368
368
  errorCode === 'capability_invalid') {
369
369
  pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
370
370
  }
371
+ else if (errorCode === 'intent_conflict' ||
372
+ errorCode === 'claim_conflict' ||
373
+ errorCode === 'entity_claimed') {
374
+ // Claim enforcement: another participant holds a live claim on
375
+ // a targeted entity. Two server layers reject this — the Hub's
376
+ // pre-commit lease check (`intent_conflict`, the code that
377
+ // reaches clients in practice) and `executeCommit`'s deeper
378
+ // guard (`entity_claimed`). Both mean "claimed", so both route
379
+ // through the typed AbloClaimedError, letting callers
380
+ // `instanceof AbloClaimedError` (or read `e.type` across worker
381
+ // boundaries) and wait/bypass — symmetric with the
382
+ // CapabilityError branch above, and with the HTTP commit path
383
+ // (`translateHttpError`).
384
+ pending.reject(new AbloClaimedError(errorMessage, {
385
+ code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
386
+ httpStatus: 409,
387
+ }));
388
+ }
371
389
  else {
372
390
  const rejection = new Error(errorMessage);
373
391
  if (errorCode)
@@ -448,6 +466,33 @@ export class SyncWebSocket extends EventEmitter {
448
466
  this.emit('intent_rejected', message.payload ?? {});
449
467
  break;
450
468
  }
469
+ case 'intent_acquired': {
470
+ // Opt-in fair queue: the target was free, so the lease is ours
471
+ // immediately (no waiting). Payload carries { intentId, target }.
472
+ this.emit('intent_acquired', message.payload ?? {});
473
+ break;
474
+ }
475
+ case 'intent_queue': {
476
+ // Per-entity wait-queue snapshot for reactive `queue(id)`.
477
+ this.emit('intent_queue', message.payload ?? {});
478
+ break;
479
+ }
480
+ case 'intent_queued': {
481
+ // Opt-in fair queue: our claim is waiting in line. Payload
482
+ // carries { intentId, target, position }.
483
+ this.emit('intent_queued', message.payload ?? {});
484
+ break;
485
+ }
486
+ case 'intent_granted': {
487
+ // Our queued claim reached the head — the lease is now ours.
488
+ this.emit('intent_granted', message.payload ?? {});
489
+ break;
490
+ }
491
+ case 'intent_lost': {
492
+ // A held/granted claim was taken from us (TTL lapse, revoke).
493
+ this.emit('intent_lost', message.payload ?? {});
494
+ break;
495
+ }
451
496
  case 'delta': {
452
497
  const p = message.payload;
453
498
  if (p?.actionType || p?.modelName) {