@abloatai/ablo 0.8.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 (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +86 -50
  52. package/dist/mutators/UndoManager.js +129 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
@@ -18,6 +18,8 @@
18
18
  * the scope on sync error if they want strict correctness.
19
19
  */
20
20
  import { createTransaction } from './Transaction.js';
21
+ import { parseUndoEntry } from './inverseOp.js';
22
+ import { resolveOps, DEFAULT_UNDO_CONFLICT_POLICY, } from './undoApply.js';
21
23
  /**
22
24
  * A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
23
25
  * Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
@@ -30,18 +32,105 @@ export class UndoScope {
30
32
  undoStack = [];
31
33
  redoStack = [];
32
34
  maxHistory;
35
+ conflictPolicy;
36
+ /**
37
+ * Observers notified after each successful {@link record}. These see FORWARD
38
+ * user actions only — `undo()`/`redo()` replays move entries between stacks
39
+ * without calling `record()`, so a listener never observes a reversal. This
40
+ * is a deliberately domain-agnostic seam: analytics, gamification, and audit
41
+ * can tap the committed-mutation stream without the scope knowing about them.
42
+ * A throwing listener is isolated (see {@link emitRecord}) so a faulty
43
+ * observer can never wedge the editor's recording path.
44
+ */
45
+ recordListeners = new Set();
46
+ /**
47
+ * Serialization tail. Recording, undo, and redo all chain off this single
48
+ * promise so they run strictly in the order they were *invoked* — never
49
+ * interleaved. This is load-bearing for correctness, not just throughput:
50
+ * - Ordering: callers fire writes un-awaited (`void mutations.x.update`).
51
+ * Without serialization, an entry lands on the stack when its mutator
52
+ * *resolves*, so a fast second write can record before a slow first one
53
+ * → undo replays in the wrong order.
54
+ * - Snapshot integrity: every recording reads/clears the shared models'
55
+ * `modifiedProperties` (the undo "before" baseline). Two recordings
56
+ * interleaving on the same model corrupt each other's inverse snapshot.
57
+ * Serializing the whole scope closes both holes with one mechanism.
58
+ */
59
+ tail = Promise.resolve();
33
60
  constructor(schema, store, organizationId, options = {}) {
34
61
  this.schema = schema;
35
62
  this.store = store;
36
63
  this.organizationId = organizationId;
37
64
  this.maxHistory = options.maxHistory ?? 100;
65
+ this.conflictPolicy = options.conflictPolicy ?? DEFAULT_UNDO_CONFLICT_POLICY;
38
66
  }
39
- /** Internal: record a mutator's inverses. Clears the redo stack. */
67
+ /**
68
+ * Run `work` after every previously-enqueued scope operation has settled,
69
+ * in invocation order. The internal `tail` always resolves (failures are
70
+ * swallowed *for the chain only*) so one rejected mutator can't wedge the
71
+ * queue; the original settlement is still surfaced to this call's caller.
72
+ */
73
+ enqueue(work) {
74
+ const result = this.tail.then(work, work);
75
+ this.tail = result.then(() => undefined, () => undefined);
76
+ return result;
77
+ }
78
+ /**
79
+ * Run a recording mutator exclusively on the scope's serialization chain.
80
+ * `useMutators` calls this so the snapshot → write → `record()` sequence is
81
+ * atomic relative to other invocations, undo, and redo.
82
+ */
83
+ runRecorded(work) {
84
+ return this.enqueue(work);
85
+ }
86
+ /**
87
+ * Internal: record a mutator's inverses. Clears the redo stack.
88
+ *
89
+ * Entries here are produced internally by `RecordingTransaction` (trusted),
90
+ * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
91
+ * (rejecting a malformed op at ingestion, with its path, instead of letting
92
+ * it crash later inside `applyOps`) without paying a Zod parse on every user
93
+ * action in production. The real validation boundary is `parseUndoEntry`,
94
+ * applied when entries are deserialized from persistence (untrusted input).
95
+ * Best practice: validate at trust boundaries, type-check internal calls.
96
+ */
40
97
  record(entry) {
98
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
99
+ parseUndoEntry(entry);
100
+ }
41
101
  this.undoStack.push(entry);
42
102
  if (this.undoStack.length > this.maxHistory)
43
103
  this.undoStack.shift();
44
104
  this.redoStack = [];
105
+ this.emitRecord(entry);
106
+ }
107
+ /**
108
+ * Subscribe to every recorded mutation. Fires synchronously at the tail of
109
+ * each {@link record} call, after the entry is on the undo stack. Returns an
110
+ * unsubscribe function — call it on teardown.
111
+ *
112
+ * Listeners receive the full {@link UndoEntry} (its `forwards` carry the
113
+ * `{ kind, modelKey, data }` ops), so a consumer can derive what changed
114
+ * (e.g. "a slideLayers row of type 'chart' was created") without re-querying.
115
+ */
116
+ onRecord(listener) {
117
+ this.recordListeners.add(listener);
118
+ return () => {
119
+ this.recordListeners.delete(listener);
120
+ };
121
+ }
122
+ emitRecord(entry) {
123
+ for (const listener of this.recordListeners) {
124
+ try {
125
+ listener(entry);
126
+ }
127
+ catch (err) {
128
+ // A faulty observer must never break the editor's recording path.
129
+ if (typeof console !== 'undefined') {
130
+ console.error('[UndoScope] onRecord listener threw', err);
131
+ }
132
+ }
133
+ }
45
134
  }
