@matatbread/matbot-core 0.4.3 → 0.4.5
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/package.json +2 -2
- package/src/index.ts +2 -2
- package/src/loader.ts +31 -7
- package/src/session-runner.ts +23 -6
- package/src/storage-base/caching-backend.ts +13 -0
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/medium-guard.ts +114 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.5",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.5"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/index.ts
CHANGED
|
@@ -28,13 +28,13 @@ export {
|
|
|
28
28
|
unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
|
|
29
29
|
createBroadcaster, subscribable, createNotifier, scopedNotifier,
|
|
30
30
|
installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
|
|
31
|
-
contextSwitch, onContextQuiesce,
|
|
31
|
+
contextSwitch, machineBusy, quiesced, onContextQuiesce, scheduleAtEdge,
|
|
32
32
|
installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
|
|
33
33
|
currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
|
|
34
34
|
} from '@matatbread/matbot-plugin-api/host';
|
|
35
35
|
export type {
|
|
36
36
|
Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
|
|
37
|
-
UsageScope,
|
|
37
|
+
UsageScope, Quiescer,
|
|
38
38
|
} from '@matatbread/matbot-plugin-api/host';
|
|
39
39
|
export * from './session.js';
|
|
40
40
|
export * from './usage.js';
|
package/src/loader.ts
CHANGED
|
@@ -62,8 +62,17 @@ let freshSeq = 0;
|
|
|
62
62
|
* - `name` — the canonical plugin name, when the host has already derived it (e.g. by
|
|
63
63
|
* reading the resolved package.json). Bypasses `resolver.identify(spec)` — necessary
|
|
64
64
|
* because `spec` (a remote URL, a versioned npm range) may not itself identify the name.
|
|
65
|
+
* - `version` — the package.json `version`, when the host has already read it. Same reason as
|
|
66
|
+
* `name`: `resolver.version(spec)` starts from `spec`, which for a bare name, a URL or a
|
|
67
|
+
* version range locates no manifest at all.
|
|
65
68
|
* - `runtimes` — the package.json `matbotRuntime`, when the host has already read it; used
|
|
66
69
|
* for the pre-import gate in place of `resolver.runtimes(spec)`.
|
|
70
|
+
* - `notes` — what the host noticed while resolving that would explain a failure but does not
|
|
71
|
+
* itself cause one: a dependency it could not satisfy, an import shape it cannot fetch, a fetch
|
|
72
|
+
* that failed before the import was even attempted. Appended to any failure recorded for this
|
|
73
|
+
* spec, because the failure that surfaces is downstream of the cause (a missing dependency
|
|
74
|
+
* arrives as ERR_MODULE_NOT_FOUND naming a file) — and dropped entirely if the plugin loads,
|
|
75
|
+
* since a host cannot tell an unused declaration from a fatal one.
|
|
67
76
|
*
|
|
68
77
|
* @param bustCache When true, the URL imported for each specifier gets a unique
|
|
69
78
|
* `${FRESH_PARAM}` query stamp appended before importing — either a host-pre-resolved
|
|
@@ -91,13 +100,22 @@ let freshSeq = 0;
|
|
|
91
100
|
* only as a confusing empty-result error downstream — fail loudly with the reason instead.
|
|
92
101
|
*/
|
|
93
102
|
export async function loadPlugins(
|
|
94
|
-
specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; runtimes?: readonly Runtime[] })[],
|
|
103
|
+
specifiers: readonly (string | { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] })[],
|
|
95
104
|
services: MatbotMachine,
|
|
96
105
|
bustCache = false,
|
|
97
106
|
prompt?: PromptFn,
|
|
98
107
|
onLoadError: 'skip' | 'throw' = 'skip',
|
|
99
108
|
): Promise<MatbotPlugin[]> {
|
|
100
|
-
const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; runtimes?: readonly Runtime[] });
|
|
109
|
+
const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s) as { spec: string; importSpec?: string; name?: string; version?: string; runtimes?: readonly Runtime[]; notes?: readonly string[] });
|
|
110
|
+
|
|
111
|
+
// A reason reaching a user, decorated with what the host noticed while resolving this spec. The
|
|
112
|
+
// failure a load reports is usually downstream of its cause, and the host is the only layer that
|
|
113
|
+
// saw the cause.
|
|
114
|
+
const notesBySpec = new Map(reqs.filter(r => r.notes !== undefined && r.notes.length > 0).map(r => [r.spec, r.notes!]));
|
|
115
|
+
const withNotes = (spec: string, reason: string): string => {
|
|
116
|
+
const notes = notesBySpec.get(spec);
|
|
117
|
+
return notes === undefined ? reason : `${reason}\nNoted while resolving it:\n${notes.map(n => ` - ${n}`).join('\n')}`;
|
|
118
|
+
};
|
|
101
119
|
if (bustCache) {
|
|
102
120
|
console.debug(
|
|
103
121
|
`[matbot] cache-bust requested for ${specifiers.length} plugin(s); the entry is re-evaluated, ` +
|
|
@@ -118,9 +136,9 @@ export async function loadPlugins(
|
|
|
118
136
|
),
|
|
119
137
|
);
|
|
120
138
|
|
|
121
|
-
const toLoad: { spec: string; importSpec: string; resolvedUrl: string; name?: string; runtimes?: readonly Runtime[] }[] = [];
|
|
139
|
+
const toLoad: { spec: string; importSpec: string; resolvedUrl: string; name?: string; version?: string; runtimes?: readonly Runtime[] }[] = [];
|
|
122
140
|
for (let i = 0; i < reqs.length; i++) {
|
|
123
|
-
const { spec, importSpec, name } = reqs[i]!;
|
|
141
|
+
const { spec, importSpec, name, version } = reqs[i]!;
|
|
124
142
|
const runtimes = declared[i];
|
|
125
143
|
if (runtimes !== undefined && runtimes.length > 0 && !runtimes.includes(CURRENT_RUNTIME)) {
|
|
126
144
|
if (onLoadError === 'throw') {
|
|
@@ -139,6 +157,7 @@ export async function loadPlugins(
|
|
|
139
157
|
// live plugin set). Falls back to the bare spec when the host pre-resolved nothing.
|
|
140
158
|
resolvedUrl: importSpec ?? spec,
|
|
141
159
|
...(name !== undefined ? { name } : {}),
|
|
160
|
+
...(version !== undefined ? { version } : {}),
|
|
142
161
|
...(runtimes !== undefined ? { runtimes } : {}),
|
|
143
162
|
});
|
|
144
163
|
}
|
|
@@ -157,7 +176,8 @@ export async function loadPlugins(
|
|
|
157
176
|
// from a `'load'` failure (import rejection — a bad path, a syntax error). Only the former is
|
|
158
177
|
// permanently-not-a-plugin: it throws NotAPluginError so the `add` flow can roll back the config
|
|
159
178
|
// write, where an import failure may be a fixable typo and is left in config.
|
|
160
|
-
const failLoad = (spec: string,
|
|
179
|
+
const failLoad = (spec: string, rawReason: string, kind: 'load' | 'shape' = 'load'): void => {
|
|
180
|
+
const reason = withNotes(spec, rawReason);
|
|
161
181
|
if (onLoadError === 'throw') throw kind === 'shape' ? notAPluginError(spec, reason) : new Error(reason);
|
|
162
182
|
console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
|
|
163
183
|
recordFailedPlugin({ specifier: spec, error: reason });
|
|
@@ -216,7 +236,11 @@ export async function loadPlugins(
|
|
|
216
236
|
const name = toLoad[i]!.name ?? (services.resolver !== undefined ? await services.resolver.identify(spec) : defaultIdentify(spec));
|
|
217
237
|
const source = sourceOf(spec);
|
|
218
238
|
const runtimes = toLoad[i]!.runtimes;
|
|
219
|
-
|
|
239
|
+
// Like `name`: the host's own reading wins, because it read the package.json it actually
|
|
240
|
+
// imported. `resolver.version(spec)` cannot — `spec` may be a bare name, a URL or a version
|
|
241
|
+
// range, none of which locate a manifest, so a plugin configured by name reported no version.
|
|
242
|
+
const version = toLoad[i]!.version
|
|
243
|
+
?? (services.resolver?.version !== undefined ? await services.resolver.version(spec) : undefined);
|
|
220
244
|
const plugin: MatbotPlugin = {
|
|
221
245
|
...spec_obj,
|
|
222
246
|
name,
|
|
@@ -248,7 +272,7 @@ export async function loadPlugins(
|
|
|
248
272
|
// Graceful but not silent: record the setup failure (skip mode — the boot batch) so the `plugin`
|
|
249
273
|
// tool and web UI can show it. Explicit single loads (`onLoadError: 'throw'`) surface the error to
|
|
250
274
|
// the caller directly, so they are not added to the persistent failed list.
|
|
251
|
-
if (onLoadError === 'skip') recordFailedPlugin({ specifier: spec, name: plugin.name, error: reason });
|
|
275
|
+
if (onLoadError === 'skip') recordFailedPlugin({ specifier: spec, name: plugin.name, error: withNotes(spec, reason) });
|
|
252
276
|
// Roll back partial registration so a failed load leaves no ghost state. setupPlugin
|
|
253
277
|
// can throw after the plugin is already in the registry with some of its tools / hooks /
|
|
254
278
|
// services / provider / storage wired (a wrong-platform plugin touching a missing
|
package/src/session-runner.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type { MatbotPlugin } from './plugin.js';
|
|
|
8
8
|
import type { HookRegistry } from './hooks.js';
|
|
9
9
|
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
|
-
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex } from '@matatbread/matbot-plugin-api';
|
|
12
|
-
import {
|
|
11
|
+
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
|
|
12
|
+
import { machineBusy, withUsageScope } from '@matatbread/matbot-plugin-api/host';
|
|
13
13
|
import { runSession } from './runner.js';
|
|
14
14
|
|
|
15
15
|
export interface SessionRunnerDeps {
|
|
@@ -255,6 +255,22 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
255
255
|
const pump = async (id: string, s: SessionState): Promise<void> => {
|
|
256
256
|
if (s.running) return;
|
|
257
257
|
s.running = true;
|
|
258
|
+
// Hold the machine for the WHOLE queue, not per turn. A deferred machine mutation (the
|
|
259
|
+
// StorageBackend swap, a plugin's deferred edit of this very session) may only land where no
|
|
260
|
+
// operation spans it — and the pump's own store work does not stop at a turn's end: it reads the
|
|
261
|
+
// committed document back for followup, appends markers to it, rewrites it for a retract, and
|
|
262
|
+
// persists the next turn's user message before that turn opens. All of that sits between turns,
|
|
263
|
+
// and a per-turn hold declared it idle. The queue draining is the real boundary — the same one
|
|
264
|
+
// usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
|
|
265
|
+
// so no early return out of the loop below can leave the machine held.
|
|
266
|
+
//
|
|
267
|
+
// It also waits for deferred work to land before taking the hold, which is why there is no separate
|
|
268
|
+
// `quiesced()` here any more: a flusher that rewrites this very session (a `session_edit` deferred
|
|
269
|
+
// out of the last turn) finishes before the loop below reads its copy, or the read would take the
|
|
270
|
+
// pre-edit document and the write-back would put it back. That wait used to be spelled at this call
|
|
271
|
+
// site, and spelling it here made it something the next caller of `machineBusy` could omit without
|
|
272
|
+
// any symptom — so it moved inside the hold it guards.
|
|
273
|
+
return machineBusy(async () => {
|
|
258
274
|
try {
|
|
259
275
|
while (s.queue.length > 0) {
|
|
260
276
|
// Per-submission concat: the head always runs; if the head is a concat submission it absorbs
|
|
@@ -350,8 +366,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
350
366
|
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
351
367
|
// access they trigger) reads it via currentPrincipal(). The consumption MUST happen inside
|
|
352
368
|
// the callback: an async iterator returned out of the scope would lose it before it pulls.
|
|
353
|
-
//
|
|
354
|
-
//
|
|
369
|
+
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
370
|
+
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
355
371
|
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
356
372
|
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
357
373
|
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
@@ -367,7 +383,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
367
383
|
// in something already tracked and needs no coordination of its own.
|
|
368
384
|
await withUsageScope(async usage => {
|
|
369
385
|
s.pendingUsage.push(usage.entries);
|
|
370
|
-
await
|
|
386
|
+
await runAs(head.principal, async () => {
|
|
371
387
|
for await (const ev of runSession({
|
|
372
388
|
session,
|
|
373
389
|
config: { provider: head.provider, traceId: head.traceId },
|
|
@@ -403,7 +419,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
403
419
|
const committed = await deps.store.get(id);
|
|
404
420
|
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
405
421
|
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
406
|
-
await
|
|
422
|
+
await runAs(head.principal, async () => {
|
|
407
423
|
followup = await hooks.runFollowup({
|
|
408
424
|
session: committed,
|
|
409
425
|
resubmitDepth: head.resubmitDepth,
|
|
@@ -530,6 +546,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
530
546
|
notify(s, { type: 'idle', sessionId: id });
|
|
531
547
|
maybeCleanup(id, s);
|
|
532
548
|
}
|
|
549
|
+
});
|
|
533
550
|
};
|
|
534
551
|
|
|
535
552
|
return {
|
|
@@ -138,9 +138,22 @@ export class CachingStorageBackend implements StorageBackend {
|
|
|
138
138
|
private readonly ttlMs: number | undefined;
|
|
139
139
|
private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Forwarded only when the wrapped backend has it. `namespaces` is optional, and its ABSENCE is the
|
|
143
|
+
* signal a caller reads ("this backend cannot be enumerated") — so a decorator that always declared
|
|
144
|
+
* the method would answer for backends that cannot, turning a degradable capability into a runtime
|
|
145
|
+
* failure. Assigned per instance rather than declared as a method so `'namespaces' in backend` stays
|
|
146
|
+
* truthful through the wrapper.
|
|
147
|
+
*
|
|
148
|
+
* Not cached: the cache is per-namespace and populated by reads, so it knows only what has been
|
|
149
|
+
* asked for — never the set of namespaces that exist.
|
|
150
|
+
*/
|
|
151
|
+
readonly namespaces?: () => Promise<string[]>;
|
|
152
|
+
|
|
141
153
|
constructor(inner: StorageBackend, options: CachingOptions = {}) {
|
|
142
154
|
this.inner = inner;
|
|
143
155
|
this.ttlMs = options.ttlMs;
|
|
156
|
+
if (inner.namespaces !== undefined) this.namespaces = () => inner.namespaces!();
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Store, CASResult, StoreQuery, QueryResult } from '@matatbread/matbot-plugin-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fail a write whose read came from a different storage medium.
|
|
5
|
+
*
|
|
6
|
+
* Exactly one `StorageBackend` is active, nothing is migrated between them, and a swap repoints every
|
|
7
|
+
* store proxy at once. A caller that read a document before the swap and writes it after is therefore
|
|
8
|
+
* addressing two different media with one read-modify-write, and neither end can tell: `cas` asks
|
|
9
|
+
* "did this document change?", which the new backend answers about a document it never issued — most
|
|
10
|
+
* often "there is nothing here", at which point an unconditional `set` writes the old backend's
|
|
11
|
+
* document into the new one and the session has silently migrated. Compare-and-swap cannot see this,
|
|
12
|
+
* because the medium is not what it compares.
|
|
13
|
+
*
|
|
14
|
+
* The fix belongs here rather than at whatever happened to be running: the exposure is not particular
|
|
15
|
+
* to the quiescent edge's deferred work — an HTTP tool call has always been able to straddle a swap
|
|
16
|
+
* the same way — so pushing the check up to every caller would be asking each of them to know
|
|
17
|
+
* something only storage knows.
|
|
18
|
+
*
|
|
19
|
+
* The version is the only token tying a read to its write, so it carries the medium: stamped on the
|
|
20
|
+
* way out, checked and stripped on the way in. Stripping is what keeps it invisible — a caller that
|
|
21
|
+
* writes a document straight back (`store.set(id, { ...doc, title })`, which does not bump the
|
|
22
|
+
* version) must not persist a stamp, or the next read would stamp it twice.
|
|
23
|
+
*
|
|
24
|
+
* An UNstamped version is always accepted: that is a document the caller minted rather than read, and
|
|
25
|
+
* a create has no earlier medium to disagree with.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const STAMP = /^m(\d+)~([\s\S]*)$/;
|
|
29
|
+
|
|
30
|
+
interface Stamped { id: string; version: string }
|
|
31
|
+
|
|
32
|
+
function stamp<T extends Stamped>(doc: T, generation: number): T {
|
|
33
|
+
return { ...doc, version: `m${generation}~${doc.version}` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Checked =
|
|
37
|
+
| { ok: true; version: string }
|
|
38
|
+
| { ok: false; from: number };
|
|
39
|
+
|
|
40
|
+
function check(version: string, generation: number): Checked {
|
|
41
|
+
const m = STAMP.exec(version);
|
|
42
|
+
if (m === null) return { ok: true, version }; // minted, not read
|
|
43
|
+
if (Number(m[1]) === generation) return { ok: true, version: m[2]! };
|
|
44
|
+
return { ok: false, from: Number(m[1]) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Strip a stamp wherever one appears, so nothing a caller hands back is persisted with one. */
|
|
48
|
+
function strip<T extends Stamped>(doc: T): T {
|
|
49
|
+
const m = STAMP.exec(doc.version);
|
|
50
|
+
return m === null ? doc : { ...doc, version: m[2]! };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap `inner` so every document it hands out is stamped with the medium generation it came from, and
|
|
55
|
+
* every write is checked against the generation current at the time of the write. `generation` must
|
|
56
|
+
* increase on every backend swap; the wrapper is placed OUTSIDE the swap proxy, so it is one stable
|
|
57
|
+
* object across every swap and a captured store reference keeps working.
|
|
58
|
+
*/
|
|
59
|
+
export function mediumGuard<T extends { id: string; version: string }>(
|
|
60
|
+
inner: Store<T>,
|
|
61
|
+
generation: () => number,
|
|
62
|
+
namespace: string,
|
|
63
|
+
): Store<T> {
|
|
64
|
+
const stale = (op: string, id: string, from: number): string =>
|
|
65
|
+
`${op} on "${namespace}/${id}" read from storage backend generation ${from}, but generation ${generation()} is now active. ` +
|
|
66
|
+
'Nothing is migrated between backends, so this write would put a document from the previous one into its replacement. Re-read and retry.';
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async get(id) {
|
|
70
|
+
const doc = await inner.get(id);
|
|
71
|
+
return doc === null ? null : stamp(doc, generation());
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async query(q: StoreQuery): Promise<QueryResult<T>> {
|
|
75
|
+
const page = await inner.query(q);
|
|
76
|
+
const gen = generation();
|
|
77
|
+
return { ...page, items: page.items.map(d => stamp(d, gen)) };
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async set(id, value) {
|
|
81
|
+
const seen = check(value.version, generation());
|
|
82
|
+
// `set` has no failure channel, and silence is what makes the migration invisible — so it
|
|
83
|
+
// throws. It is reachable only after a swap, holding a document read before it.
|
|
84
|
+
if (!seen.ok) throw new Error(stale('set', id, seen.from));
|
|
85
|
+
await inner.set(id, strip(value));
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async cas(id, expected, next): Promise<CASResult<T>> {
|
|
89
|
+
const seen = check(expected, generation());
|
|
90
|
+
// Reported as a lost CAS rather than thrown: the caller already has a path for "someone else got
|
|
91
|
+
// there first", and this is that, with the medium in the role of the other writer. `current` is
|
|
92
|
+
// read from the backend now in force — the document the caller must reconcile against.
|
|
93
|
+
if (!seen.ok) {
|
|
94
|
+
console.warn(`[matbot] ${stale('cas', id, seen.from)}`);
|
|
95
|
+
const current = await inner.get(id);
|
|
96
|
+
return { ok: false, current: current === null ? null : stamp(current, generation()) };
|
|
97
|
+
}
|
|
98
|
+
const res = await inner.cas(id, seen.version, strip(next));
|
|
99
|
+
const gen = generation();
|
|
100
|
+
if (res.ok) return { ok: true, doc: stamp(res.doc, gen) };
|
|
101
|
+
return { ok: false, current: res.current === null ? null : stamp(res.current, gen) };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async delete(id, expectedVersion) {
|
|
105
|
+
if (expectedVersion === undefined) return inner.delete(id);
|
|
106
|
+
const seen = check(expectedVersion, generation());
|
|
107
|
+
if (!seen.ok) {
|
|
108
|
+
console.warn(`[matbot] ${stale('delete', id, seen.from)}`);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return inner.delete(id, seen.version);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|