@abloatai/ablo 0.7.0 → 0.9.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 (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
@@ -0,0 +1,129 @@
1
+ /**
2
+ * inverseOp.ts — the reversible-operation model for the undo system,
3
+ * expressed as Zod schemas.
4
+ *
5
+ * Why schemas (not bare TS types):
6
+ * - Single source of truth. The `InverseOp` / `UndoEntry` TypeScript types
7
+ * are *derived* from these schemas (`z.infer`), so the wire shape and the
8
+ * static type can't drift.
9
+ * - A real validation boundary. Inverse ops are stored as plain JSON-shaped
10
+ * records (model keys + row data) so the undo manager stays schema-agnostic
11
+ * — it replays them through a strongly-typed transaction it doesn't own.
12
+ * That JSON boundary is exactly where a runtime check belongs, and is the
13
+ * seam a future cross-session persistence layer (IndexedDB-backed history)
14
+ * would deserialize through. `parseUndoEntry` is that gate.
15
+ *
16
+ * The op kinds mirror the mutator surface 1:1 — single (`create`/`update`/
17
+ * `delete`) and batch (`createMany`/`updateMany`/`deleteMany`) — so a recorded
18
+ * entry is symmetric with what was originally invoked.
19
+ */
20
+ import { z } from 'zod';
21
+ /**
22
+ * A single reversible operation. Discriminated on `kind` so a malformed op
23
+ * fails fast with a precise path (e.g. `inverses[2].patch.id`) rather than a
24
+ * vague union mismatch. Model keys/data are strings/records — the manager is
25
+ * schema-agnostic; the transaction it replays through is schema-typed.
26
+ */
27
+ export declare const inverseOpSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
28
+ kind: z.ZodLiteral<"create">;
29
+ modelKey: z.ZodString;
30
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
31
+ }, z.core.$strip>, z.ZodObject<{
32
+ kind: z.ZodLiteral<"update">;
33
+ modelKey: z.ZodString;
34
+ patch: z.ZodObject<{
35
+ id: z.ZodString;
36
+ }, z.core.$catchall<z.ZodUnknown>>;
37
+ }, z.core.$strip>, z.ZodObject<{
38
+ kind: z.ZodLiteral<"delete">;
39
+ modelKey: z.ZodString;
40
+ id: z.ZodString;
41
+ }, z.core.$strip>, z.ZodObject<{
42
+ kind: z.ZodLiteral<"createMany">;
43
+ modelKey: z.ZodString;
44
+ data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
45
+ }, z.core.$strip>, z.ZodObject<{
46
+ kind: z.ZodLiteral<"updateMany">;
47
+ modelKey: z.ZodString;
48
+ patches: z.ZodArray<z.ZodObject<{
49
+ id: z.ZodString;
50
+ }, z.core.$catchall<z.ZodUnknown>>>;
51
+ }, z.core.$strip>, z.ZodObject<{
52
+ kind: z.ZodLiteral<"deleteMany">;
53
+ modelKey: z.ZodString;
54
+ ids: z.ZodArray<z.ZodString>;
55
+ }, z.core.$strip>], "kind">;
56
+ /** One undo entry = one mutator invocation's inverses + paired forwards. */
57
+ export declare const undoEntrySchema: z.ZodObject<{
58
+ label: z.ZodOptional<z.ZodString>;
59
+ inverses: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
60
+ kind: z.ZodLiteral<"create">;
61
+ modelKey: z.ZodString;
62
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
63
+ }, z.core.$strip>, z.ZodObject<{
64
+ kind: z.ZodLiteral<"update">;
65
+ modelKey: z.ZodString;
66
+ patch: z.ZodObject<{
67
+ id: z.ZodString;
68
+ }, z.core.$catchall<z.ZodUnknown>>;
69
+ }, z.core.$strip>, z.ZodObject<{
70
+ kind: z.ZodLiteral<"delete">;
71
+ modelKey: z.ZodString;
72
+ id: z.ZodString;
73
+ }, z.core.$strip>, z.ZodObject<{
74
+ kind: z.ZodLiteral<"createMany">;
75
+ modelKey: z.ZodString;
76
+ data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
77
+ }, z.core.$strip>, z.ZodObject<{
78
+ kind: z.ZodLiteral<"updateMany">;
79
+ modelKey: z.ZodString;
80
+ patches: z.ZodArray<z.ZodObject<{
81
+ id: z.ZodString;
82
+ }, z.core.$catchall<z.ZodUnknown>>>;
83
+ }, z.core.$strip>, z.ZodObject<{
84
+ kind: z.ZodLiteral<"deleteMany">;
85
+ modelKey: z.ZodString;
86
+ ids: z.ZodArray<z.ZodString>;
87
+ }, z.core.$strip>], "kind">>;
88
+ forwards: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
89
+ kind: z.ZodLiteral<"create">;
90
+ modelKey: z.ZodString;
91
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
92
+ }, z.core.$strip>, z.ZodObject<{
93
+ kind: z.ZodLiteral<"update">;
94
+ modelKey: z.ZodString;
95
+ patch: z.ZodObject<{
96
+ id: z.ZodString;
97
+ }, z.core.$catchall<z.ZodUnknown>>;
98
+ }, z.core.$strip>, z.ZodObject<{
99
+ kind: z.ZodLiteral<"delete">;
100
+ modelKey: z.ZodString;
101
+ id: z.ZodString;
102
+ }, z.core.$strip>, z.ZodObject<{
103
+ kind: z.ZodLiteral<"createMany">;
104
+ modelKey: z.ZodString;
105
+ data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
106
+ }, z.core.$strip>, z.ZodObject<{
107
+ kind: z.ZodLiteral<"updateMany">;
108
+ modelKey: z.ZodString;
109
+ patches: z.ZodArray<z.ZodObject<{
110
+ id: z.ZodString;
111
+ }, z.core.$catchall<z.ZodUnknown>>>;
112
+ }, z.core.$strip>, z.ZodObject<{
113
+ kind: z.ZodLiteral<"deleteMany">;
114
+ modelKey: z.ZodString;
115
+ ids: z.ZodArray<z.ZodString>;
116
+ }, z.core.$strip>], "kind">>;
117
+ }, z.core.$strip>;
118
+ /** A single reversible operation (schema-derived). */
119
+ export type InverseOp = z.infer<typeof inverseOpSchema>;
120
+ /** One undo/redo stack entry (schema-derived). */
121
+ export type UndoEntry = z.infer<typeof undoEntrySchema>;
122
+ /**
123
+ * Validate an untrusted value as an `UndoEntry`. Use at any boundary where an
124
+ * entry crosses out of internal construction — deserialization from
125
+ * persistence, or a defensive check at the recording ingestion point. Throws
126
+ * `AbloValidationError` (code `undo_entry_invalid`) with the failing Zod path
127
+ * in `details` so the offending op is obvious.
128
+ */
129
+ export declare function parseUndoEntry(value: unknown): UndoEntry;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * inverseOp.ts — the reversible-operation model for the undo system,
3
+ * expressed as Zod schemas.
4
+ *
5
+ * Why schemas (not bare TS types):
6
+ * - Single source of truth. The `InverseOp` / `UndoEntry` TypeScript types
7
+ * are *derived* from these schemas (`z.infer`), so the wire shape and the
8
+ * static type can't drift.
9
+ * - A real validation boundary. Inverse ops are stored as plain JSON-shaped
10
+ * records (model keys + row data) so the undo manager stays schema-agnostic
11
+ * — it replays them through a strongly-typed transaction it doesn't own.
12
+ * That JSON boundary is exactly where a runtime check belongs, and is the
13
+ * seam a future cross-session persistence layer (IndexedDB-backed history)
14
+ * would deserialize through. `parseUndoEntry` is that gate.
15
+ *
16
+ * The op kinds mirror the mutator surface 1:1 — single (`create`/`update`/
17
+ * `delete`) and batch (`createMany`/`updateMany`/`deleteMany`) — so a recorded
18
+ * entry is symmetric with what was originally invoked.
19
+ */
20
+ import { z } from 'zod';
21
+ import { AbloValidationError } from '../errors.js';
22
+ /** A row payload — JSON-shaped record used by create/createMany inverses. */
23
+ const rowDataSchema = z.record(z.string(), z.unknown());
24
+ /**
25
+ * An update patch: an `id` plus the changed fields. Modeled as an object with
26
+ * a required `id` and an open `catchall`, so `z.infer` yields
27
+ * `{ id: string } & { [k: string]: unknown }` — the exact shape the recorder
28
+ * builds and the replayer consumes.
29
+ */
30
+ const patchSchema = z.object({ id: z.string() }).catchall(z.unknown());
31
+ /**
32
+ * A single reversible operation. Discriminated on `kind` so a malformed op
33
+ * fails fast with a precise path (e.g. `inverses[2].patch.id`) rather than a
34
+ * vague union mismatch. Model keys/data are strings/records — the manager is
35
+ * schema-agnostic; the transaction it replays through is schema-typed.
36
+ */
37
+ export const inverseOpSchema = z.discriminatedUnion('kind', [
38
+ z.object({ kind: z.literal('create'), modelKey: z.string(), data: rowDataSchema }),
39
+ z.object({ kind: z.literal('update'), modelKey: z.string(), patch: patchSchema }),
40
+ z.object({ kind: z.literal('delete'), modelKey: z.string(), id: z.string() }),
41
+ z.object({ kind: z.literal('createMany'), modelKey: z.string(), data: z.array(rowDataSchema) }),
42
+ z.object({ kind: z.literal('updateMany'), modelKey: z.string(), patches: z.array(patchSchema) }),
43
+ z.object({ kind: z.literal('deleteMany'), modelKey: z.string(), ids: z.array(z.string()) }),
44
+ ]);
45
+ /** One undo entry = one mutator invocation's inverses + paired forwards. */
46
+ export const undoEntrySchema = z.object({
47
+ /** Optional label for diagnostics / UI ("Move layer", "Delete slide", etc). */
48
+ label: z.string().optional(),
49
+ /** Applied (in array order) to reverse the invocation. */
50
+ inverses: z.array(inverseOpSchema),
51
+ /**
52
+ * Paired forward ops, captured at record time so redo can replay them
53
+ * without re-running the user's mutator (which may have non-idempotent
54
+ * side effects like generating new IDs).
55
+ */
56
+ forwards: z.array(inverseOpSchema),
57
+ });
58
+ /**
59
+ * Validate an untrusted value as an `UndoEntry`. Use at any boundary where an
60
+ * entry crosses out of internal construction — deserialization from
61
+ * persistence, or a defensive check at the recording ingestion point. Throws
62
+ * `AbloValidationError` (code `undo_entry_invalid`) with the failing Zod path
63
+ * in `details` so the offending op is obvious.
64
+ */
65
+ export function parseUndoEntry(value) {
66
+ const result = undoEntrySchema.safeParse(value);
67
+ if (!result.success) {
68
+ throw new AbloValidationError('Undo entry failed inverse-op schema validation.', {
69
+ code: 'undo_entry_invalid',
70
+ details: { issues: result.error.issues },
71
+ });
72
+ }
73
+ return result.data;
74
+ }
@@ -4,7 +4,7 @@ import type { SyncStoreContract } from '../react/context.js';
4
4
  * React-free imperative reads over a store: one-off `retrieve`/`list`/`count`
