@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.
- package/CHANGELOG.md +16 -0
- package/README.md +217 -122
- package/dist/BaseSyncedStore.d.ts +2 -2
- package/dist/BaseSyncedStore.js +2 -2
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +90 -93
- package/dist/client/Ablo.js +121 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +90 -87
- package/dist/client/createModelProxy.js +124 -127
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/errors.d.ts +8 -8
- package/dist/errors.js +18 -10
- package/dist/index.d.ts +9 -8
- package/dist/index.js +7 -11
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +3 -3
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/diff.d.ts +161 -0
- package/dist/schema/diff.js +262 -0
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +4 -1
- package/dist/schema/index.js +7 -1
- package/dist/schema/schema.d.ts +83 -32
- package/dist/schema/schema.js +58 -12
- package/dist/schema/serialize.d.ts +92 -0
- package/dist/schema/serialize.js +227 -0
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.js +43 -4
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +4 -4
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +37 -9
- package/docs/api.md +68 -158
- package/docs/audit.md +5 -5
- package/docs/client-behavior.md +41 -42
- package/docs/coordination.md +294 -0
- package/docs/data-sources.md +14 -14
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +35 -33
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +30 -55
- package/docs/identity.md +458 -0
- package/docs/index.md +12 -24
- package/docs/integration-guide.md +106 -116
- package/docs/interaction-model.md +29 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +73 -23
- package/docs/roadmap.md +5 -7
- package/llms.txt +34 -39
- package/package.json +1 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
package/dist/schema/schema.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
117
|
-
|
|
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,
|
|
248
|
-
* the identity context, and substitutes each
|
|
249
|
-
* `template`. Output is stable, deduped, and never
|
|
250
|
-
* convention string from this function itself — the
|
|
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
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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 {};
|
package/dist/schema/schema.js
CHANGED
|
@@ -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
|
-
/**
|
|
43
|
-
|
|
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,
|
|
169
|
-
* the identity context, and substitutes each
|
|
170
|
-
* `template`. Output is stable, deduped, and never
|
|
171
|
-
* convention string from this function itself — the
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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.
|
|
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) {
|