@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.
- package/CHANGELOG.md +40 -1
- package/README.md +32 -27
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +172 -2
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/agent/session.js +3 -3
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +4 -42
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +160 -42
- package/dist/client/Ablo.js +145 -75
- package/dist/client/ApiClient.d.ts +20 -4
- package/dist/client/ApiClient.js +166 -28
- package/dist/client/auth.d.ts +14 -5
- package/dist/client/auth.js +60 -7
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +130 -66
- package/dist/client/createModelProxy.js +152 -49
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +49 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +3 -3
- package/dist/client/registerDataSource.js +11 -9
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +70 -1
- package/dist/errorCodes.js +108 -9
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +72 -22
- package/dist/index.d.ts +17 -8
- package/dist/index.js +15 -6
- package/dist/keys/index.d.ts +16 -1
- package/dist/keys/index.js +26 -6
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +3 -6
- package/dist/react/AbloProvider.d.ts +23 -126
- package/dist/react/AbloProvider.js +62 -199
- package/dist/react/useAblo.d.ts +2 -2
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +4 -0
- package/dist/schema/serialize.js +4 -0
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +10 -15
- package/dist/sync/ConnectionManager.d.ts +55 -1
- package/dist/sync/ConnectionManager.js +155 -16
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +238 -39
- package/dist/sync/NetworkProbe.d.ts +58 -24
- package/dist/sync/NetworkProbe.js +118 -42
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +70 -36
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api.md +47 -44
- package/docs/cli.md +44 -44
- package/docs/client-behavior.md +30 -30
- package/docs/coordination.md +33 -36
- package/docs/data-sources.md +35 -15
- package/docs/examples/agent-human.md +45 -43
- package/docs/examples/ai-sdk-tool.md +20 -16
- package/docs/examples/existing-python-backend.md +16 -12
- package/docs/examples/nextjs.md +14 -12
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/examples/server-agent.md +24 -21
- package/docs/guarantees.md +15 -13
- package/docs/index.md +1 -1
- package/docs/integration-guide.md +30 -30
- package/docs/interaction-model.md +19 -23
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +2 -2
- package/docs/mcp.md +6 -6
- package/docs/quickstart.md +41 -31
- package/docs/react.md +13 -9
- package/docs/schema-contract.md +12 -10
- package/docs/the-loop.md +21 -0
- package/examples/data-source/README.md +4 -5
- package/examples/data-source/customer-server.ts +27 -25
- package/llms.txt +28 -5
- 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
|
-
/**
|
|
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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
package/dist/query/client.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin wrapper over fetch() that:
|
|
5
5
|
* - POSTs a QueryBatch as JSON
|
|
6
|
-
* -
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
}
|
package/dist/query/client.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin wrapper over fetch() that:
|
|
5
5
|
* - POSTs a QueryBatch as JSON
|
|
6
|
-
* -
|
|
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
|
});
|