tablinum 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +72 -61
  2. package/dist/brands.d.ts +5 -0
  3. package/dist/crud/collection-handle.d.ts +5 -5
  4. package/dist/crud/query-builder.d.ts +4 -13
  5. package/dist/crud/watch.d.ts +0 -10
  6. package/dist/db/create-tablinum.d.ts +7 -0
  7. package/dist/db/database-handle.d.ts +22 -1
  8. package/dist/db/epoch.d.ts +48 -0
  9. package/dist/db/invite.d.ts +8 -0
  10. package/dist/db/key-rotation.d.ts +24 -0
  11. package/dist/db/members.d.ts +24 -0
  12. package/dist/db/runtime-config.d.ts +16 -0
  13. package/dist/index.d.ts +8 -1
  14. package/dist/index.js +1552 -820
  15. package/dist/layers/EpochStoreLive.d.ts +6 -0
  16. package/dist/layers/GiftWrapLive.d.ts +5 -0
  17. package/dist/layers/IdentityLive.d.ts +5 -0
  18. package/dist/layers/PublishQueueLive.d.ts +5 -0
  19. package/dist/layers/RelayLive.d.ts +3 -0
  20. package/dist/layers/StorageLive.d.ts +4 -0
  21. package/dist/layers/SyncStatusLive.d.ts +3 -0
  22. package/dist/layers/TablinumLive.d.ts +5 -0
  23. package/dist/layers/index.d.ts +8 -0
  24. package/dist/schema/field.d.ts +0 -1
  25. package/dist/schema/types.d.ts +0 -4
  26. package/dist/services/Config.d.ts +16 -0
  27. package/dist/services/EpochStore.d.ts +6 -0
  28. package/dist/services/GiftWrap.d.ts +6 -0
  29. package/dist/services/Identity.d.ts +6 -0
  30. package/dist/services/PublishQueue.d.ts +6 -0
  31. package/dist/services/Relay.d.ts +6 -0
  32. package/dist/services/Storage.d.ts +6 -0
  33. package/dist/services/Sync.d.ts +6 -0
  34. package/dist/services/SyncStatus.d.ts +6 -0
  35. package/dist/services/Tablinum.d.ts +7 -0
  36. package/dist/services/index.d.ts +10 -0
  37. package/dist/storage/idb.d.ts +11 -2
  38. package/dist/storage/lww.d.ts +0 -5
  39. package/dist/storage/records-store.d.ts +0 -7
  40. package/dist/svelte/collection.svelte.d.ts +9 -6
  41. package/dist/svelte/deferred.d.ts +7 -0
  42. package/dist/svelte/index.svelte.d.ts +6 -6
  43. package/dist/svelte/index.svelte.js +1881 -1023
  44. package/dist/svelte/query.svelte.d.ts +6 -6
  45. package/dist/svelte/tablinum.svelte.d.ts +35 -0
  46. package/dist/sync/gift-wrap.d.ts +6 -2
  47. package/dist/sync/negentropy.d.ts +1 -1
  48. package/dist/sync/publish-queue.d.ts +3 -2
  49. package/dist/sync/relay.d.ts +9 -2
  50. package/dist/sync/sync-service.d.ts +5 -2
  51. package/dist/sync/sync-status.d.ts +1 -0
  52. package/dist/utils/uuid.d.ts +0 -1
  53. package/package.json +1 -1
  54. package/dist/main.d.ts +0 -1
  55. package/dist/storage/events-store.d.ts +0 -6
  56. package/dist/storage/giftwraps-store.d.ts +0 -6
  57. package/dist/svelte/database.svelte.d.ts +0 -15
  58. package/dist/svelte/live-query.svelte.d.ts +0 -8
@@ -6,15 +6,188 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  throw Error('Dynamic require of "' + x + '" is not supported');
7
7
  });
8
8
 
9
- // src/svelte/index.svelte.ts
10
- import { Effect as Effect19, Exit as Exit2, Scope as Scope4 } from "effect";
9
+ // src/schema/field.ts
10
+ function make(kind, isOptional, isArray) {
11
+ return { _tag: "FieldDef", kind, isOptional, isArray };
12
+ }
13
+ var field = {
14
+ string: () => make("string", false, false),
15
+ number: () => make("number", false, false),
16
+ boolean: () => make("boolean", false, false),
17
+ json: () => make("json", false, false),
18
+ optional: (inner) => make(inner.kind, true, inner.isArray),
19
+ array: (inner) => make(inner.kind, inner.isOptional, true)
20
+ };
21
+ // src/schema/collection.ts
22
+ var RESERVED_NAMES = new Set(["id", "_deleted", "_createdAt", "_updatedAt"]);
23
+ function collection(name, fields, options) {
24
+ if (!name || name.trim().length === 0) {
25
+ throw new Error("Collection name must not be empty");
26
+ }
27
+ const fieldNames = Object.keys(fields);
28
+ if (fieldNames.length === 0) {
29
+ throw new Error(`Collection "${name}" must have at least one field`);
30
+ }
31
+ for (const fieldName of fieldNames) {
32
+ if (RESERVED_NAMES.has(fieldName)) {
33
+ throw new Error(`Field name "${fieldName}" is reserved`);
34
+ }
35
+ }
36
+ const indices = [];
37
+ if (options?.indices) {
38
+ for (const idx of options.indices) {
39
+ const fieldDef = fields[idx];
40
+ if (!fieldDef) {
41
+ throw new Error(`Index field "${idx}" does not exist in collection "${name}"`);
42
+ }
43
+ if (fieldDef.kind === "json" || fieldDef.isArray) {
44
+ throw new Error(`Field "${idx}" cannot be indexed (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`);
45
+ }
46
+ indices.push(idx);
47
+ }
48
+ }
49
+ return { _tag: "CollectionDef", name, fields, indices };
50
+ }
51
+ // src/db/invite.ts
52
+ import { Schema as Schema2 } from "effect";
11
53
 
12
- // src/db/create-tablinum.ts
13
- import { Effect as Effect14, PubSub as PubSub2, Ref as Ref5 } from "effect";
54
+ // src/db/epoch.ts
55
+ import { Option, Schema } from "effect";
56
+ import { getPublicKey } from "nostr-tools/pure";
57
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
14
58
 
15
- // src/schema/validate.ts
16
- import { Effect, Schema } from "effect";
59
+ // src/brands.ts
60
+ import { Brand } from "effect";
61
+ var EpochId = Brand.nominal();
62
+ var DatabaseName = Brand.nominal();
63
+
64
+ // src/db/epoch.ts
65
+ var HexKeySchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/i));
66
+ var EpochKeyInputSchema = Schema.Struct({
67
+ epochId: Schema.String,
68
+ key: HexKeySchema
69
+ });
70
+ var PersistedEpochSchema = Schema.Struct({
71
+ id: Schema.String,
72
+ privateKey: HexKeySchema,
73
+ createdBy: Schema.String,
74
+ parentEpoch: Schema.optionalKey(Schema.String)
75
+ });
76
+ var PersistedEpochStoreSchema = Schema.Struct({
77
+ epochs: Schema.Array(PersistedEpochSchema),
78
+ currentEpochId: Schema.String
79
+ });
80
+ var decodePersistedEpochStore = Schema.decodeUnknownSync(Schema.fromJsonString(PersistedEpochStoreSchema));
81
+ function createEpochKey(id, privateKeyHex, createdBy, parentEpoch) {
82
+ const publicKey = getPublicKey(hexToBytes(privateKeyHex));
83
+ const base = { id, privateKey: privateKeyHex, publicKey, createdBy };
84
+ return parentEpoch !== undefined ? { ...base, parentEpoch } : base;
85
+ }
86
+ function createEpochStore(initialEpoch) {
87
+ const epochs = new Map;
88
+ const keysByPublicKey = new Map;
89
+ epochs.set(initialEpoch.id, initialEpoch);
90
+ keysByPublicKey.set(initialEpoch.publicKey, hexToBytes(initialEpoch.privateKey));
91
+ return { epochs, keysByPublicKey, currentEpochId: initialEpoch.id };
92
+ }
93
+ function addEpoch(store, epoch) {
94
+ store.epochs.set(epoch.id, epoch);
95
+ store.keysByPublicKey.set(epoch.publicKey, hexToBytes(epoch.privateKey));
96
+ }
97
+ function hydrateEpochStore(snapshot) {
98
+ const [firstEpoch, ...remainingEpochs] = snapshot.epochs.map((epoch) => createEpochKey(EpochId(epoch.id), epoch.privateKey, epoch.createdBy, epoch.parentEpoch !== undefined ? EpochId(epoch.parentEpoch) : undefined));
99
+ if (!firstEpoch) {
100
+ throw new Error("Epoch snapshot must contain at least one epoch");
101
+ }
102
+ const store = createEpochStore(firstEpoch);
103
+ for (const epoch of remainingEpochs) {
104
+ addEpoch(store, epoch);
105
+ }
106
+ store.currentEpochId = EpochId(snapshot.currentEpochId);
107
+ return store;
108
+ }
109
+ function createEpochStoreFromInputs(epochKeys, options = {}) {
110
+ if (epochKeys.length === 0) {
111
+ throw new Error("Epoch input must contain at least one key");
112
+ }
113
+ const createdBy = options.createdBy ?? "";
114
+ const epochs = epochKeys.map((epochKey, index) => createEpochKey(epochKey.epochId, epochKey.key, createdBy, index > 0 ? epochKeys[index - 1].epochId : undefined));
115
+ const store = createEpochStore(epochs[0]);
116
+ for (let i = 1;i < epochs.length; i++) {
117
+ addEpoch(store, epochs[i]);
118
+ }
119
+ store.currentEpochId = epochs[epochs.length - 1].id;
120
+ return store;
121
+ }
122
+ function getCurrentEpoch(store) {
123
+ return store.epochs.get(store.currentEpochId);
124
+ }
125
+ function getCurrentPublicKey(store) {
126
+ return getCurrentEpoch(store).publicKey;
127
+ }
128
+ function getAllPublicKeys(store) {
129
+ return Array.from(store.keysByPublicKey.keys());
130
+ }
131
+ function getDecryptionKey(store, publicKey) {
132
+ return store.keysByPublicKey.get(publicKey);
133
+ }
134
+ function exportEpochKeys(store) {
135
+ return Array.from(store.epochs.values()).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((epoch) => ({ epochId: epoch.id, key: epoch.privateKey }));
136
+ }
137
+ function serializeEpochStore(store) {
138
+ return {
139
+ epochs: Array.from(store.epochs.values()).map((epoch) => ({
140
+ id: epoch.id,
141
+ privateKey: epoch.privateKey,
142
+ createdBy: epoch.createdBy,
143
+ ...epoch.parentEpoch !== undefined ? { parentEpoch: epoch.parentEpoch } : {}
144
+ })),
145
+ currentEpochId: store.currentEpochId
146
+ };
147
+ }
148
+ function stringifyEpochStore(store) {
149
+ return JSON.stringify(serializeEpochStore(store));
150
+ }
151
+ function deserializeEpochStore(raw) {
152
+ try {
153
+ return Option.some(hydrateEpochStore(decodePersistedEpochStore(raw)));
154
+ } catch {
155
+ return Option.none();
156
+ }
157
+ }
17
158
 
159
+ // src/db/invite.ts
160
+ var InviteSchema = Schema2.Struct({
161
+ epochKeys: Schema2.Array(EpochKeyInputSchema),
162
+ relays: Schema2.Array(Schema2.String),
163
+ dbName: Schema2.String
164
+ });
165
+ var decodeInviteJson = Schema2.decodeUnknownSync(Schema2.UnknownFromJsonString);
166
+ var decodeInvitePayload = Schema2.decodeUnknownSync(InviteSchema);
167
+ function encodeInvite(invite) {
168
+ return btoa(JSON.stringify(invite));
169
+ }
170
+ function decodeInvite(encoded) {
171
+ let raw;
172
+ try {
173
+ raw = decodeInviteJson(atob(encoded));
174
+ } catch {
175
+ throw new Error("Invalid invite: failed to decode");
176
+ }
177
+ try {
178
+ const invite = decodeInvitePayload(raw);
179
+ return {
180
+ epochKeys: invite.epochKeys.map((epoch) => ({
181
+ epochId: EpochId(epoch.epochId),
182
+ key: epoch.key
183
+ })),
184
+ relays: [...invite.relays],
185
+ dbName: DatabaseName(invite.dbName)
186
+ };
187
+ } catch {
188
+ throw new Error("Invalid invite: unexpected shape");
189
+ }
190
+ }
18
191
  // src/errors.ts
19
192
  import { Data } from "effect";
20
193
 
@@ -38,211 +211,79 @@ class NotFoundError extends Data.TaggedError("NotFoundError") {
38
211
 
39
212
  class ClosedError extends Data.TaggedError("ClosedError") {
40
213
  }
214
+ // src/svelte/tablinum.svelte.ts
215
+ import { Effect as Effect25, Exit as Exit2, Scope as Scope6 } from "effect";
41
216
 
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
- });
217
+ // src/db/create-tablinum.ts
218
+ import { Effect as Effect22, Layer as Layer9, ServiceMap as ServiceMap10 } from "effect";
219
+
220
+ // src/db/runtime-config.ts
221
+ import { Effect, Schema as Schema3 } from "effect";
222
+ var PrivateKeySchema = Schema3.Uint8Array.check(Schema3.isMinLength(32), Schema3.isMaxLength(32));
223
+ var RuntimeConfigSchema = Schema3.Struct({
224
+ relays: Schema3.NonEmptyArray(Schema3.String),
225
+ dbName: Schema3.optional(Schema3.String),
226
+ privateKey: Schema3.optional(PrivateKeySchema),
227
+ epochKeys: Schema3.optional(Schema3.Array(EpochKeyInputSchema))
228
+ });
229
+ function resolveRuntimeConfig(source) {
230
+ return Schema3.decodeUnknownEffect(RuntimeConfigSchema)(source).pipe(Effect.map((config) => ({
231
+ relays: [...config.relays],
232
+ privateKey: config.privateKey,
233
+ epochKeys: config.epochKeys?.map((ek) => ({ epochId: EpochId(ek.epochId), key: ek.key })),
234
+ dbName: DatabaseName(config.dbName ?? "tablinum")
235
+ })), Effect.mapError((error) => new ValidationError({
236
+ message: `Invalid Tablinum configuration: ${error.message}`
237
+ })));
119
238
  }
