@abloatai/ablo 0.11.1 → 0.12.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 (85) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -2
  3. package/dist/Model.d.ts +39 -0
  4. package/dist/Model.js +68 -0
  5. package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
  6. package/dist/ai-sdk/claim-broadcast.js +2 -2
  7. package/dist/ai-sdk/wrap.d.ts +5 -4
  8. package/dist/ai-sdk/wrap.js +3 -3
  9. package/dist/auth/credentialPolicy.d.ts +145 -0
  10. package/dist/auth/credentialPolicy.js +130 -0
  11. package/dist/cli.cjs +42 -7
  12. package/dist/client/Ablo.d.ts +64 -91
  13. package/dist/client/Ablo.js +43 -103
  14. package/dist/client/ApiClient.d.ts +10 -1
  15. package/dist/client/ApiClient.js +45 -22
  16. package/dist/client/auth.d.ts +12 -5
  17. package/dist/client/auth.js +2 -1
  18. package/dist/client/createModelProxy.d.ts +64 -17
  19. package/dist/client/createModelProxy.js +18 -12
  20. package/dist/client/httpClient.d.ts +17 -3
  21. package/dist/client/httpClient.js +1 -0
  22. package/dist/client/identity.js +134 -122
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/sessionMint.d.ts +15 -0
  25. package/dist/client/sessionMint.js +86 -0
  26. package/dist/coordination/schema.d.ts +1 -1
  27. package/dist/coordination/schema.js +3 -1
  28. package/dist/errorCodes.d.ts +2 -0
  29. package/dist/errorCodes.js +2 -0
  30. package/dist/errors.d.ts +6 -3
  31. package/dist/errors.js +9 -3
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.js +4 -7
  34. package/dist/mutators/RecordingTransaction.js +14 -42
  35. package/dist/react/AbloProvider.d.ts +12 -13
  36. package/dist/react/AbloProvider.js +10 -10
  37. package/dist/react/context.d.ts +10 -45
  38. package/dist/react/context.js +12 -17
  39. package/dist/react/index.d.ts +8 -10
  40. package/dist/react/index.js +8 -11
  41. package/dist/react/useMutators.js +3 -2
  42. package/dist/react/useSyncStatus.d.ts +1 -1
  43. package/dist/react/useUndoScope.js +3 -2
  44. package/dist/realtime/index.d.ts +1 -1
  45. package/dist/schema/generate.js +1 -2
  46. package/dist/schema/model.d.ts +10 -3
  47. package/dist/schema/schema.d.ts +13 -2
  48. package/dist/schema/schema.js +26 -0
  49. package/dist/surface.d.ts +29 -0
  50. package/dist/surface.js +60 -0
  51. package/dist/sync/ConnectionManager.d.ts +16 -5
  52. package/dist/sync/ConnectionManager.js +42 -7
  53. package/dist/sync/createClaimStream.js +5 -4
  54. package/dist/sync/participants.js +1 -1
  55. package/dist/transactions/TransactionQueue.d.ts +0 -11
  56. package/dist/transactions/TransactionQueue.js +12 -56
  57. package/dist/types/global.d.ts +3 -0
  58. package/dist/types/streams.d.ts +17 -29
  59. package/dist/utils/mobx-setup.js +1 -0
  60. package/docs/api-keys.md +49 -0
  61. package/docs/api.md +3 -2
  62. package/docs/client-behavior.md +1 -0
  63. package/docs/coordination.md +75 -21
  64. package/docs/examples/existing-python-backend.md +9 -5
  65. package/docs/examples/scoped-agent.md +1 -1
  66. package/docs/guarantees.md +4 -3
  67. package/docs/identity.md +89 -82
  68. package/docs/integration-guide.md +19 -10
  69. package/docs/migration.md +11 -3
  70. package/docs/quickstart.md +6 -2
  71. package/docs/react.md +3 -3
  72. package/docs/schema-contract.md +23 -5
  73. package/llms-full.txt +18 -16
  74. package/llms.txt +6 -6
  75. package/package.json +1 -1
  76. package/dist/api/index.d.ts +0 -10
  77. package/dist/api/index.js +0 -9
  78. package/dist/principal.d.ts +0 -44
  79. package/dist/principal.js +0 -49
  80. package/dist/react/SyncGroupProvider.d.ts +0 -19
  81. package/dist/react/SyncGroupProvider.js +0 -44
  82. package/dist/react/useClaim.d.ts +0 -29
  83. package/dist/react/useClaim.js +0 -42
  84. package/dist/react/usePresence.d.ts +0 -32
  85. package/dist/react/usePresence.js +0 -41
@@ -2,11 +2,14 @@
2
2
  * @abloatai/ablo/react — React bindings (v0.3.0)
3
3
  *
4
4
  * Umbrella provider:
