@braidlabs/data 0.1.1
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 +41 -0
- package/src/index.d.ts +34 -0
- package/src/index.js +23 -0
- package/src/index.js.map +1 -0
- package/src/lib/adapters.d.ts +74 -0
- package/src/lib/adapters.js +10 -0
- package/src/lib/adapters.js.map +1 -0
- package/src/lib/events.d.ts +141 -0
- package/src/lib/events.js +337 -0
- package/src/lib/events.js.map +1 -0
- package/src/lib/flush.d.ts +49 -0
- package/src/lib/flush.js +60 -0
- package/src/lib/flush.js.map +1 -0
- package/src/lib/indexeddb-driver.d.ts +29 -0
- package/src/lib/indexeddb-driver.js +160 -0
- package/src/lib/indexeddb-driver.js.map +1 -0
- package/src/lib/intents.d.ts +83 -0
- package/src/lib/intents.js +136 -0
- package/src/lib/intents.js.map +1 -0
- package/src/lib/invalidation.d.ts +63 -0
- package/src/lib/invalidation.js +136 -0
- package/src/lib/invalidation.js.map +1 -0
- package/src/lib/locks.d.ts +43 -0
- package/src/lib/locks.js +78 -0
- package/src/lib/locks.js.map +1 -0
- package/src/lib/memory-driver.d.ts +10 -0
- package/src/lib/memory-driver.js +53 -0
- package/src/lib/memory-driver.js.map +1 -0
- package/src/lib/outbox.d.ts +146 -0
- package/src/lib/outbox.js +89 -0
- package/src/lib/outbox.js.map +1 -0
- package/src/lib/partitions.d.ts +70 -0
- package/src/lib/partitions.js +62 -0
- package/src/lib/partitions.js.map +1 -0
- package/src/lib/query.d.ts +235 -0
- package/src/lib/query.js +430 -0
- package/src/lib/query.js.map +1 -0
- package/src/lib/record-store.d.ts +125 -0
- package/src/lib/record-store.js +85 -0
- package/src/lib/record-store.js.map +1 -0
- package/src/lib/tenancy.d.ts +121 -0
- package/src/lib/tenancy.js +220 -0
- package/src/lib/tenancy.js.map +1 -0
package/src/lib/locks.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-context mutual exclusion, for work that must happen once across every tab and realm.
|
|
3
|
+
*
|
|
4
|
+
* The motivating case is flushing the outbox. Storage is shared per origin, so without this every
|
|
5
|
+
* open tab drains the same queue on reconnect — replaying the same mutations several times, against
|
|
6
|
+
* a server that is by definition just coming back. No storage layer helps: IndexedDB makes each
|
|
7
|
+
* *write* atomic, and says nothing about who should be doing the writing.
|
|
8
|
+
*
|
|
9
|
+
* `navigator.locks` is the only primitive that spans realms and tabs. Where it is missing — Node,
|
|
10
|
+
* a non-secure context, an older browser — this degrades to an in-process lock, which is honest
|
|
11
|
+
* about what it can offer: exclusion within one JavaScript context, which is what existed before
|
|
12
|
+
* this and is still better than nothing.
|
|
13
|
+
*/
|
|
14
|
+
/** True when this context has real cross-tab locks rather than the in-process fallback. */
|
|
15
|
+
export function hasCrossContextLocks() {
|
|
16
|
+
return typeof navigator !== 'undefined' && navigator.locks !== undefined;
|
|
17
|
+
}
|
|
18
|
+
export async function withLock(name, run, options = {}) {
|
|
19
|
+
const ifAvailable = options.ifAvailable ?? true;
|
|
20
|
+
if (!hasCrossContextLocks()) {
|
|
21
|
+
return withInProcessLock(name, run, ifAvailable);
|
|
22
|
+
}
|
|
23
|
+
const request = { mode: 'exclusive', ifAvailable };
|
|
24
|
+
if (options.signal) {
|
|
25
|
+
// The spec forbids combining them: a request that is already declining to wait has nothing for
|
|
26
|
+
// an abort signal to interrupt.
|
|
27
|
+
delete request.ifAvailable;
|
|
28
|
+
request.signal = options.signal;
|
|
29
|
+
}
|
|
30
|
+
return navigator.locks.request(name, request, async (lock) => {
|
|
31
|
+
// `ifAvailable` hands back a null lock rather than rejecting when someone else holds it.
|
|
32
|
+
if (!lock)
|
|
33
|
+
return { acquired: false };
|
|
34
|
+
return { acquired: true, value: await run() };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Module-level so every holder in this JavaScript context shares it — two services in one Angular
|
|
39
|
+
* app must exclude each other even when the platform gives us nothing.
|
|
40
|
+
*
|
|
41
|
+
* Per realm, though: realms are separate contexts, so this map is not shared between them. That is
|
|
42
|
+
* precisely the gap `navigator.locks` closes, and the reason the fallback is a fallback.
|
|
43
|
+
*/
|
|
44
|
+
const inProcessLocks = new Map();
|
|
45
|
+
async function withInProcessLock(name, run, ifAvailable) {
|
|
46
|
+
const held = inProcessLocks.get(name);
|
|
47
|
+
if (held && ifAvailable)
|
|
48
|
+
return { acquired: false };
|
|
49
|
+
const release = (async () => {
|
|
50
|
+
if (held)
|
|
51
|
+
await held.catch(() => undefined);
|
|
52
|
+
return run();
|
|
53
|
+
})();
|
|
54
|
+
// Stored before awaiting, so a synchronous second caller sees the lock as held.
|
|
55
|
+
inProcessLocks.set(name, release.catch(() => undefined));
|
|
56
|
+
try {
|
|
57
|
+
return { acquired: true, value: await release };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
// Only clear it if nobody queued behind us, or we would release someone else's turn.
|
|
61
|
+
if (inProcessLocks.get(name) === undefined)
|
|
62
|
+
inProcessLocks.delete(name);
|
|
63
|
+
else if (!held)
|
|
64
|
+
inProcessLocks.delete(name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The lock name for flushing one owner's queue.
|
|
69
|
+
*
|
|
70
|
+
* Per **owner**, not one global lock. Two different applications flush disjoint entries, so making
|
|
71
|
+
* them queue behind each other would serialize work that has no reason to be serialized. The
|
|
72
|
+
* conflict this prevents is the same application open in two tabs, both replaying the same
|
|
73
|
+
* mutations.
|
|
74
|
+
*/
|
|
75
|
+
export function outboxFlushLock(owner) {
|
|
76
|
+
return `skew:outbox:flush:${owner}`;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=locks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locks.js","sourceRoot":"","sources":["../../../../../libs/data/src/lib/locks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAiBH,2FAA2F;AAC3F,MAAM,UAAU,oBAAoB;IAClC,OAAO,OAAO,SAAS,KAAK,WAAW,IAAI,SAAS,CAAC,KAAK,KAAK,SAAS,CAAC;AAC3E,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,IAAY,EACZ,GAAqB,EACrB,UAAuB,EAAE;IAEzB,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC;IAEhD,IAAI,CAAC,oBAAoB,EAAE,EAAE,CAAC;QAC5B,OAAO,iBAAiB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,CAAC;IACnD,CAAC;IAED,MAAM,OAAO,GAAqC,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;IACrF,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACnB,+FAA+F;QAC/F,gCAAgC;QAChC,OAAQ,OAAqC,CAAC,WAAW,CAAC;QACzD,OAAoC,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAChE,CAAC;IAED,OAAO,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAsB,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QAC1E,yFAAyF;QACzF,IAAI,CAAC,IAAI;YAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAmB,CAAC;QACvD,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,EAAE,EAAmB,CAAC;IACjE,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,cAAc,GAAG,IAAI,GAAG,EAA4B,CAAC;AAE3D,KAAK,UAAU,iBAAiB,CAC9B,IAAY,EACZ,GAAqB,EACrB,WAAoB;IAEpB,MAAM,IAAI,GAAG,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAEtC,IAAI,IAAI,IAAI,WAAW;QAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IAEpD,MAAM,OAAO,GAAG,CAAC,KAAK,IAAI,EAAE;QAC1B,IAAI,IAAI;YAAE,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC5C,OAAO,GAAG,EAAE,CAAC;IACf,CAAC,CAAC,EAAE,CAAC;IAEL,gFAAgF;IAChF,cAAc,CAAC,GAAG,CAChB,IAAI,EACJ,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAC/B,CAAC;IAEF,IAAI,CAAC;QACH,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,OAAO,EAAE,CAAC;IAClD,CAAC;YAAS,CAAC;QACT,qFAAqF;QACrF,IAAI,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,SAAS;YAAE,cAAc,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;aACnE,IAAI,CAAC,IAAI;YAAE,cAAc,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC9C,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,qBAAqB,KAAK,EAAE,CAAC;AACtC,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RecordDriver } from './record-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* An in-memory driver.
|
|
4
|
+
*
|
|
5
|
+
* The reference implementation and the one tests run against — every behavior the IndexedDB driver
|
|
6
|
+
* must exhibit is pinned here first, where it is observable without a browser.
|
|
7
|
+
*
|
|
8
|
+
* Not durable, so never the primary store for anything the user would mind losing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function memoryRecordDriver(): RecordDriver;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory driver.
|
|
3
|
+
*
|
|
4
|
+
* The reference implementation and the one tests run against — every behavior the IndexedDB driver
|
|
5
|
+
* must exhibit is pinned here first, where it is observable without a browser.
|
|
6
|
+
*
|
|
7
|
+
* Not durable, so never the primary store for anything the user would mind losing.
|
|
8
|
+
*/
|
|
9
|
+
export function memoryRecordDriver() {
|
|
10
|
+
const collections = new Map();
|
|
11
|
+
const of = (collection) => {
|
|
12
|
+
let records = collections.get(collection);
|
|
13
|
+
if (!records) {
|
|
14
|
+
records = new Map();
|
|
15
|
+
collections.set(collection, records);
|
|
16
|
+
}
|
|
17
|
+
return records;
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
async put(collection, record) {
|
|
21
|
+
// Structured-cloned on the way in so a caller mutating the object it passed cannot reach
|
|
22
|
+
// back into the store — the same rule the real driver gets for free from IndexedDB.
|
|
23
|
+
of(collection).set(record.id, structuredClone(record));
|
|
24
|
+
},
|
|
25
|
+
async get(collection, id) {
|
|
26
|
+
const record = of(collection).get(id);
|
|
27
|
+
return record ? structuredClone(record) : null;
|
|
28
|
+
},
|
|
29
|
+
async delete(collection, id) {
|
|
30
|
+
of(collection).delete(id);
|
|
31
|
+
},
|
|
32
|
+
async list(collection, partition) {
|
|
33
|
+
return [...of(collection).values()]
|
|
34
|
+
.filter((record) => record.partition === partition)
|
|
35
|
+
.sort((a, b) => a.seq - b.seq)
|
|
36
|
+
.map((record) => structuredClone(record));
|
|
37
|
+
},
|
|
38
|
+
async clearPartition(collection, partition) {
|
|
39
|
+
const records = of(collection);
|
|
40
|
+
for (const [id, record] of records) {
|
|
41
|
+
if (record.partition === partition)
|
|
42
|
+
records.delete(id);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async highestSeq(collection) {
|
|
46
|
+
let highest = 0;
|
|
47
|
+
for (const record of of(collection).values())
|
|
48
|
+
highest = Math.max(highest, record.seq);
|
|
49
|
+
return highest;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=memory-driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory-driver.js","sourceRoot":"","sources":["../../../../../libs/data/src/lib/memory-driver.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB;IAChC,MAAM,WAAW,GAAG,IAAI,GAAG,EAAqC,CAAC;IAEjE,MAAM,EAAE,GAAG,CAAC,UAAkB,EAA6B,EAAE;QAC3D,IAAI,OAAO,GAAG,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,GAAG,IAAI,GAAG,EAAE,CAAC;YACpB,WAAW,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;QACvC,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IAEF,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM;YAC1B,yFAAyF;YACzF,oFAAoF;YACpF,EAAE,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC;QACzD,CAAC;QAED,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,EAAE;YACtB,MAAM,MAAM,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YACtC,OAAO,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACjD,CAAC;QAED,KAAK,CAAC,MAAM,CAAC,UAAU,EAAE,EAAE;YACzB,EAAE,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC5B,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,SAAS;YAC9B,OAAO,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC,MAAM,EAAE,CAAC;iBAChC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,KAAK,SAAS,CAAC;iBAClD,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC;iBAC7B,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC;QAC9C,CAAC;QAED,KAAK,CAAC,cAAc,CAAC,UAAU,EAAE,SAAS;YACxC,MAAM,OAAO,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC;YAC/B,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;gBACnC,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;oBAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACzD,CAAC;QACH,CAAC;QAED,KAAK,CAAC,UAAU,CAAC,UAAU;YACzB,IAAI,OAAO,GAAG,CAAC,CAAC;YAChB,KAAK,MAAM,MAAM,IAAI,EAAE,CAAC,UAAU,CAAC,CAAC,MAAM,EAAE;gBAAE,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;YACtF,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { RecordDriver } from './record-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* The offline mutation outbox: durable, shared across every app on the origin, and **owned**.
|
|
4
|
+
*
|
|
5
|
+
* Ownership is the correctness fix, not a refinement. Previously the queue was a single blob under
|
|
6
|
+
* one key, so two applications on one origin overwrote each other's; the second would rehydrate the
|
|
7
|
+
* first's entries, find no registered runner for them, and **drop the user's unsent work** through a
|
|
8
|
+
* reporting hook that was optional. An entry that names its owner is instead simply left alone by
|
|
9
|
+
* everyone else, and waits.
|
|
10
|
+
*
|
|
11
|
+
* One record per entry, so enqueue is a `put` and dequeue is a `delete`. Nothing reads before
|
|
12
|
+
* writing, so nothing can be lost to a concurrent writer.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* What a queued entry predicts about a record while it waits.
|
|
16
|
+
*
|
|
17
|
+
* Data, not a closure, for the same reason `input` is: the overlay has to be reconstructible by a
|
|
18
|
+
* build that never ran the code which queued it. A shallow patch over the confirmed value is the
|
|
19
|
+
* most an entry can promise honestly — anything richer would be a function, and a function cannot
|
|
20
|
+
* survive a reload.
|
|
21
|
+
*/
|
|
22
|
+
export interface OptimisticOverlay {
|
|
23
|
+
/** The query key this entry changes. */
|
|
24
|
+
key: string;
|
|
25
|
+
/** Applied over the confirmed record while the entry is queued. */
|
|
26
|
+
patch: Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* The tenant partition this prediction belongs to.
|
|
29
|
+
*
|
|
30
|
+
* The queue is deliberately *session*-scoped — a queued write belongs to the session that made it,
|
|
31
|
+
* not to whichever client was on screen — but the **overlay is not**. Without this, an advisor with
|
|
32
|
+
* an unsent trade for one client sees it applied to the identically-keyed record of the next client
|
|
33
|
+
* they open, in another tab, because the two share a record key and a queue.
|
|
34
|
+
*
|
|
35
|
+
* Absent on entries written before this existed, which keeps their overlay behaving as it did.
|
|
36
|
+
*/
|
|
37
|
+
partition?: string;
|
|
38
|
+
/**
|
|
39
|
+
* True when the write deletes the record rather than changing it.
|
|
40
|
+
*
|
|
41
|
+
* A patch cannot express absence, and a deletion a reader cannot see is a row that stays on
|
|
42
|
+
* screen after the user removed it — so the overlay carries the fact rather than leaving each
|
|
43
|
+
* reader to infer it from an empty patch.
|
|
44
|
+
*/
|
|
45
|
+
removed?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface OutboxEntry {
|
|
48
|
+
/** Identifies the mutation *kind*, so the owner can find its runner. */
|
|
49
|
+
mutationId: string;
|
|
50
|
+
/** Replayable input. Never a closure — the build that replays may not be the one that queued. */
|
|
51
|
+
input: unknown;
|
|
52
|
+
attempts: number;
|
|
53
|
+
queuedAt: string;
|
|
54
|
+
lastError?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Contract version of `input`, when the caller declares one.
|
|
57
|
+
*
|
|
58
|
+
* Distinct from the envelope's version, which covers this entry's own shape: an entry queued
|
|
59
|
+
* under one deploy and replayed against a later one may need its *payload* migrated before it
|
|
60
|
+
* can be sent, and only the caller knows which contract that payload belongs to.
|
|
61
|
+
*/
|
|
62
|
+
schemaVersion?: number;
|
|
63
|
+
/**
|
|
64
|
+
* What readers should show until this entry is confirmed.
|
|
65
|
+
*
|
|
66
|
+
* A list because one write routinely changes more than one record — accept an order and the
|
|
67
|
+
* position moves too — and an entry that could only predict one of them would leave the rest of
|
|
68
|
+
* the screen showing values the user has already changed.
|
|
69
|
+
*/
|
|
70
|
+
optimistic?: OptimisticOverlay[];
|
|
71
|
+
}
|
|
72
|
+
interface OutboxEntryV1 {
|
|
73
|
+
mutationId: string;
|
|
74
|
+
input: unknown;
|
|
75
|
+
attempts: number;
|
|
76
|
+
queuedAt: string;
|
|
77
|
+
lastError?: string;
|
|
78
|
+
schemaVersion?: number;
|
|
79
|
+
}
|
|
80
|
+
interface OutboxEntryV2 extends OutboxEntryV1 {
|
|
81
|
+
optimistic?: OptimisticOverlay[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The entry contract, versioned from the first commit.
|
|
85
|
+
*
|
|
86
|
+
* A queued mutation can outlive the build that queued it — the user goes offline, the app deploys,
|
|
87
|
+
* they come back. So even this small internal shape gets an envelope: a shared store is a shared
|
|
88
|
+
* contract, and treating it as "just some JSON" because it is internal is how a coordination layer
|
|
89
|
+
* becomes a fate-sharing vector.
|
|
90
|
+
*
|
|
91
|
+
* v2 adds the overlay. An entry queued by v1 migrates up without one and simply does not tint any
|
|
92
|
+
* reader's view — which is the honest answer, since that build never recorded what it predicted.
|
|
93
|
+
* The queue itself is unaffected either way, so an old entry still sends.
|
|
94
|
+
*/
|
|
95
|
+
export declare const OutboxEntrySchema: import("@braidlabs/skew").VersionedSchema<OutboxEntryV2>;
|
|
96
|
+
export interface QueuedEntry extends OutboxEntry {
|
|
97
|
+
id: string;
|
|
98
|
+
owner: string;
|
|
99
|
+
seq: number;
|
|
100
|
+
}
|
|
101
|
+
export interface OutboxOptions {
|
|
102
|
+
driver: RecordDriver;
|
|
103
|
+
/** Which fragment or app this instance speaks for. Only its own entries are replayable by it. */
|
|
104
|
+
owner: string;
|
|
105
|
+
/** Tenant partition. The outbox is page-scoped, so this is usually a constant. */
|
|
106
|
+
partition?: string;
|
|
107
|
+
buildId?: string;
|
|
108
|
+
collection?: string;
|
|
109
|
+
}
|
|
110
|
+
export interface Outbox {
|
|
111
|
+
/** Queues a mutation, returning its id. */
|
|
112
|
+
enqueue(entry: {
|
|
113
|
+
mutationId: string;
|
|
114
|
+
input: unknown;
|
|
115
|
+
id?: string;
|
|
116
|
+
schemaVersion?: number;
|
|
117
|
+
optimistic?: OptimisticOverlay[];
|
|
118
|
+
}): Promise<string>;
|
|
119
|
+
/**
|
|
120
|
+
* Every queued overlay for a key, oldest first, **whoever owns it**.
|
|
121
|
+
*
|
|
122
|
+
* Ownership governs who may *send* an entry; it does not govern who may see its effect. A user
|
|
123
|
+
* who typed a name into one app and looks at another expects to see the name they typed, and an
|
|
124
|
+
* overlay filtered by owner would show them the stale value with no indication why.
|
|
125
|
+
*
|
|
126
|
+
* Tenancy is the one thing that *does* filter. Pass the partition being read and an overlay
|
|
127
|
+
* belonging to a different one is left out — same key, different client, different answer.
|
|
128
|
+
*/
|
|
129
|
+
pendingFor(key: string, partition?: string): Promise<OptimisticOverlay[]>;
|
|
130
|
+
/** Entries this instance owns and may replay, oldest first. */
|
|
131
|
+
mine(): Promise<QueuedEntry[]>;
|
|
132
|
+
/**
|
|
133
|
+
* Every entry in the queue, whoever owns it.
|
|
134
|
+
*
|
|
135
|
+
* What "3 unsent changes" is counted from — a page-wide fact needs a page-wide read, and each app
|
|
136
|
+
* answering only for itself is how the indicator became wrong in the first place.
|
|
137
|
+
*/
|
|
138
|
+
all(): Promise<QueuedEntry[]>;
|
|
139
|
+
/** Entries owned by someone else. They are waiting, not stuck, and must not be replayed here. */
|
|
140
|
+
foreign(): Promise<QueuedEntry[]>;
|
|
141
|
+
remove(id: string): Promise<void>;
|
|
142
|
+
/** Records a failed attempt, keeping the entry queued. */
|
|
143
|
+
recordFailure(id: string, error: string): Promise<number>;
|
|
144
|
+
}
|
|
145
|
+
export declare function createOutbox(options: OutboxOptions): Outbox;
|
|
146
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { versioned } from '@braidlabs/skew';
|
|
2
|
+
import { createRecordStore } from './record-store.js';
|
|
3
|
+
/**
|
|
4
|
+
* The entry contract, versioned from the first commit.
|
|
5
|
+
*
|
|
6
|
+
* A queued mutation can outlive the build that queued it — the user goes offline, the app deploys,
|
|
7
|
+
* they come back. So even this small internal shape gets an envelope: a shared store is a shared
|
|
8
|
+
* contract, and treating it as "just some JSON" because it is internal is how a coordination layer
|
|
9
|
+
* becomes a fate-sharing vector.
|
|
10
|
+
*
|
|
11
|
+
* v2 adds the overlay. An entry queued by v1 migrates up without one and simply does not tint any
|
|
12
|
+
* reader's view — which is the honest answer, since that build never recorded what it predicted.
|
|
13
|
+
* The queue itself is unaffected either way, so an old entry still sends.
|
|
14
|
+
*/
|
|
15
|
+
export const OutboxEntrySchema = versioned('skew-outbox-entry').next('carry the optimistic overlay', {
|
|
16
|
+
up: (entry) => entry,
|
|
17
|
+
down: ({ optimistic: _optimistic, ...rest }) => rest,
|
|
18
|
+
lossy: ['optimistic'],
|
|
19
|
+
});
|
|
20
|
+
const DEFAULT_COLLECTION = 'outbox';
|
|
21
|
+
const DEFAULT_PARTITION = 'session';
|
|
22
|
+
export function createOutbox(options) {
|
|
23
|
+
const partition = options.partition ?? DEFAULT_PARTITION;
|
|
24
|
+
const store = createRecordStore({
|
|
25
|
+
driver: options.driver,
|
|
26
|
+
collection: options.collection ?? DEFAULT_COLLECTION,
|
|
27
|
+
schema: OutboxEntrySchema,
|
|
28
|
+
...(options.buildId === undefined ? {} : { buildId: options.buildId }),
|
|
29
|
+
});
|
|
30
|
+
const toQueued = (record) => ({
|
|
31
|
+
id: record.id,
|
|
32
|
+
owner: record.owner ?? '',
|
|
33
|
+
seq: record.seq,
|
|
34
|
+
...record.value,
|
|
35
|
+
});
|
|
36
|
+
async function entries() {
|
|
37
|
+
return (await store.list(partition)).map(toQueued);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
async enqueue({ mutationId, input, id, schemaVersion, optimistic }) {
|
|
41
|
+
const entryId = id ?? crypto.randomUUID();
|
|
42
|
+
await store.put({
|
|
43
|
+
id: entryId,
|
|
44
|
+
partition,
|
|
45
|
+
owner: options.owner,
|
|
46
|
+
value: {
|
|
47
|
+
mutationId,
|
|
48
|
+
input,
|
|
49
|
+
attempts: 0,
|
|
50
|
+
queuedAt: new Date().toISOString(),
|
|
51
|
+
...(schemaVersion === undefined ? {} : { schemaVersion }),
|
|
52
|
+
...(optimistic === undefined ? {} : { optimistic }),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return entryId;
|
|
56
|
+
},
|
|
57
|
+
async pendingFor(key, forPartition) {
|
|
58
|
+
return (await entries()).flatMap((entry) => (entry.optimistic ?? []).filter((overlay) => overlay.key === key &&
|
|
59
|
+
// An overlay that names no partition predates this and applies as it always did.
|
|
60
|
+
(overlay.partition === undefined || forPartition === undefined || overlay.partition === forPartition)));
|
|
61
|
+
},
|
|
62
|
+
async mine() {
|
|
63
|
+
return (await entries()).filter((entry) => entry.owner === options.owner);
|
|
64
|
+
},
|
|
65
|
+
all: entries,
|
|
66
|
+
async foreign() {
|
|
67
|
+
return (await entries()).filter((entry) => entry.owner !== options.owner);
|
|
68
|
+
},
|
|
69
|
+
remove: (id) => store.delete(id, partition),
|
|
70
|
+
async recordFailure(id, error) {
|
|
71
|
+
const record = await store.get(id, partition);
|
|
72
|
+
// Already gone — a concurrent flush removed it. Not an error worth raising: the entry is in
|
|
73
|
+
// the state the caller wanted it in.
|
|
74
|
+
if (!record)
|
|
75
|
+
return 0;
|
|
76
|
+
const attempts = record.value.attempts + 1;
|
|
77
|
+
await store.put({
|
|
78
|
+
id,
|
|
79
|
+
partition: record.partition,
|
|
80
|
+
...(record.owner === undefined ? {} : { owner: record.owner }),
|
|
81
|
+
// Preserved so a retry does not jump the queue ahead of entries queued after it.
|
|
82
|
+
seq: record.seq,
|
|
83
|
+
value: { ...record.value, attempts, lastError: error },
|
|
84
|
+
});
|
|
85
|
+
return attempts;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=outbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox.js","sourceRoot":"","sources":["../../../../../libs/data/src/lib/outbox.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type RecordDriver } from './record-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Moving records from one partition to another.
|
|
4
|
+
*
|
|
5
|
+
* A partition is a hard boundary — no read crosses one, which is what makes purge complete and a
|
|
6
|
+
* tenant switch a pointer move. That is the right default and it leaves one real case unserved:
|
|
7
|
+
* **a guest who signs in.** Their cart, their draft, their half-finished form all live in the guest
|
|
8
|
+
* partition, and the signed-in customer is a different partition by construction. Somebody has to
|
|
9
|
+
* carry it across, and "somebody" was previously every application, by hand, differently.
|
|
10
|
+
*
|
|
11
|
+
* The important decision here is what gets copied. **Records move as stored bytes — envelope
|
|
12
|
+
* untouched.** Reading each record through a typed store and writing it back would re-envelope it at
|
|
13
|
+
* *this* build's version, so a record written by a newer app would be silently down-projected on the
|
|
14
|
+
* way across, or dropped for being unreadable. Copying the envelope verbatim means a v3 record
|
|
15
|
+
* arrives as a v3 record: a reader that could project it still can, and one that cannot still gets an
|
|
16
|
+
* honest `ahead` rather than quietly lossy data.
|
|
17
|
+
*/
|
|
18
|
+
export type PartitionConflictPolicy = 'skip' | 'overwrite';
|
|
19
|
+
export interface CopyPartitionOptions {
|
|
20
|
+
driver: RecordDriver;
|
|
21
|
+
/** Where the records are now — a guest partition, usually. */
|
|
22
|
+
from: string;
|
|
23
|
+
/** Where they should end up. */
|
|
24
|
+
to: string;
|
|
25
|
+
/**
|
|
26
|
+
* Which collections to carry across.
|
|
27
|
+
*
|
|
28
|
+
* Named rather than "all", because the answer is rarely all of them: a cart should follow its
|
|
29
|
+
* owner, and a queued mutation usually should not — the outbox belongs to the session that made
|
|
30
|
+
* it, and replaying a guest's queued writes as a signed-in customer changes who performed them.
|
|
31
|
+
*/
|
|
32
|
+
collections: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* What to do when the destination already holds a record with the same id.
|
|
35
|
+
*
|
|
36
|
+
* `'skip'` by default, and the default matters: the destination is the account the person has
|
|
37
|
+
* actually signed in to, so its own saved cart is the more authoritative of the two. Overwriting
|
|
38
|
+
* it with whatever a guest session left on this device is how someone's real basket disappears.
|
|
39
|
+
*/
|
|
40
|
+
onConflict?: PartitionConflictPolicy;
|
|
41
|
+
/**
|
|
42
|
+
* `'move'` clears the source afterwards; `'copy'` (default) leaves it alone.
|
|
43
|
+
*
|
|
44
|
+
* Copy is the safer default because the operation is not atomic — an interrupted move that had
|
|
45
|
+
* already deleted the source would lose the records outright, whereas an interrupted copy leaves
|
|
46
|
+
* two readable partitions and a second attempt to make.
|
|
47
|
+
*/
|
|
48
|
+
mode?: 'copy' | 'move';
|
|
49
|
+
}
|
|
50
|
+
export interface CopyPartitionResult {
|
|
51
|
+
copied: number;
|
|
52
|
+
/** Records the destination already had, left as they were. */
|
|
53
|
+
skipped: number;
|
|
54
|
+
/** Records overwritten in the destination, under `onConflict: 'overwrite'`. */
|
|
55
|
+
replaced: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Copies (or moves) every record in `from` into `to`, across the named collections.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* await copyPartition({
|
|
62
|
+
* driver,
|
|
63
|
+
* from: guestPartition,
|
|
64
|
+
* to: tenancy.partition(),
|
|
65
|
+
* collections: ['cart'],
|
|
66
|
+
* mode: 'move',
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function copyPartition(options: CopyPartitionOptions): Promise<CopyPartitionResult>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { withLock } from './locks.js';
|
|
2
|
+
import { storageKey } from './record-store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Copies (or moves) every record in `from` into `to`, across the named collections.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* await copyPartition({
|
|
8
|
+
* driver,
|
|
9
|
+
* from: guestPartition,
|
|
10
|
+
* to: tenancy.partition(),
|
|
11
|
+
* collections: ['cart'],
|
|
12
|
+
* mode: 'move',
|
|
13
|
+
* });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export async function copyPartition(options) {
|
|
17
|
+
const result = { copied: 0, skipped: 0, replaced: 0 };
|
|
18
|
+
// Copying a partition onto itself is a no-op rather than an error: the caller is usually deriving
|
|
19
|
+
// both ends from a sign-in that may not have changed anything.
|
|
20
|
+
if (options.from === options.to)
|
|
21
|
+
return result;
|
|
22
|
+
const onConflict = options.onConflict ?? 'skip';
|
|
23
|
+
const mode = options.mode ?? 'copy';
|
|
24
|
+
// Held so a sign-out purge cannot run through the destination while records are landing in it.
|
|
25
|
+
// Keyed on the destination, which is the partition whose contents this changes.
|
|
26
|
+
const outcome = await withLock(`skew:data:partition:${options.to}`, async () => {
|
|
27
|
+
for (const collection of options.collections) {
|
|
28
|
+
for (const record of await options.driver.list(collection, options.from)) {
|
|
29
|
+
const destinationId = storageKey(options.to, record.key);
|
|
30
|
+
const existing = await options.driver.get(collection, destinationId);
|
|
31
|
+
if (existing && onConflict === 'skip') {
|
|
32
|
+
result.skipped += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
await options.driver.put(collection, {
|
|
36
|
+
// Only the address changes. `envelope` is carried verbatim, so the record keeps the
|
|
37
|
+
// version its writer stamped on it.
|
|
38
|
+
...record,
|
|
39
|
+
id: destinationId,
|
|
40
|
+
partition: options.to,
|
|
41
|
+
});
|
|
42
|
+
if (existing)
|
|
43
|
+
result.replaced += 1;
|
|
44
|
+
else
|
|
45
|
+
result.copied += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (mode === 'move') {
|
|
49
|
+
// After every collection, not per collection: a failure partway through has then left the
|
|
50
|
+
// source intact rather than half-emptied.
|
|
51
|
+
for (const collection of options.collections) {
|
|
52
|
+
await options.driver.clearPartition(collection, options.from);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}, { ifAvailable: false });
|
|
57
|
+
if (!outcome.acquired) {
|
|
58
|
+
throw new Error(`[skew/data] could not take the lock for partition "${options.to}" — nothing was copied`);
|
|
59
|
+
}
|
|
60
|
+
return outcome.value;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=partitions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"partitions.js","sourceRoot":"","sources":["../../../../../libs/data/src/lib/partitions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,UAAU,EAAqB,MAAM,mBAAmB,CAAC;AA6DlE;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAA6B;IAC/D,MAAM,MAAM,GAAwB,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;IAE3E,kGAAkG;IAClG,+DAA+D;IAC/D,IAAI,OAAO,CAAC,IAAI,KAAK,OAAO,CAAC,EAAE;QAAE,OAAO,MAAM,CAAC;IAE/C,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,MAAM,CAAC;IAChD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,MAAM,CAAC;IAEpC,+FAA+F;IAC/F,gFAAgF;IAChF,MAAM,OAAO,GAAG,MAAM,QAAQ,CAC5B,uBAAuB,OAAO,CAAC,EAAE,EAAE,EACnC,KAAK,IAAI,EAAE;QACT,KAAK,MAAM,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;YAC7C,KAAK,MAAM,MAAM,IAAI,MAAM,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;gBACzE,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;gBACzD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC;gBAErE,IAAI,QAAQ,IAAI,UAAU,KAAK,MAAM,EAAE,CAAC;oBACtC,MAAM,CAAC,OAAO,IAAI,CAAC,CAAC;oBACpB,SAAS;gBACX,CAAC;gBAED,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE;oBACnC,oFAAoF;oBACpF,oCAAoC;oBACpC,GAAG,MAAM;oBACT,EAAE,EAAE,aAAa;oBACjB,SAAS,EAAE,OAAO,CAAC,EAAE;iBACtB,CAAC,CAAC;gBAEH,IAAI,QAAQ;oBAAE,MAAM,CAAC,QAAQ,IAAI,CAAC,CAAC;;oBAC9B,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;QAED,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,0FAA0F;YAC1F,0CAA0C;YAC1C,KAAK,MAAM,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;gBAC7C,MAAM,OAAO,CAAC,MAAM,CAAC,cAAc,CAAC,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;YAChE,CAAC;QACH,CAAC;QAED,OAAO,MAAM,CAAC;IAChB,CAAC,EACD,EAAE,WAAW,EAAE,KAAK,EAAE,CACvB,CAAC;IAEF,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CACb,sDAAsD,OAAO,CAAC,EAAE,wBAAwB,CACzF,CAAC;IACJ,CAAC;IAED,OAAO,OAAO,CAAC,KAAK,CAAC;AACvB,CAAC"}
|