tablinum 0.1.0 → 0.1.3
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 +114 -2
- package/dist/db/{create-localstr.d.ts → create-tablinum.d.ts} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15 -15
- package/dist/svelte/collection.svelte.d.ts +20 -0
- package/dist/svelte/database.svelte.d.ts +15 -0
- package/dist/svelte/index.svelte.d.ts +16 -0
- package/dist/svelte/index.svelte.js +2050 -0
- package/dist/svelte/live-query.svelte.d.ts +8 -0
- package/dist/svelte/query.svelte.d.ts +39 -0
- package/package.json +7 -2
|
@@ -0,0 +1,2050 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/svelte/index.svelte.ts
|
|
10
|
+
import { Effect as Effect19, Exit as Exit2, Scope as Scope4 } from "effect";
|
|
11
|
+
|
|
12
|
+
// src/db/create-tablinum.ts
|
|
13
|
+
import { Effect as Effect14, PubSub as PubSub2, Ref as Ref5 } from "effect";
|
|
14
|
+
|
|
15
|
+
// src/schema/validate.ts
|
|
16
|
+
import { Effect, Schema } from "effect";
|
|
17
|
+
|
|
18
|
+
// src/errors.ts
|
|
19
|
+
import { Data } from "effect";
|
|
20
|
+
|
|
21
|
+
class ValidationError extends Data.TaggedError("ValidationError") {
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class StorageError extends Data.TaggedError("StorageError") {
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class CryptoError extends Data.TaggedError("CryptoError") {
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class RelayError extends Data.TaggedError("RelayError") {
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class SyncError extends Data.TaggedError("SyncError") {
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class NotFoundError extends Data.TaggedError("NotFoundError") {
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class ClosedError extends Data.TaggedError("ClosedError") {
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/schema/validate.ts
|
|
43
|
+
function fieldDefToSchema(fd) {
|
|
44
|
+
let base;
|
|
45
|
+
switch (fd.kind) {
|
|
46
|
+
case "string":
|
|
47
|
+
base = Schema.String;
|
|
48
|
+
break;
|
|
49
|
+
case "number":
|
|
50
|
+
base = Schema.Number;
|
|
51
|
+
break;
|
|
52
|
+
case "boolean":
|
|
53
|
+
base = Schema.Boolean;
|
|
54
|
+
break;
|
|
55
|
+
case "json":
|
|
56
|
+
base = Schema.Unknown;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (fd.isArray) {
|
|
60
|
+
base = Schema.Array(base);
|
|
61
|
+
}
|
|
62
|
+
if (fd.isOptional) {
|
|
63
|
+
base = Schema.UndefinedOr(base);
|
|
64
|
+
}
|
|
65
|
+
return base;
|
|
66
|
+
}
|
|
67
|
+
function buildValidator(collectionName, def) {
|
|
68
|
+
const schemaFields = {
|
|
69
|
+
id: Schema.String
|
|
70
|
+
};
|
|
71
|
+
for (const [name, fieldDef] of Object.entries(def.fields)) {
|
|
72
|
+
schemaFields[name] = fieldDefToSchema(fieldDef);
|
|
73
|
+
}
|
|
74
|
+
const recordSchema = Schema.Struct(schemaFields);
|
|
75
|
+
const decode = Schema.decodeUnknownSync(recordSchema);
|
|
76
|
+
return (input) => Effect.gen(function* () {
|
|
77
|
+
try {
|
|
78
|
+
const result = decode(input);
|
|
79
|
+
return result;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return yield* new ValidationError({
|
|
82
|
+
message: `Validation failed for collection "${collectionName}": ${e instanceof Error ? e.message : String(e)}`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function buildPartialValidator(collectionName, def) {
|
|
88
|
+
return (input) => Effect.gen(function* () {
|
|
89
|
+
if (typeof input !== "object" || input === null) {
|
|
90
|
+
return yield* new ValidationError({
|
|
91
|
+
message: `Validation failed for collection "${collectionName}": expected an object`
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const record = input;
|
|
95
|
+
for (const [key, value] of Object.entries(record)) {
|
|
96
|
+
const fieldDef = def.fields[key];
|
|
97
|
+
if (!fieldDef) {
|
|
98
|
+
return yield* new ValidationError({
|
|
99
|
+
message: `Unknown field "${key}" in collection "${collectionName}"`,
|
|
100
|
+
field: key
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (value === undefined && fieldDef.isOptional) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const fieldSchema = fieldDefToSchema(fieldDef);
|
|
107
|
+
const decode = Schema.decodeUnknownSync(fieldSchema);
|
|
108
|
+
try {
|
|
109
|
+
decode(value);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return yield* new ValidationError({
|
|
112
|
+
message: `Validation failed for field "${key}" in collection "${collectionName}": ${e instanceof Error ? e.message : String(e)}`,
|
|
113
|
+
field: key
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return record;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/storage/idb.ts
|
|
122
|
+
import { Effect as Effect2 } from "effect";
|
|
123
|
+
import { openDB } from "idb";
|
|
124
|
+
var DB_NAME = "tablinum";
|
|
125
|
+
function storeName(collection) {
|
|
126
|
+
return `col_${collection}`;
|
|
127
|
+
}
|
|
128
|
+
function schemaVersion(schema) {
|
|
129
|
+
const sig = Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)).map(([name, def]) => {
|
|
130
|
+
const indices = [...def.indices ?? []].sort().join(",");
|
|
131
|
+
return `${name}:${indices}`;
|
|
132
|
+
}).join("|");
|
|
133
|
+
let hash = 1;
|
|
134
|
+
for (let i = 0;i < sig.length; i++) {
|
|
135
|
+
hash = hash * 31 + sig.charCodeAt(i) | 0;
|
|
136
|
+
}
|
|
137
|
+
return Math.abs(hash) + 1;
|
|
138
|
+
}
|
|
139
|
+
function wrap(label, fn) {
|
|
140
|
+
return Effect2.tryPromise({
|
|
141
|
+
try: fn,
|
|
142
|
+
catch: (e) => new StorageError({
|
|
143
|
+
message: `IndexedDB ${label} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
144
|
+
cause: e
|
|
145
|
+
})
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function openIDBStorage(dbName, schema) {
|
|
149
|
+
return Effect2.gen(function* () {
|
|
150
|
+
const name = dbName ?? DB_NAME;
|
|
151
|
+
const version = schemaVersion(schema);
|
|
152
|
+
const db = yield* Effect2.tryPromise({
|
|
153
|
+
try: () => openDB(name, version, {
|
|
154
|
+
upgrade(database) {
|
|
155
|
+
if (!database.objectStoreNames.contains("events")) {
|
|
156
|
+
const events = database.createObjectStore("events", {
|
|
157
|
+
keyPath: "id"
|
|
158
|
+
});
|
|
159
|
+
events.createIndex("by-record", ["collection", "recordId"]);
|
|
160
|
+
}
|
|
161
|
+
if (!database.objectStoreNames.contains("giftwraps")) {
|
|
162
|
+
database.createObjectStore("giftwraps", {
|
|
163
|
+
keyPath: "id"
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (database.objectStoreNames.contains("records")) {
|
|
167
|
+
database.deleteObjectStore("records");
|
|
168
|
+
}
|
|
169
|
+
const expectedStores = new Set;
|
|
170
|
+
for (const [, def] of Object.entries(schema)) {
|
|
171
|
+
const sn = storeName(def.name);
|
|
172
|
+
expectedStores.add(sn);
|
|
173
|
+
if (!database.objectStoreNames.contains(sn)) {
|
|
174
|
+
const store = database.createObjectStore(sn, { keyPath: "id" });
|
|
175
|
+
for (const idx of def.indices ?? []) {
|
|
176
|
+
store.createIndex(idx, idx);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
const tx = database.transaction;
|
|
180
|
+
const store = tx.objectStore(sn);
|
|
181
|
+
const existingIndices = new Set(Array.from(store.indexNames));
|
|
182
|
+
const wantedIndices = new Set(def.indices ?? []);
|
|
183
|
+
for (const idx of existingIndices) {
|
|
184
|
+
if (!wantedIndices.has(idx)) {
|
|
185
|
+
store.deleteIndex(idx);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const idx of wantedIndices) {
|
|
189
|
+
if (!existingIndices.has(idx)) {
|
|
190
|
+
store.createIndex(idx, idx);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const allStores = Array.from(database.objectStoreNames);
|
|
196
|
+
for (const existing of allStores) {
|
|
197
|
+
if (existing.startsWith("col_") && !expectedStores.has(existing)) {
|
|
198
|
+
database.deleteObjectStore(existing);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}),
|
|
203
|
+
catch: (e) => new StorageError({
|
|
204
|
+
message: "Failed to open IndexedDB",
|
|
205
|
+
cause: e
|
|
206
|
+
})
|
|
207
|
+
});
|
|
208
|
+
yield* Effect2.addFinalizer(() => Effect2.sync(() => db.close()));
|
|
209
|
+
const handle = {
|
|
210
|
+
putRecord: (collection, record) => wrap("putRecord", () => db.put(storeName(collection), record).then(() => {
|
|
211
|
+
return;
|
|
212
|
+
})),
|
|
213
|
+
getRecord: (collection, id) => wrap("getRecord", () => db.get(storeName(collection), id)),
|
|
214
|
+
getAllRecords: (collection) => wrap("getAllRecords", () => db.getAll(storeName(collection))),
|
|
215
|
+
countRecords: (collection) => wrap("countRecords", () => db.count(storeName(collection))),
|
|
216
|
+
clearRecords: (collection) => wrap("clearRecords", () => db.clear(storeName(collection))),
|
|
217
|
+
getByIndex: (collection, indexName, value) => wrap("getByIndex", () => db.getAllFromIndex(storeName(collection), indexName, value)),
|
|
218
|
+
getByIndexRange: (collection, indexName, range) => wrap("getByIndexRange", () => db.getAllFromIndex(storeName(collection), indexName, range)),
|
|
219
|
+
getAllSorted: (collection, indexName, direction) => wrap("getAllSorted", async () => {
|
|
220
|
+
const sn = storeName(collection);
|
|
221
|
+
const tx = db.transaction(sn, "readonly");
|
|
222
|
+
const store = tx.objectStore(sn);
|
|
223
|
+
const index = store.index(indexName);
|
|
224
|
+
const results = [];
|
|
225
|
+
let cursor = await index.openCursor(null, direction ?? "next");
|
|
226
|
+
while (cursor) {
|
|
227
|
+
results.push(cursor.value);
|
|
228
|
+
cursor = await cursor.continue();
|
|
229
|
+
}
|
|
230
|
+
return results;
|
|
231
|
+
}),
|
|
232
|
+
putEvent: (event) => wrap("putEvent", () => db.put("events", event).then(() => {
|
|
233
|
+
return;
|
|
234
|
+
})),
|
|
235
|
+
getEvent: (id) => wrap("getEvent", () => db.get("events", id)),
|
|
236
|
+
getAllEvents: () => wrap("getAllEvents", () => db.getAll("events")),
|
|
237
|
+
getEventsByRecord: (collection, recordId) => wrap("getEventsByRecord", () => db.getAllFromIndex("events", "by-record", [collection, recordId])),
|
|
238
|
+
putGiftWrap: (gw) => wrap("putGiftWrap", () => db.put("giftwraps", gw).then(() => {
|
|
239
|
+
return;
|
|
240
|
+
})),
|
|
241
|
+
getGiftWrap: (id) => wrap("getGiftWrap", () => db.get("giftwraps", id)),
|
|
242
|
+
getAllGiftWraps: () => wrap("getAllGiftWraps", () => db.getAll("giftwraps")),
|
|
243
|
+
close: () => Effect2.sync(() => db.close())
|
|
244
|
+
};
|
|
245
|
+
return handle;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/storage/records-store.ts
|
|
250
|
+
import { Effect as Effect3 } from "effect";
|
|
251
|
+
|
|
252
|
+
// src/storage/lww.ts
|
|
253
|
+
function resolveWinner(existing, incoming) {
|
|
254
|
+
if (existing === null)
|
|
255
|
+
return incoming;
|
|
256
|
+
if (incoming.createdAt > existing.createdAt)
|
|
257
|
+
return incoming;
|
|
258
|
+
if (incoming.createdAt < existing.createdAt)
|
|
259
|
+
return existing;
|
|
260
|
+
return incoming.id < existing.id ? incoming : existing;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/storage/records-store.ts
|
|
264
|
+
function buildRecord(event) {
|
|
265
|
+
return {
|
|
266
|
+
id: event.recordId,
|
|
267
|
+
_deleted: event.kind === "delete",
|
|
268
|
+
_updatedAt: event.createdAt,
|
|
269
|
+
...event.data ?? {}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function applyEvent(storage, event) {
|
|
273
|
+
return Effect3.gen(function* () {
|
|
274
|
+
const existingEvents = yield* storage.getEventsByRecord(event.collection, event.recordId);
|
|
275
|
+
let currentWinner = null;
|
|
276
|
+
for (const e of existingEvents) {
|
|
277
|
+
if (e.id === event.id)
|
|
278
|
+
continue;
|
|
279
|
+
currentWinner = resolveWinner(currentWinner, e);
|
|
280
|
+
}
|
|
281
|
+
const winner = resolveWinner(currentWinner, event);
|
|
282
|
+
const incomingWon = winner.id === event.id;
|
|
283
|
+
if (incomingWon) {
|
|
284
|
+
yield* storage.putRecord(event.collection, buildRecord(event));
|
|
285
|
+
}
|
|
286
|
+
return incomingWon;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function rebuild(storage, collections) {
|
|
290
|
+
return Effect3.gen(function* () {
|
|
291
|
+
for (const col of collections) {
|
|
292
|
+
yield* storage.clearRecords(col);
|
|
293
|
+
}
|
|
294
|
+
const allEvents = yield* storage.getAllEvents();
|
|
295
|
+
const sorted = [...allEvents].sort((a, b) => a.createdAt - b.createdAt);
|
|
296
|
+
const winners = new Map;
|
|
297
|
+
for (const event of sorted) {
|
|
298
|
+
const key = `${event.collection}:${event.recordId}`;
|
|
299
|
+
const current = winners.get(key) ?? null;
|
|
300
|
+
winners.set(key, resolveWinner(current, event));
|
|
301
|
+
}
|
|
302
|
+
for (const event of winners.values()) {
|
|
303
|
+
yield* storage.putRecord(event.collection, buildRecord(event));
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/crud/collection-handle.ts
|
|
309
|
+
import { Effect as Effect6 } from "effect";
|
|
310
|
+
|
|
311
|
+
// src/utils/uuid.ts
|
|
312
|
+
function uuidv7() {
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
const bytes = new Uint8Array(16);
|
|
315
|
+
crypto.getRandomValues(bytes);
|
|
316
|
+
bytes[0] = now / 2 ** 40 & 255;
|
|
317
|
+
bytes[1] = now / 2 ** 32 & 255;
|
|
318
|
+
bytes[2] = now / 2 ** 24 & 255;
|
|
319
|
+
bytes[3] = now / 2 ** 16 & 255;
|
|
320
|
+
bytes[4] = now / 2 ** 8 & 255;
|
|
321
|
+
bytes[5] = now & 255;
|
|
322
|
+
bytes[6] = bytes[6] & 15 | 112;
|
|
323
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
324
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
325
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/crud/watch.ts
|
|
329
|
+
import { Effect as Effect4, PubSub, Ref, Stream } from "effect";
|
|
330
|
+
function watchCollection(ctx, storage, collectionName, filter, mapRecord) {
|
|
331
|
+
const query = () => Effect4.gen(function* () {
|
|
332
|
+
const all = yield* storage.getAllRecords(collectionName);
|
|
333
|
+
const filtered = all.filter((r) => !r._deleted && (filter ? filter(r) : true));
|
|
334
|
+
return mapRecord ? filtered.map(mapRecord) : filtered;
|
|
335
|
+
});
|
|
336
|
+
const changes = Stream.fromPubSub(ctx.pubsub).pipe(Stream.filter((event) => event.collection === collectionName), Stream.mapEffect(() => Effect4.gen(function* () {
|
|
337
|
+
const replaying = yield* Ref.get(ctx.replayingRef);
|
|
338
|
+
if (replaying)
|
|
339
|
+
return;
|
|
340
|
+
return yield* query();
|
|
341
|
+
})), Stream.filter((result) => result !== undefined));
|
|
342
|
+
return Stream.unwrap(Effect4.gen(function* () {
|
|
343
|
+
const initial = yield* query();
|
|
344
|
+
return Stream.concat(Stream.make(initial), changes);
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
function notifyChange(ctx, event) {
|
|
348
|
+
return PubSub.publish(ctx.pubsub, event).pipe(Effect4.asVoid);
|
|
349
|
+
}
|
|
350
|
+
function notifyReplayComplete(ctx, collections) {
|
|
351
|
+
return Effect4.gen(function* () {
|
|
352
|
+
yield* Ref.set(ctx.replayingRef, false);
|
|
353
|
+
for (const collection of collections) {
|
|
354
|
+
yield* notifyChange(ctx, {
|
|
355
|
+
collection,
|
|
356
|
+
recordId: "",
|
|
357
|
+
kind: "update"
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/crud/query-builder.ts
|
|
364
|
+
import { Effect as Effect5, Ref as Ref2, Stream as Stream2 } from "effect";
|
|
365
|
+
function emptyPlan() {
|
|
366
|
+
return { filters: [] };
|
|
367
|
+
}
|
|
368
|
+
function executeQuery(ctx, plan) {
|
|
369
|
+
return Effect5.gen(function* () {
|
|
370
|
+
if (plan.fieldName) {
|
|
371
|
+
const fieldDef = ctx.def.fields[plan.fieldName];
|
|
372
|
+
if (!fieldDef) {
|
|
373
|
+
return yield* new ValidationError({
|
|
374
|
+
message: `Unknown field "${plan.fieldName}" in collection "${ctx.collectionName}"`,
|
|
375
|
+
field: plan.fieldName
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (fieldDef.kind === "json" || fieldDef.isArray) {
|
|
379
|
+
return yield* new ValidationError({
|
|
380
|
+
message: `Field "${plan.fieldName}" does not support filtering (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`,
|
|
381
|
+
field: plan.fieldName
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
let results;
|
|
386
|
+
if (plan.indexQuery && ctx.def.indices.includes(plan.indexQuery.field)) {
|
|
387
|
+
if (plan.indexQuery.type === "value") {
|
|
388
|
+
results = [
|
|
389
|
+
...yield* ctx.storage.getByIndex(ctx.collectionName, plan.indexQuery.field, plan.indexQuery.range)
|
|
390
|
+
];
|
|
391
|
+
} else {
|
|
392
|
+
results = [
|
|
393
|
+
...yield* ctx.storage.getByIndexRange(ctx.collectionName, plan.indexQuery.field, plan.indexQuery.range)
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
} else if (plan.orderBy && ctx.def.indices.includes(plan.orderBy.field) && plan.filters.length === 0) {
|
|
397
|
+
results = [
|
|
398
|
+
...yield* ctx.storage.getAllSorted(ctx.collectionName, plan.orderBy.field, plan.orderBy.direction === "desc" ? "prev" : "next")
|
|
399
|
+
];
|
|
400
|
+
} else {
|
|
401
|
+
results = [...yield* ctx.storage.getAllRecords(ctx.collectionName)];
|
|
402
|
+
}
|
|
403
|
+
results = results.filter((r) => !r._deleted);
|
|
404
|
+
for (const f of plan.filters) {
|
|
405
|
+
results = results.filter(f);
|
|
406
|
+
}
|
|
407
|
+
if (plan.orderBy) {
|
|
408
|
+
const alreadySorted = ctx.def.indices.includes(plan.orderBy.field) && plan.filters.length === 0 && !plan.indexQuery;
|
|
409
|
+
if (!alreadySorted) {
|
|
410
|
+
const { field, direction } = plan.orderBy;
|
|
411
|
+
results = results.sort((a, b) => {
|
|
412
|
+
const va = a[field];
|
|
413
|
+
const vb = b[field];
|
|
414
|
+
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
|
415
|
+
return direction === "desc" ? -cmp : cmp;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (plan.offset) {
|
|
420
|
+
results = results.slice(plan.offset);
|
|
421
|
+
}
|
|
422
|
+
if (plan.limit !== null && plan.limit !== undefined) {
|
|
423
|
+
results = results.slice(0, plan.limit);
|
|
424
|
+
}
|
|
425
|
+
return results.map(ctx.mapRecord);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function watchQuery(ctx, plan) {
|
|
429
|
+
const query = () => executeQuery(ctx, plan);
|
|
430
|
+
const changes = Stream2.fromPubSub(ctx.watchCtx.pubsub).pipe(Stream2.filter((event) => event.collection === ctx.collectionName), Stream2.mapEffect(() => Effect5.gen(function* () {
|
|
431
|
+
const replaying = yield* Ref2.get(ctx.watchCtx.replayingRef);
|
|
432
|
+
if (replaying)
|
|
433
|
+
return;
|
|
434
|
+
return yield* query();
|
|
435
|
+
})), Stream2.filter((result) => result !== undefined));
|
|
436
|
+
return Stream2.unwrap(Effect5.gen(function* () {
|
|
437
|
+
const initial = yield* query();
|
|
438
|
+
return Stream2.concat(Stream2.make(initial), changes);
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
function makeQueryBuilder(ctx, plan) {
|
|
442
|
+
return {
|
|
443
|
+
and: (fn) => makeQueryBuilder(ctx, {
|
|
444
|
+
...plan,
|
|
445
|
+
filters: [...plan.filters, (r) => fn(ctx.mapRecord(r))]
|
|
446
|
+
}),
|
|
447
|
+
sortBy: (field) => makeQueryBuilder(ctx, {
|
|
448
|
+
...plan,
|
|
449
|
+
orderBy: { field, direction: plan.orderBy?.direction ?? "asc" }
|
|
450
|
+
}),
|
|
451
|
+
reverse: () => makeQueryBuilder(ctx, {
|
|
452
|
+
...plan,
|
|
453
|
+
orderBy: {
|
|
454
|
+
field: plan.orderBy?.field ?? "id",
|
|
455
|
+
direction: plan.orderBy?.direction === "desc" ? "asc" : "desc"
|
|
456
|
+
}
|
|
457
|
+
}),
|
|
458
|
+
offset: (n) => makeQueryBuilder(ctx, { ...plan, offset: n }),
|
|
459
|
+
limit: (n) => makeQueryBuilder(ctx, { ...plan, limit: n }),
|
|
460
|
+
get: () => executeQuery(ctx, plan),
|
|
461
|
+
first: () => Effect5.gen(function* () {
|
|
462
|
+
const limitedPlan = { ...plan, limit: 1 };
|
|
463
|
+
const results = yield* executeQuery(ctx, limitedPlan);
|
|
464
|
+
return results[0] ?? null;
|
|
465
|
+
}),
|
|
466
|
+
count: () => Effect5.gen(function* () {
|
|
467
|
+
const results = yield* executeQuery(ctx, plan);
|
|
468
|
+
return results.length;
|
|
469
|
+
}),
|
|
470
|
+
watch: () => watchQuery(ctx, plan)
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function makeOrderByBuilder(ctx, plan) {
|
|
474
|
+
return {
|
|
475
|
+
reverse: () => makeOrderByBuilder(ctx, {
|
|
476
|
+
...plan,
|
|
477
|
+
orderBy: {
|
|
478
|
+
field: plan.orderBy.field,
|
|
479
|
+
direction: plan.orderBy.direction === "desc" ? "asc" : "desc"
|
|
480
|
+
}
|
|
481
|
+
}),
|
|
482
|
+
offset: (n) => makeOrderByBuilder(ctx, { ...plan, offset: n }),
|
|
483
|
+
limit: (n) => makeOrderByBuilder(ctx, { ...plan, limit: n }),
|
|
484
|
+
get: () => executeQuery(ctx, plan),
|
|
485
|
+
first: () => Effect5.gen(function* () {
|
|
486
|
+
const limitedPlan = { ...plan, limit: 1 };
|
|
487
|
+
const results = yield* executeQuery(ctx, limitedPlan);
|
|
488
|
+
return results[0] ?? null;
|
|
489
|
+
}),
|
|
490
|
+
count: () => Effect5.gen(function* () {
|
|
491
|
+
const results = yield* executeQuery(ctx, plan);
|
|
492
|
+
return results.length;
|
|
493
|
+
}),
|
|
494
|
+
watch: () => watchQuery(ctx, plan)
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function createWhereClause(storage, watchCtx, collectionName, def, fieldName, mapRecord) {
|
|
498
|
+
const ctx = { storage, watchCtx, collectionName, def, mapRecord };
|
|
499
|
+
const fieldDef = def.fields[fieldName];
|
|
500
|
+
const isIndexed = def.indices.includes(fieldName) && fieldDef !== null && fieldDef !== undefined && fieldDef.kind !== "boolean";
|
|
501
|
+
const withFilter = (filterFn, indexQuery) => {
|
|
502
|
+
const plan = {
|
|
503
|
+
...emptyPlan(),
|
|
504
|
+
fieldName,
|
|
505
|
+
filters: [filterFn],
|
|
506
|
+
indexQuery
|
|
507
|
+
};
|
|
508
|
+
return makeQueryBuilder(ctx, plan);
|
|
509
|
+
};
|
|
510
|
+
return {
|
|
511
|
+
equals: (value) => withFilter((r) => r[fieldName] === value, isIndexed ? { field: fieldName, range: value, type: "value" } : undefined),
|
|
512
|
+
above: (value) => withFilter((r) => r[fieldName] > value, isIndexed ? { field: fieldName, range: IDBKeyRange.lowerBound(value, true), type: "range" } : undefined),
|
|
513
|
+
aboveOrEqual: (value) => withFilter((r) => r[fieldName] >= value, isIndexed ? { field: fieldName, range: IDBKeyRange.lowerBound(value, false), type: "range" } : undefined),
|
|
514
|
+
below: (value) => withFilter((r) => r[fieldName] < value, isIndexed ? { field: fieldName, range: IDBKeyRange.upperBound(value, true), type: "range" } : undefined),
|
|
515
|
+
belowOrEqual: (value) => withFilter((r) => r[fieldName] <= value, isIndexed ? { field: fieldName, range: IDBKeyRange.upperBound(value, false), type: "range" } : undefined),
|
|
516
|
+
between: (lower, upper, options) => {
|
|
517
|
+
const includeLower = options?.includeLower ?? true;
|
|
518
|
+
const includeUpper = options?.includeUpper ?? true;
|
|
519
|
+
return withFilter((r) => {
|
|
520
|
+
const v = r[fieldName];
|
|
521
|
+
const aboveLower = includeLower ? v >= lower : v > lower;
|
|
522
|
+
const belowUpper = includeUpper ? v <= upper : v < upper;
|
|
523
|
+
return aboveLower && belowUpper;
|
|
524
|
+
}, isIndexed ? {
|
|
525
|
+
field: fieldName,
|
|
526
|
+
range: IDBKeyRange.bound(lower, upper, !includeLower, !includeUpper),
|
|
527
|
+
type: "range"
|
|
528
|
+
} : undefined);
|
|
529
|
+
},
|
|
530
|
+
startsWith: (prefix) => withFilter((r) => typeof r[fieldName] === "string" && r[fieldName].startsWith(prefix), isIndexed ? {
|
|
531
|
+
field: fieldName,
|
|
532
|
+
range: IDBKeyRange.bound(prefix, prefix + "", false, false),
|
|
533
|
+
type: "range"
|
|
534
|
+
} : undefined),
|
|
535
|
+
anyOf: (values) => {
|
|
536
|
+
const set = new Set(values);
|
|
537
|
+
return withFilter((r) => set.has(r[fieldName]));
|
|
538
|
+
},
|
|
539
|
+
noneOf: (values) => {
|
|
540
|
+
const set = new Set(values);
|
|
541
|
+
return withFilter((r) => !set.has(r[fieldName]));
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName, mapRecord) {
|
|
546
|
+
const ctx = { storage, watchCtx, collectionName, def, mapRecord };
|
|
547
|
+
const plan = {
|
|
548
|
+
...emptyPlan(),
|
|
549
|
+
orderBy: { field: fieldName, direction: "asc" }
|
|
550
|
+
};
|
|
551
|
+
return makeOrderByBuilder(ctx, plan);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/crud/collection-handle.ts
|
|
555
|
+
function mapRecord(record) {
|
|
556
|
+
const { _deleted, _updatedAt, ...fields } = record;
|
|
557
|
+
return fields;
|
|
558
|
+
}
|
|
559
|
+
function createCollectionHandle(def, storage, watchCtx, validator, partialValidator, makeEventId, onWrite) {
|
|
560
|
+
const collectionName = def.name;
|
|
561
|
+
const handle = {
|
|
562
|
+
add: (data) => Effect6.gen(function* () {
|
|
563
|
+
const id = uuidv7();
|
|
564
|
+
const fullRecord = { id, ...data };
|
|
565
|
+
yield* validator(fullRecord);
|
|
566
|
+
const event = {
|
|
567
|
+
id: makeEventId(),
|
|
568
|
+
collection: collectionName,
|
|
569
|
+
recordId: id,
|
|
570
|
+
kind: "create",
|
|
571
|
+
data: fullRecord,
|
|
572
|
+
createdAt: Date.now()
|
|
573
|
+
};
|
|
574
|
+
yield* storage.putEvent(event);
|
|
575
|
+
yield* applyEvent(storage, event);
|
|
576
|
+
if (onWrite)
|
|
577
|
+
yield* onWrite(event);
|
|
578
|
+
yield* notifyChange(watchCtx, {
|
|
579
|
+
collection: collectionName,
|
|
580
|
+
recordId: id,
|
|
581
|
+
kind: "create"
|
|
582
|
+
});
|
|
583
|
+
return id;
|
|
584
|
+
}),
|
|
585
|
+
update: (id, data) => Effect6.gen(function* () {
|
|
586
|
+
const existing = yield* storage.getRecord(collectionName, id);
|
|
587
|
+
if (!existing || existing._deleted) {
|
|
588
|
+
return yield* new NotFoundError({
|
|
589
|
+
collection: collectionName,
|
|
590
|
+
id
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
yield* partialValidator(data);
|
|
594
|
+
const { _deleted, _updatedAt, ...existingFields } = existing;
|
|
595
|
+
const merged = { ...existingFields, ...data, id };
|
|
596
|
+
yield* validator(merged);
|
|
597
|
+
const event = {
|
|
598
|
+
id: makeEventId(),
|
|
599
|
+
collection: collectionName,
|
|
600
|
+
recordId: id,
|
|
601
|
+
kind: "update",
|
|
602
|
+
data: merged,
|
|
603
|
+
createdAt: Date.now()
|
|
604
|
+
};
|
|
605
|
+
yield* storage.putEvent(event);
|
|
606
|
+
yield* applyEvent(storage, event);
|
|
607
|
+
if (onWrite)
|
|
608
|
+
yield* onWrite(event);
|
|
609
|
+
yield* notifyChange(watchCtx, {
|
|
610
|
+
collection: collectionName,
|
|
611
|
+
recordId: id,
|
|
612
|
+
kind: "update"
|
|
613
|
+
});
|
|
614
|
+
}),
|
|
615
|
+
delete: (id) => Effect6.gen(function* () {
|
|
616
|
+
const existing = yield* storage.getRecord(collectionName, id);
|
|
617
|
+
if (!existing || existing._deleted) {
|
|
618
|
+
return yield* new NotFoundError({
|
|
619
|
+
collection: collectionName,
|
|
620
|
+
id
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
const event = {
|
|
624
|
+
id: makeEventId(),
|
|
625
|
+
collection: collectionName,
|
|
626
|
+
recordId: id,
|
|
627
|
+
kind: "delete",
|
|
628
|
+
data: null,
|
|
629
|
+
createdAt: Date.now()
|
|
630
|
+
};
|
|
631
|
+
yield* storage.putEvent(event);
|
|
632
|
+
yield* applyEvent(storage, event);
|
|
633
|
+
if (onWrite)
|
|
634
|
+
yield* onWrite(event);
|
|
635
|
+
yield* notifyChange(watchCtx, {
|
|
636
|
+
collection: collectionName,
|
|
637
|
+
recordId: id,
|
|
638
|
+
kind: "delete"
|
|
639
|
+
});
|
|
640
|
+
}),
|
|
641
|
+
get: (id) => Effect6.gen(function* () {
|
|
642
|
+
const record = yield* storage.getRecord(collectionName, id);
|
|
643
|
+
if (!record || record._deleted) {
|
|
644
|
+
return yield* new NotFoundError({
|
|
645
|
+
collection: collectionName,
|
|
646
|
+
id
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return mapRecord(record);
|
|
650
|
+
}),
|
|
651
|
+
first: () => Effect6.gen(function* () {
|
|
652
|
+
const all = yield* storage.getAllRecords(collectionName);
|
|
653
|
+
const found = all.find((r) => !r._deleted);
|
|
654
|
+
return found ? mapRecord(found) : null;
|
|
655
|
+
}),
|
|
656
|
+
count: () => Effect6.gen(function* () {
|
|
657
|
+
const all = yield* storage.getAllRecords(collectionName);
|
|
658
|
+
return all.filter((r) => !r._deleted).length;
|
|
659
|
+
}),
|
|
660
|
+
watch: () => watchCollection(watchCtx, storage, collectionName, undefined, mapRecord),
|
|
661
|
+
where: (fieldName) => createWhereClause(storage, watchCtx, collectionName, def, fieldName, mapRecord),
|
|
662
|
+
orderBy: (fieldName) => createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName, mapRecord)
|
|
663
|
+
};
|
|
664
|
+
return handle;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/db/identity.ts
|
|
668
|
+
import { Effect as Effect7 } from "effect";
|
|
669
|
+
import { getPublicKey } from "nostr-tools/pure";
|
|
670
|
+
function bytesToHex(bytes) {
|
|
671
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
672
|
+
}
|
|
673
|
+
function createIdentity(suppliedKey) {
|
|
674
|
+
return Effect7.gen(function* () {
|
|
675
|
+
let privateKey;
|
|
676
|
+
if (suppliedKey) {
|
|
677
|
+
if (suppliedKey.length !== 32) {
|
|
678
|
+
return yield* new CryptoError({
|
|
679
|
+
message: `Private key must be 32 bytes, got ${suppliedKey.length}`
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
privateKey = suppliedKey;
|
|
683
|
+
} else {
|
|
684
|
+
privateKey = new Uint8Array(32);
|
|
685
|
+
crypto.getRandomValues(privateKey);
|
|
686
|
+
}
|
|
687
|
+
const privateKeyHex = bytesToHex(privateKey);
|
|
688
|
+
let publicKey;
|
|
689
|
+
try {
|
|
690
|
+
publicKey = getPublicKey(privateKey);
|
|
691
|
+
} catch (e) {
|
|
692
|
+
return yield* new CryptoError({
|
|
693
|
+
message: `Failed to derive public key: ${e instanceof Error ? e.message : String(e)}`,
|
|
694
|
+
cause: e
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
privateKey,
|
|
699
|
+
publicKey,
|
|
700
|
+
exportKey: () => privateKeyHex
|
|
701
|
+
};
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/sync/gift-wrap.ts
|
|
706
|
+
import { Effect as Effect8 } from "effect";
|
|
707
|
+
import { wrapEvent, unwrapEvent } from "nostr-tools/nip59";
|
|
708
|
+
function createGiftWrapHandle(privateKey, publicKey) {
|
|
709
|
+
return {
|
|
710
|
+
wrap: (rumor) => Effect8.try({
|
|
711
|
+
try: () => wrapEvent(rumor, privateKey, publicKey),
|
|
712
|
+
catch: (e) => new CryptoError({
|
|
713
|
+
message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
714
|
+
cause: e
|
|
715
|
+
})
|
|
716
|
+
}),
|
|
717
|
+
unwrap: (giftWrap) => Effect8.try({
|
|
718
|
+
try: () => unwrapEvent(giftWrap, privateKey),
|
|
719
|
+
catch: (e) => new CryptoError({
|
|
720
|
+
message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
721
|
+
cause: e
|
|
722
|
+
})
|
|
723
|
+
})
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/sync/relay.ts
|
|
728
|
+
import { Effect as Effect9 } from "effect";
|
|
729
|
+
import { Relay } from "nostr-tools/relay";
|
|
730
|
+
function createRelayHandle() {
|
|
731
|
+
const connections = new Map;
|
|
732
|
+
const getRelay = (url) => Effect9.tryPromise({
|
|
733
|
+
try: async () => {
|
|
734
|
+
const existing = connections.get(url);
|
|
735
|
+
if (existing && existing.connected !== false) {
|
|
736
|
+
return existing;
|
|
737
|
+
}
|
|
738
|
+
connections.delete(url);
|
|
739
|
+
const relay = await Relay.connect(url);
|
|
740
|
+
connections.set(url, relay);
|
|
741
|
+
return relay;
|
|
742
|
+
},
|
|
743
|
+
catch: (e) => new RelayError({
|
|
744
|
+
message: `Connect to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
745
|
+
url,
|
|
746
|
+
cause: e
|
|
747
|
+
})
|
|
748
|
+
});
|
|
749
|
+
return {
|
|
750
|
+
publish: (event, urls) => Effect9.gen(function* () {
|
|
751
|
+
const errors = [];
|
|
752
|
+
for (const url of urls) {
|
|
753
|
+
const result = yield* Effect9.result(Effect9.gen(function* () {
|
|
754
|
+
const relay = yield* getRelay(url);
|
|
755
|
+
yield* Effect9.tryPromise({
|
|
756
|
+
try: () => relay.publish(event),
|
|
757
|
+
catch: (e) => {
|
|
758
|
+
connections.delete(url);
|
|
759
|
+
return new RelayError({
|
|
760
|
+
message: `Publish to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
761
|
+
url,
|
|
762
|
+
cause: e
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}));
|
|
767
|
+
if (result._tag === "Failure") {
|
|
768
|
+
errors.push({ url, error: result });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (errors.length === urls.length && urls.length > 0) {
|
|
772
|
+
return yield* new RelayError({
|
|
773
|
+
message: `Publish failed on all ${urls.length} relays`
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}),
|
|
777
|
+
fetchEvents: (ids, url) => Effect9.gen(function* () {
|
|
778
|
+
if (ids.length === 0)
|
|
779
|
+
return [];
|
|
780
|
+
const relay = yield* getRelay(url);
|
|
781
|
+
return yield* Effect9.tryPromise({
|
|
782
|
+
try: () => new Promise((resolve) => {
|
|
783
|
+
const events = [];
|
|
784
|
+
const timer = setTimeout(() => {
|
|
785
|
+
sub.close();
|
|
786
|
+
resolve(events);
|
|
787
|
+
}, 1e4);
|
|
788
|
+
const sub = relay.subscribe([{ ids }], {
|
|
789
|
+
onevent(evt) {
|
|
790
|
+
events.push(evt);
|
|
791
|
+
},
|
|
792
|
+
oneose() {
|
|
793
|
+
clearTimeout(timer);
|
|
794
|
+
sub.close();
|
|
795
|
+
resolve(events);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}),
|
|
799
|
+
catch: (e) => {
|
|
800
|
+
connections.delete(url);
|
|
801
|
+
return new RelayError({
|
|
802
|
+
message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
803
|
+
url,
|
|
804
|
+
cause: e
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}),
|
|
809
|
+
fetchByFilter: (filter, url) => Effect9.gen(function* () {
|
|
810
|
+
const relay = yield* getRelay(url);
|
|
811
|
+
return yield* Effect9.tryPromise({
|
|
812
|
+
try: () => new Promise((resolve) => {
|
|
813
|
+
const events = [];
|
|
814
|
+
const timer = setTimeout(() => {
|
|
815
|
+
sub.close();
|
|
816
|
+
resolve(events);
|
|
817
|
+
}, 1e4);
|
|
818
|
+
const sub = relay.subscribe([filter], {
|
|
819
|
+
onevent(evt) {
|
|
820
|
+
events.push(evt);
|
|
821
|
+
},
|
|
822
|
+
oneose() {
|
|
823
|
+
clearTimeout(timer);
|
|
824
|
+
sub.close();
|
|
825
|
+
resolve(events);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}),
|
|
829
|
+
catch: (e) => {
|
|
830
|
+
connections.delete(url);
|
|
831
|
+
return new RelayError({
|
|
832
|
+
message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
833
|
+
url,
|
|
834
|
+
cause: e
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
}),
|
|
839
|
+
subscribe: (filter, url, onEvent) => Effect9.gen(function* () {
|
|
840
|
+
const relay = yield* getRelay(url);
|
|
841
|
+
relay.subscribe([filter], {
|
|
842
|
+
onevent(evt) {
|
|
843
|
+
onEvent(evt);
|
|
844
|
+
},
|
|
845
|
+
oneose() {}
|
|
846
|
+
});
|
|
847
|
+
}),
|
|
848
|
+
sendNegMsg: (url, subId, filter, msgHex) => Effect9.gen(function* () {
|
|
849
|
+
const relay = yield* getRelay(url);
|
|
850
|
+
return yield* Effect9.tryPromise({
|
|
851
|
+
try: () => new Promise((resolve, reject) => {
|
|
852
|
+
const timer = setTimeout(() => {
|
|
853
|
+
reject(new Error("NIP-77 negotiation timeout"));
|
|
854
|
+
}, 30000);
|
|
855
|
+
const sub = relay.subscribe([filter], {
|
|
856
|
+
onevent() {},
|
|
857
|
+
oneose() {}
|
|
858
|
+
});
|
|
859
|
+
const ws = relay._ws || relay.ws;
|
|
860
|
+
if (!ws) {
|
|
861
|
+
clearTimeout(timer);
|
|
862
|
+
sub.close();
|
|
863
|
+
reject(new Error("Cannot access relay WebSocket"));
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const handler = (msg) => {
|
|
867
|
+
try {
|
|
868
|
+
const data = JSON.parse(typeof msg.data === "string" ? msg.data : "");
|
|
869
|
+
if (!Array.isArray(data))
|
|
870
|
+
return;
|
|
871
|
+
if (data[0] === "NEG-MSG" && data[1] === subId) {
|
|
872
|
+
clearTimeout(timer);
|
|
873
|
+
sub.close();
|
|
874
|
+
resolve({
|
|
875
|
+
msgHex: data[2],
|
|
876
|
+
haveIds: [],
|
|
877
|
+
needIds: []
|
|
878
|
+
});
|
|
879
|
+
ws.removeEventListener("message", handler);
|
|
880
|
+
} else if (data[0] === "NEG-ERR" && data[1] === subId) {
|
|
881
|
+
clearTimeout(timer);
|
|
882
|
+
sub.close();
|
|
883
|
+
reject(new Error(`NEG-ERR: ${data[2]}`));
|
|
884
|
+
ws.removeEventListener("message", handler);
|
|
885
|
+
}
|
|
886
|
+
} catch {}
|
|
887
|
+
};
|
|
888
|
+
ws.addEventListener("message", handler);
|
|
889
|
+
ws.send(JSON.stringify(["NEG-OPEN", subId, filter, msgHex]));
|
|
890
|
+
}),
|
|
891
|
+
catch: (e) => {
|
|
892
|
+
connections.delete(url);
|
|
893
|
+
return new RelayError({
|
|
894
|
+
message: `NIP-77 negotiation with ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
895
|
+
url,
|
|
896
|
+
cause: e
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
}),
|
|
901
|
+
closeAll: () => Effect9.sync(() => {
|
|
902
|
+
for (const [url, relay] of connections) {
|
|
903
|
+
relay.close();
|
|
904
|
+
connections.delete(url);
|
|
905
|
+
}
|
|
906
|
+
})
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// src/sync/publish-queue.ts
|
|
911
|
+
import { Effect as Effect10, Ref as Ref3 } from "effect";
|
|
912
|
+
function createPublishQueue(storage, relay) {
|
|
913
|
+
return Effect10.gen(function* () {
|
|
914
|
+
const pendingRef = yield* Ref3.make(new Set);
|
|
915
|
+
return {
|
|
916
|
+
enqueue: (eventId) => Ref3.update(pendingRef, (set) => {
|
|
917
|
+
const next = new Set(set);
|
|
918
|
+
next.add(eventId);
|
|
919
|
+
return next;
|
|
920
|
+
}),
|
|
921
|
+
flush: (relayUrls) => Effect10.gen(function* () {
|
|
922
|
+
const pending = yield* Ref3.get(pendingRef);
|
|
923
|
+
if (pending.size === 0)
|
|
924
|
+
return;
|
|
925
|
+
const succeeded = new Set;
|
|
926
|
+
for (const eventId of pending) {
|
|
927
|
+
const gw = yield* storage.getGiftWrap(eventId);
|
|
928
|
+
if (!gw) {
|
|
929
|
+
succeeded.add(eventId);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
const result = yield* Effect10.result(relay.publish(gw.event, relayUrls));
|
|
933
|
+
if (result._tag === "Success") {
|
|
934
|
+
succeeded.add(eventId);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
yield* Ref3.update(pendingRef, (set) => {
|
|
938
|
+
const next = new Set(set);
|
|
939
|
+
for (const id of succeeded) {
|
|
940
|
+
next.delete(id);
|
|
941
|
+
}
|
|
942
|
+
return next;
|
|
943
|
+
});
|
|
944
|
+
}),
|
|
945
|
+
size: () => Ref3.get(pendingRef).pipe(Effect10.map((s) => s.size))
|
|
946
|
+
};
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/sync/sync-status.ts
|
|
951
|
+
import { Effect as Effect11, SubscriptionRef } from "effect";
|
|
952
|
+
function createSyncStatusHandle() {
|
|
953
|
+
return Effect11.gen(function* () {
|
|
954
|
+
const ref = yield* SubscriptionRef.make("idle");
|
|
955
|
+
return {
|
|
956
|
+
get: () => SubscriptionRef.get(ref),
|
|
957
|
+
set: (status) => SubscriptionRef.set(ref, status)
|
|
958
|
+
};
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/sync/sync-service.ts
|
|
963
|
+
import { Effect as Effect13, Ref as Ref4 } from "effect";
|
|
964
|
+
|
|
965
|
+
// src/sync/negentropy.ts
|
|
966
|
+
import { Effect as Effect12 } from "effect";
|
|
967
|
+
|
|
968
|
+
// src/vendor/negentropy.js
|
|
969
|
+
var PROTOCOL_VERSION = 97;
|
|
970
|
+
var ID_SIZE = 32;
|
|
971
|
+
var FINGERPRINT_SIZE = 16;
|
|
972
|
+
var Mode = {
|
|
973
|
+
Skip: 0,
|
|
974
|
+
Fingerprint: 1,
|
|
975
|
+
IdList: 2
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
class WrappedBuffer {
|
|
979
|
+
constructor(buffer) {
|
|
980
|
+
this._raw = new Uint8Array(buffer || 512);
|
|
981
|
+
this.length = buffer ? buffer.length : 0;
|
|
982
|
+
}
|
|
983
|
+
unwrap() {
|
|
984
|
+
return this._raw.subarray(0, this.length);
|
|
985
|
+
}
|
|
986
|
+
get capacity() {
|
|
987
|
+
return this._raw.byteLength;
|
|
988
|
+
}
|
|
989
|
+
extend(buf) {
|
|
990
|
+
if (buf._raw)
|
|
991
|
+
buf = buf.unwrap();
|
|
992
|
+
if (typeof buf.length !== "number")
|
|
993
|
+
throw Error("bad length");
|
|
994
|
+
const targetSize = buf.length + this.length;
|
|
995
|
+
if (this.capacity < targetSize) {
|
|
996
|
+
const oldRaw = this._raw;
|
|
997
|
+
const newCapacity = Math.max(this.capacity * 2, targetSize);
|
|
998
|
+
this._raw = new Uint8Array(newCapacity);
|
|
999
|
+
this._raw.set(oldRaw);
|
|
1000
|
+
}
|
|
1001
|
+
this._raw.set(buf, this.length);
|
|
1002
|
+
this.length += buf.length;
|
|
1003
|
+
}
|
|
1004
|
+
shift() {
|
|
1005
|
+
const first = this._raw[0];
|
|
1006
|
+
this._raw = this._raw.subarray(1);
|
|
1007
|
+
this.length--;
|
|
1008
|
+
return first;
|
|
1009
|
+
}
|
|
1010
|
+
shiftN(n = 1) {
|
|
1011
|
+
const firstSubarray = this._raw.subarray(0, n);
|
|
1012
|
+
this._raw = this._raw.subarray(n);
|
|
1013
|
+
this.length -= n;
|
|
1014
|
+
return firstSubarray;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function decodeVarInt(buf) {
|
|
1018
|
+
let res = 0;
|
|
1019
|
+
while (true) {
|
|
1020
|
+
if (buf.length === 0)
|
|
1021
|
+
throw Error("parse ends prematurely");
|
|
1022
|
+
let byte = buf.shift();
|
|
1023
|
+
res = res << 7 | byte & 127;
|
|
1024
|
+
if ((byte & 128) === 0)
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
return res;
|
|
1028
|
+
}
|
|
1029
|
+
function encodeVarInt(n) {
|
|
1030
|
+
if (n === 0)
|
|
1031
|
+
return new WrappedBuffer([0]);
|
|
1032
|
+
let o = [];
|
|
1033
|
+
while (n !== 0) {
|
|
1034
|
+
o.push(n & 127);
|
|
1035
|
+
n >>>= 7;
|
|
1036
|
+
}
|
|
1037
|
+
o.reverse();
|
|
1038
|
+
for (let i = 0;i < o.length - 1; i++)
|
|
1039
|
+
o[i] |= 128;
|
|
1040
|
+
return new WrappedBuffer(o);
|
|
1041
|
+
}
|
|
1042
|
+
function getByte(buf) {
|
|
1043
|
+
return getBytes(buf, 1)[0];
|
|
1044
|
+
}
|
|
1045
|
+
function getBytes(buf, n) {
|
|
1046
|
+
if (buf.length < n)
|
|
1047
|
+
throw Error("parse ends prematurely");
|
|
1048
|
+
return buf.shiftN(n);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
class Accumulator {
|
|
1052
|
+
constructor() {
|
|
1053
|
+
this.setToZero();
|
|
1054
|
+
if (typeof window === "undefined") {
|
|
1055
|
+
const crypto2 = __require("crypto");
|
|
1056
|
+
this.sha256 = async (slice) => new Uint8Array(crypto2.createHash("sha256").update(slice).digest());
|
|
1057
|
+
} else {
|
|
1058
|
+
this.sha256 = async (slice) => new Uint8Array(await crypto.subtle.digest("SHA-256", slice));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
setToZero() {
|
|
1062
|
+
this.buf = new Uint8Array(ID_SIZE);
|
|
1063
|
+
}
|
|
1064
|
+
add(otherBuf) {
|
|
1065
|
+
let currCarry = 0, nextCarry = 0;
|
|
1066
|
+
let p = new DataView(this.buf.buffer);
|
|
1067
|
+
let po = new DataView(otherBuf.buffer);
|
|
1068
|
+
for (let i = 0;i < 8; i++) {
|
|
1069
|
+
let offset = i * 4;
|
|
1070
|
+
let orig = p.getUint32(offset, true);
|
|
1071
|
+
let otherV = po.getUint32(offset, true);
|
|
1072
|
+
let next = orig;
|
|
1073
|
+
next += currCarry;
|
|
1074
|
+
next += otherV;
|
|
1075
|
+
if (next > 4294967295)
|
|
1076
|
+
nextCarry = 1;
|
|
1077
|
+
p.setUint32(offset, next & 4294967295, true);
|
|
1078
|
+
currCarry = nextCarry;
|
|
1079
|
+
nextCarry = 0;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
negate() {
|
|
1083
|
+
let p = new DataView(this.buf.buffer);
|
|
1084
|
+
for (let i = 0;i < 8; i++) {
|
|
1085
|
+
let offset = i * 4;
|
|
1086
|
+
p.setUint32(offset, ~p.getUint32(offset, true));
|
|
1087
|
+
}
|
|
1088
|
+
let one = new Uint8Array(ID_SIZE);
|
|
1089
|
+
one[0] = 1;
|
|
1090
|
+
this.add(one);
|
|
1091
|
+
}
|
|
1092
|
+
async getFingerprint(n) {
|
|
1093
|
+
let input = new WrappedBuffer;
|
|
1094
|
+
input.extend(this.buf);
|
|
1095
|
+
input.extend(encodeVarInt(n));
|
|
1096
|
+
let hash = await this.sha256(input.unwrap());
|
|
1097
|
+
return hash.subarray(0, FINGERPRINT_SIZE);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
class NegentropyStorageVector {
|
|
1102
|
+
constructor() {
|
|
1103
|
+
this.items = [];
|
|
1104
|
+
this.sealed = false;
|
|
1105
|
+
}
|
|
1106
|
+
insert(timestamp, id) {
|
|
1107
|
+
if (this.sealed)
|
|
1108
|
+
throw Error("already sealed");
|
|
1109
|
+
id = loadInputBuffer(id);
|
|
1110
|
+
if (id.byteLength !== ID_SIZE)
|
|
1111
|
+
throw Error("bad id size for added item");
|
|
1112
|
+
this.items.push({ timestamp, id });
|
|
1113
|
+
}
|
|
1114
|
+
seal() {
|
|
1115
|
+
if (this.sealed)
|
|
1116
|
+
throw Error("already sealed");
|
|
1117
|
+
this.sealed = true;
|
|
1118
|
+
this.items.sort(itemCompare);
|
|
1119
|
+
for (let i = 1;i < this.items.length; i++) {
|
|
1120
|
+
if (itemCompare(this.items[i - 1], this.items[i]) === 0)
|
|
1121
|
+
throw Error("duplicate item inserted");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
unseal() {
|
|
1125
|
+
this.sealed = false;
|
|
1126
|
+
}
|
|
1127
|
+
size() {
|
|
1128
|
+
this._checkSealed();
|
|
1129
|
+
return this.items.length;
|
|
1130
|
+
}
|
|
1131
|
+
getItem(i) {
|
|
1132
|
+
this._checkSealed();
|
|
1133
|
+
if (i >= this.items.length)
|
|
1134
|
+
throw Error("out of range");
|
|
1135
|
+
return this.items[i];
|
|
1136
|
+
}
|
|
1137
|
+
iterate(begin, end, cb) {
|
|
1138
|
+
this._checkSealed();
|
|
1139
|
+
this._checkBounds(begin, end);
|
|
1140
|
+
for (let i = begin;i < end; ++i) {
|
|
1141
|
+
if (!cb(this.items[i], i))
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
findLowerBound(begin, end, bound) {
|
|
1146
|
+
this._checkSealed();
|
|
1147
|
+
this._checkBounds(begin, end);
|
|
1148
|
+
return this._binarySearch(this.items, begin, end, (a) => itemCompare(a, bound) < 0);
|
|
1149
|
+
}
|
|
1150
|
+
async fingerprint(begin, end) {
|
|
1151
|
+
let out = new Accumulator;
|
|
1152
|
+
out.setToZero();
|
|
1153
|
+
this.iterate(begin, end, (item, i) => {
|
|
1154
|
+
out.add(item.id);
|
|
1155
|
+
return true;
|
|
1156
|
+
});
|
|
1157
|
+
return await out.getFingerprint(end - begin);
|
|
1158
|
+
}
|
|
1159
|
+
_checkSealed() {
|
|
1160
|
+
if (!this.sealed)
|
|
1161
|
+
throw Error("not sealed");
|
|
1162
|
+
}
|
|
1163
|
+
_checkBounds(begin, end) {
|
|
1164
|
+
if (begin > end || end > this.items.length)
|
|
1165
|
+
throw Error("bad range");
|
|
1166
|
+
}
|
|
1167
|
+
_binarySearch(arr, first, last, cmp) {
|
|
1168
|
+
let count = last - first;
|
|
1169
|
+
while (count > 0) {
|
|
1170
|
+
let it = first;
|
|
1171
|
+
let step = Math.floor(count / 2);
|
|
1172
|
+
it += step;
|
|
1173
|
+
if (cmp(arr[it])) {
|
|
1174
|
+
first = ++it;
|
|
1175
|
+
count -= step + 1;
|
|
1176
|
+
} else {
|
|
1177
|
+
count = step;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return first;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
class Negentropy {
|
|
1185
|
+
constructor(storage, frameSizeLimit = 0) {
|
|
1186
|
+
if (frameSizeLimit !== 0 && frameSizeLimit < 4096)
|
|
1187
|
+
throw Error("frameSizeLimit too small");
|
|
1188
|
+
this.storage = storage;
|
|
1189
|
+
this.frameSizeLimit = frameSizeLimit;
|
|
1190
|
+
this.lastTimestampIn = 0;
|
|
1191
|
+
this.lastTimestampOut = 0;
|
|
1192
|
+
}
|
|
1193
|
+
_bound(timestamp, id) {
|
|
1194
|
+
return { timestamp, id: id ? id : new Uint8Array(0) };
|
|
1195
|
+
}
|
|
1196
|
+
async initiate() {
|
|
1197
|
+
if (this.isInitiator)
|
|
1198
|
+
throw Error("already initiated");
|
|
1199
|
+
this.isInitiator = true;
|
|
1200
|
+
let output = new WrappedBuffer;
|
|
1201
|
+
output.extend([PROTOCOL_VERSION]);
|
|
1202
|
+
await this.splitRange(0, this.storage.size(), this._bound(Number.MAX_VALUE), output);
|
|
1203
|
+
return this._renderOutput(output);
|
|
1204
|
+
}
|
|
1205
|
+
setInitiator() {
|
|
1206
|
+
this.isInitiator = true;
|
|
1207
|
+
}
|
|
1208
|
+
async reconcile(query) {
|
|
1209
|
+
let haveIds = [], needIds = [];
|
|
1210
|
+
query = new WrappedBuffer(loadInputBuffer(query));
|
|
1211
|
+
this.lastTimestampIn = this.lastTimestampOut = 0;
|
|
1212
|
+
let fullOutput = new WrappedBuffer;
|
|
1213
|
+
fullOutput.extend([PROTOCOL_VERSION]);
|
|
1214
|
+
let protocolVersion = getByte(query);
|
|
1215
|
+
if (protocolVersion < 96 || protocolVersion > 111)
|
|
1216
|
+
throw Error("invalid negentropy protocol version byte");
|
|
1217
|
+
if (protocolVersion !== PROTOCOL_VERSION) {
|
|
1218
|
+
if (this.isInitiator)
|
|
1219
|
+
throw Error("unsupported negentropy protocol version requested: " + (protocolVersion - 96));
|
|
1220
|
+
else
|
|
1221
|
+
return [this._renderOutput(fullOutput), haveIds, needIds];
|
|
1222
|
+
}
|
|
1223
|
+
let storageSize = this.storage.size();
|
|
1224
|
+
let prevBound = this._bound(0);
|
|
1225
|
+
let prevIndex = 0;
|
|
1226
|
+
let skip = false;
|
|
1227
|
+
while (query.length !== 0) {
|
|
1228
|
+
let o = new WrappedBuffer;
|
|
1229
|
+
let doSkip = () => {
|
|
1230
|
+
if (skip) {
|
|
1231
|
+
skip = false;
|
|
1232
|
+
o.extend(this.encodeBound(prevBound));
|
|
1233
|
+
o.extend(encodeVarInt(Mode.Skip));
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
let currBound = this.decodeBound(query);
|
|
1237
|
+
let mode = decodeVarInt(query);
|
|
1238
|
+
let lower = prevIndex;
|
|
1239
|
+
let upper = this.storage.findLowerBound(prevIndex, storageSize, currBound);
|
|
1240
|
+
if (mode === Mode.Skip) {
|
|
1241
|
+
skip = true;
|
|
1242
|
+
} else if (mode === Mode.Fingerprint) {
|
|
1243
|
+
let theirFingerprint = getBytes(query, FINGERPRINT_SIZE);
|
|
1244
|
+
let ourFingerprint = await this.storage.fingerprint(lower, upper);
|
|
1245
|
+
if (compareUint8Array(theirFingerprint, ourFingerprint) !== 0) {
|
|
1246
|
+
doSkip();
|
|
1247
|
+
await this.splitRange(lower, upper, currBound, o);
|
|
1248
|
+
} else {
|
|
1249
|
+
skip = true;
|
|
1250
|
+
}
|
|
1251
|
+
} else if (mode === Mode.IdList) {
|
|
1252
|
+
let numIds = decodeVarInt(query);
|
|
1253
|
+
let theirElems = {};
|
|
1254
|
+
for (let i = 0;i < numIds; i++) {
|
|
1255
|
+
let e = getBytes(query, ID_SIZE);
|
|
1256
|
+
if (this.isInitiator)
|
|
1257
|
+
theirElems[e] = e;
|
|
1258
|
+
}
|
|
1259
|
+
if (this.isInitiator) {
|
|
1260
|
+
skip = true;
|
|
1261
|
+
this.storage.iterate(lower, upper, (item) => {
|
|
1262
|
+
let k = item.id;
|
|
1263
|
+
if (!theirElems[k]) {
|
|
1264
|
+
if (this.isInitiator)
|
|
1265
|
+
haveIds.push(this.wantUint8ArrayOutput ? k : uint8ArrayToHex(k));
|
|
1266
|
+
} else {
|
|
1267
|
+
delete theirElems[k];
|
|
1268
|
+
}
|
|
1269
|
+
return true;
|
|
1270
|
+
});
|
|
1271
|
+
for (let v of Object.values(theirElems)) {
|
|
1272
|
+
needIds.push(this.wantUint8ArrayOutput ? v : uint8ArrayToHex(v));
|
|
1273
|
+
}
|
|
1274
|
+
} else {
|
|
1275
|
+
doSkip();
|
|
1276
|
+
let responseIds = new WrappedBuffer;
|
|
1277
|
+
let numResponseIds = 0;
|
|
1278
|
+
let endBound = currBound;
|
|
1279
|
+
this.storage.iterate(lower, upper, (item, index) => {
|
|
1280
|
+
if (this.exceededFrameSizeLimit(fullOutput.length + responseIds.length)) {
|
|
1281
|
+
endBound = item;
|
|
1282
|
+
upper = index;
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
responseIds.extend(item.id);
|
|
1286
|
+
numResponseIds++;
|
|
1287
|
+
return true;
|
|
1288
|
+
});
|
|
1289
|
+
o.extend(this.encodeBound(endBound));
|
|
1290
|
+
o.extend(encodeVarInt(Mode.IdList));
|
|
1291
|
+
o.extend(encodeVarInt(numResponseIds));
|
|
1292
|
+
o.extend(responseIds);
|
|
1293
|
+
fullOutput.extend(o);
|
|
1294
|
+
o = new WrappedBuffer;
|
|
1295
|
+
}
|
|
1296
|
+
} else {
|
|
1297
|
+
throw Error("unexpected mode");
|
|
1298
|
+
}
|
|
1299
|
+
if (this.exceededFrameSizeLimit(fullOutput.length + o.length)) {
|
|
1300
|
+
let remainingFingerprint = await this.storage.fingerprint(upper, storageSize);
|
|
1301
|
+
fullOutput.extend(this.encodeBound(this._bound(Number.MAX_VALUE)));
|
|
1302
|
+
fullOutput.extend(encodeVarInt(Mode.Fingerprint));
|
|
1303
|
+
fullOutput.extend(remainingFingerprint);
|
|
1304
|
+
break;
|
|
1305
|
+
} else {
|
|
1306
|
+
fullOutput.extend(o);
|
|
1307
|
+
}
|
|
1308
|
+
prevIndex = upper;
|
|
1309
|
+
prevBound = currBound;
|
|
1310
|
+
}
|
|
1311
|
+
return [
|
|
1312
|
+
fullOutput.length === 1 && this.isInitiator ? null : this._renderOutput(fullOutput),
|
|
1313
|
+
haveIds,
|
|
1314
|
+
needIds
|
|
1315
|
+
];
|
|
1316
|
+
}
|
|
1317
|
+
async splitRange(lower, upper, upperBound, o) {
|
|
1318
|
+
let numElems = upper - lower;
|
|
1319
|
+
let buckets = 16;
|
|
1320
|
+
if (numElems < buckets * 2) {
|
|
1321
|
+
o.extend(this.encodeBound(upperBound));
|
|
1322
|
+
o.extend(encodeVarInt(Mode.IdList));
|
|
1323
|
+
o.extend(encodeVarInt(numElems));
|
|
1324
|
+
this.storage.iterate(lower, upper, (item) => {
|
|
1325
|
+
o.extend(item.id);
|
|
1326
|
+
return true;
|
|
1327
|
+
});
|
|
1328
|
+
} else {
|
|
1329
|
+
let itemsPerBucket = Math.floor(numElems / buckets);
|
|
1330
|
+
let bucketsWithExtra = numElems % buckets;
|
|
1331
|
+
let curr = lower;
|
|
1332
|
+
for (let i = 0;i < buckets; i++) {
|
|
1333
|
+
let bucketSize = itemsPerBucket + (i < bucketsWithExtra ? 1 : 0);
|
|
1334
|
+
let ourFingerprint = await this.storage.fingerprint(curr, curr + bucketSize);
|
|
1335
|
+
curr += bucketSize;
|
|
1336
|
+
let nextBound;
|
|
1337
|
+
if (curr === upper) {
|
|
1338
|
+
nextBound = upperBound;
|
|
1339
|
+
} else {
|
|
1340
|
+
let prevItem, currItem;
|
|
1341
|
+
this.storage.iterate(curr - 1, curr + 1, (item, index) => {
|
|
1342
|
+
if (index === curr - 1)
|
|
1343
|
+
prevItem = item;
|
|
1344
|
+
else
|
|
1345
|
+
currItem = item;
|
|
1346
|
+
return true;
|
|
1347
|
+
});
|
|
1348
|
+
nextBound = this.getMinimalBound(prevItem, currItem);
|
|
1349
|
+
}
|
|
1350
|
+
o.extend(this.encodeBound(nextBound));
|
|
1351
|
+
o.extend(encodeVarInt(Mode.Fingerprint));
|
|
1352
|
+
o.extend(ourFingerprint);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
_renderOutput(o) {
|
|
1357
|
+
o = o.unwrap();
|
|
1358
|
+
if (!this.wantUint8ArrayOutput)
|
|
1359
|
+
o = uint8ArrayToHex(o);
|
|
1360
|
+
return o;
|
|
1361
|
+
}
|
|
1362
|
+
exceededFrameSizeLimit(n) {
|
|
1363
|
+
return this.frameSizeLimit && n > this.frameSizeLimit - 200;
|
|
1364
|
+
}
|
|
1365
|
+
decodeTimestampIn(encoded) {
|
|
1366
|
+
let timestamp = decodeVarInt(encoded);
|
|
1367
|
+
timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1;
|
|
1368
|
+
if (this.lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) {
|
|
1369
|
+
this.lastTimestampIn = Number.MAX_VALUE;
|
|
1370
|
+
return Number.MAX_VALUE;
|
|
1371
|
+
}
|
|
1372
|
+
timestamp += this.lastTimestampIn;
|
|
1373
|
+
this.lastTimestampIn = timestamp;
|
|
1374
|
+
return timestamp;
|
|
1375
|
+
}
|
|
1376
|
+
decodeBound(encoded) {
|
|
1377
|
+
let timestamp = this.decodeTimestampIn(encoded);
|
|
1378
|
+
let len = decodeVarInt(encoded);
|
|
1379
|
+
if (len > ID_SIZE)
|
|
1380
|
+
throw Error("bound key too long");
|
|
1381
|
+
let id = getBytes(encoded, len);
|
|
1382
|
+
return { timestamp, id };
|
|
1383
|
+
}
|
|
1384
|
+
encodeTimestampOut(timestamp) {
|
|
1385
|
+
if (timestamp === Number.MAX_VALUE) {
|
|
1386
|
+
this.lastTimestampOut = Number.MAX_VALUE;
|
|
1387
|
+
return encodeVarInt(0);
|
|
1388
|
+
}
|
|
1389
|
+
let temp = timestamp;
|
|
1390
|
+
timestamp -= this.lastTimestampOut;
|
|
1391
|
+
this.lastTimestampOut = temp;
|
|
1392
|
+
return encodeVarInt(timestamp + 1);
|
|
1393
|
+
}
|
|
1394
|
+
encodeBound(key) {
|
|
1395
|
+
let output = new WrappedBuffer;
|
|
1396
|
+
output.extend(this.encodeTimestampOut(key.timestamp));
|
|
1397
|
+
output.extend(encodeVarInt(key.id.length));
|
|
1398
|
+
output.extend(key.id);
|
|
1399
|
+
return output;
|
|
1400
|
+
}
|
|
1401
|
+
getMinimalBound(prev, curr) {
|
|
1402
|
+
if (curr.timestamp !== prev.timestamp) {
|
|
1403
|
+
return this._bound(curr.timestamp);
|
|
1404
|
+
} else {
|
|
1405
|
+
let sharedPrefixBytes = 0;
|
|
1406
|
+
let currKey = curr.id;
|
|
1407
|
+
let prevKey = prev.id;
|
|
1408
|
+
for (let i = 0;i < ID_SIZE; i++) {
|
|
1409
|
+
if (currKey[i] !== prevKey[i])
|
|
1410
|
+
break;
|
|
1411
|
+
sharedPrefixBytes++;
|
|
1412
|
+
}
|
|
1413
|
+
return this._bound(curr.timestamp, curr.id.subarray(0, sharedPrefixBytes + 1));
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
function loadInputBuffer(inp) {
|
|
1418
|
+
if (typeof inp === "string")
|
|
1419
|
+
inp = hexToUint8Array(inp);
|
|
1420
|
+
else if (__proto__ !== Uint8Array.prototype)
|
|
1421
|
+
inp = new Uint8Array(inp);
|
|
1422
|
+
return inp;
|
|
1423
|
+
}
|
|
1424
|
+
function hexToUint8Array(h) {
|
|
1425
|
+
if (h.startsWith("0x"))
|
|
1426
|
+
h = h.substr(2);
|
|
1427
|
+
if (h.length % 2 === 1)
|
|
1428
|
+
throw Error("odd length of hex string");
|
|
1429
|
+
let arr = new Uint8Array(h.length / 2);
|
|
1430
|
+
for (let i = 0;i < arr.length; i++)
|
|
1431
|
+
arr[i] = parseInt(h.substr(i * 2, 2), 16);
|
|
1432
|
+
return arr;
|
|
1433
|
+
}
|
|
1434
|
+
var uint8ArrayToHexLookupTable = new Array(256);
|
|
1435
|
+
{
|
|
1436
|
+
const hexAlphabet = [
|
|
1437
|
+
"0",
|
|
1438
|
+
"1",
|
|
1439
|
+
"2",
|
|
1440
|
+
"3",
|
|
1441
|
+
"4",
|
|
1442
|
+
"5",
|
|
1443
|
+
"6",
|
|
1444
|
+
"7",
|
|
1445
|
+
"8",
|
|
1446
|
+
"9",
|
|
1447
|
+
"a",
|
|
1448
|
+
"b",
|
|
1449
|
+
"c",
|
|
1450
|
+
"d",
|
|
1451
|
+
"e",
|
|
1452
|
+
"f"
|
|
1453
|
+
];
|
|
1454
|
+
for (let i = 0;i < 256; i++) {
|
|
1455
|
+
uint8ArrayToHexLookupTable[i] = hexAlphabet[i >>> 4 & 15] + hexAlphabet[i & 15];
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function uint8ArrayToHex(arr) {
|
|
1459
|
+
let out = "";
|
|
1460
|
+
for (let i = 0, edx = arr.length;i < edx; i++) {
|
|
1461
|
+
out += uint8ArrayToHexLookupTable[arr[i]];
|
|
1462
|
+
}
|
|
1463
|
+
return out;
|
|
1464
|
+
}
|
|
1465
|
+
function compareUint8Array(a, b) {
|
|
1466
|
+
for (let i = 0;i < a.byteLength; i++) {
|
|
1467
|
+
if (a[i] < b[i])
|
|
1468
|
+
return -1;
|
|
1469
|
+
if (a[i] > b[i])
|
|
1470
|
+
return 1;
|
|
1471
|
+
}
|
|
1472
|
+
if (a.byteLength > b.byteLength)
|
|
1473
|
+
return 1;
|
|
1474
|
+
if (a.byteLength < b.byteLength)
|
|
1475
|
+
return -1;
|
|
1476
|
+
return 0;
|
|
1477
|
+
}
|
|
1478
|
+
function itemCompare(a, b) {
|
|
1479
|
+
if (a.timestamp === b.timestamp) {
|
|
1480
|
+
return compareUint8Array(a.id, b.id);
|
|
1481
|
+
}
|
|
1482
|
+
return a.timestamp - b.timestamp;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// src/sync/negentropy.ts
|
|
1486
|
+
function hexToBytes(hex) {
|
|
1487
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
1488
|
+
for (let i = 0;i < hex.length; i += 2) {
|
|
1489
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
1490
|
+
}
|
|
1491
|
+
return bytes;
|
|
1492
|
+
}
|
|
1493
|
+
function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
|
|
1494
|
+
return Effect12.gen(function* () {
|
|
1495
|
+
const allGiftWraps = yield* storage.getAllGiftWraps();
|
|
1496
|
+
const storageVector = new NegentropyStorageVector;
|
|
1497
|
+
for (const gw of allGiftWraps) {
|
|
1498
|
+
storageVector.insert(gw.createdAt, hexToBytes(gw.id));
|
|
1499
|
+
}
|
|
1500
|
+
storageVector.seal();
|
|
1501
|
+
const neg = new Negentropy(storageVector, 0);
|
|
1502
|
+
const filter = {
|
|
1503
|
+
kinds: [1059],
|
|
1504
|
+
"#p": [publicKey]
|
|
1505
|
+
};
|
|
1506
|
+
const allHaveIds = [];
|
|
1507
|
+
const allNeedIds = [];
|
|
1508
|
+
const subId = `neg-${Date.now()}`;
|
|
1509
|
+
const initialMsg = yield* Effect12.tryPromise({
|
|
1510
|
+
try: () => neg.initiate(),
|
|
1511
|
+
catch: (e) => new SyncError({
|
|
1512
|
+
message: `Negentropy initiate failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1513
|
+
phase: "negotiate",
|
|
1514
|
+
cause: e
|
|
1515
|
+
})
|
|
1516
|
+
});
|
|
1517
|
+
let currentMsg = initialMsg;
|
|
1518
|
+
while (currentMsg !== null) {
|
|
1519
|
+
const response = yield* relay.sendNegMsg(relayUrl, subId, filter, currentMsg);
|
|
1520
|
+
if (response.msgHex === null)
|
|
1521
|
+
break;
|
|
1522
|
+
const reconcileResult = yield* Effect12.tryPromise({
|
|
1523
|
+
try: () => neg.reconcile(response.msgHex),
|
|
1524
|
+
catch: (e) => new SyncError({
|
|
1525
|
+
message: `Negentropy reconcile failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1526
|
+
phase: "negotiate",
|
|
1527
|
+
cause: e
|
|
1528
|
+
})
|
|
1529
|
+
});
|
|
1530
|
+
const [nextMsg, haveIds, needIds] = reconcileResult;
|
|
1531
|
+
for (const id of haveIds)
|
|
1532
|
+
allHaveIds.push(id);
|
|
1533
|
+
for (const id of needIds)
|
|
1534
|
+
allNeedIds.push(id);
|
|
1535
|
+
currentMsg = nextMsg;
|
|
1536
|
+
}
|
|
1537
|
+
return { haveIds: allHaveIds, needIds: allNeedIds };
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// src/sync/sync-service.ts
|
|
1542
|
+
function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls, publicKey, onSyncError) {
|
|
1543
|
+
const processGiftWrap = (remoteGw) => Effect13.gen(function* () {
|
|
1544
|
+
const existing = yield* storage.getGiftWrap(remoteGw.id);
|
|
1545
|
+
if (existing)
|
|
1546
|
+
return null;
|
|
1547
|
+
yield* storage.putGiftWrap({
|
|
1548
|
+
id: remoteGw.id,
|
|
1549
|
+
event: remoteGw,
|
|
1550
|
+
createdAt: remoteGw.created_at
|
|
1551
|
+
});
|
|
1552
|
+
const unwrapResult = yield* Effect13.result(giftWrapHandle.unwrap(remoteGw));
|
|
1553
|
+
if (unwrapResult._tag === "Failure")
|
|
1554
|
+
return null;
|
|
1555
|
+
const rumor = unwrapResult.success;
|
|
1556
|
+
const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
|
|
1557
|
+
if (!dTag)
|
|
1558
|
+
return null;
|
|
1559
|
+
const colonIdx = dTag.indexOf(":");
|
|
1560
|
+
if (colonIdx === -1)
|
|
1561
|
+
return null;
|
|
1562
|
+
const collectionName = dTag.substring(0, colonIdx);
|
|
1563
|
+
const recordId = dTag.substring(colonIdx + 1);
|
|
1564
|
+
let data = null;
|
|
1565
|
+
let kind = "update";
|
|
1566
|
+
try {
|
|
1567
|
+
const parsed = JSON.parse(rumor.content);
|
|
1568
|
+
if (parsed === null || parsed._deleted) {
|
|
1569
|
+
kind = "delete";
|
|
1570
|
+
} else {
|
|
1571
|
+
data = parsed;
|
|
1572
|
+
}
|
|
1573
|
+
} catch {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
const event = {
|
|
1577
|
+
id: rumor.id,
|
|
1578
|
+
collection: collectionName,
|
|
1579
|
+
recordId,
|
|
1580
|
+
kind,
|
|
1581
|
+
data,
|
|
1582
|
+
createdAt: rumor.created_at * 1000
|
|
1583
|
+
};
|
|
1584
|
+
yield* storage.putEvent(event);
|
|
1585
|
+
yield* applyEvent(storage, event);
|
|
1586
|
+
return collectionName;
|
|
1587
|
+
});
|
|
1588
|
+
return {
|
|
1589
|
+
sync: () => Effect13.gen(function* () {
|
|
1590
|
+
yield* syncStatus.set("syncing");
|
|
1591
|
+
yield* Ref4.set(watchCtx.replayingRef, true);
|
|
1592
|
+
const changedCollections = new Set;
|
|
1593
|
+
try {
|
|
1594
|
+
for (const url of relayUrls) {
|
|
1595
|
+
const reconcileResult = yield* Effect13.result(reconcileWithRelay(storage, relay, url, publicKey));
|
|
1596
|
+
if (reconcileResult._tag === "Failure")
|
|
1597
|
+
continue;
|
|
1598
|
+
const { haveIds, needIds } = reconcileResult.success;
|
|
1599
|
+
if (needIds.length > 0) {
|
|
1600
|
+
const fetchResult = yield* Effect13.result(relay.fetchEvents(needIds, url));
|
|
1601
|
+
if (fetchResult._tag === "Success") {
|
|
1602
|
+
for (const remoteGw of fetchResult.success) {
|
|
1603
|
+
const result = yield* Effect13.result(processGiftWrap(remoteGw));
|
|
1604
|
+
if (result._tag === "Success" && result.success) {
|
|
1605
|
+
changedCollections.add(result.success);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (haveIds.length > 0) {
|
|
1611
|
+
for (const id of haveIds) {
|
|
1612
|
+
const gw = yield* storage.getGiftWrap(id);
|
|
1613
|
+
if (gw) {
|
|
1614
|
+
yield* Effect13.result(relay.publish(gw.event, [url]));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
yield* Effect13.result(publishQueue.flush(relayUrls));
|
|
1620
|
+
} finally {
|
|
1621
|
+
yield* notifyReplayComplete(watchCtx, [...changedCollections]);
|
|
1622
|
+
yield* syncStatus.set("idle");
|
|
1623
|
+
}
|
|
1624
|
+
}),
|
|
1625
|
+
publishLocal: (giftWrap) => Effect13.gen(function* () {
|
|
1626
|
+
const result = yield* Effect13.result(relay.publish(giftWrap.event, relayUrls));
|
|
1627
|
+
if (result._tag === "Failure") {
|
|
1628
|
+
yield* publishQueue.enqueue(giftWrap.id);
|
|
1629
|
+
console.error("[tablinum:publishLocal] relay error:", result.failure);
|
|
1630
|
+
if (onSyncError)
|
|
1631
|
+
onSyncError(result.failure);
|
|
1632
|
+
}
|
|
1633
|
+
}),
|
|
1634
|
+
startSubscription: () => Effect13.gen(function* () {
|
|
1635
|
+
for (const url of relayUrls) {
|
|
1636
|
+
const subResult = yield* Effect13.result(relay.subscribe({ kinds: [1059], "#p": [publicKey] }, url, (evt) => {
|
|
1637
|
+
Effect13.runFork(Effect13.gen(function* () {
|
|
1638
|
+
const result = yield* Effect13.result(processGiftWrap(evt));
|
|
1639
|
+
if (result._tag === "Success" && result.success) {
|
|
1640
|
+
yield* notifyChange(watchCtx, {
|
|
1641
|
+
collection: result.success,
|
|
1642
|
+
recordId: "",
|
|
1643
|
+
kind: "create"
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
}));
|
|
1647
|
+
}));
|
|
1648
|
+
if (subResult._tag === "Failure") {
|
|
1649
|
+
console.error("[tablinum:subscribe] failed for", url, subResult.failure);
|
|
1650
|
+
if (onSyncError)
|
|
1651
|
+
onSyncError(subResult.failure);
|
|
1652
|
+
} else {
|
|
1653
|
+
console.log("[tablinum:subscribe] listening on", url);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
})
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/db/create-tablinum.ts
|
|
1661
|
+
function createTablinum(config) {
|
|
1662
|
+
return Effect14.gen(function* () {
|
|
1663
|
+
if (!config.relays || config.relays.length === 0) {
|
|
1664
|
+
return yield* new ValidationError({
|
|
1665
|
+
message: "At least one relay URL is required"
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
const schemaEntries = Object.entries(config.schema);
|
|
1669
|
+
if (schemaEntries.length === 0) {
|
|
1670
|
+
return yield* new ValidationError({
|
|
1671
|
+
message: "Schema must contain at least one collection"
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
let resolvedKey = config.privateKey;
|
|
1675
|
+
const storageKeyName = `tablinum-key-${config.dbName ?? "tablinum"}`;
|
|
1676
|
+
if (!resolvedKey && typeof globalThis.localStorage !== "undefined") {
|
|
1677
|
+
const saved = globalThis.localStorage.getItem(storageKeyName);
|
|
1678
|
+
if (saved && saved.length === 64) {
|
|
1679
|
+
const bytes = new Uint8Array(32);
|
|
1680
|
+
for (let i = 0;i < 32; i++) {
|
|
1681
|
+
bytes[i] = parseInt(saved.slice(i * 2, i * 2 + 2), 16);
|
|
1682
|
+
}
|
|
1683
|
+
resolvedKey = bytes;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
const identity = yield* createIdentity(resolvedKey);
|
|
1687
|
+
if (typeof globalThis.localStorage !== "undefined") {
|
|
1688
|
+
globalThis.localStorage.setItem(storageKeyName, identity.exportKey());
|
|
1689
|
+
}
|
|
1690
|
+
const storage = yield* openIDBStorage(config.dbName, config.schema);
|
|
1691
|
+
const pubsub = yield* PubSub2.unbounded();
|
|
1692
|
+
const replayingRef = yield* Ref5.make(false);
|
|
1693
|
+
const watchCtx = { pubsub, replayingRef };
|
|
1694
|
+
const closedRef = yield* Ref5.make(false);
|
|
1695
|
+
const giftWrapHandle = createGiftWrapHandle(identity.privateKey, identity.publicKey);
|
|
1696
|
+
const relayHandle = createRelayHandle();
|
|
1697
|
+
const publishQueue = yield* createPublishQueue(storage, relayHandle);
|
|
1698
|
+
const syncStatus = yield* createSyncStatusHandle();
|
|
1699
|
+
const syncHandle = createSyncHandle(storage, giftWrapHandle, relayHandle, publishQueue, syncStatus, watchCtx, config.relays, identity.publicKey, config.onSyncError);
|
|
1700
|
+
const onWrite = (event) => Effect14.gen(function* () {
|
|
1701
|
+
console.log("[tablinum:onWrite]", event.kind, event.collection, event.recordId);
|
|
1702
|
+
const content = event.kind === "delete" ? JSON.stringify({ _deleted: true }) : JSON.stringify(event.data);
|
|
1703
|
+
const dTag = `${event.collection}:${event.recordId}`;
|
|
1704
|
+
const wrapResult = yield* Effect14.result(giftWrapHandle.wrap({
|
|
1705
|
+
kind: 1,
|
|
1706
|
+
content,
|
|
1707
|
+
tags: [["d", dTag]],
|
|
1708
|
+
created_at: Math.floor(event.createdAt / 1000)
|
|
1709
|
+
}));
|
|
1710
|
+
if (wrapResult._tag === "Success") {
|
|
1711
|
+
const gw = wrapResult.success;
|
|
1712
|
+
console.log("[tablinum:onWrite] gift wrap created:", gw.id, "kind:", gw.kind, "tags:", JSON.stringify(gw.tags));
|
|
1713
|
+
yield* storage.putGiftWrap({
|
|
1714
|
+
id: gw.id,
|
|
1715
|
+
event: gw,
|
|
1716
|
+
createdAt: gw.created_at
|
|
1717
|
+
});
|
|
1718
|
+
console.log("[tablinum:onWrite] gift wrap stored, publishing...");
|
|
1719
|
+
const publishEffect = Effect14.gen(function* () {
|
|
1720
|
+
const pubResult = yield* Effect14.result(syncHandle.publishLocal({
|
|
1721
|
+
id: gw.id,
|
|
1722
|
+
event: gw,
|
|
1723
|
+
createdAt: gw.created_at
|
|
1724
|
+
}));
|
|
1725
|
+
if (pubResult._tag === "Failure") {
|
|
1726
|
+
const err = pubResult.failure;
|
|
1727
|
+
console.error("[tablinum:publish] failed:", err);
|
|
1728
|
+
if (config.onSyncError)
|
|
1729
|
+
config.onSyncError(err);
|
|
1730
|
+
} else {
|
|
1731
|
+
console.log("[tablinum:publish] success");
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
yield* Effect14.forkDetach(publishEffect);
|
|
1735
|
+
} else {
|
|
1736
|
+
const err = wrapResult.failure;
|
|
1737
|
+
console.error("[tablinum:onWrite] wrap failed:", err);
|
|
1738
|
+
if (config.onSyncError)
|
|
1739
|
+
config.onSyncError(err);
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
const handles = new Map;
|
|
1743
|
+
for (const [, def] of schemaEntries) {
|
|
1744
|
+
const validator = buildValidator(def.name, def);
|
|
1745
|
+
const partialValidator = buildPartialValidator(def.name, def);
|
|
1746
|
+
const handle = createCollectionHandle(def, storage, watchCtx, validator, partialValidator, uuidv7, onWrite);
|
|
1747
|
+
handles.set(def.name, handle);
|
|
1748
|
+
}
|
|
1749
|
+
yield* syncHandle.startSubscription();
|
|
1750
|
+
const dbHandle = {
|
|
1751
|
+
collection: (name) => {
|
|
1752
|
+
const handle = handles.get(name);
|
|
1753
|
+
if (!handle) {
|
|
1754
|
+
throw new Error(`Collection "${name}" not found in schema`);
|
|
1755
|
+
}
|
|
1756
|
+
return handle;
|
|
1757
|
+
},
|
|
1758
|
+
exportKey: () => identity.exportKey(),
|
|
1759
|
+
close: () => Effect14.gen(function* () {
|
|
1760
|
+
yield* Ref5.set(closedRef, true);
|
|
1761
|
+
yield* relayHandle.closeAll();
|
|
1762
|
+
yield* storage.close();
|
|
1763
|
+
}),
|
|
1764
|
+
rebuild: () => Effect14.gen(function* () {
|
|
1765
|
+
const closed = yield* Ref5.get(closedRef);
|
|
1766
|
+
if (closed) {
|
|
1767
|
+
return yield* new StorageError({ message: "Database is closed" });
|
|
1768
|
+
}
|
|
1769
|
+
yield* rebuild(storage, schemaEntries.map(([, def]) => def.name));
|
|
1770
|
+
}),
|
|
1771
|
+
sync: () => Effect14.gen(function* () {
|
|
1772
|
+
const closed = yield* Ref5.get(closedRef);
|
|
1773
|
+
if (closed) {
|
|
1774
|
+
return yield* new SyncError({ message: "Database is closed", phase: "init" });
|
|
1775
|
+
}
|
|
1776
|
+
yield* syncHandle.sync();
|
|
1777
|
+
}),
|
|
1778
|
+
getSyncStatus: () => syncStatus.get()
|
|
1779
|
+
};
|
|
1780
|
+
return dbHandle;
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/svelte/database.svelte.ts
|
|
1785
|
+
import { Effect as Effect18, Scope as Scope3, Exit } from "effect";
|
|
1786
|
+
|
|
1787
|
+
// src/svelte/collection.svelte.ts
|
|
1788
|
+
import { Effect as Effect17, Fiber as Fiber2, Stream as Stream5 } from "effect";
|
|
1789
|
+
|
|
1790
|
+
// src/svelte/query.svelte.ts
|
|
1791
|
+
import { Effect as Effect16 } from "effect";
|
|
1792
|
+
|
|
1793
|
+
// src/svelte/live-query.svelte.ts
|
|
1794
|
+
import { Effect as Effect15, Fiber, Stream as Stream4 } from "effect";
|
|
1795
|
+
|
|
1796
|
+
class LiveQuery {
|
|
1797
|
+
items = $state([]);
|
|
1798
|
+
error = $state(null);
|
|
1799
|
+
#fiber = null;
|
|
1800
|
+
constructor(stream) {
|
|
1801
|
+
const effect = Stream4.runForEach(stream, (records) => Effect15.sync(() => {
|
|
1802
|
+
this.items = records;
|
|
1803
|
+
})).pipe(Effect15.catch((e) => Effect15.sync(() => {
|
|
1804
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
|
1805
|
+
})));
|
|
1806
|
+
this.#fiber = Effect15.runFork(effect);
|
|
1807
|
+
}
|
|
1808
|
+
destroy() {
|
|
1809
|
+
if (this.#fiber) {
|
|
1810
|
+
Effect15.runFork(Fiber.interrupt(this.#fiber));
|
|
1811
|
+
this.#fiber = null;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// src/svelte/query.svelte.ts
|
|
1817
|
+
function wrapQueryBuilder(builder, onLive) {
|
|
1818
|
+
return {
|
|
1819
|
+
and: (fn) => wrapQueryBuilder(builder.and(fn), onLive),
|
|
1820
|
+
sortBy: (field) => wrapQueryBuilder(builder.sortBy(field), onLive),
|
|
1821
|
+
reverse: () => wrapQueryBuilder(builder.reverse(), onLive),
|
|
1822
|
+
offset: (n) => wrapQueryBuilder(builder.offset(n), onLive),
|
|
1823
|
+
limit: (n) => wrapQueryBuilder(builder.limit(n), onLive),
|
|
1824
|
+
get: () => Effect16.runPromise(builder.get()),
|
|
1825
|
+
first: () => Effect16.runPromise(builder.first()),
|
|
1826
|
+
count: () => Effect16.runPromise(builder.count()),
|
|
1827
|
+
live: () => {
|
|
1828
|
+
const lq = new LiveQuery(builder.watch());
|
|
1829
|
+
onLive?.(lq);
|
|
1830
|
+
return lq;
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
function wrapWhereClause(clause, onLive) {
|
|
1835
|
+
return {
|
|
1836
|
+
equals: (value) => wrapQueryBuilder(clause.equals(value), onLive),
|
|
1837
|
+
above: (value) => wrapQueryBuilder(clause.above(value), onLive),
|
|
1838
|
+
aboveOrEqual: (value) => wrapQueryBuilder(clause.aboveOrEqual(value), onLive),
|
|
1839
|
+
below: (value) => wrapQueryBuilder(clause.below(value), onLive),
|
|
1840
|
+
belowOrEqual: (value) => wrapQueryBuilder(clause.belowOrEqual(value), onLive),
|
|
1841
|
+
between: (lower, upper, options) => wrapQueryBuilder(clause.between(lower, upper, options), onLive),
|
|
1842
|
+
startsWith: (prefix) => wrapQueryBuilder(clause.startsWith(prefix), onLive),
|
|
1843
|
+
anyOf: (values) => wrapQueryBuilder(clause.anyOf(values), onLive),
|
|
1844
|
+
noneOf: (values) => wrapQueryBuilder(clause.noneOf(values), onLive)
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
function wrapOrderByBuilder(builder, onLive) {
|
|
1848
|
+
return {
|
|
1849
|
+
reverse: () => wrapOrderByBuilder(builder.reverse(), onLive),
|
|
1850
|
+
offset: (n) => wrapOrderByBuilder(builder.offset(n), onLive),
|
|
1851
|
+
limit: (n) => wrapOrderByBuilder(builder.limit(n), onLive),
|
|
1852
|
+
get: () => Effect16.runPromise(builder.get()),
|
|
1853
|
+
first: () => Effect16.runPromise(builder.first()),
|
|
1854
|
+
count: () => Effect16.runPromise(builder.count()),
|
|
1855
|
+
live: () => {
|
|
1856
|
+
const lq = new LiveQuery(builder.watch());
|
|
1857
|
+
onLive?.(lq);
|
|
1858
|
+
return lq;
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// src/svelte/collection.svelte.ts
|
|
1864
|
+
class Collection {
|
|
1865
|
+
items = $state([]);
|
|
1866
|
+
error = $state(null);
|
|
1867
|
+
#handle;
|
|
1868
|
+
#watchFiber = null;
|
|
1869
|
+
#liveQueries = new Set;
|
|
1870
|
+
constructor(handle) {
|
|
1871
|
+
this.#handle = handle;
|
|
1872
|
+
const watchEffect = Stream5.runForEach(handle.watch(), (records) => Effect17.sync(() => {
|
|
1873
|
+
this.items = records;
|
|
1874
|
+
})).pipe(Effect17.catch((e) => Effect17.sync(() => {
|
|
1875
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
|
1876
|
+
})));
|
|
1877
|
+
this.#watchFiber = Effect17.runFork(watchEffect);
|
|
1878
|
+
}
|
|
1879
|
+
#run = async (effect) => {
|
|
1880
|
+
try {
|
|
1881
|
+
this.error = null;
|
|
1882
|
+
return await Effect17.runPromise(effect);
|
|
1883
|
+
} catch (e) {
|
|
1884
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
|
1885
|
+
throw this.error;
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
#onLive = (lq) => {
|
|
1889
|
+
this.#liveQueries.add(lq);
|
|
1890
|
+
};
|
|
1891
|
+
add = (data) => this.#run(this.#handle.add(data));
|
|
1892
|
+
update = (id, data) => this.#run(this.#handle.update(id, data));
|
|
1893
|
+
delete = (id) => this.#run(this.#handle.delete(id));
|
|
1894
|
+
get = (id) => this.#run(this.#handle.get(id));
|
|
1895
|
+
first = () => this.#run(this.#handle.first());
|
|
1896
|
+
count = () => this.#run(this.#handle.count());
|
|
1897
|
+
where = (field) => {
|
|
1898
|
+
return wrapWhereClause(this.#handle.where(field), this.#onLive);
|
|
1899
|
+
};
|
|
1900
|
+
orderBy = (field) => {
|
|
1901
|
+
return wrapOrderByBuilder(this.#handle.orderBy(field), this.#onLive);
|
|
1902
|
+
};
|
|
1903
|
+
_destroy() {
|
|
1904
|
+
if (this.#watchFiber) {
|
|
1905
|
+
Effect17.runFork(Fiber2.interrupt(this.#watchFiber));
|
|
1906
|
+
this.#watchFiber = null;
|
|
1907
|
+
}
|
|
1908
|
+
for (const lq of this.#liveQueries) {
|
|
1909
|
+
lq.destroy();
|
|
1910
|
+
}
|
|
1911
|
+
this.#liveQueries.clear();
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// src/svelte/database.svelte.ts
|
|
1916
|
+
class Database {
|
|
1917
|
+
status = $state("idle");
|
|
1918
|
+
error = $state(null);
|
|
1919
|
+
#handle;
|
|
1920
|
+
#scope;
|
|
1921
|
+
#collections = new Map;
|
|
1922
|
+
#statusInterval = null;
|
|
1923
|
+
#closed = false;
|
|
1924
|
+
constructor(handle, scope) {
|
|
1925
|
+
this.#handle = handle;
|
|
1926
|
+
this.#scope = scope;
|
|
1927
|
+
this.#statusInterval = setInterval(() => {
|
|
1928
|
+
if (this.#closed)
|
|
1929
|
+
return;
|
|
1930
|
+
Effect18.runPromise(this.#handle.getSyncStatus()).then((s) => {
|
|
1931
|
+
this.status = s;
|
|
1932
|
+
}).catch(() => {});
|
|
1933
|
+
}, 1000);
|
|
1934
|
+
}
|
|
1935
|
+
collection(name) {
|
|
1936
|
+
let col = this.#collections.get(name);
|
|
1937
|
+
if (!col) {
|
|
1938
|
+
const handle = this.#handle.collection(name);
|
|
1939
|
+
col = new Collection(handle);
|
|
1940
|
+
this.#collections.set(name, col);
|
|
1941
|
+
}
|
|
1942
|
+
return col;
|
|
1943
|
+
}
|
|
1944
|
+
exportKey() {
|
|
1945
|
+
return this.#handle.exportKey();
|
|
1946
|
+
}
|
|
1947
|
+
close = async () => {
|
|
1948
|
+
if (this.#closed)
|
|
1949
|
+
return;
|
|
1950
|
+
this.#closed = true;
|
|
1951
|
+
if (this.#statusInterval) {
|
|
1952
|
+
clearInterval(this.#statusInterval);
|
|
1953
|
+
this.#statusInterval = null;
|
|
1954
|
+
}
|
|
1955
|
+
for (const col of this.#collections.values()) {
|
|
1956
|
+
col._destroy();
|
|
1957
|
+
}
|
|
1958
|
+
this.#collections.clear();
|
|
1959
|
+
await Effect18.runPromise(this.#handle.close());
|
|
1960
|
+
await Effect18.runPromise(Scope3.close(this.#scope, Exit.void));
|
|
1961
|
+
};
|
|
1962
|
+
sync = async () => {
|
|
1963
|
+
try {
|
|
1964
|
+
this.error = null;
|
|
1965
|
+
await Effect18.runPromise(this.#handle.sync());
|
|
1966
|
+
} catch (e) {
|
|
1967
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
|
1968
|
+
throw this.error;
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
rebuild = async () => {
|
|
1972
|
+
try {
|
|
1973
|
+
this.error = null;
|
|
1974
|
+
await Effect18.runPromise(this.#handle.rebuild());
|
|
1975
|
+
} catch (e) {
|
|
1976
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
|
1977
|
+
throw this.error;
|
|
1978
|
+
}
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// src/schema/field.ts
|
|
1983
|
+
function make(kind, isOptional, isArray) {
|
|
1984
|
+
return { _tag: "FieldDef", kind, isOptional, isArray };
|
|
1985
|
+
}
|
|
1986
|
+
var field = {
|
|
1987
|
+
string: () => make("string", false, false),
|
|
1988
|
+
number: () => make("number", false, false),
|
|
1989
|
+
boolean: () => make("boolean", false, false),
|
|
1990
|
+
json: () => make("json", false, false),
|
|
1991
|
+
optional: (inner) => make(inner.kind, true, inner.isArray),
|
|
1992
|
+
array: (inner) => make(inner.kind, inner.isOptional, true)
|
|
1993
|
+
};
|
|
1994
|
+
// src/schema/collection.ts
|
|
1995
|
+
var RESERVED_NAMES = new Set(["id", "_deleted", "_createdAt", "_updatedAt"]);
|
|
1996
|
+
function collection(name, fields, options) {
|
|
1997
|
+
if (!name || name.trim().length === 0) {
|
|
1998
|
+
throw new Error("Collection name must not be empty");
|
|
1999
|
+
}
|
|
2000
|
+
const fieldNames = Object.keys(fields);
|
|
2001
|
+
if (fieldNames.length === 0) {
|
|
2002
|
+
throw new Error(`Collection "${name}" must have at least one field`);
|
|
2003
|
+
}
|
|
2004
|
+
for (const fieldName of fieldNames) {
|
|
2005
|
+
if (RESERVED_NAMES.has(fieldName)) {
|
|
2006
|
+
throw new Error(`Field name "${fieldName}" is reserved`);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const indices = [];
|
|
2010
|
+
if (options?.indices) {
|
|
2011
|
+
for (const idx of options.indices) {
|
|
2012
|
+
const fieldDef = fields[idx];
|
|
2013
|
+
if (!fieldDef) {
|
|
2014
|
+
throw new Error(`Index field "${idx}" does not exist in collection "${name}"`);
|
|
2015
|
+
}
|
|
2016
|
+
if (fieldDef.kind === "json" || fieldDef.isArray) {
|
|
2017
|
+
throw new Error(`Field "${idx}" cannot be indexed (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`);
|
|
2018
|
+
}
|
|
2019
|
+
indices.push(idx);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return { _tag: "CollectionDef", name, fields, indices };
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// src/svelte/index.svelte.ts
|
|
2026
|
+
async function createTablinum2(config) {
|
|
2027
|
+
const scope = Effect19.runSync(Scope4.make());
|
|
2028
|
+
try {
|
|
2029
|
+
const handle = await Effect19.runPromise(createTablinum(config).pipe(Effect19.provideService(Scope4.Scope, scope)));
|
|
2030
|
+
return new Database(handle, scope);
|
|
2031
|
+
} catch (e) {
|
|
2032
|
+
await Effect19.runPromise(Scope4.close(scope, Exit2.fail(e)));
|
|
2033
|
+
throw e;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
export {
|
|
2037
|
+
field,
|
|
2038
|
+
createTablinum2 as createTablinum,
|
|
2039
|
+
collection,
|
|
2040
|
+
ValidationError,
|
|
2041
|
+
SyncError,
|
|
2042
|
+
StorageError,
|
|
2043
|
+
RelayError,
|
|
2044
|
+
NotFoundError,
|
|
2045
|
+
LiveQuery,
|
|
2046
|
+
Database,
|
|
2047
|
+
CryptoError,
|
|
2048
|
+
Collection,
|
|
2049
|
+
ClosedError
|
|
2050
|
+
};
|