120
239
 
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}`;
240
+ // src/services/Config.ts
241
+ import { ServiceMap } from "effect";
242
+
243
+ class Config extends ServiceMap.Service()("tablinum/Config") {
127
244
  }
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;
245
+
246
+ // src/services/Tablinum.ts
247
+ import { ServiceMap as ServiceMap2 } from "effect";
248
+
249
+ class Tablinum extends ServiceMap2.Service()("tablinum/Tablinum") {
138
250
  }
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
- })
251
+
252
+ // src/layers/TablinumLive.ts
253
+ import { Effect as Effect21, Exit, Layer as Layer8, Option as Option9, PubSub as PubSub2, Ref as Ref5, Scope as Scope4 } from "effect";
254
+
255
+ // src/crud/watch.ts
256
+ import { Effect as Effect2, PubSub, Ref, Stream } from "effect";
257
+ function watchCollection(ctx, storage, collectionName, filter, mapRecord) {
258
+ const query = () => Effect2.map(storage.getAllRecords(collectionName), (all) => {
259
+ const filtered = all.filter((r) => !r._deleted && (filter ? filter(r) : true));
260
+ return mapRecord ? filtered.map(mapRecord) : filtered;
146
261
  });
262
+ const changes = Stream.fromPubSub(ctx.pubsub).pipe(Stream.filter((event) => event.collection === collectionName), Stream.mapEffect(() => Effect2.gen(function* () {
263
+ const replaying = yield* Ref.get(ctx.replayingRef);
264
+ if (replaying)
265
+ return;
266
+ return yield* query();
267
+ })), Stream.filter((result) => result !== undefined));
268
+ return Stream.unwrap(Effect2.gen(function* () {
269
+ yield* Effect2.sleep(0);
270
+ const initial = yield* query();
271
+ return Stream.concat(Stream.make(initial), changes);
272
+ }));
147
273
  }
148
- function openIDBStorage(dbName, schema) {
274
+ function notifyChange(ctx, event) {
275
+ return PubSub.publish(ctx.pubsub, event).pipe(Effect2.asVoid);
276
+ }
277
+ function notifyReplayComplete(ctx, collections) {
149
278
  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;
279
+ yield* Ref.set(ctx.replayingRef, false);
280
+ for (const collection2 of collections) {
281
+ yield* notifyChange(ctx, {
282
+ collection: collection2,
283
+ recordId: "",
284
+ kind: "update"
285
+ });
286
+ }
246
287
  });
247
288
  }
248
289
 
@@ -266,24 +307,26 @@ function buildRecord(event) {
266
307
  id: event.recordId,
267
308
  _deleted: event.kind === "delete",
268
309
  _updatedAt: event.createdAt,
310
+ _eventId: event.id,
311
+ _author: event.author,
269
312
  ...event.data ?? {}
270
313
  };
271
314
  }
272
315
  function applyEvent(storage, event) {
273
316
  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));
317
+ const existing = yield* storage.getRecord(event.collection, event.recordId);
318
+ if (existing) {
319
+ const existingMeta = {
320
+ id: existing._eventId,
321
+ createdAt: existing._updatedAt
322
+ };
323
+ const incomingMeta = { id: event.id, createdAt: event.createdAt };
324
+ const winner = resolveWinner(existingMeta, incomingMeta);
325
+ if (winner.id !== event.id)
326
+ return false;
285
327
  }
286
- return incomingWon;
328
+ yield* storage.putRecord(event.collection, buildRecord(event));
329
+ return true;
287
330
  });
288
331
  }
289
332
  function rebuild(storage, collections) {
@@ -305,8 +348,73 @@ function rebuild(storage, collections) {
305
348
  });
306
349
  }
307
350
 
351
+ // src/schema/validate.ts
352
+ import { Effect as Effect4, Schema as Schema4 } from "effect";
353
+ function fieldDefToSchema(fd) {
354
+ let base;
355
+ switch (fd.kind) {
356
+ case "string":
357
+ base = Schema4.String;
358
+ break;
359
+ case "number":
360
+ base = Schema4.Number;
361
+ break;
362
+ case "boolean":
363
+ base = Schema4.Boolean;
364
+ break;
365
+ case "json":
366
+ base = Schema4.Unknown;
367
+ break;
368
+ }
369
+ if (fd.isArray) {
370
+ base = Schema4.Array(base);
371
+ }
372
+ if (fd.isOptional) {
373
+ base = Schema4.UndefinedOr(base);
374
+ }
375
+ return base;
376
+ }
377
+ function buildStructSchema(def, options = {}) {
378
+ const schemaFields = {};
379
+ if (options.includeId) {
380
+ schemaFields.id = Schema4.String;
381
+ }
382
+ for (const [name, fieldDef] of Object.entries(def.fields)) {
383
+ const fieldSchema = fieldDefToSchema(fieldDef);
384
+ schemaFields[name] = options.allOptional ? Schema4.optionalKey(fieldSchema) : fieldSchema;
385
+ }
386
+ return Schema4.Struct(schemaFields);
387
+ }
388
+ function buildValidator(collectionName, def) {
389
+ const decode = Schema4.decodeUnknownEffect(buildStructSchema(def, { includeId: true }));
390
+ return (input) => decode(input).pipe(Effect4.map((result) => result), Effect4.mapError((e) => new ValidationError({
391
+ message: `Validation failed for collection "${collectionName}": ${e.message}`
392
+ })));
393
+ }
394
+ function buildPartialValidator(collectionName, def) {
395
+ const decode = Schema4.decodeUnknownEffect(buildStructSchema(def, { allOptional: true }));
396
+ return (input) => Effect4.gen(function* () {
397
+ if (typeof input !== "object" || input === null) {
398
+ return yield* new ValidationError({
399
+ message: `Validation failed for collection "${collectionName}": expected an object`
400
+ });
401
+ }
402
+ const record = input;
403
+ const unknownField = Object.keys(record).find((key) => !Object.hasOwn(def.fields, key));
404
+ if (unknownField !== undefined) {
405
+ return yield* new ValidationError({
406
+ message: `Unknown field "${unknownField}" in collection "${collectionName}"`,
407
+ field: unknownField
408
+ });
409
+ }
410
+ return yield* decode(record).pipe(Effect4.map((result) => result), Effect4.mapError((e) => new ValidationError({
411
+ message: `Validation failed for collection "${collectionName}": ${e.message}`
412
+ })));
413
+ });
414
+ }
415
+
308
416
  // src/crud/collection-handle.ts
309
- import { Effect as Effect6 } from "effect";
417
+ import { Effect as Effect6, Option as Option3 } from "effect";
310
418
 
311
419
  // src/utils/uuid.ts
312
420
  function uuidv7() {
@@ -325,43 +433,8 @@ function uuidv7() {
325
433
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
326
434
  }
327
435
 
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
436
  // src/crud/query-builder.ts
364
- import { Effect as Effect5, Ref as Ref2, Stream as Stream2 } from "effect";
437
+ import { Effect as Effect5, Option as Option2, Ref as Ref2, Stream as Stream2 } from "effect";
365
438
  function emptyPlan() {
366
439
  return { filters: [] };
367
440
  }
@@ -407,10 +480,10 @@ function executeQuery(ctx, plan) {
407
480
  if (plan.orderBy) {
408
481
  const alreadySorted = ctx.def.indices.includes(plan.orderBy.field) && plan.filters.length === 0 && !plan.indexQuery;
409
482
  if (!alreadySorted) {
410
- const { field, direction } = plan.orderBy;
483
+ const { field: field2, direction } = plan.orderBy;
411
484
  results = results.sort((a, b) => {
412
- const va = a[field];
413
- const vb = b[field];
485
+ const va = a[field2];
486
+ const vb = b[field2];
414
487
  const cmp = va < vb ? -1 : va > vb ? 1 : 0;
415
488
  return direction === "desc" ? -cmp : cmp;
416
489
  });
@@ -444,9 +517,9 @@ function makeQueryBuilder(ctx, plan) {
444
517
  ...plan,
445
518
  filters: [...plan.filters, (r) => fn(ctx.mapRecord(r))]
446
519
  }),
447
- sortBy: (field) => makeQueryBuilder(ctx, {
520
+ sortBy: (field2) => makeQueryBuilder(ctx, {
448
521
  ...plan,
449
- orderBy: { field, direction: plan.orderBy?.direction ?? "asc" }
522
+ orderBy: { field: field2, direction: plan.orderBy?.direction ?? "asc" }
450
523
  }),
451
524
  reverse: () => makeQueryBuilder(ctx, {
452
525
  ...plan,
@@ -458,39 +531,8 @@ function makeQueryBuilder(ctx, plan) {
458
531
  offset: (n) => makeQueryBuilder(ctx, { ...plan, offset: n }),
459
532
  limit: (n) => makeQueryBuilder(ctx, { ...plan, limit: n }),
460
533
  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
- }),
534
+ first: () => Effect5.map(executeQuery(ctx, { ...plan, limit: 1 }), (results) => results.length > 0 ? Option2.some(results[0]) : Option2.none()),
535
+ count: () => Effect5.map(executeQuery(ctx, plan), (results) => results.length),
494
536
  watch: () => watchQuery(ctx, plan)
495
537
  };
496
538
  }
@@ -548,16 +590,27 @@ function createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName,
548
590
  ...emptyPlan(),
549
591
  orderBy: { field: fieldName, direction: "asc" }
550
592
  };
551
- return makeOrderByBuilder(ctx, plan);
593
+ return makeQueryBuilder(ctx, plan);
552
594
  }
553
595
 
554
596
  // src/crud/collection-handle.ts
555
597
  function mapRecord(record) {
556
- const { _deleted, _updatedAt, ...fields } = record;
598
+ const { _deleted, _updatedAt, _author, ...fields } = record;
557
599
  return fields;
558
600
  }
559
601
  function createCollectionHandle(def, storage, watchCtx, validator, partialValidator, makeEventId, onWrite) {
560
602
  const collectionName = def.name;
603
+ const commitEvent = (event) => Effect6.gen(function* () {
604
+ yield* storage.putEvent(event);
605
+ yield* applyEvent(storage, event);
606
+ if (onWrite)
607
+ yield* onWrite(event);
608
+ yield* notifyChange(watchCtx, {
609
+ collection: collectionName,
610
+ recordId: event.recordId,
611
+ kind: event.kind
612
+ });
613
+ });
561
614
  const handle = {
562
615
  add: (data) => Effect6.gen(function* () {
563
616
  const id = uuidv7();
@@ -571,15 +624,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
571
624
  data: fullRecord,
572
625
  createdAt: Date.now()
573
626
  };
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
- });
627
+ yield* commitEvent(event);
583
628
  return id;
584
629
  }),
585
630
  update: (id, data) => Effect6.gen(function* () {
@@ -591,7 +636,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
591
636
  });
592
637
  }
593
638
  yield* partialValidator(data);
594
- const { _deleted, _updatedAt, ...existingFields } = existing;
639
+ const { _deleted, _updatedAt, _author, ...existingFields } = existing;
595
640
  const merged = { ...existingFields, ...data, id };
596
641
  yield* validator(merged);
597
642
  const event = {
@@ -602,15 +647,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
602
647
  data: merged,
603
648
  createdAt: Date.now()
604
649
  };
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
- });
650
+ yield* commitEvent(event);
614
651
  }),
615
652
  delete: (id) => Effect6.gen(function* () {
616
653
  const existing = yield* storage.getRecord(collectionName, id);
@@ -628,15 +665,9 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
628
665
  data: null,
629
666
  createdAt: Date.now()
630
667
  };
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
- });
668
+ yield* commitEvent(event);
669
+ const oldEvents = yield* storage.getEventsByRecord(collectionName, id);
670
+ yield* Effect6.forEach(oldEvents.filter((e) => e.id !== event.id), (e) => storage.deleteEvent(e.id), { discard: true });
640
671
  }),
641
672
  get: (id) => Effect6.gen(function* () {
642
673
  const record = yield* storage.getRecord(collectionName, id);
@@ -648,15 +679,11 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
648
679
  }
649
680
  return mapRecord(record);
650
681
  }),
651
- first: () => Effect6.gen(function* () {
652
- const all = yield* storage.getAllRecords(collectionName);
682
+ first: () => Effect6.map(storage.getAllRecords(collectionName), (all) => {
653
683
  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;
684
+ return found ? Option3.some(mapRecord(found)) : Option3.none();
659
685
  }),
686
+ count: () => Effect6.map(storage.getAllRecords(collectionName), (all) => all.filter((r) => !r._deleted).length),
660
687
  watch: () => watchCollection(watchCtx, storage, collectionName, undefined, mapRecord),
661
688
  where: (fieldName) => createWhereClause(storage, watchCtx, collectionName, def, fieldName, mapRecord),
662
689
  orderBy: (fieldName) => createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName, mapRecord)
@@ -664,306 +691,13 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
664
691
  return handle;
665
692
  }
666
693
 
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
694
  // src/sync/sync-service.ts
963
- import { Effect as Effect13, Ref as Ref4 } from "effect";
695
+ import { Effect as Effect8, Option as Option5, Ref as Ref3, Schedule } from "effect";
696
+ import { unwrapEvent } from "nostr-tools/nip59";
697
+ import { GiftWrap as GiftWrap2 } from "nostr-tools/kinds";
964
698
 
965
699
  // src/sync/negentropy.ts
966
- import { Effect as Effect12 } from "effect";
700
+ import { Effect as Effect7 } from "effect";
967
701
 
968
702
  // src/vendor/negentropy.js
969
703
  var PROTOCOL_VERSION = 97;
@@ -1483,30 +1217,25 @@ function itemCompare(a, b) {
1483
1217
  }
1484
1218
 
1485
1219
  // 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* () {
1220
+ import { hexToBytes as hexToBytes2 } from "@noble/hashes/utils.js";
1221
+ import { GiftWrap } from "nostr-tools/kinds";
1222
+ function reconcileWithRelay(storage, relay, relayUrl, publicKeys) {
1223
+ return Effect7.gen(function* () {
1495
1224
  const allGiftWraps = yield* storage.getAllGiftWraps();
1496
1225
  const storageVector = new NegentropyStorageVector;
1497
1226
  for (const gw of allGiftWraps) {
1498
- storageVector.insert(gw.createdAt, hexToBytes(gw.id));
1227
+ storageVector.insert(gw.createdAt, hexToBytes2(gw.id));
1499
1228
  }
1500
1229
  storageVector.seal();
1501
1230
  const neg = new Negentropy(storageVector, 0);
1502
1231
  const filter = {
1503
- kinds: [1059],
1504
- "#p": [publicKey]
1232
+ kinds: [GiftWrap],
1233
+ "#p": Array.isArray(publicKeys) ? publicKeys : [publicKeys]
1505
1234
  };
1506
1235
  const allHaveIds = [];
1507
1236
  const allNeedIds = [];
1508
1237
  const subId = `neg-${Date.now()}`;
1509
- const initialMsg = yield* Effect12.tryPromise({
1238
+ const initialMsg = yield* Effect7.try({
1510
1239
  try: () => neg.initiate(),
1511
1240
  catch: (e) => new SyncError({
1512
1241
  message: `Negentropy initiate failed: ${e instanceof Error ? e.message : String(e)}`,
@@ -1519,7 +1248,7 @@ function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
1519
1248
  const response = yield* relay.sendNegMsg(relayUrl, subId, filter, currentMsg);
1520
1249
  if (response.msgHex === null)
1521
1250
  break;
1522
- const reconcileResult = yield* Effect12.tryPromise({
1251
+ const reconcileResult = yield* Effect7.try({
1523
1252
  try: () => neg.reconcile(response.msgHex),
1524
1253
  catch: (e) => new SyncError({
1525
1254
  message: `Negentropy reconcile failed: ${e instanceof Error ? e.message : String(e)}`,
@@ -1538,512 +1267,1641 @@ function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
1538
1267
  });
1539
1268
  }
1540
1269
 
1270
+ // src/db/key-rotation.ts
1271
+ import { Option as Option4, Schema as Schema5 } from "effect";
1272
+ import { generateSecretKey } from "nostr-tools/pure";
1273
+ import { wrapEvent } from "nostr-tools/nip59";
1274
+ var HexKeySchema2 = Schema5.String.check(Schema5.isPattern(/^[0-9a-f]{64}$/i));
1275
+ var RotationDataSchema = Schema5.Struct({
1276
+ _rotation: Schema5.Literal(true),
1277
+ epochId: Schema5.String,
1278
+ epochKey: HexKeySchema2,
1279
+ parentEpoch: Schema5.String,
1280
+ removedMembers: Schema5.Array(Schema5.String)
1281
+ });
1282
+ var RemovalNoticeSchema = Schema5.Struct({
1283
+ _removed: Schema5.Literal(true),
1284
+ epochId: Schema5.String,
1285
+ removedBy: Schema5.String
1286
+ });
1287
+ var decodeRotationData = Schema5.decodeUnknownSync(Schema5.fromJsonString(RotationDataSchema));
1288
+ var decodeRemovalNotice = Schema5.decodeUnknownSync(Schema5.fromJsonString(RemovalNoticeSchema));
1289
+ function createRotation(epochStore, senderPrivateKey, senderPublicKey, remainingMemberPubkeys, removedMemberPubkeys) {
1290
+ const newSk = generateSecretKey();
1291
+ const newKeyHex = bytesToHex(newSk);
1292
+ const currentEpoch = getCurrentEpoch(epochStore);
1293
+ const epochId = EpochId(uuidv7());
1294
+ const epoch = createEpochKey(epochId, newKeyHex, senderPublicKey, currentEpoch.id);
1295
+ const rotationData = {
1296
+ _rotation: true,
1297
+ epochId,
1298
+ epochKey: newKeyHex,
1299
+ parentEpoch: currentEpoch.id,
1300
+ removedMembers: removedMemberPubkeys
1301
+ };
1302
+ const rumor = {
1303
+ kind: 1,
1304
+ content: JSON.stringify(rotationData),
1305
+ tags: [["d", `_system:rotation:${epochId}`]],
1306
+ created_at: Math.floor(Date.now() / 1000)
1307
+ };
1308
+ const wrappedEvents = [];
1309
+ for (const memberPubkey of remainingMemberPubkeys) {
1310
+ if (memberPubkey === senderPublicKey)
1311
+ continue;
1312
+ const wrapped = wrapEvent(rumor, senderPrivateKey, memberPubkey);
1313
+ wrappedEvents.push(wrapped);
1314
+ }
1315
+ const removalData = {
1316
+ _removed: true,
1317
+ epochId,
1318
+ removedBy: senderPublicKey
1319
+ };
1320
+ const removalRumor = {
1321
+ kind: 1,
1322
+ content: JSON.stringify(removalData),
1323
+ tags: [["d", `_system:removed:${epochId}`]],
1324
+ created_at: Math.floor(Date.now() / 1000)
1325
+ };
1326
+ const removalNotices = [];
1327
+ for (const removedPubkey of removedMemberPubkeys) {
1328
+ const wrapped = wrapEvent(removalRumor, senderPrivateKey, removedPubkey);
1329
+ removalNotices.push(wrapped);
1330
+ }
1331
+ return { epoch, wrappedEvents, removalNotices };
1332
+ }
1333
+ function parseRotationEvent(content, dTag) {
1334
+ if (!dTag.startsWith("_system:rotation:"))
1335
+ return Option4.none();
1336
+ try {
1337
+ return Option4.some(decodeRotationData(content));
1338
+ } catch {
1339
+ return Option4.none();
1340
+ }
1341
+ }
1342
+ function parseRemovalNotice(content, dTag) {
1343
+ if (!dTag.startsWith("_system:removed:"))
1344
+ return Option4.none();
1345
+ try {
1346
+ return Option4.some(decodeRemovalNotice(content));
1347
+ } catch {
1348
+ return Option4.none();
1349
+ }
1350
+ }
1351
+
1541
1352
  // src/sync/sync-service.ts
1542
- function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls, publicKey, onSyncError) {
1543
- const processGiftWrap = (remoteGw) => Effect13.gen(function* () {
1353
+ function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls, knownCollections, epochStore, personalPrivateKey, personalPublicKey, scope, onSyncError, onNewAuthor, onRemoved, onMembersChanged) {
1354
+ const getSubscriptionPubKeys = () => {
1355
+ return getAllPublicKeys(epochStore);
1356
+ };
1357
+ const notifyCollectionUpdated = (collection2) => notifyChange(watchCtx, {
1358
+ collection: collection2,
1359
+ recordId: "",
1360
+ kind: "create"
1361
+ });
1362
+ const forkHandled = (effect) => {
1363
+ Effect8.runFork(effect.pipe(Effect8.tapError((e) => Effect8.sync(() => onSyncError?.(e))), Effect8.ignore, Effect8.forkIn(scope)));
1364
+ };
1365
+ let autoFlushActive = false;
1366
+ const autoFlushEffect = Effect8.gen(function* () {
1367
+ const size = yield* publishQueue.size();
1368
+ if (size === 0)
1369
+ return;
1370
+ yield* syncStatus.set("syncing");
1371
+ yield* publishQueue.flush(relayUrls);
1372
+ const remaining = yield* publishQueue.size();
1373
+ if (remaining > 0)
1374
+ yield* Effect8.fail("pending");
1375
+ }).pipe(Effect8.ensuring(syncStatus.set("idle")), Effect8.retry({ schedule: Schedule.exponential(5000).pipe(Schedule.jittered), times: 10 }), Effect8.ignore);
1376
+ const scheduleAutoFlush = () => {
1377
+ if (autoFlushActive)
1378
+ return;
1379
+ autoFlushActive = true;
1380
+ forkHandled(autoFlushEffect.pipe(Effect8.ensuring(Effect8.sync(() => {
1381
+ autoFlushActive = false;
1382
+ }))));
1383
+ };
1384
+ const shouldRejectWrite = (authorPubkey) => Effect8.gen(function* () {
1385
+ const memberRecord = yield* storage.getRecord("_members", authorPubkey);
1386
+ if (!memberRecord)
1387
+ return false;
1388
+ return !!memberRecord.removedAt;
1389
+ });
1390
+ const processGiftWrap = (remoteGw) => Effect8.gen(function* () {
1544
1391
  const existing = yield* storage.getGiftWrap(remoteGw.id);
1545
1392
  if (existing)
1546
1393
  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")
1394
+ const unwrapResult = yield* Effect8.result(giftWrapHandle.unwrap(remoteGw));
1395
+ if (unwrapResult._tag === "Failure") {
1396
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1554
1397
  return null;
1398
+ }
1555
1399
  const rumor = unwrapResult.success;
1556
1400
  const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
1557
- if (!dTag)
1401
+ if (!dTag) {
1402
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1558
1403
  return null;
1404
+ }
1559
1405
  const colonIdx = dTag.indexOf(":");
1560
- if (colonIdx === -1)
1406
+ if (colonIdx === -1) {
1407
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1561
1408
  return null;
1409
+ }
1562
1410
  const collectionName = dTag.substring(0, colonIdx);
1563
1411
  const recordId = dTag.substring(colonIdx + 1);
1412
+ if (!knownCollections.has(collectionName)) {
1413
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1414
+ return null;
1415
+ }
1416
+ if (rumor.pubkey) {
1417
+ const reject = yield* shouldRejectWrite(rumor.pubkey);
1418
+ if (reject) {
1419
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1420
+ return null;
1421
+ }
1422
+ }
1564
1423
  let data = null;
1565
1424
  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;
1425
+ const parsed = yield* Effect8.try({
1426
+ try: () => JSON.parse(rumor.content),
1427
+ catch: () => {
1428
+ return;
1572
1429
  }
1573
- } catch {
1430
+ }).pipe(Effect8.orElseSucceed(() => {
1431
+ return;
1432
+ }));
1433
+ if (parsed === undefined) {
1434
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1574
1435
  return null;
1575
1436
  }
1437
+ if (parsed === null || parsed._deleted) {
1438
+ kind = "delete";
1439
+ } else {
1440
+ data = parsed;
1441
+ }
1442
+ const author = rumor.pubkey || undefined;
1576
1443
  const event = {
1577
1444
  id: rumor.id,
1578
1445
  collection: collectionName,
1579
1446
  recordId,
1580
1447
  kind,
1581
1448
  data,
1582
- createdAt: rumor.created_at * 1000
1449
+ createdAt: rumor.created_at * 1000,
1450
+ author
1583
1451
  };
1452
+ yield* storage.putGiftWrap({
1453
+ id: remoteGw.id,
1454
+ eventId: event.id,
1455
+ createdAt: remoteGw.created_at
1456
+ });
1584
1457
  yield* storage.putEvent(event);
1585
- yield* applyEvent(storage, event);
1458
+ const didApply = yield* applyEvent(storage, event);
1459
+ if (kind === "delete" && didApply) {
1460
+ const oldEvents = yield* storage.getEventsByRecord(collectionName, recordId);
1461
+ yield* Effect8.forEach(oldEvents.filter((e) => e.id !== event.id), (e) => storage.deleteEvent(e.id), { discard: true });
1462
+ }
1463
+ if (author && onNewAuthor) {
1464
+ onNewAuthor(author);
1465
+ }
1586
1466
  return collectionName;
1587
1467
  });
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);
1468
+ const processRealtimeGiftWrap = (remoteGw) => Effect8.gen(function* () {
1469
+ const collection2 = yield* processGiftWrap(remoteGw).pipe(Effect8.orElseSucceed(() => null));
1470
+ if (collection2) {
1471
+ yield* notifyCollectionUpdated(collection2);
1472
+ }
1473
+ });
1474
+ const processRotationGiftWrap = (remoteGw) => Effect8.gen(function* () {
1475
+ const unwrapResult = yield* Effect8.result(Effect8.try({
1476
+ try: () => unwrapEvent(remoteGw, personalPrivateKey),
1477
+ catch: (e) => new CryptoError({
1478
+ message: `Rotation unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
1479
+ cause: e
1480
+ })
1481
+ }));
1482
+ if (unwrapResult._tag === "Failure")
1483
+ return false;
1484
+ const rumor = unwrapResult.success;
1485
+ const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
1486
+ if (!dTag)
1487
+ return false;
1488
+ const removalNoticeOpt = parseRemovalNotice(rumor.content, dTag);
1489
+ if (Option5.isSome(removalNoticeOpt)) {
1490
+ if (onRemoved)
1491
+ onRemoved(removalNoticeOpt.value);
1492
+ return true;
1493
+ }
1494
+ const rotationDataOpt = parseRotationEvent(rumor.content, dTag);
1495
+ if (Option5.isNone(rotationDataOpt))
1496
+ return false;
1497
+ const rotationData = rotationDataOpt.value;
1498
+ if (epochStore.epochs.has(rotationData.epochId))
1499
+ return false;
1500
+ const epoch = createEpochKey(rotationData.epochId, rotationData.epochKey, rumor.pubkey || "", rotationData.parentEpoch);
1501
+ addEpoch(epochStore, epoch);
1502
+ epochStore.currentEpochId = epoch.id;
1503
+ let membersChanged = false;
1504
+ for (const removedPubkey of rotationData.removedMembers) {
1505
+ const memberRecord = yield* storage.getRecord("_members", removedPubkey);
1506
+ if (memberRecord && !memberRecord.removedAt) {
1507
+ yield* storage.putRecord("_members", {
1508
+ ...memberRecord,
1509
+ removedAt: Date.now(),
1510
+ removedInEpoch: epoch.id
1511
+ });
1512
+ yield* notifyChange(watchCtx, {
1513
+ collection: "_members",
1514
+ recordId: removedPubkey,
1515
+ kind: "update"
1516
+ });
1517
+ membersChanged = true;
1632
1518
  }
1519
+ }
1520
+ if (membersChanged && onMembersChanged)
1521
+ onMembersChanged();
1522
+ yield* handle.addEpochSubscription(epoch.publicKey);
1523
+ return true;
1524
+ });
1525
+ const subscribeAcrossRelays = (filter, onEvent) => Effect8.forEach(relayUrls, (url) => Effect8.gen(function* () {
1526
+ yield* relay.subscribe(filter, url, (event) => {
1527
+ forkHandled(onEvent(event));
1528
+ }).pipe(Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
1529
+ }), { discard: true });
1530
+ const syncRelay = (url, pubKeys, changedCollections) => Effect8.gen(function* () {
1531
+ const reconcileResult = yield* Effect8.result(reconcileWithRelay(storage, relay, url, Array.from(pubKeys)));
1532
+ if (reconcileResult._tag === "Failure") {
1533
+ onSyncError?.(reconcileResult.failure);
1534
+ return;
1535
+ }
1536
+ const { haveIds, needIds } = reconcileResult.success;
1537
+ if (needIds.length > 0) {
1538
+ const fetched = yield* relay.fetchEvents(needIds, url).pipe(Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.orElseSucceed(() => []));
1539
+ yield* Effect8.forEach(fetched, (remoteGw) => Effect8.gen(function* () {
1540
+ const collection2 = yield* processGiftWrap(remoteGw).pipe(Effect8.orElseSucceed(() => null));
1541
+ if (collection2)
1542
+ changedCollections.add(collection2);
1543
+ }), { discard: true });
1544
+ }
1545
+ if (haveIds.length > 0) {
1546
+ yield* Effect8.forEach(haveIds, (id) => Effect8.gen(function* () {
1547
+ const gw = yield* storage.getGiftWrap(id);
1548
+ if (!gw?.event)
1549
+ return;
1550
+ yield* relay.publish(gw.event, [url]).pipe(Effect8.andThen(storage.stripGiftWrapBlob(id)), Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
1551
+ }), { discard: true });
1552
+ }
1553
+ });
1554
+ const handle = {
1555
+ sync: () => Effect8.gen(function* () {
1556
+ yield* syncStatus.set("syncing");
1557
+ yield* Ref3.set(watchCtx.replayingRef, true);
1558
+ const changedCollections = new Set;
1559
+ yield* Effect8.gen(function* () {
1560
+ const pubKeys = getSubscriptionPubKeys();
1561
+ yield* Effect8.forEach(relayUrls, (url) => syncRelay(url, pubKeys, changedCollections), {
1562
+ discard: true
1563
+ });
1564
+ yield* publishQueue.flush(relayUrls).pipe(Effect8.ignore);
1565
+ }).pipe(Effect8.ensuring(Effect8.gen(function* () {
1566
+ yield* notifyReplayComplete(watchCtx, [...changedCollections]);
1567
+ yield* syncStatus.set("idle");
1568
+ })));
1633
1569
  }),
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
- }
1570
+ publishLocal: (giftWrap) => Effect8.gen(function* () {
1571
+ if (!giftWrap.event)
1572
+ return;
1573
+ yield* relay.publish(giftWrap.event, relayUrls).pipe(Effect8.tapError(() => storage.putGiftWrap(giftWrap).pipe(Effect8.andThen(publishQueue.enqueue(giftWrap.id)), Effect8.andThen(Effect8.sync(() => scheduleAutoFlush())))), Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
1574
+ }),
1575
+ startSubscription: () => Effect8.gen(function* () {
1576
+ const pubKeys = getSubscriptionPubKeys();
1577
+ yield* subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": pubKeys }, processRealtimeGiftWrap);
1578
+ if (!pubKeys.includes(personalPublicKey)) {
1579
+ yield* subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": [personalPublicKey] }, (event) => Effect8.result(processRotationGiftWrap(event)).pipe(Effect8.asVoid));
1655
1580
  }
1656
- })
1581
+ }),
1582
+ addEpochSubscription: (publicKey) => subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": [publicKey] }, processRealtimeGiftWrap)
1657
1583
  };
