snapback2 0.0.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/README.md +127 -0
- package/bin/snapback2.mjs +36 -0
- package/dist/api.d.ts +20 -0
- package/dist/assets.d.ts +34 -0
- package/dist/client-assets.d.ts +26 -0
- package/dist/client-offline.d.ts +94 -0
- package/dist/client-outbox.d.ts +58 -0
- package/dist/client-upload.d.ts +41 -0
- package/dist/client-wire.d.ts +41 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.mjs +5 -0
- package/dist/client.mjs.map +7 -0
- package/dist/compiler-core.mjs +60 -0
- package/dist/compiler-core.mjs.map +7 -0
- package/dist/compiler-lib.d.ts +71 -0
- package/dist/compiler.mjs +5 -0
- package/dist/compiler.mjs.map +7 -0
- package/dist/drain.d.ts +33 -0
- package/dist/expo/files.d.ts +3 -0
- package/dist/expo/index.d.ts +16 -0
- package/dist/expo/token-store.d.ts +7 -0
- package/dist/expo/witness.d.ts +28 -0
- package/dist/expo/witness.mjs +2 -0
- package/dist/expo/witness.mjs.map +7 -0
- package/dist/expo.d.ts +4 -0
- package/dist/expo.mjs +9 -0
- package/dist/expo.mjs.map +7 -0
- package/dist/guide/auth.md +89 -0
- package/dist/guide/effects.md +52 -0
- package/dist/guide/families.json +446 -0
- package/dist/guide/grammar.md +52 -0
- package/dist/guide/live-query.md +27 -0
- package/dist/guide/offline.md +75 -0
- package/dist/guide/personas.md +15 -0
- package/dist/guide/quarry.md +297 -0
- package/dist/guide/testing.md +57 -0
- package/dist/index.d.ts +362 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +7 -0
- package/dist/offline-protocol.d.ts +180 -0
- package/dist/offline-schema.d.ts +1 -0
- package/dist/offline.d.ts +177 -0
- package/dist/react-core.d.ts +91 -0
- package/dist/react-core.mjs +2 -0
- package/dist/react-core.mjs.map +7 -0
- package/dist/react-native/components.d.ts +23 -0
- package/dist/react-native/files.d.ts +43 -0
- package/dist/react-native/source.d.ts +16 -0
- package/dist/react-native.d.ts +4 -0
- package/dist/react-native.mjs +2 -0
- package/dist/react-native.mjs.map +7 -0
- package/dist/react.d.ts +19 -0
- package/dist/react.mjs +2 -0
- package/dist/react.mjs.map +7 -0
- package/dist/sqlite-test.mjs +1720 -0
- package/dist/sqlite-test.mjs.map +7 -0
- package/dist/sse.d.ts +44 -0
- package/dist/store/byte-cache.d.ts +162 -0
- package/dist/store/canonical.d.ts +2 -0
- package/dist/store/indexeddb.d.ts +23 -0
- package/dist/store/locks.d.ts +11 -0
- package/dist/store/outbox.d.ts +113 -0
- package/dist/store/overlay-retirement.d.ts +17 -0
- package/dist/store/projection.d.ts +11 -0
- package/dist/store/range-store.d.ts +388 -0
- package/dist/store/sqlite-driver.conformance.d.ts +7 -0
- package/dist/store/sqlite-driver.d.ts +16 -0
- package/dist/store/sqlite-expo.d.ts +3 -0
- package/dist/store/sqlite.d.ts +31 -0
- package/dist/templates/chat/expo/App.tsx +29 -0
- package/dist/templates/chat/expo/app.json +11 -0
- package/dist/templates/chat/expo/index.js +3 -0
- package/dist/templates/chat/expo/shared/log.js +92 -0
- package/dist/templates/chat/expo/shared/log.ts +130 -0
- package/dist/templates/chat/expo/shared/offline.js +8 -0
- package/dist/templates/chat/expo/shared/offline.ts +9 -0
- package/dist/templates/chat/expo/src/Chat.tsx +23 -0
- package/dist/templates/chat/expo/src/Composer.tsx +23 -0
- package/dist/templates/chat/expo/src/MediaView.tsx +29 -0
- package/dist/templates/chat/expo/src/Witness.tsx +131 -0
- package/dist/templates/chat/expo/src/screens/Inbox.tsx +9 -0
- package/dist/templates/chat/expo/src/screens/Search.tsx +10 -0
- package/dist/templates/chat/expo/src/screens/Thread.tsx +22 -0
- package/dist/templates/chat/expo/src/witness-state.ts +68 -0
- package/dist/templates/chat/expo/tsconfig.json +5 -0
- package/dist/templates/chat/react/index.html +5 -0
- package/dist/templates/chat/react/shared/log.js +92 -0
- package/dist/templates/chat/react/shared/log.ts +130 -0
- package/dist/templates/chat/react/shared/offline.js +8 -0
- package/dist/templates/chat/react/shared/offline.ts +9 -0
- package/dist/templates/chat/react/src/App.tsx +36 -0
- package/dist/templates/chat/react/src/Composer.tsx +45 -0
- package/dist/templates/chat/react/src/Inbox.tsx +24 -0
- package/dist/templates/chat/react/src/Search.tsx +26 -0
- package/dist/templates/chat/react/src/Thread.tsx +126 -0
- package/dist/templates/chat/react/src/env.d.ts +1 -0
- package/dist/templates/chat/react/src/main.tsx +20 -0
- package/dist/templates/chat/shared/log.js +92 -0
- package/dist/templates/chat/shared/log.ts +130 -0
- package/dist/templates/chat/shared/offline.js +8 -0
- package/dist/templates/chat/shared/offline.ts +9 -0
- package/dist/templates/chat/snapback/deliveries.q +33 -0
- package/dist/templates/chat/snapback/delivery.ts +30 -0
- package/dist/templates/chat/snapback/feeds.q +14 -0
- package/dist/templates/chat/snapback/follows.q +11 -0
- package/dist/templates/chat/snapback/groups.q +40 -0
- package/dist/templates/chat/snapback/messages.q +32 -0
- package/dist/templates/chat/snapback/notifications.q +7 -0
- package/dist/templates/chat/snapback/posts.q +4 -0
- package/dist/templates/chat/snapback/profiles.q +17 -0
- package/dist/templates/chat/snapback/schema.q +136 -0
- package/dist/templates/chat/snapback/seed.ts +45 -0
- package/dist/templates/chat/snapback/tests/chat.test.ts +759 -0
- package/dist/templates/react/index.html +5 -0
- package/dist/templates/react/src/App.tsx +5 -0
- package/dist/templates/react/src/main.tsx +18 -0
- package/dist/templates/todos/snapback/schema.q +11 -0
- package/dist/templates/todos/snapback/seed.ts +10 -0
- package/dist/templates/todos/snapback/tests/todos.test.ts +13 -0
- package/dist/templates/todos/snapback/todos.q +6 -0
- package/dist/test-runner.mjs +8541 -0
- package/dist/test-runner.mjs.map +7 -0
- package/dist/test.d.ts +28 -0
- package/dist/test.mjs +8541 -0
- package/dist/test.mjs.map +7 -0
- package/dist/token-store.d.ts +3 -0
- package/dist/twin-hydrate.d.ts +43 -0
- package/dist/twin.d.ts +70 -0
- package/dist/types.d.ts +410 -0
- package/dist/witness-test.mjs +2 -0
- package/dist/witness-test.mjs.map +7 -0
- package/package.json +117 -0
|
@@ -0,0 +1,1720 @@
|
|
|
1
|
+
// packages/snapback2/src/store/sqlite-node.ts
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// packages/snapback2/src/store/sqlite-driver.ts
|
|
7
|
+
function driverQueue() {
|
|
8
|
+
let tail = Promise.resolve();
|
|
9
|
+
return (body) => {
|
|
10
|
+
const result = tail.then(body);
|
|
11
|
+
tail = result.then(() => void 0, () => void 0);
|
|
12
|
+
return result;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// packages/snapback2/src/store/sqlite-node.ts
|
|
17
|
+
function nodeSqliteDriver(path) {
|
|
18
|
+
let database;
|
|
19
|
+
let identity;
|
|
20
|
+
const db = () => database ??= new DatabaseSync(path);
|
|
21
|
+
const queue = driverQueue();
|
|
22
|
+
const executor = {
|
|
23
|
+
async run(sql, params = []) {
|
|
24
|
+
db().prepare(sql).run(...params);
|
|
25
|
+
},
|
|
26
|
+
async get(sql, params = []) {
|
|
27
|
+
return db().prepare(sql).get(...params) ?? void 0;
|
|
28
|
+
},
|
|
29
|
+
async all(sql, params = []) {
|
|
30
|
+
return db().prepare(sql).all(...params);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
get identity() {
|
|
35
|
+
db();
|
|
36
|
+
return identity ??= path === ":memory:" || path === "" ? `sqlite:temporary:${randomUUID()}` : `sqlite:${realpathSync(path)}`;
|
|
37
|
+
},
|
|
38
|
+
run: (sql, params) => queue(() => executor.run(sql, params)),
|
|
39
|
+
get: (sql, params) => queue(() => executor.get(sql, params)),
|
|
40
|
+
all: (sql, params) => queue(() => executor.all(sql, params)),
|
|
41
|
+
exec: (sql) => queue(async () => {
|
|
42
|
+
db().exec(sql);
|
|
43
|
+
}),
|
|
44
|
+
// @ref LLP 1013#3-the-sqlite-carrier — await the async body before COMMIT;
|
|
45
|
+
// a synchronous callback transaction would commit while the body is suspended.
|
|
46
|
+
transaction: (body) => queue(async () => {
|
|
47
|
+
db().exec("BEGIN IMMEDIATE");
|
|
48
|
+
try {
|
|
49
|
+
const result = await body(executor);
|
|
50
|
+
db().exec("COMMIT");
|
|
51
|
+
return result;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
try {
|
|
54
|
+
db().exec("ROLLBACK");
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
close: () => queue(async () => {
|
|
61
|
+
database?.close();
|
|
62
|
+
database = void 0;
|
|
63
|
+
})
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// packages/snapback2/src/store/byte-cache.ts
|
|
68
|
+
var ASSET_ID = /^s2id-[0-9a-f]{32}-[0-9a-f]{16}$/;
|
|
69
|
+
var SESSION_VIEW_LIMIT = 8;
|
|
70
|
+
function byteKey(assetId, width) {
|
|
71
|
+
return width === null ? assetId : `${assetId}.w${width}`;
|
|
72
|
+
}
|
|
73
|
+
function rowKey(ref) {
|
|
74
|
+
return `${ref.table}\0${ref.id}`;
|
|
75
|
+
}
|
|
76
|
+
function positive(value) {
|
|
77
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
78
|
+
}
|
|
79
|
+
function assetRecordId(value) {
|
|
80
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
81
|
+
const record = value;
|
|
82
|
+
const keys = Object.keys(record).sort().join(",");
|
|
83
|
+
if (keys !== "height,id,width" && keys !== "duration,height,id,width") return void 0;
|
|
84
|
+
if (typeof record.id !== "string" || !ASSET_ID.test(record.id)) return void 0;
|
|
85
|
+
if (!positive(record.width) || !positive(record.height)) return void 0;
|
|
86
|
+
if ("duration" in record && !positive(record.duration)) return void 0;
|
|
87
|
+
return record.id;
|
|
88
|
+
}
|
|
89
|
+
function assetIdsIn(value, output = /* @__PURE__ */ new Set()) {
|
|
90
|
+
const id = assetRecordId(value);
|
|
91
|
+
if (id !== void 0) output.add(id);
|
|
92
|
+
else if (Array.isArray(value)) for (const item of value) assetIdsIn(item, output);
|
|
93
|
+
else if (value && typeof value === "object") {
|
|
94
|
+
for (const item of Object.values(value)) assetIdsIn(item, output);
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
98
|
+
function leasedAssets(rows, answers) {
|
|
99
|
+
const leased = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const row of rows) assetIdsIn(row.row, leased);
|
|
101
|
+
for (const answer of answers) if (answer.data !== void 0) assetIdsIn(answer.data, leased);
|
|
102
|
+
return leased;
|
|
103
|
+
}
|
|
104
|
+
function viewedOnlyAssets(state) {
|
|
105
|
+
const viewed = /* @__PURE__ */ new Set();
|
|
106
|
+
for (const record of Object.values(state.viewed)) for (const id of record.assets) viewed.add(id);
|
|
107
|
+
if (viewed.size === 0) return viewed;
|
|
108
|
+
for (const [key2, row] of Object.entries(state.rows)) {
|
|
109
|
+
if (state.viewed[key2]) continue;
|
|
110
|
+
for (const id of assetIdsIn(row.row)) viewed.delete(id);
|
|
111
|
+
}
|
|
112
|
+
return viewed;
|
|
113
|
+
}
|
|
114
|
+
function checkedPut(put) {
|
|
115
|
+
if (!put || typeof put !== "object" || typeof put.assetId !== "string" || !ASSET_ID.test(put.assetId)) {
|
|
116
|
+
throw new TypeError("byte cache put must name an engine-minted asset id");
|
|
117
|
+
}
|
|
118
|
+
if (put.width !== null && !positive(put.width)) throw new TypeError("byte cache put width must be null or a positive integer");
|
|
119
|
+
if (!(put.bytes instanceof Uint8Array)) throw new TypeError("byte cache put bytes must be a Uint8Array");
|
|
120
|
+
if (typeof put.type !== "string") throw new TypeError("byte cache put type must be a string");
|
|
121
|
+
}
|
|
122
|
+
function applyByteSwap(state, swap, leased) {
|
|
123
|
+
const delta = { put: [], remove: [] };
|
|
124
|
+
for (const ref of swap.viewed ?? []) {
|
|
125
|
+
if (!ref || typeof ref.table !== "string" || typeof ref.id !== "string") throw new TypeError("viewed rows must be row references");
|
|
126
|
+
const key2 = rowKey(ref);
|
|
127
|
+
const assets = new Set(state.viewed[key2]?.assets ?? []);
|
|
128
|
+
assetIdsIn(state.rows[key2]?.row, assets);
|
|
129
|
+
state.viewed[key2] = { at: Date.now(), assets: [...assets] };
|
|
130
|
+
}
|
|
131
|
+
const viewedAssets = viewedOnlyAssets(state);
|
|
132
|
+
for (const [key2, entry] of Object.entries(state.assets)) {
|
|
133
|
+
if (leased.has(entry.assetId) && !viewedAssets.has(entry.assetId)) continue;
|
|
134
|
+
delete state.assets[key2];
|
|
135
|
+
delta.remove.push(key2);
|
|
136
|
+
}
|
|
137
|
+
for (const key2 of swap.bytes?.touch ?? []) {
|
|
138
|
+
const entry = state.assets[key2];
|
|
139
|
+
if (entry) entry.lastRead = ++state.clock;
|
|
140
|
+
}
|
|
141
|
+
const ceiling = swap.bytes?.ceiling ?? Number.POSITIVE_INFINITY;
|
|
142
|
+
if (!(ceiling > 0)) throw new TypeError("byte cache ceiling must be positive");
|
|
143
|
+
for (const put of swap.bytes?.put ?? []) {
|
|
144
|
+
checkedPut(put);
|
|
145
|
+
if (!leased.has(put.assetId) || viewedAssets.has(put.assetId)) continue;
|
|
146
|
+
const size = put.bytes.byteLength;
|
|
147
|
+
if (size === 0 || size > ceiling) continue;
|
|
148
|
+
const key2 = byteKey(put.assetId, put.width);
|
|
149
|
+
state.assets[key2] = { assetId: put.assetId, width: put.width, size, type: put.type, lastRead: ++state.clock };
|
|
150
|
+
delta.put.push({ key: key2, bytes: put.bytes });
|
|
151
|
+
}
|
|
152
|
+
let total = Object.values(state.assets).reduce((sum, entry) => sum + entry.size, 0);
|
|
153
|
+
while (total > ceiling) {
|
|
154
|
+
const [key2, victim] = Object.entries(state.assets).reduce((least, item) => item[1].lastRead < least[1].lastRead ? item : least);
|
|
155
|
+
delete state.assets[key2];
|
|
156
|
+
delta.remove.push(key2);
|
|
157
|
+
total -= victim.size;
|
|
158
|
+
}
|
|
159
|
+
delta.put = delta.put.filter((item) => state.assets[item.key] !== void 0);
|
|
160
|
+
delta.remove = [...new Set(delta.remove)].filter((key2) => state.assets[key2] === void 0);
|
|
161
|
+
return delta;
|
|
162
|
+
}
|
|
163
|
+
function isByteCarrier(store) {
|
|
164
|
+
const candidate = store;
|
|
165
|
+
return typeof candidate.readBytes === "function" && typeof candidate.bytes === "function" && typeof candidate.rowsReferencing === "function" && typeof candidate.viewedAsset === "function" && typeof candidate.viewedRows === "function" && typeof candidate.touchBytes === "function";
|
|
166
|
+
}
|
|
167
|
+
function checkedAssetId(assetId) {
|
|
168
|
+
if (typeof assetId !== "string" || !ASSET_ID.test(assetId)) throw new TypeError("asset id must be engine-minted");
|
|
169
|
+
return assetId;
|
|
170
|
+
}
|
|
171
|
+
var ByteCache = class {
|
|
172
|
+
constructor(store, options) {
|
|
173
|
+
this.store = store;
|
|
174
|
+
this.options = options;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* View-once bytes stay renderable inside the instance that viewed them, keyed by
|
|
178
|
+
* the partition that viewed them; nothing here is durable. The runtime forgets it
|
|
179
|
+
* on seal, wipe, partition change, and close, and `get` serves it only under the
|
|
180
|
+
* custody the carrier path applies: this partition, unsealed, the row still held.
|
|
181
|
+
*/
|
|
182
|
+
session = /* @__PURE__ */ new Map();
|
|
183
|
+
partition;
|
|
184
|
+
custody = 0;
|
|
185
|
+
custodyListeners = /* @__PURE__ */ new Set();
|
|
186
|
+
getCustody = () => this.custody;
|
|
187
|
+
subscribeCustody = (listener) => {
|
|
188
|
+
this.custodyListeners.add(listener);
|
|
189
|
+
return () => {
|
|
190
|
+
this.custodyListeners.delete(listener);
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
admissions = Promise.resolve();
|
|
194
|
+
/** The partition the runtime opened; a change drops every session blob. */
|
|
195
|
+
bind(partition) {
|
|
196
|
+
if (this.partition !== partition) this.forget();
|
|
197
|
+
this.partition = partition;
|
|
198
|
+
}
|
|
199
|
+
/** Drops the session blobs: seal, close, and wipe call this. */
|
|
200
|
+
forget() {
|
|
201
|
+
this.custody++;
|
|
202
|
+
this.session.clear();
|
|
203
|
+
for (const listener of this.custodyListeners) listener();
|
|
204
|
+
}
|
|
205
|
+
async get(assetId, width, viewing) {
|
|
206
|
+
const held = await this.getBytes(assetId, width, viewing);
|
|
207
|
+
return held ? new Blob([held.bytes], { type: held.type }) : void 0;
|
|
208
|
+
}
|
|
209
|
+
// @ref LLP 1013#2-decisions — native paths exchange bytes, never RN Blobs.
|
|
210
|
+
async getBytes(assetId, width, viewing) {
|
|
211
|
+
const custody = this.custody;
|
|
212
|
+
checkedAssetId(assetId);
|
|
213
|
+
const store = this.carrier();
|
|
214
|
+
if (!store) return void 0;
|
|
215
|
+
if (await store.viewedAsset(assetId)) {
|
|
216
|
+
const held2 = await this.sessionBytes(assetId, width, viewing);
|
|
217
|
+
return custody === this.custody ? held2 : void 0;
|
|
218
|
+
}
|
|
219
|
+
if (await this.viewOnce(assetId)) return void 0;
|
|
220
|
+
const held = await store.readBytes(assetId, width);
|
|
221
|
+
if (!held) return void 0;
|
|
222
|
+
await store.touchBytes([byteKey(assetId, width)]).catch(() => void 0);
|
|
223
|
+
return await this.canServe(assetId, viewing) && custody === this.custody ? held : void 0;
|
|
224
|
+
}
|
|
225
|
+
/** Completion fence for both a cached source and an uncached ordinary source. */
|
|
226
|
+
async canServe(assetId, viewing) {
|
|
227
|
+
const custody = this.custody;
|
|
228
|
+
const store = this.carrier();
|
|
229
|
+
if (!store) return false;
|
|
230
|
+
const rows = await store.rowsReferencing(assetId);
|
|
231
|
+
if (rows.length === 0) return false;
|
|
232
|
+
const viewed = await store.viewedAsset(assetId);
|
|
233
|
+
const once = rows.every((row) => this.options.viewOnce?.(row.table, row.row) === true);
|
|
234
|
+
const owns = !viewed && !once || [...this.session.entries()].some(([key2, entry]) => entry.viewing === viewing && key2.startsWith(this.sessionKey(assetId)));
|
|
235
|
+
return custody === this.custody && owns;
|
|
236
|
+
}
|
|
237
|
+
async viewed(assetId) {
|
|
238
|
+
checkedAssetId(assetId);
|
|
239
|
+
const store = this.carrier();
|
|
240
|
+
return store ? store.viewedAsset(assetId) : false;
|
|
241
|
+
}
|
|
242
|
+
async viewOnce(assetId) {
|
|
243
|
+
checkedAssetId(assetId);
|
|
244
|
+
const store = this.carrier();
|
|
245
|
+
if (!store) return false;
|
|
246
|
+
const once = await this.viewOnceRows(assetId);
|
|
247
|
+
if (once.length === 0) return false;
|
|
248
|
+
const viewed = new Set((await store.viewedRows()).map(rowKey));
|
|
249
|
+
return once.some((ref) => !viewed.has(rowKey(ref)));
|
|
250
|
+
}
|
|
251
|
+
async markViewed(assetId) {
|
|
252
|
+
checkedAssetId(assetId);
|
|
253
|
+
const store = this.carrier();
|
|
254
|
+
if (!store) return false;
|
|
255
|
+
const once = await this.viewOnceRows(assetId);
|
|
256
|
+
if (once.length === 0) return false;
|
|
257
|
+
await store.commit({ viewed: once });
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
/** After a network fetch: cache under the row's lease, or mark the view-once rows viewed and cache nothing. */
|
|
261
|
+
async put(assetId, width, blob, viewing) {
|
|
262
|
+
const custody = this.custody;
|
|
263
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
264
|
+
if (custody !== this.custody) return "refused";
|
|
265
|
+
return this.putBytes(assetId, width, { bytes, type: blob.type }, viewing);
|
|
266
|
+
}
|
|
267
|
+
putBytes(assetId, width, value, viewing) {
|
|
268
|
+
const custody = this.custody;
|
|
269
|
+
const result = this.admissions.then(() => custody === this.custody ? this.admit(assetId, width, value, viewing) : "refused");
|
|
270
|
+
this.admissions = result.catch(() => void 0);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
async admit(assetId, width, value, viewing) {
|
|
274
|
+
const custody = this.custody;
|
|
275
|
+
checkedAssetId(assetId);
|
|
276
|
+
checkedPut({ assetId, width, ...value });
|
|
277
|
+
const store = this.carrier();
|
|
278
|
+
if (!store) return "unleased";
|
|
279
|
+
if (await store.viewedAsset(assetId)) return "refused";
|
|
280
|
+
const rows = await store.rowsReferencing(assetId);
|
|
281
|
+
if (rows.length === 0 || custody !== this.custody) return "unleased";
|
|
282
|
+
const once = rows.filter((row) => this.options.viewOnce?.(row.table, row.row) === true).map(({ table, id }) => ({ table, id }));
|
|
283
|
+
if (once.length > 0 && !viewing) return "refused";
|
|
284
|
+
if (once.length === rows.length) {
|
|
285
|
+
try {
|
|
286
|
+
await store.commit({ viewed: once });
|
|
287
|
+
} catch {
|
|
288
|
+
return "refused";
|
|
289
|
+
}
|
|
290
|
+
if (custody !== this.custody || (await store.rowsReferencing(assetId)).length === 0 || !await store.viewedAsset(assetId) || custody !== this.custody) return "unleased";
|
|
291
|
+
if (this.session.size >= SESSION_VIEW_LIMIT) this.session.delete(this.session.keys().next().value);
|
|
292
|
+
if (viewing) this.session.set(this.sessionKey(assetId, width), { viewing, value: { bytes: value.bytes.slice(), type: value.type } });
|
|
293
|
+
return "viewed";
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await store.commit({
|
|
297
|
+
...once.length === 0 ? {} : { viewed: once },
|
|
298
|
+
bytes: { put: [{ assetId, width, ...value }], ceiling: this.options.maxAssetBytes }
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
return "refused";
|
|
302
|
+
}
|
|
303
|
+
const status = await store.bytes();
|
|
304
|
+
return custody === this.custody && status.entries.some((entry) => entry.assetId === assetId && entry.width === width) ? "cached" : "refused";
|
|
305
|
+
}
|
|
306
|
+
async status() {
|
|
307
|
+
const store = this.carrier();
|
|
308
|
+
return store ? store.bytes() : { entries: [], totalBytes: 0, viewed: [] };
|
|
309
|
+
}
|
|
310
|
+
/** Nothing is served, marked, or cached without an open partition and a byte carrier. */
|
|
311
|
+
carrier() {
|
|
312
|
+
return this.partition !== void 0 && isByteCarrier(this.store) ? this.store : void 0;
|
|
313
|
+
}
|
|
314
|
+
sessionKey(assetId, width) {
|
|
315
|
+
return `${this.partition}\0${assetId}\0${width === void 0 ? "" : width}`;
|
|
316
|
+
}
|
|
317
|
+
/** The session blob, only while the row that was viewed is still held on this partition. */
|
|
318
|
+
async sessionBytes(assetId, width, viewing) {
|
|
319
|
+
const store = this.carrier();
|
|
320
|
+
if (!store) return void 0;
|
|
321
|
+
const key2 = this.sessionKey(assetId, width);
|
|
322
|
+
const blob = this.session.get(key2);
|
|
323
|
+
if (!viewing || blob?.viewing !== viewing) return void 0;
|
|
324
|
+
if ((await store.rowsReferencing(assetId)).length > 0) return blob.value;
|
|
325
|
+
this.session.delete(key2);
|
|
326
|
+
return void 0;
|
|
327
|
+
}
|
|
328
|
+
async viewOnceRows(assetId) {
|
|
329
|
+
const store = this.carrier();
|
|
330
|
+
if (!store) return [];
|
|
331
|
+
const rows = await store.rowsReferencing(assetId);
|
|
332
|
+
return rows.filter((row) => this.options.viewOnce?.(row.table, row.row) === true).map(({ table, id }) => ({ table, id }));
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// packages/snapback2/src/store/canonical.ts
|
|
337
|
+
function canonical(value) {
|
|
338
|
+
if (value === void 0) return "undefined";
|
|
339
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
340
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
341
|
+
return `{${Object.keys(value).sort().map((key2) => `${JSON.stringify(key2)}:${canonical(value[key2])}`).join(",")}}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// packages/snapback2/src/store/overlay-retirement.ts
|
|
345
|
+
function object(value) {
|
|
346
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
347
|
+
}
|
|
348
|
+
function pagePosition(page, mark) {
|
|
349
|
+
if (page.op !== mark.op || !object(page.args) || !object(mark.args)) return void 0;
|
|
350
|
+
const cursor = page.cursorArg ?? mark.cursorArg;
|
|
351
|
+
const base = { ...mark.args };
|
|
352
|
+
const args = { ...page.args };
|
|
353
|
+
delete base[Object.hasOwn(base, mark.cursorArg) ? mark.cursorArg : cursor];
|
|
354
|
+
delete args[cursor];
|
|
355
|
+
if (canonical(base) !== canonical(args)) return void 0;
|
|
356
|
+
const position = page.args[cursor];
|
|
357
|
+
return position === null || typeof position === "string" ? position : void 0;
|
|
358
|
+
}
|
|
359
|
+
function cursorChain(pages, mark, committedSeq) {
|
|
360
|
+
const links = /* @__PURE__ */ new Map();
|
|
361
|
+
for (const page of pages) {
|
|
362
|
+
if ((page.nextSeq ?? page.seq) < committedSeq || typeof page.next !== "string") continue;
|
|
363
|
+
const position2 = pagePosition(page, mark);
|
|
364
|
+
if (position2 === void 0) continue;
|
|
365
|
+
links.set(position2, links.has(position2) && links.get(position2) !== page.next ? null : page.next);
|
|
366
|
+
}
|
|
367
|
+
const seen = /* @__PURE__ */ new Set([mark.watermark]);
|
|
368
|
+
const chain = [];
|
|
369
|
+
let position = mark.watermark;
|
|
370
|
+
while (links.has(position)) {
|
|
371
|
+
const next = links.get(position);
|
|
372
|
+
if (next == null || seen.has(next)) break;
|
|
373
|
+
seen.add(next);
|
|
374
|
+
chain.push(next);
|
|
375
|
+
position = next;
|
|
376
|
+
}
|
|
377
|
+
return chain;
|
|
378
|
+
}
|
|
379
|
+
function advanceOverlay(mark, tail, chain) {
|
|
380
|
+
if (!tail || pagePosition({ ...tail, args: object(tail.args) ? { ...tail.args, [tail.cursorArg ?? mark.cursorArg]: tail.watermark } : tail.args }, mark) === void 0) return false;
|
|
381
|
+
if (typeof tail.watermark !== "string" || (mark.consumed ?? []).includes(tail.watermark)) return false;
|
|
382
|
+
const at = chain.indexOf(tail.watermark);
|
|
383
|
+
if (mark.progress !== void 0 && !chain.includes(mark.progress)) {
|
|
384
|
+
mark.progress = [...mark.consumed ?? []].reverse().find((position) => chain.includes(position));
|
|
385
|
+
}
|
|
386
|
+
const previous = mark.progress === void 0 ? -1 : chain.indexOf(mark.progress);
|
|
387
|
+
if (at < 0 || at <= previous) return false;
|
|
388
|
+
mark.progress = tail.watermark;
|
|
389
|
+
mark.consumed = [.../* @__PURE__ */ new Set([...mark.consumed ?? [], ...chain.slice(0, at + 1)])];
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// packages/snapback2/src/store/range-store.ts
|
|
394
|
+
var OfflineStoreFailure = class extends Error {
|
|
395
|
+
constructor(code, message, rewrite) {
|
|
396
|
+
super(message);
|
|
397
|
+
this.code = code;
|
|
398
|
+
this.rewrite = rewrite;
|
|
399
|
+
this.name = "OfflineStoreFailure";
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
var NO_BYTES = { put: [], remove: [] };
|
|
403
|
+
function emptyState() {
|
|
404
|
+
return {
|
|
405
|
+
version: 1,
|
|
406
|
+
sealed: false,
|
|
407
|
+
clock: 0,
|
|
408
|
+
rows: {},
|
|
409
|
+
answers: {},
|
|
410
|
+
watermarks: {},
|
|
411
|
+
outbox: {},
|
|
412
|
+
grants: {},
|
|
413
|
+
overlay: {},
|
|
414
|
+
overlayMisses: {},
|
|
415
|
+
overlayWatermarks: {},
|
|
416
|
+
overlayNegatives: {},
|
|
417
|
+
bounds: {},
|
|
418
|
+
programs: {},
|
|
419
|
+
negative: {},
|
|
420
|
+
rowSeq: {},
|
|
421
|
+
assets: {},
|
|
422
|
+
viewed: {}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function clone(value) {
|
|
426
|
+
return structuredClone(value);
|
|
427
|
+
}
|
|
428
|
+
function canonicalAnswerKey(op, args) {
|
|
429
|
+
return `${op}\0${canonical(args === void 0 ? args : jsonState(args))}`;
|
|
430
|
+
}
|
|
431
|
+
function rowKey2(ref) {
|
|
432
|
+
return `${ref.table}\0${ref.id}`;
|
|
433
|
+
}
|
|
434
|
+
function viewedRef(key2) {
|
|
435
|
+
const at = key2.indexOf(String.fromCharCode(0));
|
|
436
|
+
return { table: key2.slice(0, at), id: key2.slice(at + 1) };
|
|
437
|
+
}
|
|
438
|
+
function recordEviction(evictions, eviction) {
|
|
439
|
+
const key2 = rowKey2(eviction);
|
|
440
|
+
if ((evictions.get(key2)?.seq ?? -1) <= eviction.seq) evictions.set(key2, eviction);
|
|
441
|
+
}
|
|
442
|
+
function asRowRef(value) {
|
|
443
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
444
|
+
const marker = value.$row;
|
|
445
|
+
return marker !== null && typeof marker === "object" && !Array.isArray(marker) && Object.keys(marker).sort().join(",") === "id,table" && typeof marker.table === "string" && typeof marker.id === "string" ? { table: marker.table, id: marker.id } : void 0;
|
|
446
|
+
}
|
|
447
|
+
function collectRefs(value, refs = /* @__PURE__ */ new Map()) {
|
|
448
|
+
const ref = asRowRef(value);
|
|
449
|
+
if (ref) refs.set(rowKey2(ref), ref);
|
|
450
|
+
else if (Array.isArray(value)) for (const item of value) collectRefs(item, refs);
|
|
451
|
+
else if (value && typeof value === "object") {
|
|
452
|
+
for (const item of Object.values(value)) collectRefs(item, refs);
|
|
453
|
+
}
|
|
454
|
+
return [...refs.values()];
|
|
455
|
+
}
|
|
456
|
+
function expand(value, rows, viewed) {
|
|
457
|
+
const ref = asRowRef(value);
|
|
458
|
+
if (ref) return viewed?.[rowKey2(ref)] ? null : rows[rowKey2(ref)]?.row ?? null;
|
|
459
|
+
if (Array.isArray(value)) return value.map((item) => expand(item, rows, viewed));
|
|
460
|
+
if (!value || typeof value !== "object") return value;
|
|
461
|
+
return Object.fromEntries(Object.entries(value).map(([key2, item]) => [key2, expand(item, rows, viewed)]));
|
|
462
|
+
}
|
|
463
|
+
function hiddenRefs(before, after, output = /* @__PURE__ */ new Map()) {
|
|
464
|
+
if (after === null || after === void 0) {
|
|
465
|
+
for (const ref2 of collectRefs(before)) output.set(rowKey2(ref2), ref2);
|
|
466
|
+
return [...output.values()];
|
|
467
|
+
}
|
|
468
|
+
const ref = asRowRef(before);
|
|
469
|
+
if (ref && after === null) output.set(rowKey2(ref), ref);
|
|
470
|
+
else if (Array.isArray(before) && Array.isArray(after)) {
|
|
471
|
+
for (let index = 0; index < before.length; index += 1) {
|
|
472
|
+
hiddenRefs(before[index], index < after.length ? after[index] : void 0, output);
|
|
473
|
+
}
|
|
474
|
+
} else if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
475
|
+
for (const key2 of Object.keys(before)) {
|
|
476
|
+
hiddenRefs(
|
|
477
|
+
before[key2],
|
|
478
|
+
Object.prototype.hasOwnProperty.call(after, key2) ? after[key2] : void 0,
|
|
479
|
+
output
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return [...output.values()];
|
|
484
|
+
}
|
|
485
|
+
function restoreRetained(before, after, retained) {
|
|
486
|
+
const ref = asRowRef(before);
|
|
487
|
+
if (ref && (after === null || after === void 0) && retained.has(rowKey2(ref))) return before;
|
|
488
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
489
|
+
const result = after.map((item, index) => index < before.length ? restoreRetained(before[index], item, retained) : item);
|
|
490
|
+
for (let index = after.length; index < before.length; index += 1) {
|
|
491
|
+
if (collectRefs(before[index]).some((item) => retained.has(rowKey2(item)))) result.push(clone(before[index]));
|
|
492
|
+
}
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
496
|
+
const result = clone(after);
|
|
497
|
+
for (const key2 of Object.keys(before)) {
|
|
498
|
+
const old = before[key2];
|
|
499
|
+
if (Object.prototype.hasOwnProperty.call(result, key2)) result[key2] = restoreRetained(old, result[key2], retained);
|
|
500
|
+
else if (collectRefs(old).some((item) => retained.has(rowKey2(item)))) result[key2] = clone(old);
|
|
501
|
+
}
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
if ((after === null || after === void 0) && collectRefs(before).some((item) => retained.has(rowKey2(item)))) return clone(before);
|
|
505
|
+
return after;
|
|
506
|
+
}
|
|
507
|
+
function sameRange(left, right) {
|
|
508
|
+
return left.site === right.site && left.table === right.table && left.index === right.index && canonical(left.prefix) === canonical(right.prefix) && left.order === right.order && canonical(left.after) === canonical(right.after) && canonical(left.before) === canonical(right.before) && canonical(left.gt) === canonical(right.gt) && canonical(left.gte) === canonical(right.gte) && canonical(left.lt) === canonical(right.lt) && canonical(left.lte) === canonical(right.lte) && left.ruleHash === right.ruleHash;
|
|
509
|
+
}
|
|
510
|
+
function compareValue(left, right) {
|
|
511
|
+
if (left === right) return 0;
|
|
512
|
+
if (typeof left === "number" && typeof right === "number") return left < right ? -1 : 1;
|
|
513
|
+
if (typeof left === "string" && typeof right === "string") return left < right ? -1 : 1;
|
|
514
|
+
if (typeof left === "boolean" && typeof right === "boolean") return left ? 1 : -1;
|
|
515
|
+
const a = canonical(left);
|
|
516
|
+
const b = canonical(right);
|
|
517
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
518
|
+
}
|
|
519
|
+
function compareKey(left, right) {
|
|
520
|
+
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
|
|
521
|
+
const order = compareValue(left[index], right[index]);
|
|
522
|
+
if (order !== 0) return order;
|
|
523
|
+
}
|
|
524
|
+
return left.length - right.length;
|
|
525
|
+
}
|
|
526
|
+
function indexKey(schema, range, row) {
|
|
527
|
+
const columns = schema?.tables?.[range.table]?.indexes?.[range.index]?.columns;
|
|
528
|
+
if (!row || !Array.isArray(columns) || !columns.every((item) => typeof item === "string")) return void 0;
|
|
529
|
+
const key2 = columns.map((column) => row.row[column]);
|
|
530
|
+
return key2.some((item) => item === void 0) ? void 0 : key2;
|
|
531
|
+
}
|
|
532
|
+
function cursorKey(value, width) {
|
|
533
|
+
if (value === null || value === void 0) return void 0;
|
|
534
|
+
if (Array.isArray(value)) return value.length === width ? value : void 0;
|
|
535
|
+
return width === 1 ? [value] : void 0;
|
|
536
|
+
}
|
|
537
|
+
function insidePrefixBounds(key2, range) {
|
|
538
|
+
if (key2.length < range.prefix.length || compareKey(key2.slice(0, range.prefix.length), range.prefix) !== 0) return false;
|
|
539
|
+
const suffix = key2.slice(range.prefix.length);
|
|
540
|
+
const after = cursorKey(range.after, suffix.length);
|
|
541
|
+
const before = cursorKey(range.before, suffix.length);
|
|
542
|
+
if (after && (range.order === "Asc" ? compareKey(suffix, after) <= 0 : compareKey(suffix, after) >= 0)) return false;
|
|
543
|
+
if (before && (range.order === "Asc" ? compareKey(suffix, before) >= 0 : compareKey(suffix, before) <= 0)) return false;
|
|
544
|
+
const first = suffix[0];
|
|
545
|
+
if (range.gt !== null && range.gt !== void 0 && compareValue(first, range.gt) <= 0) return false;
|
|
546
|
+
if (range.gte !== null && range.gte !== void 0 && compareValue(first, range.gte) < 0) return false;
|
|
547
|
+
if (range.lt !== null && range.lt !== void 0 && compareValue(first, range.lt) >= 0) return false;
|
|
548
|
+
if (range.lte !== null && range.lte !== void 0 && compareValue(first, range.lte) > 0) return false;
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
function inFreshExtent(id, held, fresh, rows, schema) {
|
|
552
|
+
const key2 = indexKey(schema, held, rows[rowKey2({ table: held.table, id })]);
|
|
553
|
+
if (!key2) return fresh.stop === "Exhausted";
|
|
554
|
+
if (!insidePrefixBounds(key2, fresh)) return false;
|
|
555
|
+
if (fresh.stop === "Exhausted") return true;
|
|
556
|
+
const presentKeys = fresh.rowIds.map((rowId) => indexKey(schema, fresh, rows[rowKey2({ table: fresh.table, id: rowId })])).filter(Array.isArray);
|
|
557
|
+
if (presentKeys.length === 0) return false;
|
|
558
|
+
const first = presentKeys.reduce((a, b) => compareKey(a, b) <= 0 ? a : b);
|
|
559
|
+
const last = presentKeys.reduce((a, b) => compareKey(a, b) >= 0 ? a : b);
|
|
560
|
+
return compareKey(key2, first) >= 0 && compareKey(key2, last) <= 0;
|
|
561
|
+
}
|
|
562
|
+
function jsonState(value) {
|
|
563
|
+
return JSON.parse(JSON.stringify(value));
|
|
564
|
+
}
|
|
565
|
+
function normalizeState(value) {
|
|
566
|
+
if (!value) return emptyState();
|
|
567
|
+
value = jsonState(value);
|
|
568
|
+
if (value.version !== 1 || typeof value.rows !== "object" || typeof value.answers !== "object") {
|
|
569
|
+
throw new OfflineStoreFailure("E_OFFLINE_CARRIER", "range store contains an unsupported format");
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
...value,
|
|
573
|
+
watermarks: value.watermarks ?? {},
|
|
574
|
+
outbox: value.outbox ?? {},
|
|
575
|
+
grants: value.grants ?? {},
|
|
576
|
+
overlay: value.overlay ?? {},
|
|
577
|
+
overlayMisses: value.overlayMisses ?? {},
|
|
578
|
+
overlayWatermarks: value.overlayWatermarks ?? {},
|
|
579
|
+
overlayNegatives: value.overlayNegatives ?? {},
|
|
580
|
+
bounds: value.bounds ?? {},
|
|
581
|
+
programs: value.programs ?? {},
|
|
582
|
+
negative: value.negative ?? {},
|
|
583
|
+
rowSeq: value.rowSeq ?? {},
|
|
584
|
+
assets: value.assets ?? {},
|
|
585
|
+
viewed: value.viewed ?? {}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function tailWatermark(watermarks) {
|
|
589
|
+
return [...watermarks].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || a.updatedAt - b.updatedAt || canonicalAnswerKey(a.op, a.args).localeCompare(canonicalAnswerKey(b.op, b.args)))[0];
|
|
590
|
+
}
|
|
591
|
+
var OVERLAY_PATIENCE = 2;
|
|
592
|
+
function outboxOrder(entries) {
|
|
593
|
+
return Object.values(entries).sort((a, b) => (a.order ?? a.createdAt) - (b.order ?? b.createdAt) || a.createdAt - b.createdAt || (a.intent < b.intent ? -1 : 1));
|
|
594
|
+
}
|
|
595
|
+
var PersistentRangeStore = class {
|
|
596
|
+
constructor(backend) {
|
|
597
|
+
this.backend = backend;
|
|
598
|
+
}
|
|
599
|
+
state = emptyState();
|
|
600
|
+
partition;
|
|
601
|
+
tail = Promise.resolve();
|
|
602
|
+
outbox = {
|
|
603
|
+
all: async () => this.read(() => {
|
|
604
|
+
this.ensureUnsealed();
|
|
605
|
+
return clone(outboxOrder(this.state.outbox));
|
|
606
|
+
}),
|
|
607
|
+
get: async (intent) => this.read(() => {
|
|
608
|
+
this.ensureUnsealed();
|
|
609
|
+
return this.state.outbox[intent] && clone(this.state.outbox[intent]);
|
|
610
|
+
}),
|
|
611
|
+
put: async (entry) => this.commit({ outbox: { put: [entry] } }),
|
|
612
|
+
remove: async (intent) => this.commit({ outbox: { remove: [intent] } }),
|
|
613
|
+
grant: async () => this.read(() => {
|
|
614
|
+
this.ensureUnsealed();
|
|
615
|
+
const current = this.state.currentGrant === void 0 ? void 0 : this.state.grants[this.state.currentGrant];
|
|
616
|
+
return current && clone(current);
|
|
617
|
+
}),
|
|
618
|
+
grants: async () => this.read(() => {
|
|
619
|
+
this.ensureUnsealed();
|
|
620
|
+
return clone(Object.values(this.state.grants));
|
|
621
|
+
}),
|
|
622
|
+
bounds: async () => this.read(() => {
|
|
623
|
+
this.ensureUnsealed();
|
|
624
|
+
return clone(this.state.bounds);
|
|
625
|
+
}),
|
|
626
|
+
overlay: async () => this.read(() => {
|
|
627
|
+
this.ensureUnsealed();
|
|
628
|
+
return clone(this.state.overlay);
|
|
629
|
+
}),
|
|
630
|
+
programs: async () => this.read(() => {
|
|
631
|
+
this.ensureUnsealed();
|
|
632
|
+
return clone(this.state.programs);
|
|
633
|
+
})
|
|
634
|
+
};
|
|
635
|
+
async open(partition) {
|
|
636
|
+
if (typeof partition !== "string" || partition.length === 0) {
|
|
637
|
+
throw new OfflineStoreFailure("E_OFFLINE_CARRIER", "range store partition must be a principal id");
|
|
638
|
+
}
|
|
639
|
+
await this.enqueue(async () => {
|
|
640
|
+
if (this.partition === partition) return;
|
|
641
|
+
if (this.partition !== void 0 && this.partition !== partition) await this.backend.close();
|
|
642
|
+
this.partition = partition;
|
|
643
|
+
this.state = normalizeState(await this.backend.open(partition));
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async unseal() {
|
|
647
|
+
await this.enqueue(async () => {
|
|
648
|
+
this.ensureOpen();
|
|
649
|
+
if (!this.state.sealed) return;
|
|
650
|
+
const next = { ...clone(this.state), sealed: false };
|
|
651
|
+
await this.backend.replace(next, NO_BYTES);
|
|
652
|
+
this.state = next;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
async seal() {
|
|
656
|
+
await this.enqueue(async () => {
|
|
657
|
+
this.ensureOpen();
|
|
658
|
+
const next = { ...clone(this.state), sealed: true };
|
|
659
|
+
await this.backend.replace(next, NO_BYTES);
|
|
660
|
+
this.state = next;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
async wipe(partition) {
|
|
664
|
+
await this.enqueue(async () => {
|
|
665
|
+
await this.backend.wipe(partition);
|
|
666
|
+
if (this.partition === partition) {
|
|
667
|
+
this.partition = void 0;
|
|
668
|
+
this.state = emptyState();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
async answer(op, args) {
|
|
673
|
+
return this.read(() => {
|
|
674
|
+
if (this.state.sealed) return void 0;
|
|
675
|
+
const key2 = canonicalAnswerKey(op, args);
|
|
676
|
+
const stored = this.state.answers[key2];
|
|
677
|
+
if (!stored) return void 0;
|
|
678
|
+
const watermark = this.state.watermarks[key2];
|
|
679
|
+
const { refs: _refs, order: _order, fingerprint: _fingerprint, cursorArg: _cursorArg, data, ...answer } = clone(stored);
|
|
680
|
+
return {
|
|
681
|
+
...answer,
|
|
682
|
+
// A viewed view-once row reads as absent: the view was its one delivery (LLP 1012 §9).
|
|
683
|
+
...data === void 0 ? {} : { data: expand(data, this.state.rows, this.state.viewed) },
|
|
684
|
+
...watermark === void 0 ? {} : { caughtUp: true, watermark: watermark.watermark }
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
async coverage() {
|
|
689
|
+
return this.read(() => {
|
|
690
|
+
if (this.state.sealed) return { rows: [], ranges: [], absences: [], verdicts: [], watermarks: [], viewed: [], evicted: [] };
|
|
691
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
692
|
+
const absences = /* @__PURE__ */ new Map();
|
|
693
|
+
const verdicts = /* @__PURE__ */ new Map();
|
|
694
|
+
for (const answer of Object.values(this.state.answers)) {
|
|
695
|
+
for (const range of answer.receipt?.ranges ?? []) {
|
|
696
|
+
ranges.set(`${range.site}\0${canonical(range.prefix)}\0${canonical([
|
|
697
|
+
range.order,
|
|
698
|
+
range.after,
|
|
699
|
+
range.before,
|
|
700
|
+
range.gt,
|
|
701
|
+
range.gte,
|
|
702
|
+
range.lt,
|
|
703
|
+
range.lte
|
|
704
|
+
])}`, clone(range));
|
|
705
|
+
}
|
|
706
|
+
for (const absence of answer.receipt?.absences ?? []) absences.set(canonical(absence), clone(absence));
|
|
707
|
+
for (const verdict of answer.receipt?.verdicts ?? []) verdicts.set(canonical(verdict), clone(verdict));
|
|
708
|
+
}
|
|
709
|
+
const viewed = Object.keys(this.state.viewed).map(viewedRef);
|
|
710
|
+
for (const ref of viewed) {
|
|
711
|
+
const absence = [ref.table, "byId", [ref.id]];
|
|
712
|
+
absences.set(canonical(absence), absence);
|
|
713
|
+
}
|
|
714
|
+
let deliveredAt;
|
|
715
|
+
for (const answer of Object.values(this.state.answers)) {
|
|
716
|
+
if (deliveredAt === void 0 || answer.deliveredAt > deliveredAt) deliveredAt = answer.deliveredAt;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
rows: clone(Object.entries(this.state.rows).filter(([key2]) => !this.state.viewed[key2]).map(([, row]) => row)),
|
|
720
|
+
ranges: [...ranges.values()],
|
|
721
|
+
absences: [...absences.values()],
|
|
722
|
+
verdicts: [...verdicts.values()],
|
|
723
|
+
watermarks: clone(Object.values(this.state.watermarks)),
|
|
724
|
+
viewed,
|
|
725
|
+
evicted: Object.keys(this.state.negative).map(viewedRef),
|
|
726
|
+
...this.state.facts === void 0 ? {} : { facts: clone(this.state.facts) },
|
|
727
|
+
...this.state.superseded === void 0 ? {} : { superseded: clone(this.state.superseded) },
|
|
728
|
+
...deliveredAt === void 0 ? {} : { deliveredAt }
|
|
729
|
+
};
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
async commit(swap) {
|
|
733
|
+
await this.enqueue(async () => {
|
|
734
|
+
this.ensureOpen();
|
|
735
|
+
const { bytes, ...rest } = swap;
|
|
736
|
+
const delta = { put: [], remove: [] };
|
|
737
|
+
const next = normalizeState(this.applySwap(clone(this.state), { ...jsonState(rest), ...bytes === void 0 ? {} : { bytes } }, delta));
|
|
738
|
+
await this.backend.replace(next, delta);
|
|
739
|
+
this.state = next;
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/** The cached rendition for an asset at a width; nothing when unheld, sealed, or the payload is gone. */
|
|
743
|
+
async readBytes(assetId, width) {
|
|
744
|
+
return this.read(async () => {
|
|
745
|
+
if (this.state.sealed) return void 0;
|
|
746
|
+
const key2 = byteKey(assetId, width);
|
|
747
|
+
const entry = this.state.assets[key2];
|
|
748
|
+
if (!entry) return void 0;
|
|
749
|
+
const bytes = await this.backend.readBytes?.(key2);
|
|
750
|
+
return bytes && bytes.byteLength === entry.size ? { bytes, type: entry.type } : void 0;
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
async bytes() {
|
|
754
|
+
return this.read(() => {
|
|
755
|
+
if (this.state.sealed) return { entries: [], totalBytes: 0, viewed: [] };
|
|
756
|
+
const entries = clone(Object.values(this.state.assets));
|
|
757
|
+
return {
|
|
758
|
+
entries,
|
|
759
|
+
totalBytes: entries.reduce((sum, entry) => sum + entry.size, 0),
|
|
760
|
+
viewed: Object.keys(this.state.viewed).map(viewedRef)
|
|
761
|
+
};
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
/** Held rows whose values carry this asset record: the lease under which its bytes may be cached. */
|
|
765
|
+
async rowsReferencing(assetId) {
|
|
766
|
+
return this.read(() => this.state.sealed ? [] : clone(Object.values(this.state.rows).filter((row) => assetIdsIn(row.row).has(assetId))));
|
|
767
|
+
}
|
|
768
|
+
/** True while every held row carrying this asset is a viewed view-once row (per row, LLP 1012 §9). */
|
|
769
|
+
async viewedAsset(assetId) {
|
|
770
|
+
return this.read(() => !this.state.sealed && viewedOnlyAssets(this.state).has(assetId));
|
|
771
|
+
}
|
|
772
|
+
/** The viewed view-once rows; a live frame nulls them exactly as a held answer does. */
|
|
773
|
+
async viewedRows() {
|
|
774
|
+
return this.read(() => this.state.sealed ? [] : Object.keys(this.state.viewed).map(viewedRef));
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* LRU read stamps, coalesced per swap: a read stamps the entry in memory and the next
|
|
778
|
+
* swap's `replace` persists it, so a read never rewrites the carrier (LLP 1012 §9).
|
|
779
|
+
*/
|
|
780
|
+
async touchBytes(keys) {
|
|
781
|
+
await this.enqueue(async () => {
|
|
782
|
+
if (this.partition === void 0 || this.state.sealed) return;
|
|
783
|
+
for (const key2 of keys) {
|
|
784
|
+
const entry = this.state.assets[key2];
|
|
785
|
+
if (entry) entry.lastRead = ++this.state.clock;
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
async close() {
|
|
790
|
+
await this.enqueue(async () => {
|
|
791
|
+
this.partition = void 0;
|
|
792
|
+
this.state = emptyState();
|
|
793
|
+
await this.backend.close();
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
applySwap(state, swap, bytes) {
|
|
797
|
+
if (state.sealed) throw new OfflineStoreFailure("E_OFFLINE_CARRIER", "range store is sealed");
|
|
798
|
+
if (swap.resetCoverage) {
|
|
799
|
+
state.rows = {};
|
|
800
|
+
state.rowSeq = {};
|
|
801
|
+
state.negative = {};
|
|
802
|
+
state.answers = {};
|
|
803
|
+
state.watermarks = {};
|
|
804
|
+
state.overlayWatermarks = Object.fromEntries(Object.keys(state.overlay).map((intent) => [intent, null]));
|
|
805
|
+
state.overlayMisses = {};
|
|
806
|
+
delete state.facts;
|
|
807
|
+
}
|
|
808
|
+
if (swap.superseded === null) delete state.superseded;
|
|
809
|
+
if (swap.resumeSeq !== void 0 && (!Number.isSafeInteger(swap.resumeSeq) || swap.resumeSeq < 0)) {
|
|
810
|
+
throw new TypeError("store resume seq must be a non-negative safe integer");
|
|
811
|
+
}
|
|
812
|
+
if (swap.facts) state.facts = clone(swap.facts);
|
|
813
|
+
if (swap.superseded) state.superseded = clone(swap.superseded);
|
|
814
|
+
const explicitEvictions = /* @__PURE__ */ new Map();
|
|
815
|
+
const authoritativeNegatives = /* @__PURE__ */ new Map();
|
|
816
|
+
const recordNegative = (ref, source) => {
|
|
817
|
+
const key2 = rowKey2(ref);
|
|
818
|
+
if ((authoritativeNegatives.get(key2)?.seq ?? -1) <= ref.seq) {
|
|
819
|
+
authoritativeNegatives.set(key2, { seq: ref.seq, source });
|
|
820
|
+
}
|
|
821
|
+
recordEviction(explicitEvictions, ref);
|
|
822
|
+
};
|
|
823
|
+
const recordAbsences = (answer) => {
|
|
824
|
+
for (const [table, index, key2] of answer.receipt?.absences ?? []) {
|
|
825
|
+
if (index !== "byId" || key2.length !== 1) {
|
|
826
|
+
throw new TypeError("store receipts may evict only a primary byId absence");
|
|
827
|
+
}
|
|
828
|
+
if (typeof key2[0] === "string") recordNegative({ table, id: key2[0], seq: answer.seq }, "absence");
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
for (const key2 of swap.dropAnswers ?? []) delete state.answers[key2];
|
|
832
|
+
for (const refresh of swap.refreshes ?? []) {
|
|
833
|
+
const answer = state.answers[canonicalAnswerKey(refresh.op, refresh.args)];
|
|
834
|
+
if (!answer || refresh.seq < answer.seq) continue;
|
|
835
|
+
if (answer.validator !== void 0 && answer.validator !== refresh.validator) {
|
|
836
|
+
throw new TypeError(`conditional validator changed for ${refresh.op}`);
|
|
837
|
+
}
|
|
838
|
+
if (refresh.seq === answer.seq) continue;
|
|
839
|
+
answer.nextSeq ??= answer.seq;
|
|
840
|
+
answer.seq = refresh.seq;
|
|
841
|
+
answer.validator = refresh.validator;
|
|
842
|
+
answer.deliveredAt = refresh.deliveredAt ?? Date.now();
|
|
843
|
+
answer.order = ++state.clock;
|
|
844
|
+
recordAbsences(answer);
|
|
845
|
+
}
|
|
846
|
+
const before = clone(state.answers);
|
|
847
|
+
const freshRanges = [];
|
|
848
|
+
for (const ref of swap.tombstones ?? []) {
|
|
849
|
+
recordNegative(ref, "tombstone");
|
|
850
|
+
}
|
|
851
|
+
for (const delivery of swap.deliveries ?? []) {
|
|
852
|
+
if (!Number.isSafeInteger(delivery.seq) || delivery.seq < 0) {
|
|
853
|
+
throw new TypeError("store delivery seq must be a non-negative safe integer");
|
|
854
|
+
}
|
|
855
|
+
const key2 = canonicalAnswerKey(delivery.op, delivery.args);
|
|
856
|
+
const old = before[key2];
|
|
857
|
+
const fingerprint = canonical({
|
|
858
|
+
op: delivery.op,
|
|
859
|
+
args: delivery.args,
|
|
860
|
+
state: delivery.state,
|
|
861
|
+
data: delivery.data,
|
|
862
|
+
next: delivery.next,
|
|
863
|
+
seq: delivery.seq,
|
|
864
|
+
validator: delivery.validator,
|
|
865
|
+
receipt: delivery.receipt,
|
|
866
|
+
rows: delivery.rows,
|
|
867
|
+
reason: delivery.reason,
|
|
868
|
+
rule: delivery.rule
|
|
869
|
+
});
|
|
870
|
+
if (old && delivery.seq < old.seq) continue;
|
|
871
|
+
if (old && delivery.seq === old.seq) {
|
|
872
|
+
if (old.fingerprint === fingerprint) continue;
|
|
873
|
+
throw new TypeError(`conflicting deliveries for ${delivery.op} at seq ${delivery.seq}`);
|
|
874
|
+
}
|
|
875
|
+
for (const row of delivery.rows ?? []) {
|
|
876
|
+
if (!row.table || !row.id || !row.row || typeof row.row !== "object") {
|
|
877
|
+
throw new TypeError("store delivery row is malformed");
|
|
878
|
+
}
|
|
879
|
+
const storageKey = rowKey2(row);
|
|
880
|
+
if ((state.negative[storageKey] ?? -1) >= delivery.seq) {
|
|
881
|
+
recordEviction(explicitEvictions, { table: row.table, id: row.id, seq: state.negative[storageKey] });
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (state.rowSeq[storageKey] === delivery.seq && canonical(state.rows[storageKey]) !== canonical(row)) {
|
|
885
|
+
throw new TypeError(`conflicting row ${row.table}/${row.id} at seq ${delivery.seq}`);
|
|
886
|
+
}
|
|
887
|
+
if ((state.rowSeq[storageKey] ?? -1) > delivery.seq) continue;
|
|
888
|
+
state.rows[storageKey] = clone(row);
|
|
889
|
+
state.rowSeq[storageKey] = delivery.seq;
|
|
890
|
+
}
|
|
891
|
+
let data = clone(delivery.data);
|
|
892
|
+
let answerState = delivery.state;
|
|
893
|
+
let retained = false;
|
|
894
|
+
const hidden = delivery.state === "denied" && old ? old.refs : old ? hiddenRefs(old.data, data) : [];
|
|
895
|
+
const retainedRefs = /* @__PURE__ */ new Set();
|
|
896
|
+
for (const ref of hidden) {
|
|
897
|
+
if ((swap.retention?.[ref.table] ?? "until-revoked") === "delivered-history") {
|
|
898
|
+
retainedRefs.add(rowKey2(ref));
|
|
899
|
+
retained = true;
|
|
900
|
+
} else recordEviction(explicitEvictions, { ...ref, seq: delivery.seq });
|
|
901
|
+
}
|
|
902
|
+
if (delivery.state === "denied" && old && retainedRefs.size > 0) {
|
|
903
|
+
data = clone(old.data);
|
|
904
|
+
answerState = old.state;
|
|
905
|
+
} else if (old && retainedRefs.size > 0) {
|
|
906
|
+
data = restoreRetained(old.data, data, retainedRefs);
|
|
907
|
+
}
|
|
908
|
+
const refs = collectRefs(data);
|
|
909
|
+
for (const range of delivery.receipt?.ranges ?? []) {
|
|
910
|
+
for (const id of [...range.rowIds, ...range.haloId ? [range.haloId] : []]) {
|
|
911
|
+
if (!refs.some((ref) => ref.table === range.table && ref.id === id)) refs.push({ table: range.table, id });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
for (const pooled of delivery.rows ?? []) {
|
|
915
|
+
if (!refs.some((ref) => ref.table === pooled.table && ref.id === pooled.id)) refs.push({ table: pooled.table, id: pooled.id });
|
|
916
|
+
}
|
|
917
|
+
const stored = {
|
|
918
|
+
op: delivery.op,
|
|
919
|
+
...delivery.cursorArg === void 0 ? {} : { cursorArg: delivery.cursorArg },
|
|
920
|
+
args: clone(delivery.args),
|
|
921
|
+
state: answerState,
|
|
922
|
+
...data === void 0 ? {} : { data },
|
|
923
|
+
...delivery.next === void 0 ? {} : { next: delivery.next },
|
|
924
|
+
nextSeq: delivery.seq,
|
|
925
|
+
seq: delivery.seq,
|
|
926
|
+
...delivery.validator === void 0 ? {} : { validator: delivery.validator },
|
|
927
|
+
...delivery.receipt === void 0 ? {} : { receipt: clone(delivery.receipt) },
|
|
928
|
+
deliveredAt: delivery.deliveredAt ?? Date.now(),
|
|
929
|
+
...retained ? { retained: true } : {},
|
|
930
|
+
...delivery.reason === void 0 ? {} : { reason: delivery.reason },
|
|
931
|
+
...delivery.rule === void 0 ? {} : { rule: delivery.rule },
|
|
932
|
+
refs,
|
|
933
|
+
order: ++state.clock,
|
|
934
|
+
fingerprint
|
|
935
|
+
};
|
|
936
|
+
state.answers[key2] = stored;
|
|
937
|
+
freshRanges.push(...(delivery.receipt?.ranges ?? []).map((range) => ({ range, seq: delivery.seq })));
|
|
938
|
+
recordAbsences(stored);
|
|
939
|
+
}
|
|
940
|
+
const heldRanges = Object.values(before).flatMap((answer) => answer.receipt?.ranges ?? []);
|
|
941
|
+
for (const held of heldRanges) {
|
|
942
|
+
const matching = freshRanges.filter(({ range: fresh }) => sameRange(held, fresh));
|
|
943
|
+
if (matching.length === 0) continue;
|
|
944
|
+
const present = new Set(matching.flatMap(({ range: fresh }) => [
|
|
945
|
+
...fresh.rowIds,
|
|
946
|
+
...fresh.haloId ? [fresh.haloId] : []
|
|
947
|
+
]));
|
|
948
|
+
for (const id of held.rowIds) {
|
|
949
|
+
if (present.has(id)) continue;
|
|
950
|
+
const seq = matching.filter(({ range: fresh }) => inFreshExtent(id, held, fresh, state.rows, swap.schema)).reduce((latest, item) => Math.max(latest, item.seq), -1);
|
|
951
|
+
if (seq >= 0) recordEviction(explicitEvictions, { table: held.table, id, seq });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const appliedEvictions = /* @__PURE__ */ new Set();
|
|
955
|
+
for (const [key2, eviction] of explicitEvictions) {
|
|
956
|
+
if ((state.negative[key2] ?? -1) > eviction.seq) continue;
|
|
957
|
+
if ((state.rowSeq[key2] ?? -1) > eviction.seq) continue;
|
|
958
|
+
state.negative[key2] = eviction.seq;
|
|
959
|
+
appliedEvictions.add(key2);
|
|
960
|
+
delete state.rows[key2];
|
|
961
|
+
delete state.rowSeq[key2];
|
|
962
|
+
for (const answer of Object.values(state.answers)) {
|
|
963
|
+
for (const range of answer.receipt?.ranges ?? []) {
|
|
964
|
+
if (range.table !== eviction.table) continue;
|
|
965
|
+
const id = eviction.id;
|
|
966
|
+
if (range.rowIds.includes(id)) {
|
|
967
|
+
range.rowIds = range.rowIds.filter((rowId) => rowId !== id);
|
|
968
|
+
range.evictedIds = [...range.evictedIds ?? [], id];
|
|
969
|
+
}
|
|
970
|
+
if (range.haloId === id) delete range.haloId;
|
|
971
|
+
}
|
|
972
|
+
if (!answer.refs.some((ref) => rowKey2(ref) === key2)) continue;
|
|
973
|
+
answer.refs = answer.refs.filter((ref) => rowKey2(ref) !== key2);
|
|
974
|
+
if (!answer.retained && answer.state !== "denied") {
|
|
975
|
+
answer.state = "partial";
|
|
976
|
+
answer.reason = "evicted";
|
|
977
|
+
delete answer.next;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
for (const answer of Object.values(state.answers)) {
|
|
982
|
+
if (!answer.receipt) continue;
|
|
983
|
+
answer.receipt.absences = answer.receipt.absences.filter(([table, index, key2]) => {
|
|
984
|
+
const id = index === "byId" && key2.length === 1 ? key2[0] : void 0;
|
|
985
|
+
const storageKey = typeof id === "string" ? rowKey2({ table, id }) : void 0;
|
|
986
|
+
return storageKey === void 0 || (state.rowSeq[storageKey] ?? -1) < 0 || appliedEvictions.has(storageKey);
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
const referenced = new Set(Object.values(state.answers).flatMap((answer) => answer.refs.map(rowKey2)));
|
|
990
|
+
for (const key2 of Object.keys(state.rows)) if (!referenced.has(key2)) {
|
|
991
|
+
delete state.rows[key2];
|
|
992
|
+
delete state.rowSeq[key2];
|
|
993
|
+
}
|
|
994
|
+
const byteDelta = applyByteSwap(state, swap, leasedAssets(Object.values(state.rows), Object.values(state.answers)));
|
|
995
|
+
bytes.put.push(...byteDelta.put);
|
|
996
|
+
bytes.remove.push(...byteDelta.remove);
|
|
997
|
+
const previousTail = tailWatermark(Object.values(state.watermarks));
|
|
998
|
+
if (swap.watermark) {
|
|
999
|
+
const key2 = canonicalAnswerKey(swap.watermark.op, swap.watermark.args);
|
|
1000
|
+
const order = state.watermarks[key2]?.order ?? ++state.clock;
|
|
1001
|
+
state.watermarks[key2] = { ...clone(swap.watermark), order };
|
|
1002
|
+
}
|
|
1003
|
+
const tail = tailWatermark(Object.values(state.watermarks));
|
|
1004
|
+
for (const intent of swap.outbox?.remove ?? []) {
|
|
1005
|
+
delete state.outbox[intent];
|
|
1006
|
+
delete state.overlayMisses[intent];
|
|
1007
|
+
delete state.overlay[intent];
|
|
1008
|
+
delete state.overlayWatermarks[intent];
|
|
1009
|
+
delete state.overlayNegatives[intent];
|
|
1010
|
+
}
|
|
1011
|
+
const committedNow = /* @__PURE__ */ new Set();
|
|
1012
|
+
for (const entry of swap.outbox?.put ?? []) {
|
|
1013
|
+
if (typeof entry.intent !== "string" || entry.intent.length === 0) throw new TypeError("outbox entry needs an intent");
|
|
1014
|
+
const previous = state.outbox[entry.intent];
|
|
1015
|
+
if (entry.state === "committed" && previous?.state !== "committed") committedNow.add(entry.intent);
|
|
1016
|
+
const order = previous?.order ?? entry.order ?? ++state.clock;
|
|
1017
|
+
state.outbox[entry.intent] = { ...clone(entry), order };
|
|
1018
|
+
}
|
|
1019
|
+
if (swap.bounds) state.bounds = clone(swap.bounds);
|
|
1020
|
+
if (swap.programs) state.programs = clone(swap.programs);
|
|
1021
|
+
for (const id of swap.grants?.remove ?? []) {
|
|
1022
|
+
delete state.grants[id];
|
|
1023
|
+
if (state.currentGrant === id) delete state.currentGrant;
|
|
1024
|
+
}
|
|
1025
|
+
for (const grant of swap.grants?.put ?? []) state.grants[grant.id] = clone(grant);
|
|
1026
|
+
if (swap.grants?.current === null) delete state.currentGrant;
|
|
1027
|
+
else if (typeof swap.grants?.current === "string") {
|
|
1028
|
+
if (!state.grants[swap.grants.current]) throw new TypeError("current grant must be a held grant");
|
|
1029
|
+
state.currentGrant = swap.grants.current;
|
|
1030
|
+
}
|
|
1031
|
+
for (const intent of swap.overlay?.remove ?? []) {
|
|
1032
|
+
delete state.overlay[intent];
|
|
1033
|
+
delete state.overlayMisses[intent];
|
|
1034
|
+
delete state.overlayWatermarks[intent];
|
|
1035
|
+
delete state.overlayNegatives[intent];
|
|
1036
|
+
}
|
|
1037
|
+
for (const [intent, rows] of Object.entries(swap.overlay?.put ?? {})) {
|
|
1038
|
+
state.overlay[intent] = clone(rows);
|
|
1039
|
+
delete state.overlayMisses[intent];
|
|
1040
|
+
delete state.overlayWatermarks[intent];
|
|
1041
|
+
delete state.overlayNegatives[intent];
|
|
1042
|
+
}
|
|
1043
|
+
const answers = Object.keys(state.overlay).some((intent) => state.outbox[intent]?.state === "committed") ? Object.values(state.answers) : [];
|
|
1044
|
+
const pages = answers.filter((answer) => {
|
|
1045
|
+
const nonempty = Array.isArray(answer.data) ? answer.data.length > 0 : collectRefs(answer.data).length > 0;
|
|
1046
|
+
return (answer.state === "complete" || answer.state === "capped") && answer.receipt !== void 0 && !answer.receipt.ranges.some((range) => (range.evictedIds?.length ?? 0) > 0) && nonempty && answer.refs.length > 0 && answer.refs.every((ref) => state.rows[rowKey2(ref)] !== void 0);
|
|
1047
|
+
});
|
|
1048
|
+
const chains = /* @__PURE__ */ new Map();
|
|
1049
|
+
for (const intent of Object.keys(state.overlay)) {
|
|
1050
|
+
const entry = state.outbox[intent];
|
|
1051
|
+
if (entry === void 0 || entry.state === "rejected" || entry.state === "expired") {
|
|
1052
|
+
delete state.overlay[intent];
|
|
1053
|
+
delete state.overlayMisses[intent];
|
|
1054
|
+
delete state.overlayWatermarks[intent];
|
|
1055
|
+
delete state.overlayNegatives[intent];
|
|
1056
|
+
if (entry) delete entry.predicted;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
for (const row of state.overlay[intent]) {
|
|
1060
|
+
const key2 = rowKey2(row);
|
|
1061
|
+
const evidence = authoritativeNegatives.get(key2);
|
|
1062
|
+
if (evidence !== void 0 && evidence.seq >= (state.overlayNegatives[intent]?.[key2]?.seq ?? -1)) {
|
|
1063
|
+
(state.overlayNegatives[intent] ??= {})[key2] = evidence;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (entry.state !== "committed") continue;
|
|
1067
|
+
if (state.overlayWatermarks[intent] == null && tail !== void 0) {
|
|
1068
|
+
const snapshot = committedNow.has(intent) && state.overlayWatermarks[intent] !== null ? previousTail ?? tail : tail;
|
|
1069
|
+
state.overlayWatermarks[intent] = {
|
|
1070
|
+
op: snapshot.op,
|
|
1071
|
+
args: clone(snapshot.args),
|
|
1072
|
+
cursorArg: snapshot.cursorArg ?? "c",
|
|
1073
|
+
watermark: snapshot.watermark
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
const committedSeq = entry.response?.seq;
|
|
1077
|
+
if (!Number.isSafeInteger(committedSeq)) {
|
|
1078
|
+
delete state.overlay[intent];
|
|
1079
|
+
delete state.overlayMisses[intent];
|
|
1080
|
+
delete state.overlayWatermarks[intent];
|
|
1081
|
+
delete state.overlayNegatives[intent];
|
|
1082
|
+
delete entry.predicted;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
const overlay = state.overlay[intent];
|
|
1086
|
+
const seq = Number(committedSeq);
|
|
1087
|
+
const predicted = new Set(overlay.filter((row) => !row.removed).map(rowKey2));
|
|
1088
|
+
const committedWatermark = state.overlayWatermarks[intent] ?? void 0;
|
|
1089
|
+
const chainKey = committedWatermark ? canonical({
|
|
1090
|
+
op: committedWatermark.op,
|
|
1091
|
+
args: committedWatermark.args,
|
|
1092
|
+
cursorArg: committedWatermark.cursorArg,
|
|
1093
|
+
watermark: committedWatermark.watermark,
|
|
1094
|
+
seq
|
|
1095
|
+
}) : "";
|
|
1096
|
+
if (!chains.has(chainKey)) {
|
|
1097
|
+
const chain2 = committedWatermark ? cursorChain(pages, committedWatermark, seq) : [];
|
|
1098
|
+
chains.set(chainKey, { chain: chain2, reached: new Set(chain2) });
|
|
1099
|
+
}
|
|
1100
|
+
const { chain, reached } = chains.get(chainKey);
|
|
1101
|
+
const negative = overlay.some((row) => {
|
|
1102
|
+
const evidence = state.overlayNegatives[intent]?.[rowKey2(row)];
|
|
1103
|
+
return evidence !== void 0 && evidence.seq >= seq && (state.rowSeq[rowKey2(row)] ?? -1) <= evidence.seq;
|
|
1104
|
+
});
|
|
1105
|
+
const arrived = negative || answers.some((answer) => answer.seq >= seq && !answer.retained && answer.state !== "denied" && answer.refs.some((ref) => predicted.has(rowKey2(ref)) && state.rows[rowKey2(ref)] !== void 0)) || predicted.size > 0 && committedWatermark !== void 0 && pages.some((page) => page.seq >= seq && reached.has(pagePosition(page, committedWatermark)));
|
|
1106
|
+
const advanced = committedWatermark !== void 0 && swap.watermark !== void 0 && tail !== void 0 && canonicalAnswerKey(tail.op, tail.args) === canonicalAnswerKey(swap.watermark.op, swap.watermark.args) && advanceOverlay(committedWatermark, tail, chain);
|
|
1107
|
+
const moved = advanced || swap.resumeSeq !== void 0 && swap.resumeSeq > seq;
|
|
1108
|
+
const misses = arrived ? 0 : (state.overlayMisses[intent] ?? 0) + (moved && !committedNow.has(intent) ? 1 : 0);
|
|
1109
|
+
if (!arrived && misses < OVERLAY_PATIENCE) {
|
|
1110
|
+
if (misses > 0) state.overlayMisses[intent] = misses;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
delete state.overlay[intent];
|
|
1114
|
+
delete state.overlayMisses[intent];
|
|
1115
|
+
delete state.overlayWatermarks[intent];
|
|
1116
|
+
delete state.overlayNegatives[intent];
|
|
1117
|
+
delete entry.predicted;
|
|
1118
|
+
}
|
|
1119
|
+
return state;
|
|
1120
|
+
}
|
|
1121
|
+
ensureOpen() {
|
|
1122
|
+
if (this.partition === void 0) {
|
|
1123
|
+
throw new OfflineStoreFailure("E_OFFLINE_CARRIER", `${this.backend.carrier} range store is not open`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
ensureUnsealed() {
|
|
1127
|
+
if (this.state.sealed) throw new OfflineStoreFailure("E_OFFLINE_CARRIER", "range store is sealed");
|
|
1128
|
+
}
|
|
1129
|
+
enqueue(action) {
|
|
1130
|
+
const run = this.tail.then(action);
|
|
1131
|
+
this.tail = run.then(() => void 0, () => void 0);
|
|
1132
|
+
return run;
|
|
1133
|
+
}
|
|
1134
|
+
async read(action) {
|
|
1135
|
+
await this.tail;
|
|
1136
|
+
this.ensureOpen();
|
|
1137
|
+
return action();
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
// packages/snapback2/src/store/locks.ts
|
|
1142
|
+
var key = Symbol.for("snapback2.offline.storeLocks");
|
|
1143
|
+
var scopes = globalThis[key] ??= /* @__PURE__ */ new Map();
|
|
1144
|
+
function inProcessLocks(scope = "default") {
|
|
1145
|
+
return {
|
|
1146
|
+
async request(name, _options, callback) {
|
|
1147
|
+
const held = scopes.get(scope) ?? /* @__PURE__ */ new Map();
|
|
1148
|
+
if (held.has(name)) return callback(null);
|
|
1149
|
+
scopes.set(scope, held);
|
|
1150
|
+
const lock = { name };
|
|
1151
|
+
held.set(name, lock);
|
|
1152
|
+
try {
|
|
1153
|
+
return await callback(lock);
|
|
1154
|
+
} finally {
|
|
1155
|
+
if (held.get(name) === lock) held.delete(name);
|
|
1156
|
+
if (held.size === 0) scopes.delete(scope);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
async function acquireStoreLock(locks, partition) {
|
|
1162
|
+
let accept;
|
|
1163
|
+
let refuse;
|
|
1164
|
+
let release;
|
|
1165
|
+
const acquired = new Promise((resolve, reject) => {
|
|
1166
|
+
accept = resolve;
|
|
1167
|
+
refuse = reject;
|
|
1168
|
+
});
|
|
1169
|
+
const released = new Promise((resolve) => {
|
|
1170
|
+
release = resolve;
|
|
1171
|
+
});
|
|
1172
|
+
const request = locks.request(`snapback2:${partition}`, { ifAvailable: true }, async (lock) => {
|
|
1173
|
+
if (!lock) {
|
|
1174
|
+
refuse(new OfflineStoreFailure("E_OFFLINE_LOCK", "this account is open in another client; close it and try again"));
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
accept();
|
|
1178
|
+
await released;
|
|
1179
|
+
});
|
|
1180
|
+
void request.catch(refuse);
|
|
1181
|
+
await acquired;
|
|
1182
|
+
return async () => {
|
|
1183
|
+
release();
|
|
1184
|
+
await request;
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// packages/snapback2/src/store/projection.ts
|
|
1189
|
+
function projectState(state) {
|
|
1190
|
+
const { rows = {}, answers = {}, outbox = {}, ...meta } = jsonState(state);
|
|
1191
|
+
const entries = (value) => Object.entries(value).map(([key2, value2]) => ({ key: key2, value: value2 }));
|
|
1192
|
+
return { rows: entries(rows), answers: entries(answers), outbox: entries(outbox), meta: [{ key: "state", value: meta }] };
|
|
1193
|
+
}
|
|
1194
|
+
function unprojectState(projected) {
|
|
1195
|
+
const meta = projected.meta.find(({ key: key2 }) => key2 === "state");
|
|
1196
|
+
if (!meta && !projected.rows.length && !projected.answers.length && !projected.outbox.length) return void 0;
|
|
1197
|
+
const record = (entries) => Object.fromEntries(entries.map(({ key: key2, value }) => [key2, value]));
|
|
1198
|
+
return {
|
|
1199
|
+
...emptyState(),
|
|
1200
|
+
...meta?.value,
|
|
1201
|
+
rows: record(projected.rows),
|
|
1202
|
+
answers: record(projected.answers),
|
|
1203
|
+
outbox: record(projected.outbox)
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function encodeStateKey(key2, tuple) {
|
|
1207
|
+
const at = tuple ? key2.indexOf("\0") : -1;
|
|
1208
|
+
return JSON.stringify(at < 0 ? [key2] : [key2.slice(0, at), key2.slice(at + 1)]);
|
|
1209
|
+
}
|
|
1210
|
+
function decodeStateKey(key2) {
|
|
1211
|
+
const parts = JSON.parse(key2);
|
|
1212
|
+
if (!Array.isArray(parts) || parts.length < 1 || parts.length > 2 || parts.some((part) => typeof part !== "string")) {
|
|
1213
|
+
throw new Error("invalid sqlite state key");
|
|
1214
|
+
}
|
|
1215
|
+
return parts.join("\0");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// packages/snapback2/src/store/sqlite.ts
|
|
1219
|
+
var STATE_TABLES = ["state", "rows", "answers", "outbox"];
|
|
1220
|
+
var TABLES = [...STATE_TABLES, "bytes"];
|
|
1221
|
+
var SCHEMA = `
|
|
1222
|
+
CREATE TABLE IF NOT EXISTS state (partition TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL);
|
|
1223
|
+
CREATE TABLE IF NOT EXISTS rows (partition TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (partition, key));
|
|
1224
|
+
CREATE TABLE IF NOT EXISTS answers (partition TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (partition, key));
|
|
1225
|
+
CREATE TABLE IF NOT EXISTS outbox (partition TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (partition, key));
|
|
1226
|
+
CREATE TABLE IF NOT EXISTS bytes (partition TEXT NOT NULL, key TEXT NOT NULL, value BLOB NOT NULL, PRIMARY KEY (partition, key));
|
|
1227
|
+
`;
|
|
1228
|
+
var SqliteBackend = class {
|
|
1229
|
+
constructor(source, options = {}) {
|
|
1230
|
+
this.source = source;
|
|
1231
|
+
this.options = options;
|
|
1232
|
+
}
|
|
1233
|
+
carrier = "SQLite";
|
|
1234
|
+
driver;
|
|
1235
|
+
partition;
|
|
1236
|
+
release;
|
|
1237
|
+
locks;
|
|
1238
|
+
async guard(body) {
|
|
1239
|
+
try {
|
|
1240
|
+
return await body();
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (error instanceof OfflineStoreFailure) throw error;
|
|
1243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1244
|
+
const busy = /SQLITE_BUSY|SQLITE_LOCKED|database (?:is )?(?:busy|locked)/i.test(message);
|
|
1245
|
+
const rewrite = busy ? "another writer holds the store; close it or wait" : "open a writable database with expo-sqlite ^16";
|
|
1246
|
+
throw new OfflineStoreFailure("E_OFFLINE_CARRIER", `SQLite carrier: ${message}; ${rewrite}`, rewrite);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
async open(partition) {
|
|
1250
|
+
await this.close();
|
|
1251
|
+
return this.guard(async () => {
|
|
1252
|
+
const driver = typeof this.source === "function" ? await this.source() : this.source;
|
|
1253
|
+
try {
|
|
1254
|
+
const identity = driver.identity;
|
|
1255
|
+
if (typeof identity !== "string" || !identity.trim()) {
|
|
1256
|
+
throw new OfflineStoreFailure(
|
|
1257
|
+
"E_OFFLINE_CARRIER",
|
|
1258
|
+
"driver has no database identity",
|
|
1259
|
+
"supply a stable database identity on the SQLite driver"
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
this.locks = this.options.locks ?? inProcessLocks(identity);
|
|
1263
|
+
this.release = await acquireStoreLock(this.locks, partition);
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
if (typeof this.source === "function") await driver.close().catch(() => void 0);
|
|
1266
|
+
throw error;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
this.driver = driver;
|
|
1270
|
+
this.partition = partition;
|
|
1271
|
+
const mode = await this.driver.get("PRAGMA journal_mode=WAL");
|
|
1272
|
+
if (mode?.journal_mode.toLowerCase() !== "wal") throw new Error(`journal_mode is ${mode?.journal_mode ?? "missing"}, expected wal`);
|
|
1273
|
+
await this.driver.exec(SCHEMA);
|
|
1274
|
+
return await this.driver.transaction(async (tx) => {
|
|
1275
|
+
const meta = await tx.get("SELECT value FROM state WHERE partition = ?", [partition]);
|
|
1276
|
+
const read = async (table) => (await tx.all(`SELECT key, value FROM ${table} WHERE partition = ?`, [partition])).map(({ key: key2, value }) => ({ key: decodeStateKey(key2), value: JSON.parse(value) }));
|
|
1277
|
+
return unprojectState({
|
|
1278
|
+
rows: await read("rows"),
|
|
1279
|
+
answers: await read("answers"),
|
|
1280
|
+
outbox: await read("outbox"),
|
|
1281
|
+
meta: meta ? [{ key: "state", value: JSON.parse(meta.value) }] : []
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
await this.close().catch(() => void 0);
|
|
1286
|
+
throw error;
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
// @ref LLP 1013#3-the-sqlite-carrier — state rewrite plus byte delta in one
|
|
1291
|
+
// transaction; the byte table is never rewritten wholesale and has no MIME column.
|
|
1292
|
+
async replace(state, bytes) {
|
|
1293
|
+
return this.guard(async () => {
|
|
1294
|
+
if (!this.driver || this.partition === void 0) throw new Error("not open");
|
|
1295
|
+
const partition = this.partition;
|
|
1296
|
+
const projected = projectState(state);
|
|
1297
|
+
await this.driver.transaction(async (tx) => {
|
|
1298
|
+
for (const table of STATE_TABLES) await tx.run(`DELETE FROM ${table} WHERE partition = ?`, [partition]);
|
|
1299
|
+
await tx.run("INSERT INTO state (partition, value) VALUES (?, ?)", [partition, JSON.stringify(projected.meta[0].value)]);
|
|
1300
|
+
for (const table of ["rows", "answers", "outbox"]) {
|
|
1301
|
+
for (const { key: key2, value } of projected[table]) {
|
|
1302
|
+
await tx.run(
|
|
1303
|
+
`INSERT INTO ${table} (partition, key, value) VALUES (?, ?, ?)`,
|
|
1304
|
+
[partition, encodeStateKey(key2, table !== "outbox"), JSON.stringify(value)]
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
for (const item of bytes.put) {
|
|
1309
|
+
await tx.run(
|
|
1310
|
+
"INSERT OR REPLACE INTO bytes (partition, key, value) VALUES (?, ?, ?)",
|
|
1311
|
+
[partition, encodeStateKey(item.key, false), item.bytes]
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
for (const key2 of bytes.remove) await tx.run("DELETE FROM bytes WHERE partition = ? AND key = ?", [partition, encodeStateKey(key2, false)]);
|
|
1315
|
+
await this.options.beforeCommit?.(state, bytes);
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
async readBytes(key2) {
|
|
1320
|
+
return this.guard(async () => {
|
|
1321
|
+
if (!this.driver || this.partition === void 0) return void 0;
|
|
1322
|
+
const row = await this.driver.get(
|
|
1323
|
+
"SELECT value FROM bytes WHERE partition = ? AND key = ?",
|
|
1324
|
+
[this.partition, encodeStateKey(key2, false)]
|
|
1325
|
+
);
|
|
1326
|
+
return row?.value;
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
async wipe(partition) {
|
|
1330
|
+
if (!this.driver) {
|
|
1331
|
+
await this.open(partition);
|
|
1332
|
+
return this.wipe(partition);
|
|
1333
|
+
}
|
|
1334
|
+
if (this.partition !== partition) {
|
|
1335
|
+
const release = await acquireStoreLock(this.locks, partition);
|
|
1336
|
+
try {
|
|
1337
|
+
await this.guard(() => this.driver.transaction(async (tx) => {
|
|
1338
|
+
for (const table of TABLES) await tx.run(`DELETE FROM ${table} WHERE partition = ?`, [partition]);
|
|
1339
|
+
}));
|
|
1340
|
+
} finally {
|
|
1341
|
+
await release();
|
|
1342
|
+
}
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
await this.guard(async () => {
|
|
1346
|
+
await this.driver.transaction(async (tx) => {
|
|
1347
|
+
for (const table of TABLES) await tx.run(`DELETE FROM ${table} WHERE partition = ?`, [partition]);
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
await this.close();
|
|
1351
|
+
}
|
|
1352
|
+
async close() {
|
|
1353
|
+
const driver = this.driver;
|
|
1354
|
+
const release = this.release;
|
|
1355
|
+
this.driver = void 0;
|
|
1356
|
+
this.partition = void 0;
|
|
1357
|
+
this.release = void 0;
|
|
1358
|
+
try {
|
|
1359
|
+
if (driver) await this.guard(() => driver.close());
|
|
1360
|
+
} finally {
|
|
1361
|
+
await release?.();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
function sqliteRangeStore(driver, options = {}) {
|
|
1366
|
+
const factory = (() => new PersistentRangeStore(new SqliteBackend(driver, options)));
|
|
1367
|
+
Object.defineProperty(factory, "carrier", { value: "SQLite" });
|
|
1368
|
+
return factory;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// packages/snapback2/src/store/sqlite-driver.conformance.ts
|
|
1372
|
+
async function runSqliteDriverConformance(open) {
|
|
1373
|
+
const first = await open();
|
|
1374
|
+
const second = await open();
|
|
1375
|
+
const checks = [];
|
|
1376
|
+
const check = (condition, message) => {
|
|
1377
|
+
if (!condition) throw new Error(`sqlite conformance: ${message}`);
|
|
1378
|
+
};
|
|
1379
|
+
try {
|
|
1380
|
+
const mode = await first.get("PRAGMA journal_mode=WAL");
|
|
1381
|
+
const readback = await first.get("PRAGMA journal_mode");
|
|
1382
|
+
check(mode?.journal_mode === readback?.journal_mode, "journal_mode readback");
|
|
1383
|
+
checks.push("journal_mode readback");
|
|
1384
|
+
await first.exec("CREATE TABLE IF NOT EXISTS conformance (key TEXT PRIMARY KEY, value BLOB NOT NULL); DELETE FROM conformance;");
|
|
1385
|
+
for (const length of [1, 1024 * 1024, 0]) {
|
|
1386
|
+
const bytes = new Uint8Array(length);
|
|
1387
|
+
for (let index = 0; index < length; index++) bytes[index] = index % 251;
|
|
1388
|
+
await first.run("INSERT INTO conformance VALUES (?, ?)", [String(length), bytes]);
|
|
1389
|
+
const held = await first.get("SELECT value FROM conformance WHERE key = ?", [String(length)]);
|
|
1390
|
+
check(held?.value instanceof Uint8Array && held.value.length === length && held.value.every((value, index) => value === bytes[index]), `BLOB ${length}`);
|
|
1391
|
+
checks.push(`BLOB ${length}`);
|
|
1392
|
+
}
|
|
1393
|
+
const keys = [
|
|
1394
|
+
["messages", "m\0x"],
|
|
1395
|
+
["messages", "m\0y"],
|
|
1396
|
+
["messages", ""],
|
|
1397
|
+
["messages", 'quote",\u96EA'],
|
|
1398
|
+
["thread", JSON.stringify({ nested: { words: ["a", "b"], empty: null } })]
|
|
1399
|
+
];
|
|
1400
|
+
for (const key2 of keys) await first.run("INSERT INTO conformance VALUES (?, ?)", [JSON.stringify(key2), new Uint8Array([9])]);
|
|
1401
|
+
for (const key2 of keys) {
|
|
1402
|
+
const held = await first.get("SELECT key FROM conformance WHERE key = ?", [JSON.stringify(key2)]);
|
|
1403
|
+
check(held?.key === JSON.stringify(key2), "JSON tuple key round trip");
|
|
1404
|
+
}
|
|
1405
|
+
checks.push("JSON tuple key round trip");
|
|
1406
|
+
const absent = await first.get("SELECT key FROM conformance WHERE key = ?", ["missing"]);
|
|
1407
|
+
check(absent === void 0, "missing row is undefined");
|
|
1408
|
+
checks.push("missing row is undefined");
|
|
1409
|
+
const marker = new Error("rollback marker");
|
|
1410
|
+
try {
|
|
1411
|
+
await first.transaction(async (tx) => {
|
|
1412
|
+
await tx.run("INSERT INTO conformance VALUES (?, ?)", ["rollback", new Uint8Array([1])]);
|
|
1413
|
+
await Promise.resolve();
|
|
1414
|
+
throw marker;
|
|
1415
|
+
});
|
|
1416
|
+
throw new Error("transaction swallowed throw");
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
check(error === marker, "rollback propagates the body error");
|
|
1419
|
+
}
|
|
1420
|
+
check(await first.get("SELECT key FROM conformance WHERE key = ?", ["rollback"]) === void 0, "rollback on throw");
|
|
1421
|
+
checks.push("rollback on throw");
|
|
1422
|
+
let commitFailed = false;
|
|
1423
|
+
await first.transaction(async (tx) => {
|
|
1424
|
+
await tx.run("INSERT INTO conformance VALUES (?, ?)", ["failed-commit", new Uint8Array([7])]);
|
|
1425
|
+
await tx.run("ROLLBACK");
|
|
1426
|
+
}).catch(() => {
|
|
1427
|
+
commitFailed = true;
|
|
1428
|
+
});
|
|
1429
|
+
check(commitFailed, "COMMIT after rollback must fail");
|
|
1430
|
+
check(await first.get("SELECT key FROM conformance WHERE key = ?", ["failed-commit"]) === void 0, "failed COMMIT leaves no row");
|
|
1431
|
+
await first.transaction((tx) => tx.run("INSERT INTO conformance VALUES (?, ?)", ["after-failed-commit", new Uint8Array([8])]));
|
|
1432
|
+
check(await first.get("SELECT key FROM conformance WHERE key = ?", ["after-failed-commit"]) !== void 0, "next transaction after failed COMMIT succeeds");
|
|
1433
|
+
checks.push("body-ended transaction rejects and next transaction succeeds");
|
|
1434
|
+
let release;
|
|
1435
|
+
let reached;
|
|
1436
|
+
let committed = false;
|
|
1437
|
+
let interleaved = false;
|
|
1438
|
+
const barrier = new Promise((resolve) => {
|
|
1439
|
+
release = resolve;
|
|
1440
|
+
});
|
|
1441
|
+
const atBarrier = new Promise((resolve) => {
|
|
1442
|
+
reached = resolve;
|
|
1443
|
+
});
|
|
1444
|
+
const write = first.transaction(async (tx) => {
|
|
1445
|
+
await tx.run("INSERT INTO conformance VALUES (?, ?)", ["barrier", new Uint8Array([2])]);
|
|
1446
|
+
reached();
|
|
1447
|
+
await barrier;
|
|
1448
|
+
return 42;
|
|
1449
|
+
}).then((value) => {
|
|
1450
|
+
committed = true;
|
|
1451
|
+
return value;
|
|
1452
|
+
});
|
|
1453
|
+
await atBarrier;
|
|
1454
|
+
const competing = second.run("INSERT INTO conformance VALUES (?, ?)", ["competing", new Uint8Array([3])]).then(() => {
|
|
1455
|
+
if (!committed) interleaved = true;
|
|
1456
|
+
}, (error) => {
|
|
1457
|
+
check(/busy|locked/i.test(String(error)), `competing writer must busy or wait: ${error}`);
|
|
1458
|
+
});
|
|
1459
|
+
let outerFinished = false;
|
|
1460
|
+
const outer = first.run("INSERT INTO conformance VALUES (?, ?)", ["outer", new Uint8Array([4])]).then(() => {
|
|
1461
|
+
outerFinished = true;
|
|
1462
|
+
});
|
|
1463
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1464
|
+
const prematureOuter = outerFinished;
|
|
1465
|
+
release();
|
|
1466
|
+
check(await write === 42, "transaction result");
|
|
1467
|
+
await Promise.all([competing, outer]);
|
|
1468
|
+
check(!interleaved && !prematureOuter, "late beforeCommit holds transaction");
|
|
1469
|
+
checks.push("late beforeCommit holds transaction", "transaction result");
|
|
1470
|
+
return { journalMode: readback.journal_mode, checks };
|
|
1471
|
+
} finally {
|
|
1472
|
+
await first.close();
|
|
1473
|
+
await second.close();
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// packages/snapback2/tests/sqlite-state.ts
|
|
1478
|
+
var fixtureAsset = "s2id-00000000000000000000000000000006-0000000000000001";
|
|
1479
|
+
function sqliteState(clock = 1) {
|
|
1480
|
+
const id = `m\0${clock}`;
|
|
1481
|
+
const row = { table: "messages", id, row: { id, body: `hello ${clock}`, photo: { id: fixtureAsset, width: 8, height: 8 } } };
|
|
1482
|
+
const args = { nested: { a: [1, "\u96EA", null], quote: '"' } };
|
|
1483
|
+
const answerKey = `thread\0${JSON.stringify(args)}`;
|
|
1484
|
+
const facts = { generation: "g", schemaHash: "s", authEpoch: 2 };
|
|
1485
|
+
const intent = `grant:${clock}`;
|
|
1486
|
+
return {
|
|
1487
|
+
version: 1,
|
|
1488
|
+
sealed: false,
|
|
1489
|
+
clock,
|
|
1490
|
+
rows: { [`messages\0${id}`]: row, "messages\0plain": { table: "messages", id: "plain", row: { id: "plain" } } },
|
|
1491
|
+
answers: { [answerKey]: {
|
|
1492
|
+
op: "thread",
|
|
1493
|
+
args,
|
|
1494
|
+
state: "complete",
|
|
1495
|
+
data: { $row: { table: "messages", id } },
|
|
1496
|
+
next: null,
|
|
1497
|
+
seq: clock,
|
|
1498
|
+
validator: "v",
|
|
1499
|
+
receipt: { ranges: [], absences: [["messages", "byId", ["absent"]]], verdicts: [true] },
|
|
1500
|
+
deliveredAt: 100,
|
|
1501
|
+
refs: [{ table: "messages", id }],
|
|
1502
|
+
order: 1,
|
|
1503
|
+
fingerprint: "fp",
|
|
1504
|
+
retained: true,
|
|
1505
|
+
caughtUp: true,
|
|
1506
|
+
watermark: null
|
|
1507
|
+
} },
|
|
1508
|
+
watermarks: { [answerKey]: { op: "thread", args, watermark: null, validator: "v", ...facts, updatedAt: 100, order: 1 } },
|
|
1509
|
+
outbox: { [intent]: {
|
|
1510
|
+
intent,
|
|
1511
|
+
grant: "grant",
|
|
1512
|
+
offset: clock,
|
|
1513
|
+
count: 2,
|
|
1514
|
+
ids: [id],
|
|
1515
|
+
op: "send",
|
|
1516
|
+
args: { body: "queued" },
|
|
1517
|
+
issuedSeq: clock,
|
|
1518
|
+
state: "queued",
|
|
1519
|
+
predicted: true,
|
|
1520
|
+
trace: { points: [], verdicts: [] },
|
|
1521
|
+
createdAt: 100,
|
|
1522
|
+
updatedAt: 100,
|
|
1523
|
+
order: 1
|
|
1524
|
+
} },
|
|
1525
|
+
grants: { grant: { id: "grant", from: "start", count: 100, mintedAt: 100, expiresAt: 200, used: clock } },
|
|
1526
|
+
currentGrant: "grant",
|
|
1527
|
+
overlay: { [intent]: [row] },
|
|
1528
|
+
overlayMisses: { [intent]: clock },
|
|
1529
|
+
// Lane T1's commit-time tail snapshot (null after a reset) and T2's scoped negatives ride the same projection.
|
|
1530
|
+
overlayWatermarks: { [intent]: { op: "inbox", args: { c: null }, watermark: `cursor-${clock}`, cursorArg: "c", progress: `cursor-${clock}`, consumed: ["cursor-0"] }, "grant:0": null },
|
|
1531
|
+
overlayNegatives: { [intent]: { "messages\0absent": { seq: clock, source: "absence" }, "messages\0gone": { seq: clock, source: "tombstone" } } },
|
|
1532
|
+
bounds: { send: 1 },
|
|
1533
|
+
programs: { g: { generation: "g", schemaHash: "s", schema: { tables: {} }, programs: {
|
|
1534
|
+
thread: { kind: "query", planHash: "p", ir: { result: null }, certificate: { maxNewIds: 0 } }
|
|
1535
|
+
} } },
|
|
1536
|
+
facts,
|
|
1537
|
+
superseded: { ...facts, generation: "new" },
|
|
1538
|
+
negative: { "messages\0absent": clock },
|
|
1539
|
+
rowSeq: { [`messages\0${id}`]: clock },
|
|
1540
|
+
assets: { [fixtureAsset]: { assetId: fixtureAsset, width: null, type: "image/jpeg", size: 3, lastRead: clock } },
|
|
1541
|
+
viewed: { "messages\0viewed": { at: 100, assets: ["viewed-asset"] } }
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// packages/snapback2/src/react-native/files.ts
|
|
1546
|
+
var FILES = Symbol.for("snapback2.native.assetFiles");
|
|
1547
|
+
function attachAssetFiles(store, files) {
|
|
1548
|
+
Object.defineProperty(store, FILES, { value: files });
|
|
1549
|
+
}
|
|
1550
|
+
function partitionDirectory(root, partition) {
|
|
1551
|
+
return `${root.replace(/\/$/, "")}/${encodeURIComponent(partition).replace(/\./g, "%2E")}/`;
|
|
1552
|
+
}
|
|
1553
|
+
var NativeAssetFiles = class {
|
|
1554
|
+
constructor(fs) {
|
|
1555
|
+
this.fs = fs;
|
|
1556
|
+
}
|
|
1557
|
+
partition;
|
|
1558
|
+
state;
|
|
1559
|
+
revision = 0;
|
|
1560
|
+
custody = 0;
|
|
1561
|
+
assetRevisions = /* @__PURE__ */ new Map();
|
|
1562
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1563
|
+
materialized = /* @__PURE__ */ new Set();
|
|
1564
|
+
// Serialize file writes and removals; a late write cannot resurrect an evicted file.
|
|
1565
|
+
tail = Promise.resolve();
|
|
1566
|
+
getRevision = () => this.revision;
|
|
1567
|
+
getAssetRevision = (id) => `${this.custody}:${this.assetRevisions.get(id) ?? 0}`;
|
|
1568
|
+
getCustody = () => this.custody;
|
|
1569
|
+
available = () => this.partition !== void 0 && !this.state?.sealed;
|
|
1570
|
+
subscribe = (listener) => {
|
|
1571
|
+
this.listeners.add(listener);
|
|
1572
|
+
return () => {
|
|
1573
|
+
this.listeners.delete(listener);
|
|
1574
|
+
};
|
|
1575
|
+
};
|
|
1576
|
+
changed(custody = false) {
|
|
1577
|
+
this.revision++;
|
|
1578
|
+
if (custody) this.custody++;
|
|
1579
|
+
for (const listener of this.listeners) {
|
|
1580
|
+
try {
|
|
1581
|
+
listener();
|
|
1582
|
+
} catch {
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
enqueue(body) {
|
|
1587
|
+
const result = this.tail.then(body);
|
|
1588
|
+
this.tail = result.catch(() => void 0);
|
|
1589
|
+
return result;
|
|
1590
|
+
}
|
|
1591
|
+
remove(uri) {
|
|
1592
|
+
this.materialized.delete(uri);
|
|
1593
|
+
return this.fs.remove(uri).catch(() => void 0);
|
|
1594
|
+
}
|
|
1595
|
+
path(key2, partition = this.partition) {
|
|
1596
|
+
return partition === void 0 ? void 0 : partitionDirectory(this.fs.root, partition) + key2;
|
|
1597
|
+
}
|
|
1598
|
+
eligible(assetId, width) {
|
|
1599
|
+
return this.partition !== void 0 && !this.state?.sealed && this.state?.assets[byteKey(assetId, width)] !== void 0;
|
|
1600
|
+
}
|
|
1601
|
+
async get(cache, assetId, width) {
|
|
1602
|
+
if (!ASSET_ID.test(assetId)) return void 0;
|
|
1603
|
+
if (width !== null && (!Number.isSafeInteger(width) || width <= 0)) throw new TypeError("asset width must be a positive integer");
|
|
1604
|
+
const revision = this.getAssetRevision(assetId);
|
|
1605
|
+
const uri = this.path(byteKey(assetId, width));
|
|
1606
|
+
if (!uri) return void 0;
|
|
1607
|
+
const once = await cache.viewOnce(assetId) || await cache.viewed(assetId);
|
|
1608
|
+
const held = once ? void 0 : await cache.getBytes(assetId, width);
|
|
1609
|
+
return this.enqueue(async () => {
|
|
1610
|
+
if (!held || revision !== this.getAssetRevision(assetId) || !this.eligible(assetId, width) || await cache.viewOnce(assetId) || await cache.viewed(assetId)) {
|
|
1611
|
+
await this.remove(uri);
|
|
1612
|
+
return void 0;
|
|
1613
|
+
}
|
|
1614
|
+
if (!this.materialized.has(uri) || !await this.fs.exists?.(uri)) await this.fs.write(uri, held.bytes);
|
|
1615
|
+
if (revision !== this.getAssetRevision(assetId) || !this.eligible(assetId, width) || await cache.viewOnce(assetId) || await cache.viewed(assetId)) {
|
|
1616
|
+
await this.remove(uri);
|
|
1617
|
+
return void 0;
|
|
1618
|
+
}
|
|
1619
|
+
this.materialized.add(uri);
|
|
1620
|
+
return uri;
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
/** Only backend success is a commit notification; never call this from beforeCommit. */
|
|
1624
|
+
async opened(partition, state) {
|
|
1625
|
+
this.materialized.clear();
|
|
1626
|
+
this.partition = partition;
|
|
1627
|
+
this.state = state;
|
|
1628
|
+
this.changed(true);
|
|
1629
|
+
const directory = partitionDirectory(this.fs.root, partition);
|
|
1630
|
+
await this.enqueue(async () => {
|
|
1631
|
+
for (const name of await this.fs.list(directory)) {
|
|
1632
|
+
if (!state?.assets[name]) await this.remove(directory + name);
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
async replaced(state, bytes) {
|
|
1637
|
+
const sealed = this.state?.sealed !== state.sealed;
|
|
1638
|
+
const before = assetLeases(this.state), after = assetLeases(state);
|
|
1639
|
+
const changed = new Set([...before.keys(), ...after.keys()].filter((id) => before.get(id) !== after.get(id)));
|
|
1640
|
+
for (const key2 of [...bytes.remove, ...bytes.put.map((put) => put.key)]) changed.add(key2.split(".w")[0]);
|
|
1641
|
+
for (const id of changed) this.assetRevisions.set(id, (this.assetRevisions.get(id) ?? 0) + 1);
|
|
1642
|
+
this.state = state;
|
|
1643
|
+
this.changed(sealed);
|
|
1644
|
+
const partition = this.partition;
|
|
1645
|
+
void this.enqueue(async () => {
|
|
1646
|
+
for (const key2 of [...bytes.remove, ...bytes.put.map((put) => put.key)]) {
|
|
1647
|
+
const uri = this.path(key2, partition);
|
|
1648
|
+
if (uri) await this.remove(uri);
|
|
1649
|
+
}
|
|
1650
|
+
}).catch(() => void 0);
|
|
1651
|
+
}
|
|
1652
|
+
async wiped(partition) {
|
|
1653
|
+
if (this.partition === partition) this.closed();
|
|
1654
|
+
await this.enqueue(() => this.remove(partitionDirectory(this.fs.root, partition)));
|
|
1655
|
+
}
|
|
1656
|
+
closed() {
|
|
1657
|
+
this.materialized.clear();
|
|
1658
|
+
this.partition = void 0;
|
|
1659
|
+
this.state = void 0;
|
|
1660
|
+
this.changed(true);
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
function assetLeases(state) {
|
|
1664
|
+
const refs = /* @__PURE__ */ new Map();
|
|
1665
|
+
for (const [key2, row] of Object.entries(state?.rows ?? {})) {
|
|
1666
|
+
for (const id of assetIdsIn(row.row)) {
|
|
1667
|
+
const leases = refs.get(id) ?? [];
|
|
1668
|
+
leases.push(JSON.stringify([key2, row.row, Boolean(state?.viewed[key2])]));
|
|
1669
|
+
refs.set(id, leases);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return new Map([...refs].map(([id, leases]) => [id, leases.sort().join("\n")]));
|
|
1673
|
+
}
|
|
1674
|
+
function fileBackend(backend, files) {
|
|
1675
|
+
return {
|
|
1676
|
+
carrier: backend.carrier,
|
|
1677
|
+
async open(partition) {
|
|
1678
|
+
const state = await backend.open(partition);
|
|
1679
|
+
try {
|
|
1680
|
+
await files.opened(partition, state);
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
files.closed();
|
|
1683
|
+
await backend.close();
|
|
1684
|
+
throw error;
|
|
1685
|
+
}
|
|
1686
|
+
return state;
|
|
1687
|
+
},
|
|
1688
|
+
async replace(state, bytes) {
|
|
1689
|
+
await backend.replace(state, bytes);
|
|
1690
|
+
await files.replaced(state, bytes);
|
|
1691
|
+
},
|
|
1692
|
+
readBytes: backend.readBytes ? (key2) => backend.readBytes(key2) : void 0,
|
|
1693
|
+
async wipe(partition) {
|
|
1694
|
+
await backend.wipe(partition);
|
|
1695
|
+
await files.wiped(partition);
|
|
1696
|
+
},
|
|
1697
|
+
async close() {
|
|
1698
|
+
files.closed();
|
|
1699
|
+
await backend.close();
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
export {
|
|
1704
|
+
ByteCache,
|
|
1705
|
+
NativeAssetFiles,
|
|
1706
|
+
PersistentRangeStore,
|
|
1707
|
+
SqliteBackend,
|
|
1708
|
+
attachAssetFiles,
|
|
1709
|
+
fileBackend,
|
|
1710
|
+
fixtureAsset,
|
|
1711
|
+
inProcessLocks,
|
|
1712
|
+
nodeSqliteDriver,
|
|
1713
|
+
partitionDirectory,
|
|
1714
|
+
projectState,
|
|
1715
|
+
runSqliteDriverConformance,
|
|
1716
|
+
sqliteRangeStore,
|
|
1717
|
+
sqliteState,
|
|
1718
|
+
unprojectState
|
|
1719
|
+
};
|
|
1720
|
+
//# sourceMappingURL=sqlite-test.mjs.map
|