46
135
  canUndo() {
47
136
  return this.undoStack.length > 0;
@@ -49,27 +138,45 @@ export class UndoScope {
49
138
  canRedo() {
50
139
  return this.redoStack.length > 0;
51
140
  }
52
- /** Pop the last mutator and apply its inverses. Pushes to redo. */
53
- async undo() {
54
- const entry = this.undoStack.pop();
55
- if (!entry)
56
- return;
57
- const tx = createTransaction(this.schema, this.store, this.organizationId);
58
- await applyOps(tx, entry.inverses);
59
- this.redoStack.push(entry);
60
- if (this.redoStack.length > this.maxHistory)
61
- this.redoStack.shift();
62
- }
63
- /** Pop the last undone entry and re-apply the forward ops. Pushes to undo. */
64
- async redo() {
65
- const entry = this.redoStack.pop();
66
- if (!entry)
67
- return;
68
- const tx = createTransaction(this.schema, this.store, this.organizationId);
69
- await applyOps(tx, entry.forwards);
70
- this.undoStack.push(entry);
71
- if (this.undoStack.length > this.maxHistory)
72
- this.undoStack.shift();
141
+ /**
142
+ * Pop the last mutator and apply its inverses. Pushes to redo.
143
+ *
144
+ * Under the default `skip-stale` policy the inverses are filtered against
145
+ * live state first (paired with the entry's forwards = "what I set"), so a
146
+ * field a collaborator changed after my op is left untouched — undo reverts
147
+ * my change only where it still stands.
148
+ */
149
+ undo() {
150
+ return this.enqueue(async () => {
151
+ const entry = this.undoStack.pop();
152
+ if (!entry)
153
+ return;
154
+ const tx = createTransaction(this.schema, this.store, this.organizationId);
155
+ const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
156
+ await applyOps(tx, ops);
157
+ this.redoStack.push(entry);
158
+ if (this.redoStack.length > this.maxHistory)
159
+ this.redoStack.shift();
160
+ });
161
+ }
162
+ /**
163
+ * Pop the last undone entry and re-apply the forward ops. Pushes to undo.
164
+ * Symmetric to {@link undo}: forwards are filtered against live state
165
+ * (paired with the entry's inverses = "what undo restored"), so redo
166
+ * re-asserts my change only where the undone value still stands.
167
+ */
168
+ redo() {
169
+ return this.enqueue(async () => {
170
+ const entry = this.redoStack.pop();
171
+ if (!entry)
172
+ return;
173
+ const tx = createTransaction(this.schema, this.store, this.organizationId);
174
+ const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
175
+ await applyOps(tx, ops);
176
+ this.undoStack.push(entry);
177
+ if (this.undoStack.length > this.maxHistory)
178
+ this.undoStack.shift();
179
+ });
73
180
  }
74
181
  /** Drop all history. Use after bootstrap / sync group change / sync error. */
75
182
  clear() {
@@ -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
  *
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { z } from 'zod';
15
15
  import { translateHttpError } from '../errors.js';
16
+ import { withAuthHeaders } from '../auth/credentialSource.js';
16
17
  // ── Response validation ─────────────────────────────────────────────────
17
18
  //
18
19
  // Each result slot is an array of rows (or an object for bundled
@@ -49,14 +50,10 @@ export async function postQuery(options, batch) {
49
50
  const controller = new AbortController();
50
51
  const timer = setTimeout(() => controller.abort(), timeout);
51
52
  try {
52
- const headers = { 'Content-Type': 'application/json' };
53
- if (options.capabilityToken) {
54
- headers.Authorization = `Bearer ${options.capabilityToken}`;
55
- }
53
+ const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken);
56
54
  const response = await fetch(url, {
57
55
  method: 'POST',
58
56
  headers,
59
- credentials: 'include',
60
57
  body: JSON.stringify(batch),
61
58
  signal: controller.signal,
62
59
  });