5
- * <AbloProvider schema={...} userId={...} orgId={...} fallback={<Skeleton/>}>
6
- * owns sync engine + multiplayer lifecycle; the `fallback` prop
5
+ * const ablo = Ablo({ schema, apiKey }) // build once — module scope or useMemo
6
+ * <AbloProvider client={ablo} fallback={<Skeleton/>}>
7
+ * — `client` is the only required prop (construct it yourself; the provider
8
+ * is the thin reactive binding, like `<Elements stripe={...}>`). `userId`
9
+ * is optional + informational. Owns sync engine + multiplayer lifecycle;
10
+ * the `fallback` prop
7
11
  * gates children on first bootstrap. Pass `fallback="passthrough"`
8
12
  * to disable the gate.
9
- * <SyncGroupProvider id="matter:..."> — per-entity scope
10
13
  * <ClientSideSuspense fallback={<Skeleton/>}> — NESTED gate inside an
11
14
  * already-ready provider. Use only when you need a separate gate
12
15
  * for a heavy subtree (e.g. a canvas) while app chrome renders
@@ -28,9 +31,7 @@
28
31
  *
29
32
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
33
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
- * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
- * usePresence() — typed presence view
33
- * useClaim(name) — typed claim dispatcher
34
+ * useWatch({ scope }) — join multiplayer for a scope, get peers/claims
34
35
  *
35
36
  * ── Breaking changes from v0.2.x ───────────────────────────────────
36
37
  * Removed: <SyncProvider>, SyncContext, useSyncContext — folded into
@@ -44,8 +45,7 @@
44
45
  * migration notes in CHANGELOG.md.
45
46
  */
46
47
  export type { DefaultSyncShape, ResolveSchema, ResolvePresence, ResolveClaims, ResolveUserMeta, ResolveModelKey, } from '../types/global.js';
47
- export { AbloProvider, useParticipant, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseParticipantOptions, type UseParticipantReturn, type MeshParticipantStatus, } from './AbloProvider.js';
48
- export { SyncGroupProvider, useSyncGroup, type SyncGroupProviderProps, } from './SyncGroupProvider.js';
48
+ export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseWatchOptions, type UseWatchReturn, type MeshParticipantStatus, } from './AbloProvider.js';
49
49
  export { ClientSideSuspense, type ClientSideSuspenseProps, } from './ClientSideSuspense.js';
50
50
  export { DefaultFallback } from './DefaultFallback.js';
51
51
  export type { SyncStoreContract } from './context.js';
@@ -59,6 +59,4 @@ export type { ReaderActions, ReaderFindOptions } from '../mutators/readerActions
59
59
  export { useMutators, type MutatorInvokers, type InvokerFor, type UseMutatorsOptions, } from './useMutators.js';
60
60
  export { useUndoScope, type UseUndoScopeResult } from './useUndoScope.js';
61
61
  export { useAblo, type UseAbloHydratedModelResult, type UseAbloModelOptions, type UseAbloModelResult, } from './useAblo.js';
62
- export { usePresence } from './usePresence.js';
63
- export { useClaim } from './useClaim.js';
64
62
  export { ModelScope } from '../types/index.js';
@@ -2,11 +2,14 @@
2
2
  * @abloatai/ablo/react — React bindings (v0.3.0)
3
3
  *
4
4
  * Umbrella provider:
5
- * <AbloProvider schema={...} userId={...} orgId={...} fallback={<Skeleton/>}>
6
- * owns sync engine + multiplayer lifecycle; the `fallback` prop
5
+ * const ablo = Ablo({ schema, apiKey }) // build once — module scope or useMemo
6
+ * <AbloProvider client={ablo} fallback={<Skeleton/>}>
7
+ * — `client` is the only required prop (construct it yourself; the provider
8
+ * is the thin reactive binding, like `<Elements stripe={...}>`). `userId`
9
+ * is optional + informational. Owns sync engine + multiplayer lifecycle;
10
+ * the `fallback` prop
7
11
  * gates children on first bootstrap. Pass `fallback="passthrough"`
8
12
  * to disable the gate.
9
- * <SyncGroupProvider id="matter:..."> — per-entity scope
10
13
  * <ClientSideSuspense fallback={<Skeleton/>}> — NESTED gate inside an
11
14
  * already-ready provider. Use only when you need a separate gate
12
15
  * for a heavy subtree (e.g. a canvas) while app chrome renders
@@ -28,9 +31,7 @@
28
31
  *
29
32
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
33
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
- * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
- * usePresence() — typed presence view
33
- * useClaim(name) — typed claim dispatcher
34
+ * useWatch({ scope }) — join multiplayer for a scope, get peers/claims
34
35
  *
35
36
  * ── Breaking changes from v0.2.x ───────────────────────────────────
36
37
  * Removed: <SyncProvider>, SyncContext, useSyncContext — folded into
@@ -44,8 +45,7 @@
44
45
  * migration notes in CHANGELOG.md.
45
46
  */
46
47
  // ── Umbrella provider + lifecycle hooks ────────────────────────────