5
5
  * snapshots that do NOT subscribe to changes. Used by the transaction system
6
6
  * and `BaseSyncedStore`. For reactive reads in components use
7
- * `useAblo((ablo) => ablo.<model>.retrieve(id) / .list(opts))`.
7
+ * `useAblo((ablo) => ablo.<model>.retrieve({ id }) / .list(opts))`.
8
8
  */
9
9
  export interface ReaderFindOptions<T> {
10
10
  /** Equality filter — uses FK index when the field is registered. */
@@ -0,0 +1,42 @@
1
+ /**
2
+ * undoApply.ts — conflict-aware resolution of undo/redo ops (per-user undo).
3
+ *
4
+ * The undo stack is already per-client (only local mutator invocations call
5
+ * `UndoScope.record`; a collaborator's edits arrive as inbound sync deltas and
6
+ * never land here). What this module adds is the second half of "undo per
7
+ * user": when replaying a recorded op, only touch a field whose CURRENT value
8
+ * still equals what THIS op established — so undo reverts your own change only
9
+ * where it still stands, and never clobbers a field a collaborator changed
10
+ * after you (the Yjs/CRDT "selective undo" principle, adapted to our
11
+ * field-level last-writer-wins model).
12
+ *
13
+ * `resolveOps(apply, paired, store, policy)`:
14
+ * - `apply` — the ops we're about to replay (inverses on undo, forwards on redo).
15
+ * - `paired` — their counterparts, carrying the value this op established
16
+ * (forwards on undo = "what I set"; inverses on redo = "what undo restored").
17
+ * - For `update`/`updateMany` ops it drops fields whose live value no longer
18
+ * matches the established value. `create`/`delete` families are structural
19
+ * and applied unconditionally (undoing your create removes the row you
20
+ * added; undoing your delete restores it).
21
+ *
22
+ * With no collaborator, the live value always equals what you set, so nothing
23
+ * is dropped — single-user undo is byte-for-byte unchanged.
24
+ */
25
+ import type { SyncStoreContract } from '../react/context.js';
26
+ import type { InverseOp } from './inverseOp.js';
27
+ /**
28
+ * How undo/redo handles a field a collaborator changed after your op:
29
+ * - `skip-stale` (default): leave it — your change is already superseded, so
30
+ * reverting it would clobber theirs. This is the per-user guarantee.
31
+ * - `last-writer-wins`: apply the op verbatim (legacy behavior). Your undo
32
+ * overwrites their change.
33
+ */
34
+ export type UndoConflictPolicy = 'skip-stale' | 'last-writer-wins';
35
+ export declare const DEFAULT_UNDO_CONFLICT_POLICY: UndoConflictPolicy;
36
+ /** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
37
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
38
+ /**
39
+ * Filter the ops to apply so they don't clobber concurrent collaborator edits.
40
+ * See the module docblock. `last-writer-wins` returns the ops unchanged.
41
+ */
42
+ export declare function resolveOps(apply: InverseOp[], paired: InverseOp[], store: SyncStoreContract, policy: UndoConflictPolicy): InverseOp[];
@@ -0,0 +1,143 @@
1
+ /**
2
+ * undoApply.ts — conflict-aware resolution of undo/redo ops (per-user undo).
3
+ *
4
+ * The undo stack is already per-client (only local mutator invocations call
5
+ * `UndoScope.record`; a collaborator's edits arrive as inbound sync deltas and
6
+ * never land here). What this module adds is the second half of "undo per
7
+ * user": when replaying a recorded op, only touch a field whose CURRENT value
8
+ * still equals what THIS op established — so undo reverts your own change only
9
+ * where it still stands, and never clobbers a field a collaborator changed
10
+ * after you (the Yjs/CRDT "selective undo" principle, adapted to our
11
+ * field-level last-writer-wins model).
12
+ *
13
+ * `resolveOps(apply, paired, store, policy)`:
14
+ * - `apply` — the ops we're about to replay (inverses on undo, forwards on redo).
15
+ * - `paired` — their counterparts, carrying the value this op established
16
+ * (forwards on undo = "what I set"; inverses on redo = "what undo restored").
17
+ * - For `update`/`updateMany` ops it drops fields whose live value no longer
18
+ * matches the established value. `create`/`delete` families are structural
19
+ * and applied unconditionally (undoing your create removes the row you
20
+ * added; undoing your delete restores it).
21
+ *
22
+ * With no collaborator, the live value always equals what you set, so nothing
23
+ * is dropped — single-user undo is byte-for-byte unchanged.
24
+ */
25
+ export const DEFAULT_UNDO_CONFLICT_POLICY = 'skip-stale';
26
+ /** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
27
+ export function deepEqual(a, b) {
28
+ if (a === b)
29
+ return true;
30
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
31
+ return false;
32
+ }
33
+ const aArr = Array.isArray(a);
34
+ if (aArr !== Array.isArray(b))
35
+ return false;
36
+ if (aArr) {
37
+ const av = a;
38
+ const bv = b;
39
+ if (av.length !== bv.length)
40
+ return false;
41
+ for (let i = 0; i < av.length; i++) {
42
+ if (!deepEqual(av[i], bv[i]))
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+ const ao = a;
48
+ const bo = b;
49
+ const ak = Object.keys(ao);
50
+ const bk = Object.keys(bo);
51
+ if (ak.length !== bk.length)
52
+ return false;
53
+ for (const k of ak) {
54
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
55
+ return false;
56
+ if (!deepEqual(ao[k], bo[k]))
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+ /**
62
+ * Map `id → { field: establishedValue }` from the paired ops. Only update-family
63
+ * ops carry per-field values worth comparing.
64
+ */
65
+ function buildEstablished(paired) {
66
+ const map = new Map();
67
+ for (const op of paired) {
68
+ if (op.kind === 'update') {
69
+ map.set(op.patch.id, op.patch);
70
+ }
71
+ else if (op.kind === 'updateMany') {
72
+ for (const p of op.patches)
73
+ map.set(p.id, p);
74
+ }
75
+ }
76
+ return map;
77
+ }
78
+ /** Read the live value of a field from the store's pool, or `undefined`. */
79
+ function readCurrentField(store, id, field) {
80
+ const model = store.pool.get(id);
81
+ if (!model)
82
+ return undefined;
83
+ const json = model.toJSON?.();
84
+ return json ? json[field] : undefined;
85
+ }
86
+ /**
87
+ * Keep only the fields whose live value still equals what this op established
88
+ * (`established[field]`). Returns `null` if nothing survives (the whole op is a
89
+ * no-op — every field was superseded by a collaborator).
90
+ */
91
+ function filterStalePatch(store, patch, established) {
92
+ const out = { id: patch.id };
93
+ let kept = 0;
94
+ for (const field of Object.keys(patch)) {
95
+ if (field === 'id')
96
+ continue;
97
+ if (established && field in established) {
98
+ // Apply only if the field still holds the value WE established — i.e. no
99
+ // collaborator overwrote it since. Otherwise skip (don't clobber them).
100
+ if (deepEqual(readCurrentField(store, patch.id, field), established[field])) {
101
+ out[field] = patch[field];
102
+ kept++;
103
+ }
104
+ }
105
+ else {
106
+ // No paired value to compare against. The recorder always pairs fields,
107
+ // so this is theoretical; apply to preserve undo functionality.
108
+ out[field] = patch[field];
109
+ kept++;
110
+ }
111
+ }
112
+ return kept > 0 ? out : null;
113
+ }
114
+ /**
115
+ * Filter the ops to apply so they don't clobber concurrent collaborator edits.
116
+ * See the module docblock. `last-writer-wins` returns the ops unchanged.
117
+ */
118
+ export function resolveOps(apply, paired, store, policy) {
119
+ if (policy === 'last-writer-wins')
120
+ return apply;
121
+ const established = buildEstablished(paired);
122
+ const out = [];
123
+ for (const op of apply) {
124
+ if (op.kind === 'update') {
125
+ const filtered = filterStalePatch(store, op.patch, established.get(op.patch.id));
126
+ if (filtered)
127
+ out.push({ kind: 'update', modelKey: op.modelKey, patch: filtered });
128
+ }
129
+ else if (op.kind === 'updateMany') {
130
+ const patches = op.patches
131
+ .map((p) => filterStalePatch(store, p, established.get(p.id)))
132
+ .filter((p) => p !== null);
133
+ if (patches.length > 0) {
134
+ out.push({ kind: 'updateMany', modelKey: op.modelKey, patches });
135
+ }
136
+ }
137
+ else {
138
+ // create / createMany / delete / deleteMany — structural, applied as-is.
139
+ out.push(op);
140
+ }
141
+ }
142
+ return out;
143
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Thin wrapper over fetch() that:
5
5
  * - POSTs a QueryBatch as JSON
6
- * - Handles auth via `credentials: 'include'` (session cookie)
6
+ * - Sends the bearer credential via withAuthHeaders (Authorization header)
7
7
  * - Throws on non-2xx responses
8
8
  * - Parses the response into a typed QueryBatchResult
9
9
  *
@@ -12,6 +12,7 @@
12
12
  * without duplicating the fetch boilerplate.
13
13
  */
14
14
  import type { QueryBatch, QueryBatchResult } from './types.js';
15
+ import { type AuthTokenGetter } from '../auth/credentialSource.js';
15
16
  export interface PostQueryOptions {
16
17
  /**
17
18
  * Full base URL of the sync server including the `/api` prefix.
@@ -22,14 +23,14 @@ export interface PostQueryOptions {
22
23
  /** Timeout in ms for the fetch request. Default: 30000. */
23
24
  fetchTimeout?: number;
24
25
  /**
25
- * Bearer credential a restricted (`rk_`) API key — attached as
26
- * `Authorization: Bearer <token>`. Required for Node consumers
27
- * (agent-worker, server-side tests) that have no session cookie to
28
- * ride. Browser consumers can omit this and fall back to
29
- * `credentials: 'include'`. When both are present the server prefers
30
- * the Bearer header (see `apiKeyProvider` in
31
- * `apps/sync-server/src/auth`), so passing the token in browser code
32
- * is harmless. (Field name predates the Biscuit→opaque-key migration.)
26
+ * Live bearer credential getter. Preferred over `capabilityToken` because it
27
+ * is read per request, so token refreshes propagate without reconstructing
28
+ * query helpers.
29
+ */
30
+ getAuthToken?: AuthTokenGetter;
31
+ /**
32
+ * Compatibility fallback for callers that have only a copied token string.
33
+ * New SDK internals should pass `getAuthToken`.
33
34
  */
34
35
  capabilityToken?: string;
35
36
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Thin wrapper over fetch() that:
5
5
  * - POSTs a QueryBatch as JSON
6
- * - Handles auth via `credentials: 'include'` (session cookie)
6
+ * - Sends the bearer credential via withAuthHeaders (Authorization header)
7
7
  * - Throws on non-2xx responses
8
8
  * - Parses the response into a typed QueryBatchResult
9
9
  *
@@ -12,6 +12,8 @@
12
12
  * without duplicating the fetch boilerplate.
13
13
  */
14
14
  import { z } from 'zod';
15
+ import { translateHttpError } from '../errors.js';
16
+ import { withAuthHeaders } from '../auth/credentialSource.js';
15
17
  // ── Response validation ─────────────────────────────────────────────────
16
18
  //
17
19
  // Each result slot is an array of rows (or an object for bundled
@@ -48,26 +50,32 @@ export async function postQuery(options, batch) {
48
50
  const controller = new AbortController();
49
51
  const timer = setTimeout(() => controller.abort(), timeout);
50
52
  try {
51
- const headers = { 'Content-Type': 'application/json' };
52
- if (options.capabilityToken) {
53
- headers.Authorization = `Bearer ${options.capabilityToken}`;
54
- }
53
+ const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken);
55
54
  const response = await fetch(url, {
56
55
  method: 'POST',
57
56
  headers,
58
- credentials: 'include',
59
57
  body: JSON.stringify(batch),
60
58
  signal: controller.signal,
61
59
  });
62
60
  if (!response.ok) {
63
- // Direct console.error is INTENTIONAL operators alert on the
64
- // `[postQuery.error]` prefix in browser console. Routing through
65
- // an injected logger here would require a coordinated change to
66
- // the alerting pipeline. Tracked as future work; the dual-channel
67
- // alternative (logger + observability.captureException) is the
68
- // production target. Never throw fire-and-forget callers would
69
- // kill Next.js router on unhandled rejection.
70
- console.error(`[postQuery.error] ${response.status} ${response.statusText} for ${batch.queries.map((q) => q.model).join(',')}`);
61
+ // Build the typed AbloError for this HTTP failure (same code→class
62
+ // map the throwing paths use) so the log is tagged + carries a
63
+ // registry `code` (e.g. AbloAuthenticationError/session_expired on a
64
+ // 401) instead of a bare status. We deliberately DON'T throw
65
+ // fire-and-forget callers would kill the Next.js router on an
66
+ // unhandled rejection — and still return empty slots, but the failure
67
+ // is now legible as an Ablo error. Direct console.error is
68
+ // INTENTIONAL: operators alert on the `[postQuery.error]` prefix.
69
+ let body = null;
70
+ try {
71
+ body = await response.clone().json();
72
+ }
73
+ catch {
74
+ // non-JSON error page — translateHttpError falls back to status text
75
+ }
76
+ const err = translateHttpError(response.status, body);
77
+ console.error(`[postQuery.error] ${err.type} ${err.code ?? response.status} for ` +
78
+ `${batch.queries.map((q) => q.model).join(',')}: ${err.message}`);
71
79
  return { results: batch.queries.map(() => []) };
72
80
  }
73
81
  const raw = await response.json();
@@ -1,10 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { Schema, SchemaRecord } from '../schema/schema.js';
2
+ import type { SchemaRecord } from '../schema/schema.js';
3
3
  import { Ablo } from '../client/Ablo.js';
4
- import type { AbloPersistence } from '../client/persistence.js';
5
- import type { SyncEngineConfig, MutationExecutor, MutationDispatcher, SessionErrorDetector, OnlineStatusProvider, SyncLogger, SyncObservabilityProvider } from '../config/index.js';
6
- import type { UseMutatorsOptions } from './useMutators.js';
7
- import type { MutatorDefs } from '../mutators/defineMutators.js';
8
4
  import type { ActiveIntent, Peer } from '../types/streams.js';
9
5
  import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
10
6
  import { type SyncStoreContract } from './context.js';
@@ -49,115 +45,41 @@ import { type SyncStoreContract } from './context.js';
49
45
  */
50
46
  export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
51
47
  /**
52
- * Schema from `defineSchema()`. Determines the typed hook surface.
53
- * This is the only prop most apps pass start here.
54
- */
55
- schema: Schema<R>;
56
- /**
57
- * WebSocket URL of the sync server (`wss://...` or `ws://...`).
58
- * Hosted apps omit this.
48
+ * A prebuilt {@link Ablo} client — **the only way to configure the engine.**
49
+ * Construct it yourself with `Ablo({ schema, apiKey, ... })` and pass the
50
+ * instance: the CLIENT owns auth, the credential lifecycle, transport, and
51
+ * connection; this provider is the thin REACTIVE binding over it (context,
52
+ * the bootstrap gate, error/​session forwarding). Mirrors Stripe
53
+ * `<Elements stripe={...}>` and a Supabase client passed into a context.
54
+ *
55
+ * Memoize it (build it once, e.g. with `useMemo` or module scope) — a new
56
+ * instance each render re-keys the bootstrap gate and tears down the socket.
59
57
  */
60
- url?: string;
58
+ client: Ablo<R>;
61
59
  /**
62
- * Optional app user id for app-owned fields. Ablo resolves sync
63
- * participant identity from auth; this is not required to connect.
60
+ * The app user id, surfaced via `useCurrentUserId()` for app-owned fields.
61
+ * Purely informational for the React tree — sync identity is resolved by the
62
+ * client from its auth, not from this. Optional.
64
63
  */
65
64
  userId?: string;
66
- /** Team IDs the user belongs to. Expanded into sync groups. */
67
- teamIds?: string[];
68
- /**
69
- * API key for engine bootstrap auth. Used by the bootstrap fetch
70
- * path; falls back to `credentials: 'include'` (session cookie)
71
- * when unset. Browser apps typically omit this and rely on
72
- * same-origin session cookies.
73
- */
74
- apiKey?: string;
75
- /** Optional Zero-style custom mutators. */
76
- mutators?: MutatorDefs<Schema<R>>;
77
- /** Options forwarded to the internal `useMutators` call (e.g., `undoScope`). */
78
- mutatorOptions?: UseMutatorsOptions<Schema<R>>;
79
65
  /**
80
- * Block browser tab close when there are unsynced local writes.
81
- * Triggers the standard `beforeunload` "Leave site?" prompt.
82
- * Browsers ignore custom messages — do not pass one. Consumers
83
- * who want telemetry should read
84
- * `useSyncStatus().hasUnsyncedChanges` directly.
66
+ * Block tab close while there are unsynced local writes (the standard
67
+ * `beforeunload` prompt). Browsers ignore custom messages — don't pass one.
85
68
  */
86
69
  preventUnsavedChanges?: boolean;
87
70
  /**
88
- * Milliseconds to tolerate connection loss before `useSyncStatus()`
89
- * flips to `disconnected`. Defaults to 5000. Set to 0 to
90
- * disable the grace period (immediate transition).
91
- *
92
- * v0.3.0 scope: reserved for future wiring. Current transition is
93
- * driven by the engine's built-in state machine.
94
- */
95
- lostConnectionTimeout?: number;
96
- /**
97
- * Fired when the server rejects the session. The provider has
98
- * ALREADY called `engine.purge()` (disposed + wiped IndexedDB) by
99
- * the time this runs — the callback is for app-level side effects
100
- * (e.g., redirect to sign-in, clear analytics identity).
71
+ * Fired when the server rejects the session. The provider has ALREADY called
72
+ * `client.purge()` (disposed + wiped IndexedDB) by the time this runs — use it
73
+ * for app side effects (redirect to sign-in, clear analytics identity).
101
74
  */
102
75
  onSessionExpired?: () => void | Promise<void>;
103
76
  /**
104
- * Fired on any error the provider surfaces: engine errors,
105
- * WebSocket errors, uncaught `postBootstrap` exceptions. Use for
106
- * Sentry / Datadog. Consumers who only want errors inside React
107
- * can use the `useErrorListener()` hook instead.
77
+ * Fired on any error the provider surfaces (engine/WebSocket/bootstrap). For
78
+ * Sentry/Datadog. React-only consumers can use `useErrorListener()` instead.
108
79
  */
109
80
  onError?: (error: Error) => void;
110
- observability?: SyncObservabilityProvider;
111
- logger?: SyncLogger;
112
- mutationExecutor?: MutationExecutor;
113
- mutationDispatcher?: MutationDispatcher;
114
- sessionErrorDetector?: SessionErrorDetector;
115
- onlineStatus?: OnlineStatusProvider;
116
- configOverrides?: SyncEngineConfig;
117
- /**
118
- * Raw sync-group strings for the initial connection. Prefer {@link scope} —
119
- * the model form (`{ decks: deckId }`) that the engine resolves through the
120
- * schema's `scope`, so you never hand-write a `deck:<id>` string. Both merge.
121
- */
122
- syncGroups?: string[];
123
- /**
124
- * Model-form connection scope: `{ decks: deckId, documents: documentId }` or
125
- * entity refs. Resolved through the schema's per-model `scope` into group
126
- * strings (so typename `SlideDeck` → `deck:<id>`), unioned with {@link syncGroups}.
127
- * Memoize the object if it's derived, to avoid rotating the engine each render.
128
- */
129
- scope?: ParticipantScope;
130
- bootstrapBaseUrl?: string;
131
- maxPoolSize?: number;
132
- /**
133
- * Local persistence mode for the underlying `Ablo` client. Defaults
134
- * to `volatile` — pass `'indexeddb'` to opt back into offline-queue +
135
- * reload-surviving cache in a browser. See `AbloOptions.persistence`
136
- * for the full semantics.
137
- */
138
- persistence?: AbloPersistence;
139
- /**
140
- * How aggressively this provider pulls baseline state at startup.
141
- *
142
- * - `'full'` (default): pull every delta in the configured sync
143
- * groups before the engine reports ready — a local replica of the
144
- * org's tenant plane. Right for collaborative editors and any page
145
- * that reads a lot of shared state.
146
- * - `'none'`: open the connection and process live deltas only — no
147
- * baseline fetch. Reads round-trip via `ablo.<model>.retrieve(...)`
148
- * and subscriptions populate the pool lazily. Right for read-light
149
- * pages (a mostly-static dashboard, a settings screen) that don't
150
- * want to download the whole org to render.
151
- *
152
- * Note: `'none'` still opens the realtime connection — it skips the
153
- * baseline pull, not the socket. A fully connection-free mode for
154
- * pages that do zero multiplayer is a separate follow-up (the socket
155
- * open lives inside `engine.ready()`, so deferring it needs
156
- * engine-level lazy-connect support, not just a provider prop).
157
- *
158
- * Mirrors `AbloOptions.bootstrapMode`. Changing it rotates the engine.
159
- */
160
- bootstrapMode?: 'full' | 'none';
81
+ /** @internal placeholder so the old WS-URL prop shape doesn't silently leak in. */
82
+ url?: never;
161
83
  /**
162
84
  * Rendered in place of `children` during the *first* bootstrap pass —
163
85
  * while the engine is actively transitioning from `initial` →