1584
+ forkHandled(publishQueue.size().pipe(Effect8.flatMap((size) => Effect8.sync(() => {
1585
+ if (size > 0)
1586
+ scheduleAutoFlush();
1587
+ }))));
1588
+ return handle;
1658
1589
  }
1659
1590
 
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
- });
1591
+ // src/db/members.ts
1592
+ import { Effect as Effect9, Option as Option6, Schema as Schema6 } from "effect";
1593
+ var optionalString = {
1594
+ _tag: "FieldDef",
1595
+ kind: "string",
1596
+ isOptional: true,
1597
+ isArray: false
1598
+ };
1599
+ var optionalNumber = {
1600
+ _tag: "FieldDef",
1601
+ kind: "number",
1602
+ isOptional: true,
1603
+ isArray: false
1604
+ };
1605
+ var requiredNumber = {
1606
+ _tag: "FieldDef",
1607
+ kind: "number",
1608
+ isOptional: false,
1609
+ isArray: false
1610
+ };
1611
+ var requiredString = {
1612
+ _tag: "FieldDef",
1613
+ kind: "string",
1614
+ isOptional: false,
1615
+ isArray: false
1616
+ };
1617
+ var membersCollectionDef = {
1618
+ _tag: "CollectionDef",
1619
+ name: "_members",
1620
+ fields: {
1621
+ name: optionalString,
1622
+ picture: optionalString,
1623
+ about: optionalString,
1624
+ nip05: optionalString,
1625
+ addedAt: requiredNumber,
1626
+ addedInEpoch: requiredString,
1627
+ removedAt: optionalNumber,
1628
+ removedInEpoch: optionalString
1629
+ },
1630
+ indices: []
1631
+ };
1632
+ var AuthorProfileSchema = Schema6.Struct({
1633
+ name: Schema6.optionalKey(Schema6.String),
1634
+ picture: Schema6.optionalKey(Schema6.String),
1635
+ about: Schema6.optionalKey(Schema6.String),
1636
+ nip05: Schema6.optionalKey(Schema6.String)
1637
+ });
1638
+ var decodeAuthorProfile = Schema6.decodeUnknownEffect(Schema6.fromJsonString(AuthorProfileSchema));
1639
+ function fetchAuthorProfile(relay, relayUrls, pubkey) {
1640
+ return Effect9.gen(function* () {
1641
+ for (const url of relayUrls) {
1642
+ const result = yield* Effect9.result(relay.fetchByFilter({ kinds: [0], authors: [pubkey], limit: 1 }, url));
1643
+ if (result._tag === "Success" && result.success.length > 0) {
1644
+ return yield* decodeAuthorProfile(result.success[0].content).pipe(Effect9.map(Option6.some), Effect9.orElseSucceed(() => Option6.none()));
1645
+ }
1667
1646
  }
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
- });
1647
+ return Option6.none();
1648
+ });
1649
+ }
1650
+
1651
+ // src/services/Identity.ts
1652
+ import { ServiceMap as ServiceMap3 } from "effect";
1653
+
1654
+ class Identity extends ServiceMap3.Service()("tablinum/Identity") {
1655
+ }
1656
+
1657
+ // src/services/EpochStore.ts
1658
+ import { ServiceMap as ServiceMap4 } from "effect";
1659
+
1660
+ class EpochStore extends ServiceMap4.Service()("tablinum/EpochStore") {
1661
+ }
1662
+
1663
+ // src/services/Storage.ts
1664
+ import { ServiceMap as ServiceMap5 } from "effect";
1665
+
1666
+ class Storage extends ServiceMap5.Service()("tablinum/Storage") {
1667
+ }
1668
+
1669
+ // src/services/Relay.ts
1670
+ import { ServiceMap as ServiceMap6 } from "effect";
1671
+
1672
+ class Relay extends ServiceMap6.Service()("tablinum/Relay") {
1673
+ }
1674
+
1675
+ // src/services/GiftWrap.ts
1676
+ import { ServiceMap as ServiceMap7 } from "effect";
1677
+
1678
+ class GiftWrap3 extends ServiceMap7.Service()("tablinum/GiftWrap") {
1679
+ }
1680
+
1681
+ // src/services/PublishQueue.ts
1682
+ import { ServiceMap as ServiceMap8 } from "effect";
1683
+
1684
+ class PublishQueue extends ServiceMap8.Service()("tablinum/PublishQueue") {
1685
+ }
1686
+
1687
+ // src/services/SyncStatus.ts
1688
+ import { ServiceMap as ServiceMap9 } from "effect";
1689
+
1690
+ class SyncStatus extends ServiceMap9.Service()("tablinum/SyncStatus") {
1691
+ }
1692
+
1693
+ // src/layers/IdentityLive.ts
1694
+ import { Effect as Effect11, Layer } from "effect";
1695
+ import { hexToBytes as hexToBytes3 } from "@noble/hashes/utils.js";
1696
+
1697
+ // src/db/identity.ts
1698
+ import { Effect as Effect10 } from "effect";
1699
+ import { getPublicKey as getPublicKey2 } from "nostr-tools/pure";
1700
+ import { bytesToHex as bytesToHex2 } from "@noble/hashes/utils.js";
1701
+ function createIdentity(suppliedKey) {
1702
+ return Effect10.gen(function* () {
1703
+ let privateKey;
1704
+ if (suppliedKey) {
1705
+ if (suppliedKey.length !== 32) {
1706
+ return yield* new CryptoError({
1707
+ message: `Private key must be 32 bytes, got ${suppliedKey.length}`
1708
+ });
1709
+ }
1710
+ privateKey = suppliedKey;
1711
+ } else {
1712
+ privateKey = new Uint8Array(32);
1713
+ crypto.getRandomValues(privateKey);
1673
1714
  }
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;
1715
+ const privateKeyHex = bytesToHex2(privateKey);
1716
+ const publicKey = yield* Effect10.try({
1717
+ try: () => getPublicKey2(privateKey),
1718
+ catch: (e) => new CryptoError({
1719
+ message: `Failed to derive public key: ${e instanceof Error ? e.message : String(e)}`,
1720
+ cause: e
1721
+ })
1722
+ });
1723
+ return {
1724
+ privateKey,
1725
+ publicKey,
1726
+ exportKey: () => privateKeyHex
1727
+ };
1728
+ });
1729
+ }
1730
+
1731
+ // src/layers/IdentityLive.ts
1732
+ var IdentityLive = Layer.effect(Identity, Effect11.gen(function* () {
1733
+ const config = yield* Config;
1734
+ const storage = yield* Storage;
1735
+ const idbKey = yield* storage.getMeta("identity_key");
1736
+ const resolvedKey = config.privateKey ?? (typeof idbKey === "string" && idbKey.length === 64 ? hexToBytes3(idbKey) : undefined);
1737
+ const identity = yield* createIdentity(resolvedKey);
1738
+ yield* storage.putMeta("identity_key", identity.exportKey());
1739
+ return identity;
1740
+ }));
1741
+
1742
+ // src/layers/EpochStoreLive.ts
1743
+ import { Effect as Effect12, Layer as Layer2, Option as Option7 } from "effect";
1744
+ import { generateSecretKey as generateSecretKey2 } from "nostr-tools/pure";
1745
+ import { bytesToHex as bytesToHex3 } from "@noble/hashes/utils.js";
1746
+ var EpochStoreLive = Layer2.effect(EpochStore, Effect12.gen(function* () {
1747
+ const config = yield* Config;
1748
+ const identity = yield* Identity;
1749
+ const storage = yield* Storage;
1750
+ const idbRaw = yield* storage.getMeta("epochs");
1751
+ if (typeof idbRaw === "string") {
1752
+ const idbStore = deserializeEpochStore(idbRaw);
1753
+ if (Option7.isSome(idbStore)) {
1754
+ return idbStore.value;
1755
+ }
1756
+ }
1757
+ if (config.epochKeys && config.epochKeys.length > 0) {
1758
+ const store2 = createEpochStoreFromInputs(config.epochKeys);
1759
+ yield* storage.putMeta("epochs", stringifyEpochStore(store2));
1760
+ return store2;
1761
+ }
1762
+ const store = createEpochStoreFromInputs([{ epochId: EpochId("epoch-0"), key: bytesToHex3(generateSecretKey2()) }], { createdBy: identity.publicKey });
1763
+ yield* storage.putMeta("epochs", stringifyEpochStore(store));
1764
+ return store;
1765
+ }));
1766
+
1767
+ // src/layers/StorageLive.ts
1768
+ import { Effect as Effect14, Layer as Layer3 } from "effect";
1769
+
1770
+ // src/storage/idb.ts
1771
+ import { Effect as Effect13 } from "effect";
1772
+ import { openDB } from "idb";
1773
+ var DB_NAME = "tablinum";
1774
+ function storeName(collection2) {
1775
+ return `col_${collection2}`;
1776
+ }
1777
+ function computeSchemaSig(schema) {
1778
+ return Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)).map(([name, def]) => {
1779
+ const indices = [...def.indices ?? []].sort().join(",");
1780
+ return `${name}:${indices}`;
1781
+ }).join("|");
1782
+ }
1783
+ function wrap(label, fn) {
1784
+ return Effect13.tryPromise({
1785
+ try: fn,
1786
+ catch: (e) => new StorageError({
1787
+ message: `IndexedDB ${label} failed: ${e instanceof Error ? e.message : String(e)}`,
1788
+ cause: e
1789
+ })
1790
+ });
1791
+ }
1792
+ function upgradeSchema(database, schema, tx) {
1793
+ if (!database.objectStoreNames.contains("_meta")) {
1794
+ database.createObjectStore("_meta");
1795
+ }
1796
+ if (!database.objectStoreNames.contains("events")) {
1797
+ const events = database.createObjectStore("events", { keyPath: "id" });
1798
+ events.createIndex("by-record", ["collection", "recordId"]);
1799
+ }
1800
+ if (!database.objectStoreNames.contains("giftwraps")) {
1801
+ database.createObjectStore("giftwraps", { keyPath: "id" });
1802
+ }
1803
+ const expectedStores = new Set;
1804
+ for (const [, def] of Object.entries(schema)) {
1805
+ const sn = storeName(def.name);
1806
+ expectedStores.add(sn);
1807
+ if (!database.objectStoreNames.contains(sn)) {
1808
+ const store = database.createObjectStore(sn, { keyPath: "id" });
1809
+ for (const idx of def.indices ?? []) {
1810
+ store.createIndex(idx, idx);
1811
+ }
1812
+ } else {
1813
+ const store = tx.objectStore(sn);
1814
+ const existingIndices = new Set(Array.from(store.indexNames));
1815
+ const wantedIndices = new Set(def.indices ?? []);
1816
+ for (const idx of existingIndices) {
1817
+ if (!wantedIndices.has(idx))
1818
+ store.deleteIndex(idx);
1819
+ }
1820
+ for (const idx of wantedIndices) {
1821
+ if (!existingIndices.has(idx))
1822
+ store.createIndex(idx, idx);
1684
1823
  }
1685
1824
  }
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");
1825
+ }
1826
+ for (const existing of Array.from(database.objectStoreNames)) {
1827
+ if (existing.startsWith("col_") && !expectedStores.has(existing)) {
1828
+ database.deleteObjectStore(existing);
1829
+ }
1830
+ }
1831
+ tx.objectStore("_meta").put(computeSchemaSig(schema), "schema_sig");
1832
+ }
1833
+ function openIDBStorage(dbName, schema) {
1834
+ return Effect13.gen(function* () {
1835
+ const name = dbName ?? DB_NAME;
1836
+ const schemaSig = computeSchemaSig(schema);
1837
+ const probeDb = yield* Effect13.tryPromise({
1838
+ try: () => openDB(name),
1839
+ catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
1840
+ });
1841
+ const currentVersion = probeDb.version;
1842
+ let needsUpgrade = true;
1843
+ if (probeDb.objectStoreNames.contains("_meta")) {
1844
+ const storedSig = yield* Effect13.tryPromise({
1845
+ try: () => probeDb.get("_meta", "schema_sig"),
1846
+ catch: () => new StorageError({ message: "Failed to read schema meta" })
1847
+ }).pipe(Effect13.catch(() => Effect13.succeed(undefined)));
1848
+ needsUpgrade = storedSig !== schemaSig;
1849
+ }
1850
+ probeDb.close();
1851
+ const db = needsUpgrade ? yield* Effect13.tryPromise({
1852
+ try: () => openDB(name, currentVersion + 1, {
1853
+ upgrade(database, _oldVersion, _newVersion, transaction) {
1854
+ upgradeSchema(database, schema, transaction);
1855
+ }
1856
+ }),
1857
+ catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
1858
+ }) : yield* Effect13.tryPromise({
1859
+ try: () => openDB(name),
1860
+ catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
1861
+ });
1862
+ yield* Effect13.addFinalizer(() => Effect13.sync(() => db.close()));
1863
+ const handle = {
1864
+ putRecord: (collection2, record) => wrap("putRecord", () => db.put(storeName(collection2), record).then(() => {
1865
+ return;
1866
+ })),
1867
+ getRecord: (collection2, id) => wrap("getRecord", () => db.get(storeName(collection2), id)),
1868
+ getAllRecords: (collection2) => wrap("getAllRecords", () => db.getAll(storeName(collection2))),
1869
+ countRecords: (collection2) => wrap("countRecords", () => db.count(storeName(collection2))),
1870
+ clearRecords: (collection2) => wrap("clearRecords", () => db.clear(storeName(collection2))),
1871
+ getByIndex: (collection2, indexName, value) => wrap("getByIndex", () => db.getAllFromIndex(storeName(collection2), indexName, value)),
1872
+ getByIndexRange: (collection2, indexName, range) => wrap("getByIndexRange", () => db.getAllFromIndex(storeName(collection2), indexName, range)),
1873
+ getAllSorted: (collection2, indexName, direction) => wrap("getAllSorted", async () => {
1874
+ const sn = storeName(collection2);
1875
+ const tx = db.transaction(sn, "readonly");
1876
+ const store = tx.objectStore(sn);
1877
+ const index = store.index(indexName);
1878
+ const results = [];
1879
+ let cursor = await index.openCursor(null, direction ?? "next");
1880
+ while (cursor) {
1881
+ results.push(cursor.value);
1882
+ cursor = await cursor.continue();
1883
+ }
1884
+ return results;
1885
+ }),
1886
+ putEvent: (event) => wrap("putEvent", () => db.put("events", event).then(() => {
1887
+ return;
1888
+ })),
1889
+ getEvent: (id) => wrap("getEvent", () => db.get("events", id)),
1890
+ getAllEvents: () => wrap("getAllEvents", () => db.getAll("events")),
1891
+ getEventsByRecord: (collection2, recordId) => wrap("getEventsByRecord", () => db.getAllFromIndex("events", "by-record", [collection2, recordId])),
1892
+ putGiftWrap: (gw) => wrap("putGiftWrap", () => db.put("giftwraps", gw).then(() => {
1893
+ return;
1894
+ })),
1895
+ getGiftWrap: (id) => wrap("getGiftWrap", () => db.get("giftwraps", id)),
1896
+ getAllGiftWraps: () => wrap("getAllGiftWraps", () => db.getAll("giftwraps")),
1897
+ deleteGiftWrap: (id) => wrap("deleteGiftWrap", () => db.delete("giftwraps", id).then(() => {
1898
+ return;
1899
+ })),
1900
+ stripGiftWrapBlob: (id) => wrap("stripGiftWrapBlob", async () => {
1901
+ const existing = await db.get("giftwraps", id);
1902
+ if (existing) {
1903
+ const { event: _, ...tombstone } = existing;
1904
+ await db.put("giftwraps", tombstone);
1905
+ }
1906
+ }),
1907
+ deleteEvent: (id) => wrap("deleteEvent", () => db.delete("events", id).then(() => {
1908
+ return;
1909
+ })),
1910
+ getMeta: (key) => wrap("getMeta", () => db.get("_meta", key)),
1911
+ putMeta: (key, value) => wrap("putMeta", () => db.put("_meta", value, key).then(() => {
1912
+ return;
1913
+ })),
1914
+ close: () => Effect13.sync(() => db.close())
1915
+ };
1916
+ return handle;
1917
+ });
1918
+ }
1919
+
1920
+ // src/layers/StorageLive.ts
1921
+ var StorageLive = Layer3.effect(Storage, Effect14.gen(function* () {
1922
+ const config = yield* Config;
1923
+ return yield* openIDBStorage(config.dbName, {
1924
+ ...config.schema,
1925
+ _members: membersCollectionDef
1926
+ });
1927
+ }));
1928
+
1929
+ // src/layers/RelayLive.ts
1930
+ import { Layer as Layer4 } from "effect";
1931
+
1932
+ // src/sync/relay.ts
1933
+ import { Effect as Effect15, Option as Option8, Schema as Schema7, ScopedCache, Scope as Scope3 } from "effect";
1934
+ import { Relay as Relay2 } from "nostr-tools/relay";
1935
+ var NegMessageFrameSchema = Schema7.Tuple([
1936
+ Schema7.Literal("NEG-MSG"),
1937
+ Schema7.String,
1938
+ Schema7.String
1939
+ ]);
1940
+ var NegErrorFrameSchema = Schema7.Tuple([Schema7.Literal("NEG-ERR"), Schema7.String, Schema7.String]);
1941
+ var decodeNegFrame = Schema7.decodeUnknownEffect(Schema7.fromJsonString(Schema7.Union([NegMessageFrameSchema, NegErrorFrameSchema])));
1942
+ function parseNegMessageFrame(data) {
1943
+ return Effect15.runSync(decodeNegFrame(data).pipe(Effect15.map(Option8.some), Effect15.orElseSucceed(() => Option8.none())));
1944
+ }
1945
+ function createRelayHandle() {
1946
+ return Effect15.gen(function* () {
1947
+ const relayScope = yield* Effect15.scope;
1948
+ const connections = yield* ScopedCache.make({
1949
+ capacity: 64,
1950
+ lookup: (url) => Effect15.acquireRelease(Effect15.tryPromise({
1951
+ try: () => Relay2.connect(url),
1952
+ catch: (e) => new RelayError({
1953
+ message: `Connect to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
1954
+ url,
1955
+ cause: e
1956
+ })
1957
+ }), (relay) => Effect15.sync(() => {
1958
+ relay.close();
1959
+ }))
1960
+ });
1961
+ const connectedUrls = new Set;
1962
+ const statusListeners = new Set;
1963
+ const notifyStatus = () => {
1964
+ const status = { connectedUrls: [...connectedUrls] };
1965
+ for (const listener of statusListeners)
1966
+ listener(status);
1967
+ };
1968
+ const markConnected = (url) => {
1969
+ if (!connectedUrls.has(url)) {
1970
+ connectedUrls.add(url);
1971
+ notifyStatus();
1972
+ }
1973
+ };
1974
+ const markDisconnected = (url) => {
1975
+ if (connectedUrls.has(url)) {
1976
+ connectedUrls.delete(url);
1977
+ notifyStatus();
1978
+ }
1979
+ };
1980
+ const getRelay = (url) => ScopedCache.get(connections, url).pipe(Effect15.flatMap((relay) => relay.connected === false ? ScopedCache.invalidate(connections, url).pipe(Effect15.andThen(ScopedCache.get(connections, url))) : Effect15.succeed(relay)));
1981
+ const withRelay = (url, run) => getRelay(url).pipe(Effect15.tap(() => Effect15.sync(() => markConnected(url))), Effect15.flatMap((relay) => run(relay)), Effect15.tapError(() => ScopedCache.invalidate(connections, url).pipe(Effect15.tap(() => Effect15.sync(() => markDisconnected(url))))));
1982
+ const collectEvents = (url, filters) => withRelay(url, (relay) => Effect15.callback((resume) => {
1983
+ const events = [];
1984
+ let settled = false;
1985
+ let timer;
1986
+ let sub;
1987
+ const cleanup = () => {
1988
+ settled = true;
1989
+ if (timer !== undefined) {
1990
+ clearTimeout(timer);
1991
+ timer = undefined;
1992
+ }
1993
+ sub?.close();
1994
+ sub = undefined;
1995
+ };
1996
+ const fail = (e) => resume(Effect15.fail(new RelayError({
1997
+ message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
1998
+ url,
1999
+ cause: e
2000
+ })));
2001
+ try {
2002
+ sub = relay.subscribe([...filters], {
2003
+ onevent(evt) {
2004
+ if (!settled) {
2005
+ events.push(evt);
2006
+ }
2007
+ },
2008
+ oneose() {
2009
+ if (settled)
2010
+ return;
2011
+ cleanup();
2012
+ resume(Effect15.succeed(events));
1732
2013
  }
1733
2014
  });
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);
2015
+ timer = setTimeout(() => {
2016
+ if (settled)
2017
+ return;
2018
+ cleanup();
2019
+ resume(Effect15.succeed(events));
2020
+ }, 1e4);
2021
+ } catch (e) {
2022
+ cleanup();
2023
+ fail(e);
1740
2024
  }
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`);
2025
+ return Effect15.sync(cleanup);
2026
+ }));
2027
+ return {
2028
+ publish: (event, urls) => Effect15.gen(function* () {
2029
+ const results = yield* Effect15.forEach(urls, (url) => Effect15.result(withRelay(url, (relay) => Effect15.tryPromise({
2030
+ try: () => relay.publish(event),
2031
+ catch: (e) => new RelayError({
2032
+ message: `Publish to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
2033
+ url,
2034
+ cause: e
2035
+ })
2036
+ }).pipe(Effect15.timeoutOrElse({
2037
+ duration: "10 seconds",
2038
+ onTimeout: () => Effect15.fail(new RelayError({ message: `Publish to ${url} timed out`, url }))
2039
+ })))), { concurrency: "unbounded" });
2040
+ const failures = results.filter((r) => r._tag === "Failure");
2041
+ if (failures.length === urls.length && urls.length > 0) {
2042
+ return yield* new RelayError({
2043
+ message: `Publish failed on all ${urls.length} relays`
2044
+ });
1755
2045
  }
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
2046
  }),
1764
- rebuild: () => Effect14.gen(function* () {
1765
- const closed = yield* Ref5.get(closedRef);
1766
- if (closed) {
1767
- return yield* new StorageError({ message: "Database is closed" });
2047
+ fetchEvents: (ids, url) => Effect15.gen(function* () {
2048
+ if (ids.length === 0)
2049
+ return [];
2050
+ return yield* collectEvents(url, [{ ids }]);
2051
+ }),
2052
+ fetchByFilter: (filter, url) => collectEvents(url, [filter]),
2053
+ subscribe: (filter, url, onEvent) => withRelay(url, (relay) => Effect15.acquireRelease(Effect15.try({
2054
+ try: () => relay.subscribe([filter], {
2055
+ onevent(evt) {
2056
+ onEvent(evt);
2057
+ },
2058
+ oneose() {}
2059
+ }),
2060
+ catch: (e) => new RelayError({
2061
+ message: `Subscribe to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
2062
+ url,
2063
+ cause: e
2064
+ })
2065
+ }), (sub) => Effect15.sync(() => {
2066
+ sub.close();
2067
+ })).pipe(Effect15.provideService(Scope3.Scope, relayScope), Effect15.asVoid)),
2068
+ sendNegMsg: (url, subId, filter, msgHex) => withRelay(url, (relay) => Effect15.callback((resume) => {
2069
+ let settled = false;
2070
+ let timer;
2071
+ let sub;
2072
+ let ws;
2073
+ const cleanup = () => {
2074
+ settled = true;
2075
+ if (timer !== undefined) {
2076
+ clearTimeout(timer);
2077
+ timer = undefined;
2078
+ }
2079
+ sub?.close();
2080
+ sub = undefined;
2081
+ ws?.removeEventListener("message", handler);
2082
+ ws = undefined;
2083
+ };
2084
+ const fail = (e) => resume(Effect15.fail(new RelayError({
2085
+ message: `NIP-77 negotiation with ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
2086
+ url,
2087
+ cause: e
2088
+ })));
2089
+ const handler = (msg) => {
2090
+ if (settled || typeof msg.data !== "string")
2091
+ return;
2092
+ const frameOpt = parseNegMessageFrame(msg.data);
2093
+ if (Option8.isNone(frameOpt) || frameOpt.value[1] !== subId)
2094
+ return;
2095
+ const frame = frameOpt.value;
2096
+ cleanup();
2097
+ if (frame[0] === "NEG-MSG") {
2098
+ resume(Effect15.succeed({
2099
+ msgHex: frame[2],
2100
+ haveIds: [],
2101
+ needIds: []
2102
+ }));
2103
+ return;
2104
+ }
2105
+ fail(new Error(`NEG-ERR: ${frame[2]}`));
2106
+ };
2107
+ try {
2108
+ sub = relay.subscribe([filter], {
2109
+ onevent() {},
2110
+ oneose() {}
2111
+ });
2112
+ ws = relay._ws || relay.ws;
2113
+ if (!ws) {
2114
+ cleanup();
2115
+ fail(new Error("Cannot access relay WebSocket"));
2116
+ return Effect15.succeed(undefined);
2117
+ }
2118
+ timer = setTimeout(() => {
2119
+ if (settled)
2120
+ return;
2121
+ cleanup();
2122
+ fail(new Error("NIP-77 negotiation timeout"));
2123
+ }, 30000);
2124
+ ws.addEventListener("message", handler);
2125
+ ws.send(JSON.stringify(["NEG-OPEN", subId, filter, msgHex]));
2126
+ } catch (e) {
2127
+ cleanup();
2128
+ fail(e);
1768
2129
  }
1769
- yield* rebuild(storage, schemaEntries.map(([, def]) => def.name));
2130
+ return Effect15.sync(cleanup);
2131
+ })),
2132
+ closeAll: () => ScopedCache.invalidateAll(connections),
2133
+ getStatus: () => ({ connectedUrls: [...connectedUrls] }),
2134
+ subscribeStatus: (callback) => {
2135
+ statusListeners.add(callback);
2136
+ return () => statusListeners.delete(callback);
2137
+ }
2138
+ };
2139
+ });
2140
+ }
2141
+
2142
+ // src/layers/RelayLive.ts
2143
+ var RelayLive = Layer4.effect(Relay, createRelayHandle());
2144
+
2145
+ // src/layers/GiftWrapLive.ts
2146
+ import { Effect as Effect17, Layer as Layer5 } from "effect";
2147
+
2148
+ // src/sync/gift-wrap.ts
2149
+ import { Effect as Effect16 } from "effect";
2150
+ import { wrapEvent as wrapEvent2, unwrapEvent as unwrapEvent2 } from "nostr-tools/nip59";
2151
+ function createEpochGiftWrapHandle(senderPrivateKey, epochStore) {
2152
+ return {
2153
+ wrap: (rumor) => Effect16.try({
2154
+ try: () => wrapEvent2(rumor, senderPrivateKey, getCurrentPublicKey(epochStore)),
2155
+ catch: (e) => new CryptoError({
2156
+ message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
2157
+ cause: e
2158
+ })
2159
+ }),
2160
+ unwrap: (giftWrap) => Effect16.gen(function* () {
2161
+ const pTag = giftWrap.tags.find((t) => t[0] === "p")?.[1];
2162
+ if (!pTag) {
2163
+ return yield* new CryptoError({ message: "Gift wrap missing #p tag" });
2164
+ }
2165
+ const decKey = getDecryptionKey(epochStore, pTag);
2166
+ if (!decKey) {
2167
+ return yield* new CryptoError({
2168
+ message: `No epoch key for public key ${pTag.slice(0, 8)}...`
2169
+ });
2170
+ }
2171
+ return yield* Effect16.try({
2172
+ try: () => unwrapEvent2(giftWrap, decKey),
2173
+ catch: (e) => new CryptoError({
2174
+ message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
2175
+ cause: e
2176
+ })
2177
+ });
2178
+ })
2179
+ };
2180
+ }
2181
+
2182
+ // src/layers/GiftWrapLive.ts
2183
+ var GiftWrapLive = Layer5.effect(GiftWrap3, Effect17.gen(function* () {
2184
+ const identity = yield* Identity;
2185
+ const epochStore = yield* EpochStore;
2186
+ return createEpochGiftWrapHandle(identity.privateKey, epochStore);
2187
+ }));
2188
+
2189
+ // src/layers/PublishQueueLive.ts
2190
+ import { Effect as Effect19, Layer as Layer6 } from "effect";
2191
+
2192
+ // src/sync/publish-queue.ts
2193
+ import { Effect as Effect18, Ref as Ref4 } from "effect";
2194
+ var META_KEY = "publish_queue";
2195
+ function persist(storage, pending) {
2196
+ return storage.putMeta(META_KEY, [...pending]);
2197
+ }
2198
+ function createPublishQueue(storage, relay) {
2199
+ return Effect18.gen(function* () {
2200
+ const stored = yield* storage.getMeta(META_KEY);
2201
+ const initial = Array.isArray(stored) ? new Set(stored) : new Set;
2202
+ const pendingRef = yield* Ref4.make(initial);
2203
+ const listeners = new Set;
2204
+ const notify = (pending) => {
2205
+ for (const listener of listeners)
2206
+ listener(pending.size);
2207
+ };
2208
+ return {
2209
+ enqueue: (eventId) => Effect18.gen(function* () {
2210
+ const next = yield* Ref4.updateAndGet(pendingRef, (set) => {
2211
+ const n = new Set(set);
2212
+ n.add(eventId);
2213
+ return n;
2214
+ });
2215
+ yield* persist(storage, next);
2216
+ notify(next);
1770
2217
  }),
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" });
2218
+ flush: (relayUrls) => Effect18.gen(function* () {
2219
+ const pending = yield* Ref4.get(pendingRef);
2220
+ if (pending.size === 0)
2221
+ return;
2222
+ const succeeded = new Set;
2223
+ let consecutiveFailures = 0;
2224
+ for (const eventId of pending) {
2225
+ if (consecutiveFailures >= 3)
2226
+ break;
2227
+ const gw = yield* storage.getGiftWrap(eventId);
2228
+ if (!gw || !gw.event) {
2229
+ succeeded.add(eventId);
2230
+ consecutiveFailures = 0;
2231
+ continue;
2232
+ }
2233
+ const result = yield* Effect18.result(relay.publish(gw.event, relayUrls));
2234
+ if (result._tag === "Success") {
2235
+ succeeded.add(eventId);
2236
+ yield* storage.stripGiftWrapBlob(eventId);
2237
+ consecutiveFailures = 0;
2238
+ } else {
2239
+ consecutiveFailures++;
2240
+ }
2241
+ }
2242
+ if (succeeded.size > 0) {
2243
+ const updated = yield* Ref4.updateAndGet(pendingRef, (set) => {
2244
+ const next = new Set(set);
2245
+ for (const id of succeeded) {
2246
+ next.delete(id);
2247
+ }
2248
+ return next;
2249
+ });
2250
+ yield* persist(storage, updated);
2251
+ notify(updated);
1775
2252
  }
1776
- yield* syncHandle.sync();
1777
2253
  }),
1778
- getSyncStatus: () => syncStatus.get()
2254
+ size: () => Ref4.get(pendingRef).pipe(Effect18.map((s) => s.size)),
2255
+ subscribe: (callback) => {
2256
+ listeners.add(callback);
2257
+ return () => listeners.delete(callback);
2258
+ }
1779
2259
  };
1780
- return dbHandle;
1781
2260
  });
1782
2261
  }
1783
2262
 
1784
- // src/svelte/database.svelte.ts
1785
- import { Effect as Effect18, Scope as Scope3, Exit } from "effect";
2263
+ // src/layers/PublishQueueLive.ts
2264
+ var PublishQueueLive = Layer6.effect(PublishQueue, Effect19.gen(function* () {
2265
+ const storage = yield* Storage;
2266
+ const relay = yield* Relay;
2267
+ return yield* createPublishQueue(storage, relay);
2268
+ }));
1786
2269
 
1787
- // src/svelte/collection.svelte.ts
1788
- import { Effect as Effect17, Fiber as Fiber2, Stream as Stream5 } from "effect";
2270
+ // src/layers/SyncStatusLive.ts
2271
+ import { Layer as Layer7 } from "effect";
1789
2272
 
1790
- // src/svelte/query.svelte.ts
1791
- import { Effect as Effect16 } from "effect";
2273
+ // src/sync/sync-status.ts
2274
+ import { Effect as Effect20, SubscriptionRef } from "effect";
2275
+ function createSyncStatusHandle() {
2276
+ return Effect20.gen(function* () {
2277
+ const ref = yield* SubscriptionRef.make("idle");
2278
+ const listeners = new Set;
2279
+ return {
2280
+ get: () => SubscriptionRef.get(ref),
2281
+ set: (status) => Effect20.gen(function* () {
2282
+ yield* SubscriptionRef.set(ref, status);
2283
+ for (const listener of listeners)
2284
+ listener(status);
2285
+ }),
2286
+ subscribe: (callback) => {
2287
+ listeners.add(callback);
2288
+ return () => listeners.delete(callback);
2289
+ }
2290
+ };
2291
+ });
2292
+ }
1792
2293
 
1793
- // src/svelte/live-query.svelte.ts
1794
- import { Effect as Effect15, Fiber, Stream as Stream4 } from "effect";
2294
+ // src/layers/SyncStatusLive.ts
2295
+ var SyncStatusLive = Layer7.effect(SyncStatus, createSyncStatusHandle());
1795
2296
 
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;
2297
+ // src/layers/TablinumLive.ts
2298
+ function reportSyncError(onSyncError, error) {
2299
+ if (!onSyncError)
2300
+ return;
2301
+ onSyncError(error instanceof Error ? error : new Error(String(error)));
2302
+ }
2303
+ function mapMemberRecord(record) {
2304
+ return {
2305
+ id: record.id,
2306
+ addedAt: record.addedAt,
2307
+ addedInEpoch: record.addedInEpoch,
2308
+ ...record.name !== undefined ? { name: record.name } : {},
2309
+ ...record.picture !== undefined ? { picture: record.picture } : {},
2310
+ ...record.about !== undefined ? { about: record.about } : {},
2311
+ ...record.nip05 !== undefined ? { nip05: record.nip05 } : {},
2312
+ ...record.removedAt !== undefined ? { removedAt: record.removedAt } : {},
2313
+ ...record.removedInEpoch !== undefined ? { removedInEpoch: record.removedInEpoch } : {}
2314
+ };
2315
+ }
2316
+ var IdentityWithDeps = IdentityLive.pipe(Layer8.provide(StorageLive));
2317
+ var EpochStoreWithDeps = EpochStoreLive.pipe(Layer8.provide(IdentityWithDeps), Layer8.provide(StorageLive));
2318
+ var GiftWrapWithDeps = GiftWrapLive.pipe(Layer8.provide(IdentityWithDeps), Layer8.provide(EpochStoreWithDeps));
2319
+ var PublishQueueWithDeps = PublishQueueLive.pipe(Layer8.provide(StorageLive), Layer8.provide(RelayLive));
2320
+ var AllServicesLive = Layer8.mergeAll(IdentityWithDeps, EpochStoreWithDeps, StorageLive, RelayLive, GiftWrapWithDeps, PublishQueueWithDeps, SyncStatusLive);
2321
+ var TablinumLive = Layer8.effect(Tablinum, Effect21.gen(function* () {
2322
+ const config = yield* Config;
2323
+ const identity = yield* Identity;
2324
+ const epochStore = yield* EpochStore;
2325
+ const storage = yield* Storage;
2326
+ const relay = yield* Relay;
2327
+ const giftWrap = yield* GiftWrap3;
2328
+ const publishQueue = yield* PublishQueue;
2329
+ const syncStatus = yield* SyncStatus;
2330
+ const scope = yield* Effect21.scope;
2331
+ const pubsub = yield* PubSub2.unbounded();
2332
+ const replayingRef = yield* Ref5.make(false);
2333
+ const closedRef = yield* Ref5.make(false);
2334
+ const watchCtx = { pubsub, replayingRef };
2335
+ const schemaEntries = Object.entries(config.schema);
2336
+ const allSchemaEntries = [...schemaEntries, ["_members", membersCollectionDef]];
2337
+ const knownCollections = new Set(allSchemaEntries.map(([, def]) => def.name));
2338
+ let notifyAuthor;
2339
+ const syncHandle = createSyncHandle(storage, giftWrap, relay, publishQueue, syncStatus, watchCtx, config.relays, knownCollections, epochStore, identity.privateKey, identity.publicKey, scope, config.onSyncError ? (error) => reportSyncError(config.onSyncError, error) : undefined, (pubkey) => notifyAuthor?.(pubkey), config.onRemoved, config.onMembersChanged);
2340
+ const onWrite = (event) => Effect21.gen(function* () {
2341
+ const content = event.kind === "delete" ? JSON.stringify({ _deleted: true }) : JSON.stringify(event.data);
2342
+ const dTag = `${event.collection}:${event.recordId}`;
2343
+ const wrapResult = yield* Effect21.result(giftWrap.wrap({
2344
+ kind: 1,
2345
+ content,
2346
+ tags: [["d", dTag]],
2347
+ created_at: Math.floor(event.createdAt / 1000)
2348
+ }));
2349
+ if (wrapResult._tag === "Failure") {
2350
+ reportSyncError(config.onSyncError, wrapResult.failure);
2351
+ return;
1812
2352
  }
2353
+ const gw = wrapResult.success;
2354
+ yield* storage.putGiftWrap({ id: gw.id, eventId: event.id, createdAt: gw.created_at });
2355
+ yield* Effect21.forkIn(Effect21.gen(function* () {
2356
+ const publishResult = yield* Effect21.result(syncHandle.publishLocal({
2357
+ id: gw.id,
2358
+ eventId: event.id,
2359
+ event: gw,
2360
+ createdAt: gw.created_at
2361
+ }));
2362
+ if (publishResult._tag === "Failure") {
2363
+ reportSyncError(config.onSyncError, publishResult.failure);
2364
+ }
2365
+ }), scope);
2366
+ });
2367
+ const knownAuthors = new Set;
2368
+ const putMemberRecord = (record) => Effect21.gen(function* () {
2369
+ const existing = yield* storage.getRecord("_members", record.id);
2370
+ const event = {
2371
+ id: uuidv7(),
2372
+ collection: "_members",
2373
+ recordId: record.id,
2374
+ kind: existing ? "update" : "create",
2375
+ data: record,
2376
+ createdAt: Date.now()
2377
+ };
2378
+ yield* storage.putEvent(event);
2379
+ yield* applyEvent(storage, event);
2380
+ yield* onWrite(event);
2381
+ yield* notifyChange(watchCtx, {
2382
+ collection: "_members",
2383
+ recordId: record.id,
2384
+ kind: existing ? "update" : "create"
2385
+ });
2386
+ config.onMembersChanged?.();
2387
+ });
2388
+ notifyAuthor = (pubkey) => {
2389
+ if (knownAuthors.has(pubkey))
2390
+ return;
2391
+ knownAuthors.add(pubkey);
2392
+ Effect21.runFork(Effect21.gen(function* () {
2393
+ const existing = yield* storage.getRecord("_members", pubkey);
2394
+ if (!existing) {
2395
+ yield* putMemberRecord({
2396
+ id: pubkey,
2397
+ addedAt: Date.now(),
2398
+ addedInEpoch: getCurrentEpoch(epochStore).id
2399
+ });
2400
+ }
2401
+ const profileOpt = yield* fetchAuthorProfile(relay, config.relays, pubkey).pipe(Effect21.catchTag("RelayError", () => Effect21.succeed(Option9.none())));
2402
+ if (Option9.isSome(profileOpt)) {
2403
+ const current = yield* storage.getRecord("_members", pubkey);
2404
+ if (current) {
2405
+ yield* storage.putRecord("_members", {
2406
+ ...current,
2407
+ ...profileOpt.value
2408
+ });
2409
+ yield* notifyChange(watchCtx, {
2410
+ collection: "_members",
2411
+ recordId: pubkey,
2412
+ kind: "update"
2413
+ });
2414
+ config.onMembersChanged?.();
2415
+ }
2416
+ }
2417
+ }).pipe(Effect21.ignore, Effect21.forkIn(scope)));
2418
+ };
2419
+ const handles = new Map;
2420
+ for (const [, def] of allSchemaEntries) {
2421
+ const validator = buildValidator(def.name, def);
2422
+ const partialValidator = buildPartialValidator(def.name, def);
2423
+ const handle = createCollectionHandle(def, storage, watchCtx, validator, partialValidator, uuidv7, onWrite);
2424
+ handles.set(def.name, handle);
2425
+ }
2426
+ yield* syncHandle.startSubscription();
2427
+ const selfMember = yield* storage.getRecord("_members", identity.publicKey);
2428
+ if (!selfMember) {
2429
+ yield* putMemberRecord({
2430
+ id: identity.publicKey,
2431
+ addedAt: Date.now(),
2432
+ addedInEpoch: getCurrentEpoch(epochStore).id
2433
+ });
1813
2434
  }
2435
+ const ensureOpen = (effect) => Effect21.gen(function* () {
2436
+ if (yield* Ref5.get(closedRef)) {
2437
+ return yield* new StorageError({ message: "Database is closed" });
2438
+ }
2439
+ return yield* effect;
2440
+ });
2441
+ const ensureSyncOpen = (effect) => Effect21.gen(function* () {
2442
+ if (yield* Ref5.get(closedRef)) {
2443
+ return yield* new SyncError({ message: "Database is closed", phase: "init" });
2444
+ }
2445
+ return yield* effect;
2446
+ });
2447
+ const dbHandle = {
2448
+ collection: (name) => {
2449
+ const handle = handles.get(name);
2450
+ if (!handle)
2451
+ throw new Error(`Collection "${name}" not found in schema`);
2452
+ return handle;
2453
+ },
2454
+ publicKey: identity.publicKey,
2455
+ members: handles.get("_members"),
2456
+ exportKey: () => identity.exportKey(),
2457
+ exportInvite: () => ({
2458
+ epochKeys: [...exportEpochKeys(epochStore)],
2459
+ relays: [...config.relays],
2460
+ dbName: config.dbName
2461
+ }),
2462
+ close: () => Effect21.gen(function* () {
2463
+ if (yield* Ref5.get(closedRef))
2464
+ return;
2465
+ yield* Ref5.set(closedRef, true);
2466
+ yield* Scope4.close(scope, Exit.void);
2467
+ }),
2468
+ rebuild: () => ensureOpen(rebuild(storage, allSchemaEntries.map(([, def]) => def.name))),
2469
+ sync: () => ensureSyncOpen(syncHandle.sync()),
2470
+ getSyncStatus: () => syncStatus.get(),
2471
+ subscribeSyncStatus: (callback) => syncStatus.subscribe(callback),
2472
+ pendingCount: () => publishQueue.size(),
2473
+ subscribePendingCount: (callback) => publishQueue.subscribe(callback),
2474
+ getRelayStatus: () => relay.getStatus(),
2475
+ subscribeRelayStatus: (callback) => relay.subscribeStatus(callback),
2476
+ addMember: (pubkey) => ensureOpen(Effect21.gen(function* () {
2477
+ const existing = yield* storage.getRecord("_members", pubkey);
2478
+ if (existing && !existing.removedAt)
2479
+ return;
2480
+ yield* putMemberRecord({
2481
+ id: pubkey,
2482
+ addedAt: Date.now(),
2483
+ addedInEpoch: getCurrentEpoch(epochStore).id,
2484
+ ...existing ? { removedAt: undefined, removedInEpoch: undefined } : {}
2485
+ });
2486
+ })),
2487
+ removeMember: (pubkey) => ensureOpen(Effect21.gen(function* () {
2488
+ const allMembers = yield* storage.getAllRecords("_members");
2489
+ const activeMembers = allMembers.filter((member) => !member.removedAt && member.id !== pubkey);
2490
+ const activePubkeys = activeMembers.map((member) => member.id);
2491
+ const result = createRotation(epochStore, identity.privateKey, identity.publicKey, activePubkeys, [pubkey]);
2492
+ addEpoch(epochStore, result.epoch);
2493
+ epochStore.currentEpochId = result.epoch.id;
2494
+ yield* storage.putMeta("epochs", stringifyEpochStore(epochStore));
2495
+ const memberRecord = yield* storage.getRecord("_members", pubkey);
2496
+ yield* putMemberRecord({
2497
+ ...memberRecord ?? {
2498
+ id: pubkey,
2499
+ addedAt: 0,
2500
+ addedInEpoch: EpochId("epoch-0")
2501
+ },
2502
+ removedAt: Date.now(),
2503
+ removedInEpoch: result.epoch.id
2504
+ });
2505
+ yield* Effect21.forEach(result.wrappedEvents, (wrappedEvent) => relay.publish(wrappedEvent, [...config.relays]).pipe(Effect21.tapError((e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))), Effect21.ignore), { discard: true });
2506
+ yield* Effect21.forEach(result.removalNotices, (notice) => relay.publish(notice, [...config.relays]).pipe(Effect21.tapError((e) => Effect21.sync(() => reportSyncError(config.onSyncError, e))), Effect21.ignore), { discard: true });
2507
+ yield* syncHandle.addEpochSubscription(result.epoch.publicKey);
2508
+ })),
2509
+ getMembers: () => ensureOpen(Effect21.gen(function* () {
2510
+ const allRecords = yield* storage.getAllRecords("_members");
2511
+ return allRecords.filter((record) => !record._deleted).map(mapMemberRecord);
2512
+ })),
2513
+ setProfile: (profile) => ensureOpen(Effect21.gen(function* () {
2514
+ const existing = yield* storage.getRecord("_members", identity.publicKey);
2515
+ if (!existing) {
2516
+ return yield* new ValidationError({ message: "Current user is not a member" });
2517
+ }
2518
+ yield* putMemberRecord({ ...existing, ...profile });
2519
+ }))
2520
+ };
2521
+ return dbHandle;
2522
+ })).pipe(Layer8.provide(AllServicesLive));
2523
+
2524
+ // src/db/create-tablinum.ts
2525
+ function validateConfig(config) {
2526
+ return Effect22.gen(function* () {
2527
+ if (Object.keys(config.schema).length === 0) {
2528
+ return yield* new ValidationError({
2529
+ message: "Schema must contain at least one collection"
2530
+ });
2531
+ }
2532
+ });
2533
+ }
2534
+ function createTablinum(config) {
2535
+ return Effect22.gen(function* () {
2536
+ yield* validateConfig(config);
2537
+ const runtimeConfig = yield* resolveRuntimeConfig(config);
2538
+ const configValue = {
2539
+ ...runtimeConfig,
2540
+ schema: config.schema,
2541
+ onSyncError: config.onSyncError,
2542
+ onRemoved: config.onRemoved,
2543
+ onMembersChanged: config.onMembersChanged
2544
+ };
2545
+ const configLayer = Layer9.succeed(Config, configValue);
2546
+ const fullLayer = TablinumLive.pipe(Layer9.provide(configLayer));
2547
+ const ctx = yield* Layer9.build(fullLayer);
2548
+ return ServiceMap10.get(ctx, Tablinum);
2549
+ });
2550
+ }
2551
+
2552
+ // src/svelte/collection.svelte.ts
2553
+ import { Effect as Effect24, Fiber, Option as Option11, Stream as Stream4 } from "effect";
2554
+
2555
+ // src/svelte/deferred.ts
2556
+ function createDeferred() {
2557
+ let settled = false;
2558
+ let resolvePromise;
2559
+ let rejectPromise;
2560
+ const promise = new Promise((resolve, reject) => {
2561
+ resolvePromise = resolve;
2562
+ rejectPromise = reject;
2563
+ });
2564
+ return {
2565
+ promise,
2566
+ settled: () => settled,
2567
+ resolve: (value) => {
2568
+ if (settled)
2569
+ return;
2570
+ settled = true;
2571
+ resolvePromise(value);
2572
+ },
2573
+ reject: (reason) => {
2574
+ if (settled)
2575
+ return;
2576
+ settled = true;
2577
+ rejectPromise(reason);
2578
+ }
2579
+ };
1814
2580
  }
1815
2581
 
1816
2582
  // src/svelte/query.svelte.ts
1817
- function wrapQueryBuilder(builder, onLive) {
2583
+ import { Effect as Effect23, Option as Option10 } from "effect";
2584
+ function wrapQueryBuilder(getBuilder, touchVersion, ready) {
1818
2585
  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;
2586
+ and: (fn) => wrapQueryBuilder(() => getBuilder().and(fn), touchVersion, ready),
2587
+ sortBy: (field2) => wrapQueryBuilder(() => getBuilder().sortBy(field2), touchVersion, ready),
2588
+ reverse: () => wrapQueryBuilder(() => getBuilder().reverse(), touchVersion, ready),
2589
+ offset: (n) => wrapQueryBuilder(() => getBuilder().offset(n), touchVersion, ready),
2590
+ limit: (n) => wrapQueryBuilder(() => getBuilder().limit(n), touchVersion, ready),
2591
+ get: () => {
2592
+ touchVersion();
2593
+ return ready.then(() => Effect23.runPromise(getBuilder().get()));
2594
+ },
2595
+ first: () => {
2596
+ touchVersion();
2597
+ return ready.then(() => Effect23.runPromise(Effect23.map(getBuilder().first(), Option10.getOrNull)));
2598
+ },
2599
+ count: () => {
2600
+ touchVersion();
2601
+ return ready.then(() => Effect23.runPromise(getBuilder().count()));
1831
2602
  }
1832
2603
  };
1833
2604
  }
1834
- function wrapWhereClause(clause, onLive) {
2605
+ function wrapWhereClause(getClause, touchVersion, ready) {
1835
2606
  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)
2607
+ equals: (value) => wrapQueryBuilder(() => getClause().equals(value), touchVersion, ready),
2608
+ above: (value) => wrapQueryBuilder(() => getClause().above(value), touchVersion, ready),
2609
+ aboveOrEqual: (value) => wrapQueryBuilder(() => getClause().aboveOrEqual(value), touchVersion, ready),
2610
+ below: (value) => wrapQueryBuilder(() => getClause().below(value), touchVersion, ready),
2611
+ belowOrEqual: (value) => wrapQueryBuilder(() => getClause().belowOrEqual(value), touchVersion, ready),
2612
+ between: (lower, upper, options) => wrapQueryBuilder(() => getClause().between(lower, upper, options), touchVersion, ready),
2613
+ startsWith: (prefix) => wrapQueryBuilder(() => getClause().startsWith(prefix), touchVersion, ready),
2614
+ anyOf: (values) => wrapQueryBuilder(() => getClause().anyOf(values), touchVersion, ready),
2615
+ noneOf: (values) => wrapQueryBuilder(() => getClause().noneOf(values), touchVersion, ready)
1845
2616
  };
1846
2617
  }
1847
- function wrapOrderByBuilder(builder, onLive) {
2618
+ function wrapOrderByBuilder(getBuilder, touchVersion, ready) {
1848
2619
  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;
2620
+ reverse: () => wrapOrderByBuilder(() => getBuilder().reverse(), touchVersion, ready),
2621
+ offset: (n) => wrapOrderByBuilder(() => getBuilder().offset(n), touchVersion, ready),
2622
+ limit: (n) => wrapOrderByBuilder(() => getBuilder().limit(n), touchVersion, ready),
2623
+ get: () => {
2624
+ touchVersion();
2625
+ return ready.then(() => Effect23.runPromise(getBuilder().get()));
2626
+ },
2627
+ first: () => {
2628
+ touchVersion();
2629
+ return ready.then(() => Effect23.runPromise(Effect23.map(getBuilder().first(), Option10.getOrNull)));
2630
+ },
2631
+ count: () => {
2632
+ touchVersion();
2633
+ return ready.then(() => Effect23.runPromise(getBuilder().count()));
1859
2634
  }
1860
2635
  };
1861
2636
  }
1862
2637
 
1863
2638
  // src/svelte/collection.svelte.ts
1864
2639
  class Collection {
1865
- items = $state([]);
1866
2640
  error = $state(null);
1867
- #handle;
2641
+ #handle = null;
2642
+ #ready = createDeferred();
2643
+ #version = $state(0);
2644
+ #watchAbort = null;
1868
2645
  #watchFiber = null;
1869
- #liveQueries = new Set;
1870
- constructor(handle) {
2646
+ _bind(handle) {
2647
+ if (this.#handle)
2648
+ return;
1871
2649
  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);
2650
+ this.error = null;
2651
+ this.#settleReady();
2652
+ this.#startWatch();
1878
2653
  }
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;
2654
+ _fail(err) {
2655
+ this.error = err;
2656
+ this.#settleReady(err);
2657
+ }
2658
+ #settleReady(err) {
2659
+ if (err) {
2660
+ this.#ready.reject(err);
2661
+ } else {
2662
+ this.#ready.resolve();
2663
+ }
2664
+ }
2665
+ #startWatch() {
2666
+ if (!this.#handle)
2667
+ return;
2668
+ const abort = new AbortController;
2669
+ this.#watchAbort = abort;
2670
+ this.#watchFiber = Effect24.runFork(Stream4.runForEach(this.#handle.watch(), (_records) => Effect24.sync(() => {
2671
+ if (!abort.signal.aborted) {
2672
+ this.#version++;
2673
+ }
2674
+ })).pipe(Effect24.catch((e) => Effect24.sync(() => {
2675
+ if (!abort.signal.aborted) {
2676
+ this.error = e instanceof Error ? e : new Error(String(e));
2677
+ }
2678
+ }))));
2679
+ }
2680
+ #touchVersion = () => {
2681
+ this.#version;
2682
+ };
2683
+ #handleOrThrow = () => {
2684
+ if (this.#handle)
2685
+ return this.#handle;
2686
+ throw this.error ?? new ClosedError({ message: "Collection is not ready" });
2687
+ };
2688
+ #run = async (getEffect) => {
2689
+ await this.#ready.promise;
2690
+ return Effect24.runPromise(getEffect());
2691
+ };
2692
+ add = (data) => {
2693
+ return this.#run(() => this.#handleOrThrow().add(data));
2694
+ };
2695
+ update = (id, data) => {
2696
+ return this.#run(() => this.#handleOrThrow().update(id, data));
2697
+ };
2698
+ delete = (id) => {
2699
+ return this.#run(() => this.#handleOrThrow().delete(id));
2700
+ };
2701
+ get(id) {
2702
+ if (typeof id === "string") {
2703
+ return this.#run(() => this.#handleOrThrow().get(id));
1886
2704
  }
2705
+ this.#touchVersion();
2706
+ return this.#run(() => Stream4.runHead(this.#handleOrThrow().watch()).pipe(Effect24.map((opt) => Option11.getOrElse(opt, () => []))));
2707
+ }
2708
+ first = () => {
2709
+ this.#touchVersion();
2710
+ return this.#run(() => Effect24.map(this.#handleOrThrow().first(), Option11.getOrNull));
1887
2711
  };
1888
- #onLive = (lq) => {
1889
- this.#liveQueries.add(lq);
2712
+ count = () => {
2713
+ this.#touchVersion();
2714
+ return this.#run(() => this.#handleOrThrow().count());
1890
2715
  };
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);
2716
+ where = (field2) => {
2717
+ return wrapWhereClause(() => this.#handleOrThrow().where(field2), this.#touchVersion, this.#ready.promise);
1899
2718
  };
1900
- orderBy = (field) => {
1901
- return wrapOrderByBuilder(this.#handle.orderBy(field), this.#onLive);
2719
+ orderBy = (field2) => {
2720
+ return wrapOrderByBuilder(() => this.#handleOrThrow().orderBy(field2), this.#touchVersion, this.#ready.promise);
1902
2721
  };
1903
- _destroy() {
2722
+ _destroy(reason = new ClosedError({ message: "Collection is closed" })) {
2723
+ if (this.#watchAbort) {
2724
+ this.#watchAbort.abort();
2725
+ this.#watchAbort = null;
2726
+ }
1904
2727
  if (this.#watchFiber) {
1905
- Effect17.runFork(Fiber2.interrupt(this.#watchFiber));
2728
+ Effect24.runFork(Fiber.interrupt(this.#watchFiber));
1906
2729
  this.#watchFiber = null;
1907
2730
  }
1908
- for (const lq of this.#liveQueries) {
1909
- lq.destroy();
1910
- }
1911
- this.#liveQueries.clear();
2731
+ this.#handle = null;
2732
+ this.error ??= reason;
2733
+ this.#settleReady(this.error);
1912
2734
  }
1913
2735
  }
1914
2736
 
1915
- // src/svelte/database.svelte.ts
1916
- class Database {
1917
- status = $state("idle");
2737
+ // src/svelte/tablinum.svelte.ts
2738
+ class Tablinum2 {
2739
+ status = $state("initializing");
2740
+ syncStatus = $state("idle");
2741
+ pendingCount = $state(0);
2742
+ relayStatus = $state({ connectedUrls: [] });
1918
2743
  error = $state(null);
1919
- #handle;
1920
- #scope;
2744
+ ready;
2745
+ #handle = null;
2746
+ #scope = null;
1921
2747
  #collections = new Map;
1922
- #statusInterval = null;
2748
+ #members = new Collection;
2749
+ #unsubscribeSyncStatus = null;
2750
+ #unsubscribePendingCount = null;
2751
+ #unsubscribeRelayStatus = null;
1923
2752
  #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);
2753
+ #readyState = createDeferred();
2754
+ constructor(config) {
2755
+ this.ready = this.#readyState.promise;
2756
+ this.#init(config);
1934
2757
  }
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);
2758
+ #settleReady(err) {
2759
+ if (err) {
2760
+ this.#readyState.reject(err);
2761
+ } else {
2762
+ this.#readyState.resolve();
1941
2763
  }
1942
- return col;
1943
2764
  }
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();
2765
+ #bindCollections(handle) {
2766
+ this.#members._bind(handle.members);
2767
+ for (const [name, collection2] of this.#collections) {
2768
+ collection2._bind(handle.collection(name));
1957
2769
  }
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 () => {
2770
+ }
2771
+ #runHandleEffect = async (run) => {
2772
+ const handle = this.#requireReady();
1963
2773
  try {
1964
2774
  this.error = null;
1965
- await Effect18.runPromise(this.#handle.sync());
2775
+ return await Effect25.runPromise(run(handle));
1966
2776
  } catch (e) {
1967
2777
  this.error = e instanceof Error ? e : new Error(String(e));
1968
2778
  throw this.error;
1969
2779
  }
1970
2780
  };
1971
- rebuild = async () => {
2781
+ async#init(config) {
2782
+ const scope = Effect25.runSync(Scope6.make());
1972
2783
  try {
1973
- this.error = null;
1974
- await Effect18.runPromise(this.#handle.rebuild());
2784
+ const handle = await Effect25.runPromise(createTablinum(config).pipe(Effect25.provideService(Scope6.Scope, scope)));
2785
+ if (this.#closed) {
2786
+ await Effect25.runPromise(Scope6.close(scope, Exit2.void));
2787
+ this.#scope = null;
2788
+ return;
2789
+ }
2790
+ this.#handle = handle;
2791
+ this.#scope = scope;
2792
+ this.#bindCollections(handle);
2793
+ this.#unsubscribeSyncStatus = handle.subscribeSyncStatus((s) => {
2794
+ this.syncStatus = s;
2795
+ });
2796
+ this.#unsubscribePendingCount = handle.subscribePendingCount((count) => {
2797
+ this.pendingCount = count;
2798
+ });
2799
+ this.pendingCount = await Effect25.runPromise(handle.pendingCount());
2800
+ this.#unsubscribeRelayStatus = handle.subscribeRelayStatus((s) => {
2801
+ this.relayStatus = s;
2802
+ });
2803
+ this.relayStatus = handle.getRelayStatus();
2804
+ this.status = "ready";
2805
+ this.#settleReady();
1975
2806
  } catch (e) {
1976
- this.error = e instanceof Error ? e : new Error(String(e));
1977
- throw this.error;
2807
+ await Effect25.runPromise(Scope6.close(scope, Exit2.fail(e))).catch(() => {});
2808
+ const err = e instanceof Error ? e : new Error(String(e));
2809
+ this.error = err;
2810
+ this.status = "error";
2811
+ this.#members._fail(err);
2812
+ for (const col of this.#collections.values()) {
2813
+ col._fail(err);
2814
+ }
2815
+ this.#settleReady(err);
1978
2816
  }
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
2817
  }
2000
- const fieldNames = Object.keys(fields);
2001
- if (fieldNames.length === 0) {
2002
- throw new Error(`Collection "${name}" must have at least one field`);
2818
+ get publicKey() {
2819
+ return this.#handle?.publicKey ?? "";
2003
2820
  }
2004
- for (const fieldName of fieldNames) {
2005
- if (RESERVED_NAMES.has(fieldName)) {
2006
- throw new Error(`Field name "${fieldName}" is reserved`);
2007
- }
2821
+ get members() {
2822
+ return this.#members;
2008
2823
  }
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 ? "[]" : ""})`);
2824
+ collection(name) {
2825
+ if (this.#closed) {
2826
+ throw new ClosedError({ message: "Tablinum is closed" });
2827
+ }
2828
+ let col = this.#collections.get(name);
2829
+ if (!col) {
2830
+ col = new Collection;
2831
+ this.#collections.set(name, col);
2832
+ if (this.#handle) {
2833
+ col._bind(this.#handle.collection(name));
2018
2834
  }
2019
- indices.push(idx);
2020
2835
  }
2836
+ return col;
2021
2837
  }
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;
2838
+ #requireReady() {
2839
+ if (!this.#handle) {
2840
+ throw this.error ?? new ClosedError({ message: "Tablinum is not ready" });
2841
+ }
2842
+ return this.#handle;
2034
2843
  }
2844
+ exportKey() {
2845
+ return this.#requireReady().exportKey();
2846
+ }
2847
+ exportInvite() {
2848
+ return this.#requireReady().exportInvite();
2849
+ }
2850
+ close = async () => {
2851
+ if (this.#closed)
2852
+ return;
2853
+ this.#closed = true;
2854
+ const closeError = new ClosedError({
2855
+ message: this.#readyState.settled() ? "Tablinum is closed" : "Tablinum was closed before initialization completed"
2856
+ });
2857
+ if (this.#unsubscribeSyncStatus) {
2858
+ this.#unsubscribeSyncStatus();
2859
+ this.#unsubscribeSyncStatus = null;
2860
+ }
2861
+ if (this.#unsubscribePendingCount) {
2862
+ this.#unsubscribePendingCount();
2863
+ this.#unsubscribePendingCount = null;
2864
+ }
2865
+ if (this.#unsubscribeRelayStatus) {
2866
+ this.#unsubscribeRelayStatus();
2867
+ this.#unsubscribeRelayStatus = null;
2868
+ }
2869
+ this.#members._destroy(closeError);
2870
+ for (const col of this.#collections.values())
2871
+ col._destroy(closeError);
2872
+ this.#collections.clear();
2873
+ const handle = this.#handle;
2874
+ const scope = this.#scope;
2875
+ this.#handle = null;
2876
+ this.#scope = null;
2877
+ if (handle && scope) {
2878
+ await Effect25.runPromise(handle.close());
2879
+ await Effect25.runPromise(Scope6.close(scope, Exit2.void));
2880
+ }
2881
+ if (!this.#readyState.settled()) {
2882
+ this.error = closeError;
2883
+ this.#settleReady(closeError);
2884
+ }
2885
+ this.status = "closed";
2886
+ };
2887
+ sync = async () => this.#runHandleEffect((handle) => handle.sync());
2888
+ rebuild = async () => this.#runHandleEffect((handle) => handle.rebuild());
2889
+ addMember = async (pubkey) => this.#runHandleEffect((handle) => handle.addMember(pubkey));
2890
+ removeMember = async (pubkey) => this.#runHandleEffect((handle) => handle.removeMember(pubkey));
2891
+ getMembers = async () => this.#runHandleEffect((handle) => handle.getMembers());
2892
+ setProfile = async (profile) => this.#runHandleEffect((handle) => handle.setProfile(profile));
2035
2893
  }
2036
2894
  export {
2037
2895
  field,
2038
- createTablinum2 as createTablinum,
2896
+ encodeInvite,
2897
+ decodeInvite,
2039
2898
  collection,
2040
2899
  ValidationError,
2900
+ Tablinum2 as Tablinum,
2041
2901
  SyncError,
2042
2902
  StorageError,
2043
2903
  RelayError,
2044
2904
  NotFoundError,
2045
- LiveQuery,
2046
- Database,
2047
2905
  CryptoError,
2048
2906
  Collection,
2049
2907
  ClosedError