47
- export { AbloProvider, useParticipant, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
48
- export { SyncGroupProvider, useSyncGroup, } from './SyncGroupProvider.js';
48
+ export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
49
49
  export { ClientSideSuspense, } from './ClientSideSuspense.js';
50
50
  export { DefaultFallback } from './DefaultFallback.js';
51
51
  // ── Status + errors + identity ─────────────────────────────────────
@@ -63,8 +63,5 @@ export { useReactive } from './useReactive.js';
63
63
  export { useMutators, } from './useMutators.js';
64
64
  export { useUndoScope } from './useUndoScope.js';
65
65
  export { useAblo, } from './useAblo.js';
66
- // ── Presence + claim (typed via Register module augmentation) ─────
67
- export { usePresence } from './usePresence.js';
68
- export { useClaim } from './useClaim.js';
69
66
  // ── ModelScope re-export ───────────────────────────────────────────
70
67
  export { ModelScope } from '../types/index.js';
@@ -17,8 +17,9 @@ export function useMutators(schemaOrMutators, mutatorsOrOptions, maybeOptions) {
17
17
  const mutators = (isExplicit ? mutatorsOrOptions : schemaOrMutators);
18
18
  const options = (isExplicit ? maybeOptions : mutatorsOrOptions);
19
19
  if (!schema) {
20
- throw new AbloValidationError('useMutators: no schema available. Pass the schema as the first arg ' +
21
- 'or wire SyncProvider with a `schema` prop when using the zero-arg overload.', { code: 'mutators_schema_missing' });
20
+ throw new AbloValidationError('useMutators: no schema available. Pass the schema as the first arg, ' +
21
+ 'or build the <AbloProvider> above with `Ablo({ schema })` so the ' +
22
+ 'zero-arg overload can read it from context.', { code: 'mutators_schema_missing' });
22
23
  }
23
24
  const { undoScope } = options ?? {};
24
25
  return useMemo(() => {
@@ -17,7 +17,7 @@
17
17
  * `reason` carries the human-readable close reason when available.
18
18
  * - `disconnected` — network failure, server error, or the retry loop
19
19
  * gave up. Show the offline / error UI.
20
- * - `needs-auth` — server rejected the session (1008/4001/4003). The
20
+ * - `needs-auth` — server rejected the auth token (1008/4001/4003). The
21
21
  * consumer's `onSessionExpired` callback has already been invoked
22
22
  * by `<AbloProvider>`; this variant exists for UI that wants to
23
23
  * reflect the auth state itself.
@@ -34,8 +34,9 @@ export function useUndoScope(schemaOrName, nameOrOptions, maybeOptions) {
34
34
  const name = isExplicit ? nameOrOptions : schemaOrName;
35
35
  const options = (isExplicit ? maybeOptions : nameOrOptions);
36
36
  if (!schema) {
37
- throw new AbloValidationError('useUndoScope: no schema available. Pass the schema as the first arg ' +
38
- 'or wire SyncProvider with a `schema` prop when using the zero-arg overload.', { code: 'undo_scope_schema_missing' });
37
+ throw new AbloValidationError('useUndoScope: no schema available. Pass the schema as the first arg, ' +
38
+ 'or build the <AbloProvider> above with `Ablo({ schema })` so the ' +
39
+ 'zero-arg overload can read it from context.', { code: 'undo_scope_schema_missing' });
39
40
  }
40
41
  const scope = useMemo(() => {
41
42
  // Store is the identity for the manager — one per SyncProvider.
@@ -5,6 +5,6 @@
5
5
  * subscriptions, presence, offline queueing, and a long-lived WebSocket.
6
6
  */
7
7
  export { Ablo, computeFKDepthPriority } from '../client/Ablo.js';
8
- export type { AbloOptions, InternalAbloOptions, ModelCountOptions, ModelListOptions, ModelListScope, ModelLoadOptions, ModelOperations, } from '../client/Ablo.js';
8
+ export type { AbloOptions, InternalAbloOptions, LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelOperations, } from '../client/Ablo.js';
9
9
  import { Ablo } from '../client/Ablo.js';
10
10
  export default Ablo;
@@ -14,8 +14,7 @@
14
14
  * unions). Relations are resolved by the runtime SDK's typed accessors and are
15
15
  * not expanded here.
16
16
  */
17
- /** The base columns every model carries (mirrors `baseFieldsSchema`). */
18
- const BASE_FIELDS = ['id', 'createdAt', 'updatedAt', 'organizationId', 'createdBy'];
17
+ import { BASE_FIELDS } from './schema.js';
19
18
  function tsType(meta) {
20
19
  switch (meta.type) {
21
20
  case 'string':
@@ -95,9 +95,16 @@ export interface ModelOptions {
95
95
  */
96
96
  tableName?: string;
97
97
  /**
98
- * Whether this model's table has an organization_id column.
99
- * Default: true. When false, the bootstrap query omits the
100
- * `WHERE organization_id = $1` clause for this model.
98
+ * Whether this model's table has an `organization_id` column. Default: true.
99
+ * When false, the bootstrap/read query omits the `WHERE organization_id = $1`
100
+ * tenant filter for this model.
101
+ *
102
+ * ⚠ SECURITY — `orgScoped: false` makes the table GLOBALLY READABLE: every
103
+ * client of every tenant sees every row. It is ONLY correct for genuinely
104
+ * tenant-less tables (the `organizations` table itself, global lookups). If
105
+ * rows belong to a tenant through a foreign key but this table has no
106
+ * `organization_id` of its own, use {@link scopedVia} INSTEAD — reaching for
107
+ * `orgScoped: false` there silently exposes the entire table cross-tenant.
101
108
  */
102
109
  orgScoped?: boolean;
103
110
  /**
@@ -78,6 +78,15 @@ export declare const baseFieldsSchema: z.ZodObject<{
78
78
  organizationId: z.ZodOptional<z.ZodString>;
79
79
  createdBy: z.ZodOptional<z.ZodString>;
80
80
  }, z.core.$strip>;
81
+ /**
82
+ * The base-column names every model carries automatically (the keys of
83
+ * {@link baseFieldsSchema}). The single source of truth — `generate.ts`
84
+ * imports this to avoid double-emitting a redeclared base column, and the
85
+ * `defineSchema` field loop uses it to reject a model that tries to redeclare
86
+ * one (Zod `.merge` would otherwise silently overwrite the base field with the
87
+ * user's, producing a `string & Date` type that breaks the build).
88
+ */
89
+ export declare const BASE_FIELDS: readonly ["id", "createdAt", "updatedAt", "organizationId", "createdBy"];
81
90
  /** The base fields type — pure data columns. */
82
91
  export type BaseModelFields = z.infer<typeof baseFieldsSchema>;
83
92
  /**
@@ -138,7 +147,8 @@ export type Model<A, B = never> = [B] extends [never] ? A extends keyof Register
138
147
  * Drizzle deprecated its own `InferModel` for the same reason. Kept as an
139
148
  * alias; no behavior difference.
140
149
  */
141
- export type InferModel<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape, infer R, infer C> ? z.infer<z.ZodObject<Shape>> & BaseModelFields & BaseModelMethods & InferComputed<C> & InferRelations<S, R> : never;
150
+ export type InferModel<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape, infer R, infer C> ? // `Omit<…, keyof BaseModelFields>` so a model that (wrongly) redeclares a
151
+ Omit<z.infer<z.ZodObject<Shape>>, keyof BaseModelFields> & BaseModelFields & BaseModelMethods & InferComputed<C> & InferRelations<S, R> : never;
142
152
  /**
143
153
  * Infer relation accessor types from a model's relations record.
144
154
  *
@@ -184,7 +194,8 @@ export type InferComputed<C> = string extends keyof C ? unknown : {
184
194
  * // createdAt, updatedAt are NOT accepted — they're auto-generated
185
195
  * ```
186
196
  */
187
- export type InferCreate<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape> ? z.input<z.ZodObject<Shape>> & Partial<BaseModelFields> : never;
197
+ export type InferCreate<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape> ? // Same reserved-field guard as InferModel: drop any (wrongly) redeclared
198
+ Omit<z.input<z.ZodObject<Shape>>, keyof BaseModelFields> & Partial<BaseModelFields> : never;
188
199
  /**
189
200
  * Extract all model names from a schema.
190
201
  */
@@ -58,6 +58,21 @@ export const baseFieldsSchema = z.object({
58
58
  organizationId: z.string().optional(),
59
59
  createdBy: z.string().optional(),
60
60
  });
61
+ /**
62
+ * The base-column names every model carries automatically (the keys of
63
+ * {@link baseFieldsSchema}). The single source of truth — `generate.ts`
64
+ * imports this to avoid double-emitting a redeclared base column, and the
65
+ * `defineSchema` field loop uses it to reject a model that tries to redeclare
66
+ * one (Zod `.merge` would otherwise silently overwrite the base field with the
67
+ * user's, producing a `string & Date` type that breaks the build).
68
+ */
69
+ export const BASE_FIELDS = [
70
+ 'id',
71
+ 'createdAt',
72
+ 'updatedAt',
73
+ 'organizationId',
74
+ 'createdBy',
75
+ ];
61
76
  // ── Factory ───────────────────────────────────────────────────────────────
62
77
  /**
63
78
  * Define a sync engine schema.
@@ -146,6 +161,17 @@ export function defineSchema(models, options) {
146
161
  // failure immediate and unambiguous.
147
162
  for (const fieldName of Object.keys(def.shape)) {
148
163
  assertRoundTrippableCamelCase(name, fieldName);
164
+ // Reserved base columns are merged in below via `baseFieldsSchema.merge`,
165
+ // and Zod `.merge` silently OVERWRITES the base field with the user's —
166
+ // e.g. a model declaring `createdAt: z.string()` ends up with a field
167
+ // typed `string & Date`, which breaks the build. Reject the collision at
168
+ // definition time so the author sees an unambiguous error instead.
169
+ if (BASE_FIELDS.includes(fieldName)) {
170
+ throw new AbloValidationError(`[defineSchema] ${name}.${fieldName}: field \`${fieldName}\` collides with a ` +
171
+ `reserved field that the SDK provides automatically ` +
172
+ `(${BASE_FIELDS.join(', ')}). Remove it from your model — redeclaring it ` +
173
+ `produces a \`string & Date\` type and breaks the build.`, { code: 'schema_reserved_field', param: `${name}.${fieldName}` });
174
+ }
149
175
  }
150
176
  validators[name] = baseFieldsSchema.merge(def.schema);
151
177
  // Resolve every relation's `foreignKeyColumn` once, now. The builder
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Machine-checked public API-surface manifest — the SDK owns the description of
3
+ * its OWN surface, bound to the real exported types at COMPILE TIME so the MCP
4
+ * `get_api_surface` / docs can never drift from reality.
5
+ *
6
+ * This exists because the hand-authored surface (apps/sync-web/.../api-surface.ts)
7
+ * once named `load` / `count` / `scope` — verbs/options that don't exist — with no
8
+ * coupling to the code. The fix: the name lists live HERE, next to the types, and
9
+ * each is proven EXACTLY equal to the keys of its source interface via
10
+ * `Expect<Equal<…>>`. Add or remove a verb/option without updating the matching
11
+ * tuple and THIS FILE FAILS TO COMPILE (the `Equal` constraint is checked eagerly
12
+ * at the alias declaration — both directions: no phantom name, no missing name).
13
+ *
14
+ * Consumers (the MCP `get_api_surface`) import these NAME tuples and build their
15
+ * prose from them, so a summary can never reference a verb that doesn't exist.
16
+ * NAMES are guaranteed; descriptions stay hand-written (prose can't be type-checked).
17
+ */
18
+ /** Every method on `ablo.<model>` (the stateful `ModelOperations`). The single
19
+ * source of truth for the model-verb names the docs/MCP may describe. */
20
+ export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "getAll", "getCount", "create", "update", "delete", "claim", "watch", "onChange"];
21
+ /** Keys accepted by `list`/`getAll`/`onChange` options (`LocalReadOptions`).
22
+ * Note `state` (lifecycle filter) — NOT `scope` (a historic doc drift). */
23
+ export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orderBy", "limit", "offset", "state"];
24
+ /** Public keys of `AbloOptions`. `schema` is required; the rest are optional
25
+ * (the locked happy path is `Ablo({ schema, apiKey, databaseUrl, transport })`). */
26
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
27
+ export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
28
+ export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
29
+ export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Machine-checked public API-surface manifest — the SDK owns the description of
3
+ * its OWN surface, bound to the real exported types at COMPILE TIME so the MCP
4
+ * `get_api_surface` / docs can never drift from reality.
5
+ *
6
+ * This exists because the hand-authored surface (apps/sync-web/.../api-surface.ts)
7
+ * once named `load` / `count` / `scope` — verbs/options that don't exist — with no
8
+ * coupling to the code. The fix: the name lists live HERE, next to the types, and
9
+ * each is proven EXACTLY equal to the keys of its source interface via
10
+ * `Expect<Equal<…>>`. Add or remove a verb/option without updating the matching
11
+ * tuple and THIS FILE FAILS TO COMPILE (the `Equal` constraint is checked eagerly
12
+ * at the alias declaration — both directions: no phantom name, no missing name).
13
+ *
14
+ * Consumers (the MCP `get_api_surface`) import these NAME tuples and build their
15
+ * prose from them, so a summary can never reference a verb that doesn't exist.
16
+ * NAMES are guaranteed; descriptions stay hand-written (prose can't be type-checked).
17
+ */
18
+ // ── the per-`ablo.<model>` verb surface ────────────────────────────────────
19
+ /** Every method on `ablo.<model>` (the stateful `ModelOperations`). The single
20
+ * source of truth for the model-verb names the docs/MCP may describe. */
21
+ export const PUBLIC_MODEL_VERBS = [
22
+ 'retrieve',
23
+ 'list',
24
+ 'get',
25
+ 'getAll',
26
+ 'getCount',
27
+ 'create',
28
+ 'update',
29
+ 'delete',
30
+ 'claim',
31
+ 'watch',
32
+ 'onChange',
33
+ ];
34
+ // ── the read/list query option surface ─────────────────────────────────────
35
+ /** Keys accepted by `list`/`getAll`/`onChange` options (`LocalReadOptions`).
36
+ * Note `state` (lifecycle filter) — NOT `scope` (a historic doc drift). */
37
+ export const PUBLIC_LIST_OPTION_KEYS = [
38
+ 'where',
39
+ 'filter',
40
+ 'orderBy',
41
+ 'limit',
42
+ 'offset',
43
+ 'state',
44
+ ];
45
+ // ── the `Ablo({ … })` constructor option surface ───────────────────────────
46
+ /** Public keys of `AbloOptions`. `schema` is required; the rest are optional
47
+ * (the locked happy path is `Ablo({ schema, apiKey, databaseUrl, transport })`). */
48
+ export const PUBLIC_ABLO_OPTION_KEYS = [
49
+ 'schema',
50
+ 'apiKey',
51
+ 'databaseUrl',
52
+ 'persistence',
53
+ 'transport',
54
+ 'authToken',
55
+ 'baseURL',
56
+ 'fetch',
57
+ 'defaultHeaders',
58
+ 'defaultQuery',
59
+ 'dangerouslyAllowBrowser',
60
+ ];
@@ -20,18 +20,29 @@
20
20
  * Designed to be embedded by `BaseSyncedStore`: one instance per store,
21
21
  * started on first successful connect, disposed on teardown.
22
22
  *
23
- * CONNECTED ──► OFFLINE ──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
- *
25
- *
26
- * WAITING_FOR_NETWORK SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
23
+ * CONNECTED ──(socket drop)──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
+ *
25
+ * (network lost)
26
+ *SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
27
+ * OFFLINE ──(online)──► PROBING_NETWORK
28
+ * │
29
+ * ▼
30
+ * WAITING_FOR_NETWORK
27
31
  *
28
- * Includes two fixes over the original app-side FSM:
32
+ * Includes three fixes over the original app-side FSM:
29
33
  * 1. `backoff` accepts `NETWORK_ONLINE` / `TAB_VISIBLE` — jumps to
30
34
  * probing immediately when the network comes back, without
31
35
  * waiting for the backoff timer to elapse.
32
36
  * 2. `scheduleBackoff` parks in `waiting_for_network` (resetting
33
37
  * `attempt`) when `navigator.onLine === false` at max retries,
34
38
  * instead of hard-reloading an already-offline browser.
39
+ * 3. A socket drop (`WS_DISCONNECTED`, typically code 1006) goes
40
+ * STRAIGHT to `probing_network`, not the passive `offline` state.
41
+ * 1006 is browser-local and carries no connectivity signal, so on a
42
+ * healthy machine no `online`/`offline` event ever fires — parking in
43
+ * `offline` stranded recovery until the 30s watchdog, long enough for
44
+ * queued commits to roll back. Only a genuine OS-level `NETWORK_LOST`
45
+ * parks in `offline` and waits for the `online` event.
35
46
  */
36
47
  import { type ProbeResult } from './NetworkProbe.js';
37
48
  import type { AuthTokenGetter } from '../auth/credentialSource.js';
@@ -20,18 +20,29 @@
20
20
  * Designed to be embedded by `BaseSyncedStore`: one instance per store,
21
21
  * started on first successful connect, disposed on teardown.
22
22
  *
23
- * CONNECTED ──► OFFLINE ──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
- *
25
- *
26
- * WAITING_FOR_NETWORK SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
23
+ * CONNECTED ──(socket drop)──► PROBING_NETWORK ──► RECONNECTING ──► CONNECTED
24
+ *
25
+ * (network lost)
26
+ *SESSION_EXPIRED BACKOFF ──► PROBING_NETWORK
27
+ * OFFLINE ──(online)──► PROBING_NETWORK
28
+ * │
29
+ * ▼
30
+ * WAITING_FOR_NETWORK
27
31
  *
28
- * Includes two fixes over the original app-side FSM:
32
+ * Includes three fixes over the original app-side FSM:
29
33
  * 1. `backoff` accepts `NETWORK_ONLINE` / `TAB_VISIBLE` — jumps to
30
34
  * probing immediately when the network comes back, without
31
35
  * waiting for the backoff timer to elapse.
32
36
  * 2. `scheduleBackoff` parks in `waiting_for_network` (resetting
33
37
  * `attempt`) when `navigator.onLine === false` at max retries,
34
38
  * instead of hard-reloading an already-offline browser.
39
+ * 3. A socket drop (`WS_DISCONNECTED`, typically code 1006) goes
40
+ * STRAIGHT to `probing_network`, not the passive `offline` state.
41
+ * 1006 is browser-local and carries no connectivity signal, so on a
42
+ * healthy machine no `online`/`offline` event ever fires — parking in
43
+ * `offline` stranded recovery until the 30s watchdog, long enough for
44
+ * queued commits to roll back. Only a genuine OS-level `NETWORK_LOST`
45
+ * parks in `offline` and waits for the `online` event.
35
46
  */
36
47
  import { makeAutoObservable, runInAction } from 'mobx';
37
48
  import { getContext } from '../context.js';
@@ -142,8 +153,19 @@ export class ConnectionManager {
142
153
  case 'connected':
143
154
  switch (event.type) {
144
155
  case 'NETWORK_LOST':
145
- case 'WS_DISCONNECTED':
156
+ // The OS reported the NIC down — park passively in `offline` and
157
+ // wait for the `online` event. Probing a downed adapter is wasted
158
+ // work.
146
159
  return 'offline';
160
+ case 'WS_DISCONNECTED':
161
+ // The socket died (typically code 1006) but the OS network is
162
+ // almost certainly fine — 1006 is generated locally when the TCP
163
+ // conn vanishes and carries NO connectivity signal, so the browser
164
+ // fires no online/offline event. Probe IMMEDIATELY rather than
165
+ // landing in the passive `offline` dead-end (which only escaped via
166
+ // the 30s watchdog, long after queued commits rolled back). The
167
+ // probe fast-fails if we genuinely ARE offline → waiting_for_network.
168
+ return 'probing_network';
147
169
  case 'WS_SESSION_ERROR':
148
170
  case 'BOOTSTRAP_FAILED_SESSION':
149
171
  return 'session_expired';
@@ -301,7 +323,7 @@ export class ConnectionManager {
301
323
  }
302
324
  }
303
325
  // ── Side effects per state ───────────────────────────────────────────
304
- onEnterState(state, _event) {
326
+ onEnterState(state, event) {
305
327
  switch (state) {
306
328
  case 'connected':
307
329
  this.clearBackoffTimer();
@@ -314,6 +336,19 @@ export class ConnectionManager {
314
336
  this.callbacks?.onDisconnectWebSocket();
315
337
  break;
316
338
  case 'probing_network':
339
+ // A socket drop (`WS_DISCONNECTED`) now lands here directly so recovery
340
+ // starts immediately. Tear the dead socket down FIRST — this is what
341
+ // sets SyncWebSocket's `isManualClose=true` and suppresses its own
342
+ // scheduleReconnect, keeping the FSM the single reconnect authority on
343
+ // the human path. The teardown runs synchronously inside the
344
+ // `disconnected` emit, before `SyncWebSocket.onclose` checks the flag,
345
+ // so the timing matches the previous `offline`-entry teardown. We gate
346
+ // on the drop event specifically: the other paths into `probing_network`
347
+ // (TAB_VISIBLE re-validation, handshake retry, backoff elapse) must NOT
348
+ // tear down a socket that may still be live.
349
+ if (event.type === 'WS_DISCONNECTED') {
350
+ this.callbacks?.onDisconnectWebSocket();
351
+ }
317
352
  this.runProbe();
318
353
  break;
319
354
  case 'waiting_for_network':
@@ -192,7 +192,8 @@ export function createClaimStream(config, transport = null) {
192
192
  entityId: claim.entityId,
193
193
  path: claim.path,
194
194
  range: claim.range,
195
- action: claim.action,
195
+ // Wire field stays `action` (coordination schema); source is `reason`.
196
+ action: claim.reason,
196
197
  field: claim.field,
197
198
  meta: claim.meta,
198
199
  estimatedMs: claim.estimatedMs,
@@ -244,7 +245,7 @@ export function createClaimStream(config, transport = null) {
244
245
  range: args.range,
245
246
  field: args.field,
246
247
  meta: args.meta,
247
- action: args.action,
248
+ reason: args.reason,
248
249
  estimatedMs,
249
250
  queue: args.queue,
250
251
  };
@@ -261,7 +262,7 @@ export function createClaimStream(config, transport = null) {
261
262
  return {
262
263
  object: 'claim',
263
264
  claimId,
264
- action: args.action,
265
+ reason: args.reason,
265
266
  target: {
266
267
  model: args.entityType,
267
268
  id: args.entityId,
@@ -294,7 +295,7 @@ export function createClaimStream(config, transport = null) {
294
295
  range: resolved.range,
295
296
  field: resolved.field,
296
297
  meta: withDescription(resolved.meta, opts?.description),
297
- action: opts?.reason ?? 'editing',
298
+ reason: opts?.reason ?? 'editing',
298
299
  ttl: opts?.ttl,
299
300
  queue: opts?.queue,
300
301
  });
@@ -221,7 +221,7 @@ function createJoinedParticipant(args) {
221
221
  return {
222
222
  object: 'claim',
223
223
  claimId: handle.claimId,
224
- action: handle.action,
224
+ reason: handle.reason,
225
225
  target: handle.target,
226
226
  async release() {
227
227
  ownHandles.delete(handle);
@@ -427,17 +427,6 @@ export declare class TransactionQueue extends EventEmitter {
427
427
  private extractUpdateData;
428
428
  private buildUpdateInput;
429
429
  private extractPreviousData;
430
- /**
431
- * Re-baseline `modifiedProperties` for the fields a freshly-staged update just
432
- * committed. Called right after {@link extractPreviousData} freezes their
433
- * `.old` into the transaction, so the NEXT update to the same field sees this
434
- * update's result as its baseline rather than the stale pre-session `.old`
435
- * preserved by `Model.propertyChanged`'s first-old-wins policy. Only consumes
436
- * keys present in this update — untouched fields keep their baselines. Safe
437
- * because the wire payload lives on `transaction.data` and rollback restores
438
- * from `transaction.previousData`; neither re-reads `modifiedProperties`.
439
- */
440
- private consumeModifiedFields;
441
430
  /**
442
431
  * Public API
443
432
  */
@@ -780,8 +780,8 @@ export class TransactionQueue extends EventEmitter {
780
780
  // instead of THIS update's result — corrupting the stream-recorded undo
781
781
  // inverse (the second move's "before" would point all the way back). The
782
782
  // wire payload is already frozen in `transaction.data`, so dropping the
783
- // consumed entries is safe. Mirrors `RecordingTransaction.consumeModifiedFields`.
784
- this.consumeModifiedFields(model, updateInput);
783
+ // consumed entries is safe.
784
+ model.consumeModifiedFields(Object.keys(updateInput));
785
785
  const modelKey = normalizeModelKey(actualModelName);
786
786
  const priorityScore = this.computePriorityScore('update', actualModelName);
787
787
  const transaction = {
@@ -1999,63 +1999,19 @@ export class TransactionQueue extends EventEmitter {
1999
1999
  // model ever needs to surface previous-state outside `modifiedProperties`,
2000
2000
  // expose a typed `getPreviousData()` accessor on Model and call that.
2001
2001
  extractPreviousData(model, updateInput) {
2002
- const prev = { id: model.id };
2003
- const modified = model.modifiedProperties instanceof Map ? model.modifiedProperties : null;
2004
2002
  // When the update's written keys are known, capture a before-image for
2005
2003
  // EXACTLY those keys so the recorded undo inverse can revert them and only
2006
2004
  // them (a full-row inverse would clobber concurrent edits to unrelated
2007
- // fields). Resolution order mirrors `RecordingTransaction.snapshotFields`:
2008
- // 1. `modifiedProperties.old` first-old-wins pre-session baseline, set
2009
- // whenever the caller mutated the field in place before committing.
2010
- // 2. `getOriginalSnapshot()` the last loaded/acked row, the correct
2011
- // before-image for a key written WITHOUT a prior in-place mutation
2012
- // (e.g. a `precomputedChanges` write).
2013
- // Without (2) such a key yields an empty `previousData`, and `buildUndoOps`
2014
- // nulls the inverse entirely — making updates silently un-undoable where a
2015
- // create's `delete(id)` inverse never is. This closes that asymmetry.
2016
- if (updateInput) {
2017
- const original = model.getOriginalSnapshot();
2018
- for (const key of Object.keys(updateInput)) {
2019
- if (key === 'id')
2020
- continue;
2021
- const mod = modified?.get(key);
2022
- if (mod) {
2023
- prev[key] = mod.old;
2024
- }
2025
- else if (original && key in original) {
2026
- prev[key] = original[key];
2027
- }
2028
- }
2029
- return prev;
2030
- }
2031
- if (modified && modified.size > 0) {
2032
- for (const [key, change] of modified) {
2033
- prev[key] = change.old;
2034
- }
2035
- }
2036
- return prev;
2037
- }
2038
- /**
2039
- * Re-baseline `modifiedProperties` for the fields a freshly-staged update just
2040
- * committed. Called right after {@link extractPreviousData} freezes their
2041
- * `.old` into the transaction, so the NEXT update to the same field sees this
2042
- * update's result as its baseline rather than the stale pre-session `.old`
2043
- * preserved by `Model.propertyChanged`'s first-old-wins policy. Only consumes
2044
- * keys present in this update — untouched fields keep their baselines. Safe
2045
- * because the wire payload lives on `transaction.data` and rollback restores
2046
- * from `transaction.previousData`; neither re-reads `modifiedProperties`.
2047
- */
2048
- consumeModifiedFields(model, updateInput) {
2049
- if (!(model.modifiedProperties instanceof Map) || model.modifiedProperties.size === 0) {
2050
- return;
2051
- }
2052
- for (const key of [...model.modifiedProperties.keys()]) {
2053
- if (key === 'id')
2054
- continue;
2055
- if (updateInput && !(key in updateInput))
2056
- continue;
2057
- model.modifiedProperties.delete(key);
2058
- }
2005
+ // fields). `fallbackToLive: false` makes `Model.capturePreviousValues` OMIT
2006
+ // any key it can't resolve from `modifiedProperties.old` / the original
2007
+ // snapshot `buildUndoOps` then drops an un-revertible inverse rather than
2008
+ // inventing one. With no `updateInput` (full extract) fall back to every
2009
+ // tracked field. `Model.capturePreviousValues` is the single before-image
2010
+ // source shared with `RecordingTransaction.snapshotFields`.
2011
+ const keys = updateInput
2012
+ ? Object.keys(updateInput)
2013
+ : [...(model.modifiedProperties instanceof Map ? model.modifiedProperties.keys() : [])];
2014
+ return { id: model.id, ...model.capturePreviousValues(keys, { fallbackToLive: false }) };
2059
2015
  }
2060
2016
  /**
2061
2017
  * Public API
@@ -60,6 +60,9 @@ export interface DefaultSyncShape {
60
60
  * Empty by default — every SDK resolver falls back to {@link DefaultSyncShape}
61
61
  * when an expected key is absent. Exported from the package root so the module
62
62
  * augmentation merges into this declaration.
63
+ *
64
+ * The `Schema` augmentation key holds the type produced by `defineSchema`, so
65
+ * the same noun reads consistently here and in {@link ResolveSchema}.
63
66
  */
64
67
  export interface Register {
65
68
  }