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
package/dist/index.js CHANGED
@@ -49,10 +49,10 @@ function collection(name, fields, options) {
49
49
  return { _tag: "CollectionDef", name, fields, indices };
50
50
  }
51
51
  // src/db/create-tablinum.ts
52
- import { Effect as Effect14, PubSub as PubSub2, Ref as Ref5 } from "effect";
52
+ import { Effect as Effect22, Layer as Layer9, ServiceMap as ServiceMap10 } from "effect";
53
53
 
54
- // src/schema/validate.ts
55
- import { Effect, Schema } from "effect";
54
+ // src/db/runtime-config.ts
55
+ import { Effect, Schema as Schema2 } from "effect";
56
56
 
57
57
  // src/errors.ts
58
58
  import { Data } from "effect";
@@ -78,210 +78,177 @@ class NotFoundError extends Data.TaggedError("NotFoundError") {
78
78
  class ClosedError extends Data.TaggedError("ClosedError") {
79
79
  }
80
80
 
81
- // src/schema/validate.ts
82
- function fieldDefToSchema(fd) {
83
- let base;
84
- switch (fd.kind) {
85
- case "string":
86
- base = Schema.String;
87
- break;
88
- case "number":
89
- base = Schema.Number;
90
- break;
91
- case "boolean":
92
- base = Schema.Boolean;
93
- break;
94
- case "json":
95
- base = Schema.Unknown;
96
- break;
81
+ // src/db/epoch.ts
82
+ import { Option, Schema } from "effect";
83
+ import { getPublicKey } from "nostr-tools/pure";
84
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
85
+
86
+ // src/brands.ts
87
+ import { Brand } from "effect";
88
+ var EpochId = Brand.nominal();
89
+ var DatabaseName = Brand.nominal();
90
+
91
+ // src/db/epoch.ts
92
+ var HexKeySchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/i));
93
+ var EpochKeyInputSchema = Schema.Struct({
94
+ epochId: Schema.String,
95
+ key: HexKeySchema
96
+ });
97
+ var PersistedEpochSchema = Schema.Struct({
98
+ id: Schema.String,
99
+ privateKey: HexKeySchema,
100
+ createdBy: Schema.String,
101
+ parentEpoch: Schema.optionalKey(Schema.String)
102
+ });
103
+ var PersistedEpochStoreSchema = Schema.Struct({
104
+ epochs: Schema.Array(PersistedEpochSchema),
105
+ currentEpochId: Schema.String
106
+ });
107
+ var decodePersistedEpochStore = Schema.decodeUnknownSync(Schema.fromJsonString(PersistedEpochStoreSchema));
108
+ function createEpochKey(id, privateKeyHex, createdBy, parentEpoch) {
109
+ const publicKey = getPublicKey(hexToBytes(privateKeyHex));
110
+ const base = { id, privateKey: privateKeyHex, publicKey, createdBy };
111
+ return parentEpoch !== undefined ? { ...base, parentEpoch } : base;
112
+ }
113
+ function createEpochStore(initialEpoch) {
114
+ const epochs = new Map;
115
+ const keysByPublicKey = new Map;
116
+ epochs.set(initialEpoch.id, initialEpoch);
117
+ keysByPublicKey.set(initialEpoch.publicKey, hexToBytes(initialEpoch.privateKey));
118
+ return { epochs, keysByPublicKey, currentEpochId: initialEpoch.id };
119
+ }
120
+ function addEpoch(store, epoch) {
121
+ store.epochs.set(epoch.id, epoch);
122
+ store.keysByPublicKey.set(epoch.publicKey, hexToBytes(epoch.privateKey));
123
+ }
124
+ function hydrateEpochStore(snapshot) {
125
+ const [firstEpoch, ...remainingEpochs] = snapshot.epochs.map((epoch) => createEpochKey(EpochId(epoch.id), epoch.privateKey, epoch.createdBy, epoch.parentEpoch !== undefined ? EpochId(epoch.parentEpoch) : undefined));
126
+ if (!firstEpoch) {
127
+ throw new Error("Epoch snapshot must contain at least one epoch");
97
128
  }
98
- if (fd.isArray) {
99
- base = Schema.Array(base);
129
+ const store = createEpochStore(firstEpoch);
130
+ for (const epoch of remainingEpochs) {
131
+ addEpoch(store, epoch);
100
132
  }
101
- if (fd.isOptional) {
102
- base = Schema.UndefinedOr(base);
133
+ store.currentEpochId = EpochId(snapshot.currentEpochId);
134
+ return store;
135
+ }
136
+ function createEpochStoreFromInputs(epochKeys, options = {}) {
137
+ if (epochKeys.length === 0) {
138
+ throw new Error("Epoch input must contain at least one key");
103
139
  }
104
- return base;
140
+ const createdBy = options.createdBy ?? "";
141
+ const epochs = epochKeys.map((epochKey, index) => createEpochKey(epochKey.epochId, epochKey.key, createdBy, index > 0 ? epochKeys[index - 1].epochId : undefined));
142
+ const store = createEpochStore(epochs[0]);
143
+ for (let i = 1;i < epochs.length; i++) {
144
+ addEpoch(store, epochs[i]);
145
+ }
146
+ store.currentEpochId = epochs[epochs.length - 1].id;
147
+ return store;
105
148
  }
106
- function buildValidator(collectionName, def) {
107
- const schemaFields = {
108
- id: Schema.String
149
+ function getCurrentEpoch(store) {
150
+ return store.epochs.get(store.currentEpochId);
151
+ }
152
+ function getCurrentPublicKey(store) {
153
+ return getCurrentEpoch(store).publicKey;
154
+ }
155
+ function getAllPublicKeys(store) {
156
+ return Array.from(store.keysByPublicKey.keys());
157
+ }
158
+ function getDecryptionKey(store, publicKey) {
159
+ return store.keysByPublicKey.get(publicKey);
160
+ }
161
+ function exportEpochKeys(store) {
162
+ 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 }));
163
+ }
164
+ function serializeEpochStore(store) {
165
+ return {
166
+ epochs: Array.from(store.epochs.values()).map((epoch) => ({
167
+ id: epoch.id,
168
+ privateKey: epoch.privateKey,
169
+ createdBy: epoch.createdBy,
170
+ ...epoch.parentEpoch !== undefined ? { parentEpoch: epoch.parentEpoch } : {}
171
+ })),
172
+ currentEpochId: store.currentEpochId
109
173
  };
110
- for (const [name, fieldDef] of Object.entries(def.fields)) {
111
- schemaFields[name] = fieldDefToSchema(fieldDef);
112
- }
113
- const recordSchema = Schema.Struct(schemaFields);
114
- const decode = Schema.decodeUnknownSync(recordSchema);
115
- return (input) => Effect.gen(function* () {
116
- try {
117
- const result = decode(input);
118
- return result;
119
- } catch (e) {
120
- return yield* new ValidationError({
121
- message: `Validation failed for collection "${collectionName}": ${e instanceof Error ? e.message : String(e)}`
122
- });
123
- }
124
- });
125
174
  }
126
- function buildPartialValidator(collectionName, def) {
127
- return (input) => Effect.gen(function* () {
128
- if (typeof input !== "object" || input === null) {
129
- return yield* new ValidationError({
130
- message: `Validation failed for collection "${collectionName}": expected an object`
131
- });
132
- }
133
- const record = input;
134
- for (const [key, value] of Object.entries(record)) {
135
- const fieldDef = def.fields[key];
136
- if (!fieldDef) {
137
- return yield* new ValidationError({
138
- message: `Unknown field "${key}" in collection "${collectionName}"`,
139
- field: key
140
- });
141
- }
142
- if (value === undefined && fieldDef.isOptional) {
143
- continue;
144
- }
145
- const fieldSchema = fieldDefToSchema(fieldDef);
146
- const decode = Schema.decodeUnknownSync(fieldSchema);
147
- try {
148
- decode(value);
149
- } catch (e) {
150
- return yield* new ValidationError({
151
- message: `Validation failed for field "${key}" in collection "${collectionName}": ${e instanceof Error ? e.message : String(e)}`,
152
- field: key
153
- });
154
- }
155
- }
156
- return record;
157
- });
175
+ function stringifyEpochStore(store) {
176
+ return JSON.stringify(serializeEpochStore(store));
177
+ }
178
+ function deserializeEpochStore(raw) {
179
+ try {
180
+ return Option.some(hydrateEpochStore(decodePersistedEpochStore(raw)));
181
+ } catch {
182
+ return Option.none();
183
+ }
158
184
  }
159
185
 
160
- // src/storage/idb.ts
161
- import { Effect as Effect2 } from "effect";
162
- import { openDB } from "idb";
163
- var DB_NAME = "tablinum";
164
- function storeName(collection2) {
165
- return `col_${collection2}`;
186
+ // src/db/runtime-config.ts
187
+ var PrivateKeySchema = Schema2.Uint8Array.check(Schema2.isMinLength(32), Schema2.isMaxLength(32));
188
+ var RuntimeConfigSchema = Schema2.Struct({
189
+ relays: Schema2.NonEmptyArray(Schema2.String),
190
+ dbName: Schema2.optional(Schema2.String),
191
+ privateKey: Schema2.optional(PrivateKeySchema),
192
+ epochKeys: Schema2.optional(Schema2.Array(EpochKeyInputSchema))
193
+ });
194
+ function resolveRuntimeConfig(source) {
195
+ return Schema2.decodeUnknownEffect(RuntimeConfigSchema)(source).pipe(Effect.map((config) => ({
196
+ relays: [...config.relays],
197
+ privateKey: config.privateKey,
198
+ epochKeys: config.epochKeys?.map((ek) => ({ epochId: EpochId(ek.epochId), key: ek.key })),
199
+ dbName: DatabaseName(config.dbName ?? "tablinum")
200
+ })), Effect.mapError((error) => new ValidationError({
201
+ message: `Invalid Tablinum configuration: ${error.message}`
202
+ })));
166
203
  }
167
- function schemaVersion(schema) {
168
- const sig = Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)).map(([name, def]) => {
169
- const indices = [...def.indices ?? []].sort().join(",");
170
- return `${name}:${indices}`;
171
- }).join("|");
172
- let hash = 1;
173
- for (let i = 0;i < sig.length; i++) {
174
- hash = hash * 31 + sig.charCodeAt(i) | 0;
175
- }
176
- return Math.abs(hash) + 1;
204
+
205
+ // src/services/Config.ts
206
+ import { ServiceMap } from "effect";
207
+
208
+ class Config extends ServiceMap.Service()("tablinum/Config") {
177
209
  }
178
- function wrap(label, fn) {
179
- return Effect2.tryPromise({
180
- try: fn,
181
- catch: (e) => new StorageError({
182
- message: `IndexedDB ${label} failed: ${e instanceof Error ? e.message : String(e)}`,
183
- cause: e
184
- })
210
+
211
+ // src/services/Tablinum.ts
212
+ import { ServiceMap as ServiceMap2 } from "effect";
213
+
214
+ class Tablinum extends ServiceMap2.Service()("tablinum/Tablinum") {
215
+ }
216
+
217
+ // src/layers/TablinumLive.ts
218
+ import { Effect as Effect21, Exit, Layer as Layer8, Option as Option9, PubSub as PubSub2, Ref as Ref5, Scope as Scope4 } from "effect";
219
+
220
+ // src/crud/watch.ts
221
+ import { Effect as Effect2, PubSub, Ref, Stream } from "effect";
222
+ function watchCollection(ctx, storage, collectionName, filter, mapRecord) {
223
+ const query = () => Effect2.map(storage.getAllRecords(collectionName), (all) => {
224
+ const filtered = all.filter((r) => !r._deleted && (filter ? filter(r) : true));
225
+ return mapRecord ? filtered.map(mapRecord) : filtered;
185
226
  });
227
+ const changes = Stream.fromPubSub(ctx.pubsub).pipe(Stream.filter((event) => event.collection === collectionName), Stream.mapEffect(() => Effect2.gen(function* () {
228
+ const replaying = yield* Ref.get(ctx.replayingRef);
229
+ if (replaying)
230
+ return;
231
+ return yield* query();
232
+ })), Stream.filter((result) => result !== undefined));
233
+ return Stream.unwrap(Effect2.gen(function* () {
234
+ yield* Effect2.sleep(0);
235
+ const initial = yield* query();
236
+ return Stream.concat(Stream.make(initial), changes);
237
+ }));
186
238
  }
187
- function openIDBStorage(dbName, schema) {
239
+ function notifyChange(ctx, event) {
240
+ return PubSub.publish(ctx.pubsub, event).pipe(Effect2.asVoid);
241
+ }
242
+ function notifyReplayComplete(ctx, collections) {
188
243
  return Effect2.gen(function* () {
189
- const name = dbName ?? DB_NAME;
190
- const version = schemaVersion(schema);
191
- const db = yield* Effect2.tryPromise({
192
- try: () => openDB(name, version, {
193
- upgrade(database) {
194
- if (!database.objectStoreNames.contains("events")) {
195
- const events = database.createObjectStore("events", {
196
- keyPath: "id"
197
- });
198
- events.createIndex("by-record", ["collection", "recordId"]);
199
- }
200
- if (!database.objectStoreNames.contains("giftwraps")) {
201
- database.createObjectStore("giftwraps", {
202
- keyPath: "id"
203
- });
204
- }
205
- if (database.objectStoreNames.contains("records")) {
206
- database.deleteObjectStore("records");
207
- }
208
- const expectedStores = new Set;
209
- for (const [, def] of Object.entries(schema)) {
210
- const sn = storeName(def.name);
211
- expectedStores.add(sn);
212
- if (!database.objectStoreNames.contains(sn)) {
213
- const store = database.createObjectStore(sn, { keyPath: "id" });
214
- for (const idx of def.indices ?? []) {
215
- store.createIndex(idx, idx);
216
- }
217
- } else {
218
- const tx = database.transaction;
219
- const store = tx.objectStore(sn);
220
- const existingIndices = new Set(Array.from(store.indexNames));
221
- const wantedIndices = new Set(def.indices ?? []);
222
- for (const idx of existingIndices) {
223
- if (!wantedIndices.has(idx)) {
224
- store.deleteIndex(idx);
225
- }
226
- }
227
- for (const idx of wantedIndices) {
228
- if (!existingIndices.has(idx)) {
229
- store.createIndex(idx, idx);
230
- }
231
- }
232
- }
233
- }
234
- const allStores = Array.from(database.objectStoreNames);
235
- for (const existing of allStores) {
236
- if (existing.startsWith("col_") && !expectedStores.has(existing)) {
237
- database.deleteObjectStore(existing);
238
- }
239
- }
240
- }
241
- }),
242
- catch: (e) => new StorageError({
243
- message: "Failed to open IndexedDB",
244
- cause: e
245
- })
246
- });
247
- yield* Effect2.addFinalizer(() => Effect2.sync(() => db.close()));
248
- const handle = {
249
- putRecord: (collection2, record) => wrap("putRecord", () => db.put(storeName(collection2), record).then(() => {
250
- return;
251
- })),
252
- getRecord: (collection2, id) => wrap("getRecord", () => db.get(storeName(collection2), id)),
253
- getAllRecords: (collection2) => wrap("getAllRecords", () => db.getAll(storeName(collection2))),
254
- countRecords: (collection2) => wrap("countRecords", () => db.count(storeName(collection2))),
255
- clearRecords: (collection2) => wrap("clearRecords", () => db.clear(storeName(collection2))),
256
- getByIndex: (collection2, indexName, value) => wrap("getByIndex", () => db.getAllFromIndex(storeName(collection2), indexName, value)),
257
- getByIndexRange: (collection2, indexName, range) => wrap("getByIndexRange", () => db.getAllFromIndex(storeName(collection2), indexName, range)),
258
- getAllSorted: (collection2, indexName, direction) => wrap("getAllSorted", async () => {
259
- const sn = storeName(collection2);
260
- const tx = db.transaction(sn, "readonly");
261
- const store = tx.objectStore(sn);
262
- const index = store.index(indexName);
263
- const results = [];
264
- let cursor = await index.openCursor(null, direction ?? "next");
265
- while (cursor) {
266
- results.push(cursor.value);
267
- cursor = await cursor.continue();
268
- }
269
- return results;
270
- }),
271
- putEvent: (event) => wrap("putEvent", () => db.put("events", event).then(() => {
272
- return;
273
- })),
274
- getEvent: (id) => wrap("getEvent", () => db.get("events", id)),
275
- getAllEvents: () => wrap("getAllEvents", () => db.getAll("events")),
276
- getEventsByRecord: (collection2, recordId) => wrap("getEventsByRecord", () => db.getAllFromIndex("events", "by-record", [collection2, recordId])),
277
- putGiftWrap: (gw) => wrap("putGiftWrap", () => db.put("giftwraps", gw).then(() => {
278
- return;
279
- })),
280
- getGiftWrap: (id) => wrap("getGiftWrap", () => db.get("giftwraps", id)),
281
- getAllGiftWraps: () => wrap("getAllGiftWraps", () => db.getAll("giftwraps")),
282
- close: () => Effect2.sync(() => db.close())
283
- };
284
- return handle;
244
+ yield* Ref.set(ctx.replayingRef, false);
245
+ for (const collection2 of collections) {
246
+ yield* notifyChange(ctx, {
247
+ collection: collection2,
248
+ recordId: "",
249
+ kind: "update"
250
+ });
251
+ }
285
252
  });
286
253
  }
287
254
 
@@ -305,24 +272,26 @@ function buildRecord(event) {
305
272
  id: event.recordId,
306
273
  _deleted: event.kind === "delete",
307
274
  _updatedAt: event.createdAt,
275
+ _eventId: event.id,
276
+ _author: event.author,
308
277
  ...event.data ?? {}
309
278
  };
310
279
  }
311
280
  function applyEvent(storage, event) {
312
281
  return Effect3.gen(function* () {
313
- const existingEvents = yield* storage.getEventsByRecord(event.collection, event.recordId);
314
- let currentWinner = null;
315
- for (const e of existingEvents) {
316
- if (e.id === event.id)
317
- continue;
318
- currentWinner = resolveWinner(currentWinner, e);
319
- }
320
- const winner = resolveWinner(currentWinner, event);
321
- const incomingWon = winner.id === event.id;
322
- if (incomingWon) {
323
- yield* storage.putRecord(event.collection, buildRecord(event));
282
+ const existing = yield* storage.getRecord(event.collection, event.recordId);
283
+ if (existing) {
284
+ const existingMeta = {
285
+ id: existing._eventId,
286
+ createdAt: existing._updatedAt
287
+ };
288
+ const incomingMeta = { id: event.id, createdAt: event.createdAt };
289
+ const winner = resolveWinner(existingMeta, incomingMeta);
290
+ if (winner.id !== event.id)
291
+ return false;
324
292
  }
325
- return incomingWon;
293
+ yield* storage.putRecord(event.collection, buildRecord(event));
294
+ return true;
326
295
  });
327
296
  }
328
297
  function rebuild(storage, collections) {
@@ -344,8 +313,73 @@ function rebuild(storage, collections) {
344
313
  });
345
314
  }
346
315
 
316
+ // src/schema/validate.ts
317
+ import { Effect as Effect4, Schema as Schema3 } from "effect";
318
+ function fieldDefToSchema(fd) {
319
+ let base;
320
+ switch (fd.kind) {
321
+ case "string":
322
+ base = Schema3.String;
323
+ break;
324
+ case "number":
325
+ base = Schema3.Number;
326
+ break;
327
+ case "boolean":
328
+ base = Schema3.Boolean;
329
+ break;
330
+ case "json":
331
+ base = Schema3.Unknown;
332
+ break;
333
+ }
334
+ if (fd.isArray) {
335
+ base = Schema3.Array(base);
336
+ }
337
+ if (fd.isOptional) {
338
+ base = Schema3.UndefinedOr(base);
339
+ }
340
+ return base;
341
+ }
342
+ function buildStructSchema(def, options = {}) {
343
+ const schemaFields = {};
344
+ if (options.includeId) {
345
+ schemaFields.id = Schema3.String;
346
+ }
347
+ for (const [name, fieldDef] of Object.entries(def.fields)) {
348
+ const fieldSchema = fieldDefToSchema(fieldDef);
349
+ schemaFields[name] = options.allOptional ? Schema3.optionalKey(fieldSchema) : fieldSchema;
350
+ }
351
+ return Schema3.Struct(schemaFields);
352
+ }
353
+ function buildValidator(collectionName, def) {
354
+ const decode = Schema3.decodeUnknownEffect(buildStructSchema(def, { includeId: true }));
355
+ return (input) => decode(input).pipe(Effect4.map((result) => result), Effect4.mapError((e) => new ValidationError({
356
+ message: `Validation failed for collection "${collectionName}": ${e.message}`
357
+ })));
358
+ }
359
+ function buildPartialValidator(collectionName, def) {
360
+ const decode = Schema3.decodeUnknownEffect(buildStructSchema(def, { allOptional: true }));
361
+ return (input) => Effect4.gen(function* () {
362
+ if (typeof input !== "object" || input === null) {
363
+ return yield* new ValidationError({
364
+ message: `Validation failed for collection "${collectionName}": expected an object`
365
+ });
366
+ }
367
+ const record = input;
368
+ const unknownField = Object.keys(record).find((key) => !Object.hasOwn(def.fields, key));
369
+ if (unknownField !== undefined) {
370
+ return yield* new ValidationError({
371
+ message: `Unknown field "${unknownField}" in collection "${collectionName}"`,
372
+ field: unknownField
373
+ });
374
+ }
375
+ return yield* decode(record).pipe(Effect4.map((result) => result), Effect4.mapError((e) => new ValidationError({
376
+ message: `Validation failed for collection "${collectionName}": ${e.message}`
377
+ })));
378
+ });
379
+ }
380
+
347
381
  // src/crud/collection-handle.ts
348
- import { Effect as Effect6 } from "effect";
382
+ import { Effect as Effect6, Option as Option3 } from "effect";
349
383
 
350
384
  // src/utils/uuid.ts
351
385
  function uuidv7() {
@@ -364,43 +398,8 @@ function uuidv7() {
364
398
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
365
399
  }
366
400
 
367
- // src/crud/watch.ts
368
- import { Effect as Effect4, PubSub, Ref, Stream } from "effect";
369
- function watchCollection(ctx, storage, collectionName, filter, mapRecord) {
370
- const query = () => Effect4.gen(function* () {
371
- const all = yield* storage.getAllRecords(collectionName);
372
- const filtered = all.filter((r) => !r._deleted && (filter ? filter(r) : true));
373
- return mapRecord ? filtered.map(mapRecord) : filtered;
374
- });
375
- const changes = Stream.fromPubSub(ctx.pubsub).pipe(Stream.filter((event) => event.collection === collectionName), Stream.mapEffect(() => Effect4.gen(function* () {
376
- const replaying = yield* Ref.get(ctx.replayingRef);
377
- if (replaying)
378
- return;
379
- return yield* query();
380
- })), Stream.filter((result) => result !== undefined));
381
- return Stream.unwrap(Effect4.gen(function* () {
382
- const initial = yield* query();
383
- return Stream.concat(Stream.make(initial), changes);
384
- }));
385
- }
386
- function notifyChange(ctx, event) {
387
- return PubSub.publish(ctx.pubsub, event).pipe(Effect4.asVoid);
388
- }
389
- function notifyReplayComplete(ctx, collections) {
390
- return Effect4.gen(function* () {
391
- yield* Ref.set(ctx.replayingRef, false);
392
- for (const collection2 of collections) {
393
- yield* notifyChange(ctx, {
394
- collection: collection2,
395
- recordId: "",
396
- kind: "update"
397
- });
398
- }
399
- });
400
- }
401
-
402
401
  // src/crud/query-builder.ts
403
- import { Effect as Effect5, Ref as Ref2, Stream as Stream2 } from "effect";
402
+ import { Effect as Effect5, Option as Option2, Ref as Ref2, Stream as Stream2 } from "effect";
404
403
  function emptyPlan() {
405
404
  return { filters: [] };
406
405
  }
@@ -497,39 +496,8 @@ function makeQueryBuilder(ctx, plan) {
497
496
  offset: (n) => makeQueryBuilder(ctx, { ...plan, offset: n }),
498
497
  limit: (n) => makeQueryBuilder(ctx, { ...plan, limit: n }),
499
498
  get: () => executeQuery(ctx, plan),
500
- first: () => Effect5.gen(function* () {
501
- const limitedPlan = { ...plan, limit: 1 };
502
- const results = yield* executeQuery(ctx, limitedPlan);
503
- return results[0] ?? null;
504
- }),
505
- count: () => Effect5.gen(function* () {
506
- const results = yield* executeQuery(ctx, plan);
507
- return results.length;
508
- }),
509
- watch: () => watchQuery(ctx, plan)
510
- };
511
- }
512
- function makeOrderByBuilder(ctx, plan) {
513
- return {
514
- reverse: () => makeOrderByBuilder(ctx, {
515
- ...plan,
516
- orderBy: {
517
- field: plan.orderBy.field,
518
- direction: plan.orderBy.direction === "desc" ? "asc" : "desc"
519
- }
520
- }),
521
- offset: (n) => makeOrderByBuilder(ctx, { ...plan, offset: n }),
522
- limit: (n) => makeOrderByBuilder(ctx, { ...plan, limit: n }),
523
- get: () => executeQuery(ctx, plan),
524
- first: () => Effect5.gen(function* () {
525
- const limitedPlan = { ...plan, limit: 1 };
526
- const results = yield* executeQuery(ctx, limitedPlan);
527
- return results[0] ?? null;
528
- }),
529
- count: () => Effect5.gen(function* () {
530
- const results = yield* executeQuery(ctx, plan);
531
- return results.length;
532
- }),
499
+ first: () => Effect5.map(executeQuery(ctx, { ...plan, limit: 1 }), (results) => results.length > 0 ? Option2.some(results[0]) : Option2.none()),
500
+ count: () => Effect5.map(executeQuery(ctx, plan), (results) => results.length),
533
501
  watch: () => watchQuery(ctx, plan)
534
502
  };
535
503
  }
@@ -587,16 +555,27 @@ function createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName,
587
555
  ...emptyPlan(),
588
556
  orderBy: { field: fieldName, direction: "asc" }
589
557
  };
590
- return makeOrderByBuilder(ctx, plan);
558
+ return makeQueryBuilder(ctx, plan);
591
559
  }
592
560
 
593
561
  // src/crud/collection-handle.ts
594
562
  function mapRecord(record) {
595
- const { _deleted, _updatedAt, ...fields } = record;
563
+ const { _deleted, _updatedAt, _author, ...fields } = record;
596
564
  return fields;
597
565
  }
598
566
  function createCollectionHandle(def, storage, watchCtx, validator, partialValidator, makeEventId, onWrite) {
599
567
  const collectionName = def.name;
568
+ const commitEvent = (event) => Effect6.gen(function* () {
569
+ yield* storage.putEvent(event);
570
+ yield* applyEvent(storage, event);
571
+ if (onWrite)
572
+ yield* onWrite(event);
573
+ yield* notifyChange(watchCtx, {
574
+ collection: collectionName,
575
+ recordId: event.recordId,
576
+ kind: event.kind
577
+ });
578
+ });
600
579
  const handle = {
601
580
  add: (data) => Effect6.gen(function* () {
602
581
  const id = uuidv7();
@@ -610,15 +589,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
610
589
  data: fullRecord,
611
590
  createdAt: Date.now()
612
591
  };
613
- yield* storage.putEvent(event);
614
- yield* applyEvent(storage, event);
615
- if (onWrite)
616
- yield* onWrite(event);
617
- yield* notifyChange(watchCtx, {
618
- collection: collectionName,
619
- recordId: id,
620
- kind: "create"
621
- });
592
+ yield* commitEvent(event);
622
593
  return id;
623
594
  }),
624
595
  update: (id, data) => Effect6.gen(function* () {
@@ -630,7 +601,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
630
601
  });
631
602
  }
632
603
  yield* partialValidator(data);
633
- const { _deleted, _updatedAt, ...existingFields } = existing;
604
+ const { _deleted, _updatedAt, _author, ...existingFields } = existing;
634
605
  const merged = { ...existingFields, ...data, id };
635
606
  yield* validator(merged);
636
607
  const event = {
@@ -641,15 +612,7 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
641
612
  data: merged,
642
613
  createdAt: Date.now()
643
614
  };
644
- yield* storage.putEvent(event);
645
- yield* applyEvent(storage, event);
646
- if (onWrite)
647
- yield* onWrite(event);
648
- yield* notifyChange(watchCtx, {
649
- collection: collectionName,
650
- recordId: id,
651
- kind: "update"
652
- });
615
+ yield* commitEvent(event);
653
616
  }),
654
617
  delete: (id) => Effect6.gen(function* () {
655
618
  const existing = yield* storage.getRecord(collectionName, id);
@@ -667,15 +630,9 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
667
630
  data: null,
668
631
  createdAt: Date.now()
669
632
  };
670
- yield* storage.putEvent(event);
671
- yield* applyEvent(storage, event);
672
- if (onWrite)
673
- yield* onWrite(event);
674
- yield* notifyChange(watchCtx, {
675
- collection: collectionName,
676
- recordId: id,
677
- kind: "delete"
678
- });
633
+ yield* commitEvent(event);
634
+ const oldEvents = yield* storage.getEventsByRecord(collectionName, id);
635
+ yield* Effect6.forEach(oldEvents.filter((e) => e.id !== event.id), (e) => storage.deleteEvent(e.id), { discard: true });
679
636
  }),
680
637
  get: (id) => Effect6.gen(function* () {
681
638
  const record = yield* storage.getRecord(collectionName, id);
@@ -687,15 +644,11 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
687
644
  }
688
645
  return mapRecord(record);
689
646
  }),
690
- first: () => Effect6.gen(function* () {
691
- const all = yield* storage.getAllRecords(collectionName);
647
+ first: () => Effect6.map(storage.getAllRecords(collectionName), (all) => {
692
648
  const found = all.find((r) => !r._deleted);
693
- return found ? mapRecord(found) : null;
694
- }),
695
- count: () => Effect6.gen(function* () {
696
- const all = yield* storage.getAllRecords(collectionName);
697
- return all.filter((r) => !r._deleted).length;
649
+ return found ? Option3.some(mapRecord(found)) : Option3.none();
698
650
  }),
651
+ count: () => Effect6.map(storage.getAllRecords(collectionName), (all) => all.filter((r) => !r._deleted).length),
699
652
  watch: () => watchCollection(watchCtx, storage, collectionName, undefined, mapRecord),
700
653
  where: (fieldName) => createWhereClause(storage, watchCtx, collectionName, def, fieldName, mapRecord),
701
654
  orderBy: (fieldName) => createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName, mapRecord)
@@ -703,306 +656,13 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
703
656
  return handle;
704
657
  }
705
658
 
706
- // src/db/identity.ts
707
- import { Effect as Effect7 } from "effect";
708
- import { getPublicKey } from "nostr-tools/pure";
709
- function bytesToHex(bytes) {
710
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
711
- }
712
- function createIdentity(suppliedKey) {
713
- return Effect7.gen(function* () {
714
- let privateKey;
715
- if (suppliedKey) {
716
- if (suppliedKey.length !== 32) {
717
- return yield* new CryptoError({
718
- message: `Private key must be 32 bytes, got ${suppliedKey.length}`
719
- });
720
- }
721
- privateKey = suppliedKey;
722
- } else {
723
- privateKey = new Uint8Array(32);
724
- crypto.getRandomValues(privateKey);
725
- }
726
- const privateKeyHex = bytesToHex(privateKey);
727
- let publicKey;
728
- try {
729
- publicKey = getPublicKey(privateKey);
730
- } catch (e) {
731
- return yield* new CryptoError({
732
- message: `Failed to derive public key: ${e instanceof Error ? e.message : String(e)}`,
733
- cause: e
734
- });
735
- }
736
- return {
737
- privateKey,
738
- publicKey,
739
- exportKey: () => privateKeyHex
740
- };
741
- });
742
- }
743
-
744
- // src/sync/gift-wrap.ts
745
- import { Effect as Effect8 } from "effect";
746
- import { wrapEvent, unwrapEvent } from "nostr-tools/nip59";
747
- function createGiftWrapHandle(privateKey, publicKey) {
748
- return {
749
- wrap: (rumor) => Effect8.try({
750
- try: () => wrapEvent(rumor, privateKey, publicKey),
751
- catch: (e) => new CryptoError({
752
- message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
753
- cause: e
754
- })
755
- }),
756
- unwrap: (giftWrap) => Effect8.try({
757
- try: () => unwrapEvent(giftWrap, privateKey),
758
- catch: (e) => new CryptoError({
759
- message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
760
- cause: e
761
- })
762
- })
763
- };
764
- }
765
-
766
- // src/sync/relay.ts
767
- import { Effect as Effect9 } from "effect";
768
- import { Relay } from "nostr-tools/relay";
769
- function createRelayHandle() {
770
- const connections = new Map;
771
- const getRelay = (url) => Effect9.tryPromise({
772
- try: async () => {
773
- const existing = connections.get(url);
774
- if (existing && existing.connected !== false) {
775
- return existing;
776
- }
777
- connections.delete(url);
778
- const relay = await Relay.connect(url);
779
- connections.set(url, relay);
780
- return relay;
781
- },
782
- catch: (e) => new RelayError({
783
- message: `Connect to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
784
- url,
785
- cause: e
786
- })
787
- });
788
- return {
789
- publish: (event, urls) => Effect9.gen(function* () {
790
- const errors = [];
791
- for (const url of urls) {
792
- const result = yield* Effect9.result(Effect9.gen(function* () {
793
- const relay = yield* getRelay(url);
794
- yield* Effect9.tryPromise({
795
- try: () => relay.publish(event),
796
- catch: (e) => {
797
- connections.delete(url);
798
- return new RelayError({
799
- message: `Publish to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
800
- url,
801
- cause: e
802
- });
803
- }
804
- });
805
- }));
806
- if (result._tag === "Failure") {
807
- errors.push({ url, error: result });
808
- }
809
- }
810
- if (errors.length === urls.length && urls.length > 0) {
811
- return yield* new RelayError({
812
- message: `Publish failed on all ${urls.length} relays`
813
- });
814
- }
815
- }),
816
- fetchEvents: (ids, url) => Effect9.gen(function* () {
817
- if (ids.length === 0)
818
- return [];
819
- const relay = yield* getRelay(url);
820
- return yield* Effect9.tryPromise({
821
- try: () => new Promise((resolve) => {
822
- const events = [];
823
- const timer = setTimeout(() => {
824
- sub.close();
825
- resolve(events);
826
- }, 1e4);
827
- const sub = relay.subscribe([{ ids }], {
828
- onevent(evt) {
829
- events.push(evt);
830
- },
831
- oneose() {
832
- clearTimeout(timer);
833
- sub.close();
834
- resolve(events);
835
- }
836
- });
837
- }),
838
- catch: (e) => {
839
- connections.delete(url);
840
- return new RelayError({
841
- message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
842
- url,
843
- cause: e
844
- });
845
- }
846
- });
847
- }),
848
- fetchByFilter: (filter, url) => Effect9.gen(function* () {
849
- const relay = yield* getRelay(url);
850
- return yield* Effect9.tryPromise({
851
- try: () => new Promise((resolve) => {
852
- const events = [];
853
- const timer = setTimeout(() => {
854
- sub.close();
855
- resolve(events);
856
- }, 1e4);
857
- const sub = relay.subscribe([filter], {
858
- onevent(evt) {
859
- events.push(evt);
860
- },
861
- oneose() {
862
- clearTimeout(timer);
863
- sub.close();
864
- resolve(events);
865
- }
866
- });
867
- }),
868
- catch: (e) => {
869
- connections.delete(url);
870
- return new RelayError({
871
- message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
872
- url,
873
- cause: e
874
- });
875
- }
876
- });
877
- }),
878
- subscribe: (filter, url, onEvent) => Effect9.gen(function* () {
879
- const relay = yield* getRelay(url);
880
- relay.subscribe([filter], {
881
- onevent(evt) {
882
- onEvent(evt);
883
- },
884
- oneose() {}
885
- });
886
- }),
887
- sendNegMsg: (url, subId, filter, msgHex) => Effect9.gen(function* () {
888
- const relay = yield* getRelay(url);
889
- return yield* Effect9.tryPromise({
890
- try: () => new Promise((resolve, reject) => {
891
- const timer = setTimeout(() => {
892
- reject(new Error("NIP-77 negotiation timeout"));
893
- }, 30000);
894
- const sub = relay.subscribe([filter], {
895
- onevent() {},
896
- oneose() {}
897
- });
898
- const ws = relay._ws || relay.ws;
899
- if (!ws) {
900
- clearTimeout(timer);
901
- sub.close();
902
- reject(new Error("Cannot access relay WebSocket"));
903
- return;
904
- }
905
- const handler = (msg) => {
906
- try {
907
- const data = JSON.parse(typeof msg.data === "string" ? msg.data : "");
908
- if (!Array.isArray(data))
909
- return;
910
- if (data[0] === "NEG-MSG" && data[1] === subId) {
911
- clearTimeout(timer);
912
- sub.close();
913
- resolve({
914
- msgHex: data[2],
915
- haveIds: [],
916
- needIds: []
917
- });
918
- ws.removeEventListener("message", handler);
919
- } else if (data[0] === "NEG-ERR" && data[1] === subId) {
920
- clearTimeout(timer);
921
- sub.close();
922
- reject(new Error(`NEG-ERR: ${data[2]}`));
923
- ws.removeEventListener("message", handler);
924
- }
925
- } catch {}
926
- };
927
- ws.addEventListener("message", handler);
928
- ws.send(JSON.stringify(["NEG-OPEN", subId, filter, msgHex]));
929
- }),
930
- catch: (e) => {
931
- connections.delete(url);
932
- return new RelayError({
933
- message: `NIP-77 negotiation with ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
934
- url,
935
- cause: e
936
- });
937
- }
938
- });
939
- }),
940
- closeAll: () => Effect9.sync(() => {
941
- for (const [url, relay] of connections) {
942
- relay.close();
943
- connections.delete(url);
944
- }
945
- })
946
- };
947
- }
948
-
949
- // src/sync/publish-queue.ts
950
- import { Effect as Effect10, Ref as Ref3 } from "effect";
951
- function createPublishQueue(storage, relay) {
952
- return Effect10.gen(function* () {
953
- const pendingRef = yield* Ref3.make(new Set);
954
- return {
955
- enqueue: (eventId) => Ref3.update(pendingRef, (set) => {
956
- const next = new Set(set);
957
- next.add(eventId);
958
- return next;
959
- }),
960
- flush: (relayUrls) => Effect10.gen(function* () {
961
- const pending = yield* Ref3.get(pendingRef);
962
- if (pending.size === 0)
963
- return;
964
- const succeeded = new Set;
965
- for (const eventId of pending) {
966
- const gw = yield* storage.getGiftWrap(eventId);
967
- if (!gw) {
968
- succeeded.add(eventId);
969
- continue;
970
- }
971
- const result = yield* Effect10.result(relay.publish(gw.event, relayUrls));
972
- if (result._tag === "Success") {
973
- succeeded.add(eventId);
974
- }
975
- }
976
- yield* Ref3.update(pendingRef, (set) => {
977
- const next = new Set(set);
978
- for (const id of succeeded) {
979
- next.delete(id);
980
- }
981
- return next;
982
- });
983
- }),
984
- size: () => Ref3.get(pendingRef).pipe(Effect10.map((s) => s.size))
985
- };
986
- });
987
- }
988
-
989
- // src/sync/sync-status.ts
990
- import { Effect as Effect11, SubscriptionRef } from "effect";
991
- function createSyncStatusHandle() {
992
- return Effect11.gen(function* () {
993
- const ref = yield* SubscriptionRef.make("idle");
994
- return {
995
- get: () => SubscriptionRef.get(ref),
996
- set: (status) => SubscriptionRef.set(ref, status)
997
- };
998
- });
999
- }
1000
-
1001
659
  // src/sync/sync-service.ts
1002
- import { Effect as Effect13, Ref as Ref4 } from "effect";
660
+ import { Effect as Effect8, Option as Option5, Ref as Ref3, Schedule } from "effect";
661
+ import { unwrapEvent } from "nostr-tools/nip59";
662
+ import { GiftWrap as GiftWrap2 } from "nostr-tools/kinds";
1003
663
 
1004
664
  // src/sync/negentropy.ts
1005
- import { Effect as Effect12 } from "effect";
665
+ import { Effect as Effect7 } from "effect";
1006
666
 
1007
667
  // src/vendor/negentropy.js
1008
668
  var PROTOCOL_VERSION = 97;
@@ -1522,30 +1182,25 @@ function itemCompare(a, b) {
1522
1182
  }
1523
1183
 
1524
1184
  // src/sync/negentropy.ts
1525
- function hexToBytes(hex) {
1526
- const bytes = new Uint8Array(hex.length / 2);
1527
- for (let i = 0;i < hex.length; i += 2) {
1528
- bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
1529
- }
1530
- return bytes;
1531
- }
1532
- function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
1533
- return Effect12.gen(function* () {
1185
+ import { hexToBytes as hexToBytes2 } from "@noble/hashes/utils.js";
1186
+ import { GiftWrap } from "nostr-tools/kinds";
1187
+ function reconcileWithRelay(storage, relay, relayUrl, publicKeys) {
1188
+ return Effect7.gen(function* () {
1534
1189
  const allGiftWraps = yield* storage.getAllGiftWraps();
1535
1190
  const storageVector = new NegentropyStorageVector;
1536
1191
  for (const gw of allGiftWraps) {
1537
- storageVector.insert(gw.createdAt, hexToBytes(gw.id));
1192
+ storageVector.insert(gw.createdAt, hexToBytes2(gw.id));
1538
1193
  }
1539
1194
  storageVector.seal();
1540
1195
  const neg = new Negentropy(storageVector, 0);
1541
1196
  const filter = {
1542
- kinds: [1059],
1543
- "#p": [publicKey]
1197
+ kinds: [GiftWrap],
1198
+ "#p": Array.isArray(publicKeys) ? publicKeys : [publicKeys]
1544
1199
  };
1545
1200
  const allHaveIds = [];
1546
1201
  const allNeedIds = [];
1547
1202
  const subId = `neg-${Date.now()}`;
1548
- const initialMsg = yield* Effect12.tryPromise({
1203
+ const initialMsg = yield* Effect7.try({
1549
1204
  try: () => neg.initiate(),
1550
1205
  catch: (e) => new SyncError({
1551
1206
  message: `Negentropy initiate failed: ${e instanceof Error ? e.message : String(e)}`,
@@ -1558,7 +1213,7 @@ function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
1558
1213
  const response = yield* relay.sendNegMsg(relayUrl, subId, filter, currentMsg);
1559
1214
  if (response.msgHex === null)
1560
1215
  break;
1561
- const reconcileResult = yield* Effect12.tryPromise({
1216
+ const reconcileResult = yield* Effect7.try({
1562
1217
  try: () => neg.reconcile(response.msgHex),
1563
1218
  catch: (e) => new SyncError({
1564
1219
  message: `Negentropy reconcile failed: ${e instanceof Error ? e.message : String(e)}`,
@@ -1577,257 +1232,1334 @@ function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
1577
1232
  });
1578
1233
  }
1579
1234
 
1235
+ // src/db/key-rotation.ts
1236
+ import { Option as Option4, Schema as Schema4 } from "effect";
1237
+ import { generateSecretKey } from "nostr-tools/pure";
1238
+ import { wrapEvent } from "nostr-tools/nip59";
1239
+ var HexKeySchema2 = Schema4.String.check(Schema4.isPattern(/^[0-9a-f]{64}$/i));
1240
+ var RotationDataSchema = Schema4.Struct({
1241
+ _rotation: Schema4.Literal(true),
1242
+ epochId: Schema4.String,
1243
+ epochKey: HexKeySchema2,
1244
+ parentEpoch: Schema4.String,
1245
+ removedMembers: Schema4.Array(Schema4.String)
1246
+ });
1247
+ var RemovalNoticeSchema = Schema4.Struct({
1248
+ _removed: Schema4.Literal(true),
1249
+ epochId: Schema4.String,
1250
+ removedBy: Schema4.String
1251
+ });
1252
+ var decodeRotationData = Schema4.decodeUnknownSync(Schema4.fromJsonString(RotationDataSchema));
1253
+ var decodeRemovalNotice = Schema4.decodeUnknownSync(Schema4.fromJsonString(RemovalNoticeSchema));
1254
+ function createRotation(epochStore, senderPrivateKey, senderPublicKey, remainingMemberPubkeys, removedMemberPubkeys) {
1255
+ const newSk = generateSecretKey();
1256
+ const newKeyHex = bytesToHex(newSk);
1257
+ const currentEpoch = getCurrentEpoch(epochStore);
1258
+ const epochId = EpochId(uuidv7());
1259
+ const epoch = createEpochKey(epochId, newKeyHex, senderPublicKey, currentEpoch.id);
1260
+ const rotationData = {
1261
+ _rotation: true,
1262
+ epochId,
1263
+ epochKey: newKeyHex,
1264
+ parentEpoch: currentEpoch.id,
1265
+ removedMembers: removedMemberPubkeys
1266
+ };
1267
+ const rumor = {
1268
+ kind: 1,
1269
+ content: JSON.stringify(rotationData),
1270
+ tags: [["d", `_system:rotation:${epochId}`]],
1271
+ created_at: Math.floor(Date.now() / 1000)
1272
+ };
1273
+ const wrappedEvents = [];
1274
+ for (const memberPubkey of remainingMemberPubkeys) {
1275
+ if (memberPubkey === senderPublicKey)
1276
+ continue;
1277
+ const wrapped = wrapEvent(rumor, senderPrivateKey, memberPubkey);
1278
+ wrappedEvents.push(wrapped);
1279
+ }
1280
+ const removalData = {
1281
+ _removed: true,
1282
+ epochId,
1283
+ removedBy: senderPublicKey
1284
+ };
1285
+ const removalRumor = {
1286
+ kind: 1,
1287
+ content: JSON.stringify(removalData),
1288
+ tags: [["d", `_system:removed:${epochId}`]],
1289
+ created_at: Math.floor(Date.now() / 1000)
1290
+ };
1291
+ const removalNotices = [];
1292
+ for (const removedPubkey of removedMemberPubkeys) {
1293
+ const wrapped = wrapEvent(removalRumor, senderPrivateKey, removedPubkey);
1294
+ removalNotices.push(wrapped);
1295
+ }
1296
+ return { epoch, wrappedEvents, removalNotices };
1297
+ }
1298
+ function parseRotationEvent(content, dTag) {
1299
+ if (!dTag.startsWith("_system:rotation:"))
1300
+ return Option4.none();
1301
+ try {
1302
+ return Option4.some(decodeRotationData(content));
1303
+ } catch {
1304
+ return Option4.none();
1305
+ }
1306
+ }
1307
+ function parseRemovalNotice(content, dTag) {
1308
+ if (!dTag.startsWith("_system:removed:"))
1309
+ return Option4.none();
1310
+ try {
1311
+ return Option4.some(decodeRemovalNotice(content));
1312
+ } catch {
1313
+ return Option4.none();
1314
+ }
1315
+ }
1316
+
1580
1317
  // src/sync/sync-service.ts
1581
- function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls, publicKey, onSyncError) {
1582
- const processGiftWrap = (remoteGw) => Effect13.gen(function* () {
1318
+ function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls, knownCollections, epochStore, personalPrivateKey, personalPublicKey, scope, onSyncError, onNewAuthor, onRemoved, onMembersChanged) {
1319
+ const getSubscriptionPubKeys = () => {
1320
+ return getAllPublicKeys(epochStore);
1321
+ };
1322
+ const notifyCollectionUpdated = (collection2) => notifyChange(watchCtx, {
1323
+ collection: collection2,
1324
+ recordId: "",
1325
+ kind: "create"
1326
+ });
1327
+ const forkHandled = (effect) => {
1328
+ Effect8.runFork(effect.pipe(Effect8.tapError((e) => Effect8.sync(() => onSyncError?.(e))), Effect8.ignore, Effect8.forkIn(scope)));
1329
+ };
1330
+ let autoFlushActive = false;
1331
+ const autoFlushEffect = Effect8.gen(function* () {
1332
+ const size = yield* publishQueue.size();
1333
+ if (size === 0)
1334
+ return;
1335
+ yield* syncStatus.set("syncing");
1336
+ yield* publishQueue.flush(relayUrls);
1337
+ const remaining = yield* publishQueue.size();
1338
+ if (remaining > 0)
1339
+ yield* Effect8.fail("pending");
1340
+ }).pipe(Effect8.ensuring(syncStatus.set("idle")), Effect8.retry({ schedule: Schedule.exponential(5000).pipe(Schedule.jittered), times: 10 }), Effect8.ignore);
1341
+ const scheduleAutoFlush = () => {
1342
+ if (autoFlushActive)
1343
+ return;
1344
+ autoFlushActive = true;
1345
+ forkHandled(autoFlushEffect.pipe(Effect8.ensuring(Effect8.sync(() => {
1346
+ autoFlushActive = false;
1347
+ }))));
1348
+ };
1349
+ const shouldRejectWrite = (authorPubkey) => Effect8.gen(function* () {
1350
+ const memberRecord = yield* storage.getRecord("_members", authorPubkey);
1351
+ if (!memberRecord)
1352
+ return false;
1353
+ return !!memberRecord.removedAt;
1354
+ });
1355
+ const processGiftWrap = (remoteGw) => Effect8.gen(function* () {
1583
1356
  const existing = yield* storage.getGiftWrap(remoteGw.id);
1584
1357
  if (existing)
1585
1358
  return null;
1586
- yield* storage.putGiftWrap({
1587
- id: remoteGw.id,
1588
- event: remoteGw,
1589
- createdAt: remoteGw.created_at
1590
- });
1591
- const unwrapResult = yield* Effect13.result(giftWrapHandle.unwrap(remoteGw));
1592
- if (unwrapResult._tag === "Failure")
1359
+ const unwrapResult = yield* Effect8.result(giftWrapHandle.unwrap(remoteGw));
1360
+ if (unwrapResult._tag === "Failure") {
1361
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1593
1362
  return null;
1363
+ }
1594
1364
  const rumor = unwrapResult.success;
1595
1365
  const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
1596
- if (!dTag)
1366
+ if (!dTag) {
1367
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1597
1368
  return null;
1369
+ }
1598
1370
  const colonIdx = dTag.indexOf(":");
1599
- if (colonIdx === -1)
1371
+ if (colonIdx === -1) {
1372
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1600
1373
  return null;
1374
+ }
1601
1375
  const collectionName = dTag.substring(0, colonIdx);
1602
1376
  const recordId = dTag.substring(colonIdx + 1);
1377
+ if (!knownCollections.has(collectionName)) {
1378
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1379
+ return null;
1380
+ }
1381
+ if (rumor.pubkey) {
1382
+ const reject = yield* shouldRejectWrite(rumor.pubkey);
1383
+ if (reject) {
1384
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1385
+ return null;
1386
+ }
1387
+ }
1603
1388
  let data = null;
1604
1389
  let kind = "update";
1605
- try {
1606
- const parsed = JSON.parse(rumor.content);
1607
- if (parsed === null || parsed._deleted) {
1608
- kind = "delete";
1609
- } else {
1610
- data = parsed;
1390
+ const parsed = yield* Effect8.try({
1391
+ try: () => JSON.parse(rumor.content),
1392
+ catch: () => {
1393
+ return;
1611
1394
  }
1612
- } catch {
1395
+ }).pipe(Effect8.orElseSucceed(() => {
1396
+ return;
1397
+ }));
1398
+ if (parsed === undefined) {
1399
+ yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
1613
1400
  return null;
1614
1401
  }
1402
+ if (parsed === null || parsed._deleted) {
1403
+ kind = "delete";
1404
+ } else {
1405
+ data = parsed;
1406
+ }
1407
+ const author = rumor.pubkey || undefined;
1615
1408
  const event = {
1616
1409
  id: rumor.id,
1617
1410
  collection: collectionName,
1618
1411
  recordId,
1619
1412
  kind,
1620
1413
  data,
1621
- createdAt: rumor.created_at * 1000
1414
+ createdAt: rumor.created_at * 1000,
1415
+ author
1622
1416
  };
1417
+ yield* storage.putGiftWrap({
1418
+ id: remoteGw.id,
1419
+ eventId: event.id,
1420
+ createdAt: remoteGw.created_at
1421
+ });
1623
1422
  yield* storage.putEvent(event);
1624
- yield* applyEvent(storage, event);
1423
+ const didApply = yield* applyEvent(storage, event);
1424
+ if (kind === "delete" && didApply) {
1425
+ const oldEvents = yield* storage.getEventsByRecord(collectionName, recordId);
1426
+ yield* Effect8.forEach(oldEvents.filter((e) => e.id !== event.id), (e) => storage.deleteEvent(e.id), { discard: true });
1427
+ }
1428
+ if (author && onNewAuthor) {
1429
+ onNewAuthor(author);
1430
+ }
1625
1431
  return collectionName;
1626
1432
  });
1627
- return {
1628
- sync: () => Effect13.gen(function* () {
1433
+ const processRealtimeGiftWrap = (remoteGw) => Effect8.gen(function* () {
1434
+ const collection2 = yield* processGiftWrap(remoteGw).pipe(Effect8.orElseSucceed(() => null));
1435
+ if (collection2) {
1436
+ yield* notifyCollectionUpdated(collection2);
1437
+ }
1438
+ });
1439
+ const processRotationGiftWrap = (remoteGw) => Effect8.gen(function* () {
1440
+ const unwrapResult = yield* Effect8.result(Effect8.try({
1441
+ try: () => unwrapEvent(remoteGw, personalPrivateKey),
1442
+ catch: (e) => new CryptoError({
1443
+ message: `Rotation unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
1444
+ cause: e
1445
+ })
1446
+ }));
1447
+ if (unwrapResult._tag === "Failure")
1448
+ return false;
1449
+ const rumor = unwrapResult.success;
1450
+ const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
1451
+ if (!dTag)
1452
+ return false;
1453
+ const removalNoticeOpt = parseRemovalNotice(rumor.content, dTag);
1454
+ if (Option5.isSome(removalNoticeOpt)) {
1455
+ if (onRemoved)
1456
+ onRemoved(removalNoticeOpt.value);
1457
+ return true;
1458
+ }
1459
+ const rotationDataOpt = parseRotationEvent(rumor.content, dTag);
1460
+ if (Option5.isNone(rotationDataOpt))
1461
+ return false;
1462
+ const rotationData = rotationDataOpt.value;
1463
+ if (epochStore.epochs.has(rotationData.epochId))
1464
+ return false;
1465
+ const epoch = createEpochKey(rotationData.epochId, rotationData.epochKey, rumor.pubkey || "", rotationData.parentEpoch);
1466
+ addEpoch(epochStore, epoch);
1467
+ epochStore.currentEpochId = epoch.id;
1468
+ let membersChanged = false;
1469
+ for (const removedPubkey of rotationData.removedMembers) {
1470
+ const memberRecord = yield* storage.getRecord("_members", removedPubkey);
1471
+ if (memberRecord && !memberRecord.removedAt) {
1472
+ yield* storage.putRecord("_members", {
1473
+ ...memberRecord,
1474
+ removedAt: Date.now(),
1475
+ removedInEpoch: epoch.id
1476
+ });
1477
+ yield* notifyChange(watchCtx, {
1478
+ collection: "_members",
1479
+ recordId: removedPubkey,
1480
+ kind: "update"
1481
+ });
1482
+ membersChanged = true;
1483
+ }
1484
+ }
1485
+ if (membersChanged && onMembersChanged)
1486
+ onMembersChanged();
1487
+ yield* handle.addEpochSubscription(epoch.publicKey);
1488
+ return true;
1489
+ });
1490
+ const subscribeAcrossRelays = (filter, onEvent) => Effect8.forEach(relayUrls, (url) => Effect8.gen(function* () {
1491
+ yield* relay.subscribe(filter, url, (event) => {
1492
+ forkHandled(onEvent(event));
1493
+ }).pipe(Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
1494
+ }), { discard: true });
1495
+ const syncRelay = (url, pubKeys, changedCollections) => Effect8.gen(function* () {
1496
+ const reconcileResult = yield* Effect8.result(reconcileWithRelay(storage, relay, url, Array.from(pubKeys)));
1497
+ if (reconcileResult._tag === "Failure") {
1498
+ onSyncError?.(reconcileResult.failure);
1499
+ return;
1500
+ }
1501
+ const { haveIds, needIds } = reconcileResult.success;
1502
+ if (needIds.length > 0) {
1503
+ const fetched = yield* relay.fetchEvents(needIds, url).pipe(Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.orElseSucceed(() => []));
1504
+ yield* Effect8.forEach(fetched, (remoteGw) => Effect8.gen(function* () {
1505
+ const collection2 = yield* processGiftWrap(remoteGw).pipe(Effect8.orElseSucceed(() => null));
1506
+ if (collection2)
1507
+ changedCollections.add(collection2);
1508
+ }), { discard: true });
1509
+ }
1510
+ if (haveIds.length > 0) {
1511
+ yield* Effect8.forEach(haveIds, (id) => Effect8.gen(function* () {
1512
+ const gw = yield* storage.getGiftWrap(id);
1513
+ if (!gw?.event)
1514
+ return;
1515
+ yield* relay.publish(gw.event, [url]).pipe(Effect8.andThen(storage.stripGiftWrapBlob(id)), Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
1516
+ }), { discard: true });
1517
+ }
1518
+ });
1519
+ const handle = {
1520
+ sync: () => Effect8.gen(function* () {
1629
1521
  yield* syncStatus.set("syncing");
1630
- yield* Ref4.set(watchCtx.replayingRef, true);
1522
+ yield* Ref3.set(watchCtx.replayingRef, true);
1631
1523
  const changedCollections = new Set;
1632
- try {
1633
- for (const url of relayUrls) {
1634
- const reconcileResult = yield* Effect13.result(reconcileWithRelay(storage, relay, url, publicKey));
1635
- if (reconcileResult._tag === "Failure")
1636
- continue;
1637
- const { haveIds, needIds } = reconcileResult.success;
1638
- if (needIds.length > 0) {
1639
- const fetchResult = yield* Effect13.result(relay.fetchEvents(needIds, url));
1640
- if (fetchResult._tag === "Success") {
1641
- for (const remoteGw of fetchResult.success) {
1642
- const result = yield* Effect13.result(processGiftWrap(remoteGw));
1643
- if (result._tag === "Success" && result.success) {
1644
- changedCollections.add(result.success);
1645
- }
1646
- }
1647
- }
1648
- }
1649
- if (haveIds.length > 0) {
1650
- for (const id of haveIds) {
1651
- const gw = yield* storage.getGiftWrap(id);
1652
- if (gw) {
1653
- yield* Effect13.result(relay.publish(gw.event, [url]));
1654
- }
1655
- }
1656
- }
1657
- }
1658
- yield* Effect13.result(publishQueue.flush(relayUrls));
1659
- } finally {
1524
+ yield* Effect8.gen(function* () {
1525
+ const pubKeys = getSubscriptionPubKeys();
1526
+ yield* Effect8.forEach(relayUrls, (url) => syncRelay(url, pubKeys, changedCollections), {
1527
+ discard: true
1528
+ });
1529
+ yield* publishQueue.flush(relayUrls).pipe(Effect8.ignore);
1530
+ }).pipe(Effect8.ensuring(Effect8.gen(function* () {
1660
1531
  yield* notifyReplayComplete(watchCtx, [...changedCollections]);
1661
1532
  yield* syncStatus.set("idle");
1662
- }
1533
+ })));
1663
1534
  }),
1664
- publishLocal: (giftWrap) => Effect13.gen(function* () {
1665
- const result = yield* Effect13.result(relay.publish(giftWrap.event, relayUrls));
1666
- if (result._tag === "Failure") {
1667
- yield* publishQueue.enqueue(giftWrap.id);
1668
- console.error("[tablinum:publishLocal] relay error:", result.failure);
1669
- if (onSyncError)
1670
- onSyncError(result.failure);
1671
- }
1535
+ publishLocal: (giftWrap) => Effect8.gen(function* () {
1536
+ if (!giftWrap.event)
1537
+ return;
1538
+ 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);
1672
1539
  }),
1673
- startSubscription: () => Effect13.gen(function* () {
1674
- for (const url of relayUrls) {
1675
- const subResult = yield* Effect13.result(relay.subscribe({ kinds: [1059], "#p": [publicKey] }, url, (evt) => {
1676
- Effect13.runFork(Effect13.gen(function* () {
1677
- const result = yield* Effect13.result(processGiftWrap(evt));
1678
- if (result._tag === "Success" && result.success) {
1679
- yield* notifyChange(watchCtx, {
1680
- collection: result.success,
1681
- recordId: "",
1682
- kind: "create"
1683
- });
1684
- }
1685
- }));
1686
- }));
1687
- if (subResult._tag === "Failure") {
1688
- console.error("[tablinum:subscribe] failed for", url, subResult.failure);
1689
- if (onSyncError)
1690
- onSyncError(subResult.failure);
1691
- } else {
1692
- console.log("[tablinum:subscribe] listening on", url);
1693
- }
1540
+ startSubscription: () => Effect8.gen(function* () {
1541
+ const pubKeys = getSubscriptionPubKeys();
1542
+ yield* subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": pubKeys }, processRealtimeGiftWrap);
1543
+ if (!pubKeys.includes(personalPublicKey)) {
1544
+ yield* subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": [personalPublicKey] }, (event) => Effect8.result(processRotationGiftWrap(event)).pipe(Effect8.asVoid));
1694
1545
  }
1695
- })
1546
+ }),
1547
+ addEpochSubscription: (publicKey) => subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": [publicKey] }, processRealtimeGiftWrap)
1696
1548
  };
1549
+ forkHandled(publishQueue.size().pipe(Effect8.flatMap((size) => Effect8.sync(() => {
1550
+ if (size > 0)
1551
+ scheduleAutoFlush();
1552
+ }))));
1553
+ return handle;
1697
1554
  }
1698
1555
 
1699
- // src/db/create-tablinum.ts
1700
- function createTablinum(config) {
1701
- return Effect14.gen(function* () {
1702
- if (!config.relays || config.relays.length === 0) {
1703
- return yield* new ValidationError({
1704
- message: "At least one relay URL is required"
1705
- });
1706
- }
1707
- const schemaEntries = Object.entries(config.schema);
1708
- if (schemaEntries.length === 0) {
1709
- return yield* new ValidationError({
1710
- message: "Schema must contain at least one collection"
1711
- });
1712
- }
1713
- let resolvedKey = config.privateKey;
1714
- const storageKeyName = `tablinum-key-${config.dbName ?? "tablinum"}`;
1715
- if (!resolvedKey && typeof globalThis.localStorage !== "undefined") {
1716
- const saved = globalThis.localStorage.getItem(storageKeyName);
1717
- if (saved && saved.length === 64) {
1718
- const bytes = new Uint8Array(32);
1719
- for (let i = 0;i < 32; i++) {
1720
- bytes[i] = parseInt(saved.slice(i * 2, i * 2 + 2), 16);
1721
- }
1722
- resolvedKey = bytes;
1723
- }
1724
- }
1725
- const identity = yield* createIdentity(resolvedKey);
1726
- if (typeof globalThis.localStorage !== "undefined") {
1727
- globalThis.localStorage.setItem(storageKeyName, identity.exportKey());
1728
- }
1729
- const storage = yield* openIDBStorage(config.dbName, config.schema);
1730
- const pubsub = yield* PubSub2.unbounded();
1731
- const replayingRef = yield* Ref5.make(false);
1732
- const watchCtx = { pubsub, replayingRef };
1733
- const closedRef = yield* Ref5.make(false);
1734
- const giftWrapHandle = createGiftWrapHandle(identity.privateKey, identity.publicKey);
1735
- const relayHandle = createRelayHandle();
1736
- const publishQueue = yield* createPublishQueue(storage, relayHandle);
1737
- const syncStatus = yield* createSyncStatusHandle();
1738
- const syncHandle = createSyncHandle(storage, giftWrapHandle, relayHandle, publishQueue, syncStatus, watchCtx, config.relays, identity.publicKey, config.onSyncError);
1739
- const onWrite = (event) => Effect14.gen(function* () {
1740
- console.log("[tablinum:onWrite]", event.kind, event.collection, event.recordId);
1741
- const content = event.kind === "delete" ? JSON.stringify({ _deleted: true }) : JSON.stringify(event.data);
1742
- const dTag = `${event.collection}:${event.recordId}`;
1743
- const wrapResult = yield* Effect14.result(giftWrapHandle.wrap({
1744
- kind: 1,
1745
- content,
1746
- tags: [["d", dTag]],
1747
- created_at: Math.floor(event.createdAt / 1000)
1748
- }));
1749
- if (wrapResult._tag === "Success") {
1750
- const gw = wrapResult.success;
1751
- console.log("[tablinum:onWrite] gift wrap created:", gw.id, "kind:", gw.kind, "tags:", JSON.stringify(gw.tags));
1752
- yield* storage.putGiftWrap({
1753
- id: gw.id,
1754
- event: gw,
1755
- createdAt: gw.created_at
1756
- });
1757
- console.log("[tablinum:onWrite] gift wrap stored, publishing...");
1758
- const publishEffect = Effect14.gen(function* () {
1759
- const pubResult = yield* Effect14.result(syncHandle.publishLocal({
1760
- id: gw.id,
1761
- event: gw,
1762
- createdAt: gw.created_at
1763
- }));
1764
- if (pubResult._tag === "Failure") {
1765
- const err = pubResult.failure;
1766
- console.error("[tablinum:publish] failed:", err);
1767
- if (config.onSyncError)
1768
- config.onSyncError(err);
1769
- } else {
1770
- console.log("[tablinum:publish] success");
1771
- }
1772
- });
1773
- yield* Effect14.forkDetach(publishEffect);
1774
- } else {
1775
- const err = wrapResult.failure;
1776
- console.error("[tablinum:onWrite] wrap failed:", err);
1777
- if (config.onSyncError)
1778
- config.onSyncError(err);
1556
+ // src/db/members.ts
1557
+ import { Effect as Effect9, Option as Option6, Schema as Schema5 } from "effect";
1558
+ var optionalString = {
1559
+ _tag: "FieldDef",
1560
+ kind: "string",
1561
+ isOptional: true,
1562
+ isArray: false
1563
+ };
1564
+ var optionalNumber = {
1565
+ _tag: "FieldDef",
1566
+ kind: "number",
1567
+ isOptional: true,
1568
+ isArray: false
1569
+ };
1570
+ var requiredNumber = {
1571
+ _tag: "FieldDef",
1572
+ kind: "number",
1573
+ isOptional: false,
1574
+ isArray: false
1575
+ };
1576
+ var requiredString = {
1577
+ _tag: "FieldDef",
1578
+ kind: "string",
1579
+ isOptional: false,
1580
+ isArray: false
1581
+ };
1582
+ var membersCollectionDef = {
1583
+ _tag: "CollectionDef",
1584
+ name: "_members",
1585
+ fields: {
1586
+ name: optionalString,
1587
+ picture: optionalString,
1588
+ about: optionalString,
1589
+ nip05: optionalString,
1590
+ addedAt: requiredNumber,
1591
+ addedInEpoch: requiredString,
1592
+ removedAt: optionalNumber,
1593
+ removedInEpoch: optionalString
1594
+ },
1595
+ indices: []
1596
+ };
1597
+ var AuthorProfileSchema = Schema5.Struct({
1598
+ name: Schema5.optionalKey(Schema5.String),
1599
+ picture: Schema5.optionalKey(Schema5.String),
1600
+ about: Schema5.optionalKey(Schema5.String),
1601
+ nip05: Schema5.optionalKey(Schema5.String)
1602
+ });
1603
+ var decodeAuthorProfile = Schema5.decodeUnknownEffect(Schema5.fromJsonString(AuthorProfileSchema));
1604
+ function fetchAuthorProfile(relay, relayUrls, pubkey) {
1605
+ return Effect9.gen(function* () {
1606
+ for (const url of relayUrls) {
1607
+ const result = yield* Effect9.result(relay.fetchByFilter({ kinds: [0], authors: [pubkey], limit: 1 }, url));
1608
+ if (result._tag === "Success" && result.success.length > 0) {
1609
+ return yield* decodeAuthorProfile(result.success[0].content).pipe(Effect9.map(Option6.some), Effect9.orElseSucceed(() => Option6.none()));
1779
1610
  }
1780
- });
1781
- const handles = new Map;
1782
- for (const [, def] of schemaEntries) {
1783
- const validator = buildValidator(def.name, def);
1784
- const partialValidator = buildPartialValidator(def.name, def);
1785
- const handle = createCollectionHandle(def, storage, watchCtx, validator, partialValidator, uuidv7, onWrite);
1786
- handles.set(def.name, handle);
1787
1611
  }
1788
- yield* syncHandle.startSubscription();
1789
- const dbHandle = {
1790
- collection: (name) => {
1791
- const handle = handles.get(name);
1792
- if (!handle) {
1793
- throw new Error(`Collection "${name}" not found in schema`);
1794
- }
1795
- return handle;
1796
- },
1797
- exportKey: () => identity.exportKey(),
1798
- close: () => Effect14.gen(function* () {
1799
- yield* Ref5.set(closedRef, true);
1800
- yield* relayHandle.closeAll();
1801
- yield* storage.close();
1802
- }),
1803
- rebuild: () => Effect14.gen(function* () {
1804
- const closed = yield* Ref5.get(closedRef);
1805
- if (closed) {
1806
- return yield* new StorageError({ message: "Database is closed" });
1612
+ return Option6.none();
1613
+ });
1614
+ }
1615
+
1616
+ // src/services/Identity.ts
1617
+ import { ServiceMap as ServiceMap3 } from "effect";
1618
+
1619
+ class Identity extends ServiceMap3.Service()("tablinum/Identity") {
1620
+ }
1621
+
1622
+ // src/services/EpochStore.ts
1623
+ import { ServiceMap as ServiceMap4 } from "effect";
1624
+
1625
+ class EpochStore extends ServiceMap4.Service()("tablinum/EpochStore") {
1626
+ }
1627
+
1628
+ // src/services/Storage.ts
1629
+ import { ServiceMap as ServiceMap5 } from "effect";
1630
+
1631
+ class Storage extends ServiceMap5.Service()("tablinum/Storage") {
1632
+ }
1633
+
1634
+ // src/services/Relay.ts
1635
+ import { ServiceMap as ServiceMap6 } from "effect";
1636
+
1637
+ class Relay extends ServiceMap6.Service()("tablinum/Relay") {
1638
+ }
1639
+
1640
+ // src/services/GiftWrap.ts
1641
+ import { ServiceMap as ServiceMap7 } from "effect";
1642
+
1643
+ class GiftWrap3 extends ServiceMap7.Service()("tablinum/GiftWrap") {
1644
+ }
1645
+
1646
+ // src/services/PublishQueue.ts
1647
+ import { ServiceMap as ServiceMap8 } from "effect";
1648
+
1649
+ class PublishQueue extends ServiceMap8.Service()("tablinum/PublishQueue") {
1650
+ }
1651
+
1652
+ // src/services/SyncStatus.ts
1653
+ import { ServiceMap as ServiceMap9 } from "effect";
1654
+
1655
+ class SyncStatus extends ServiceMap9.Service()("tablinum/SyncStatus") {
1656
+ }
1657
+
1658
+ // src/layers/IdentityLive.ts
1659
+ import { Effect as Effect11, Layer } from "effect";
1660
+ import { hexToBytes as hexToBytes3 } from "@noble/hashes/utils.js";
1661
+
1662
+ // src/db/identity.ts
1663
+ import { Effect as Effect10 } from "effect";
1664
+ import { getPublicKey as getPublicKey2 } from "nostr-tools/pure";
1665
+ import { bytesToHex as bytesToHex2 } from "@noble/hashes/utils.js";
1666
+ function createIdentity(suppliedKey) {
1667
+ return Effect10.gen(function* () {
1668
+ let privateKey;
1669
+ if (suppliedKey) {
1670
+ if (suppliedKey.length !== 32) {
1671
+ return yield* new CryptoError({
1672
+ message: `Private key must be 32 bytes, got ${suppliedKey.length}`
1673
+ });
1674
+ }
1675
+ privateKey = suppliedKey;
1676
+ } else {
1677
+ privateKey = new Uint8Array(32);
1678
+ crypto.getRandomValues(privateKey);
1679
+ }
1680
+ const privateKeyHex = bytesToHex2(privateKey);
1681
+ const publicKey = yield* Effect10.try({
1682
+ try: () => getPublicKey2(privateKey),
1683
+ catch: (e) => new CryptoError({
1684
+ message: `Failed to derive public key: ${e instanceof Error ? e.message : String(e)}`,
1685
+ cause: e
1686
+ })
1687
+ });
1688
+ return {
1689
+ privateKey,
1690
+ publicKey,
1691
+ exportKey: () => privateKeyHex
1692
+ };
1693
+ });
1694
+ }
1695
+
1696
+ // src/layers/IdentityLive.ts
1697
+ var IdentityLive = Layer.effect(Identity, Effect11.gen(function* () {
1698
+ const config = yield* Config;
1699
+ const storage = yield* Storage;
1700
+ const idbKey = yield* storage.getMeta("identity_key");
1701
+ const resolvedKey = config.privateKey ?? (typeof idbKey === "string" && idbKey.length === 64 ? hexToBytes3(idbKey) : undefined);
1702
+ const identity = yield* createIdentity(resolvedKey);
1703
+ yield* storage.putMeta("identity_key", identity.exportKey());
1704
+ return identity;
1705
+ }));
1706
+
1707
+ // src/layers/EpochStoreLive.ts
1708
+ import { Effect as Effect12, Layer as Layer2, Option as Option7 } from "effect";
1709
+ import { generateSecretKey as generateSecretKey2 } from "nostr-tools/pure";
1710
+ import { bytesToHex as bytesToHex3 } from "@noble/hashes/utils.js";
1711
+ var EpochStoreLive = Layer2.effect(EpochStore, Effect12.gen(function* () {
1712
+ const config = yield* Config;
1713
+ const identity = yield* Identity;
1714
+ const storage = yield* Storage;
1715
+ const idbRaw = yield* storage.getMeta("epochs");
1716
+ if (typeof idbRaw === "string") {
1717
+ const idbStore = deserializeEpochStore(idbRaw);
1718
+ if (Option7.isSome(idbStore)) {
1719
+ return idbStore.value;
1720
+ }
1721
+ }
1722
+ if (config.epochKeys && config.epochKeys.length > 0) {
1723
+ const store2 = createEpochStoreFromInputs(config.epochKeys);
1724
+ yield* storage.putMeta("epochs", stringifyEpochStore(store2));
1725
+ return store2;
1726
+ }
1727
+ const store = createEpochStoreFromInputs([{ epochId: EpochId("epoch-0"), key: bytesToHex3(generateSecretKey2()) }], { createdBy: identity.publicKey });
1728
+ yield* storage.putMeta("epochs", stringifyEpochStore(store));
1729
+ return store;
1730
+ }));
1731
+
1732
+ // src/layers/StorageLive.ts
1733
+ import { Effect as Effect14, Layer as Layer3 } from "effect";
1734
+
1735
+ // src/storage/idb.ts
1736
+ import { Effect as Effect13 } from "effect";
1737
+ import { openDB } from "idb";
1738
+ var DB_NAME = "tablinum";
1739
+ function storeName(collection2) {
1740
+ return `col_${collection2}`;
1741
+ }
1742
+ function computeSchemaSig(schema) {
1743
+ return Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)).map(([name, def]) => {
1744
+ const indices = [...def.indices ?? []].sort().join(",");
1745
+ return `${name}:${indices}`;
1746
+ }).join("|");
1747
+ }
1748
+ function wrap(label, fn) {
1749
+ return Effect13.tryPromise({
1750
+ try: fn,
1751
+ catch: (e) => new StorageError({
1752
+ message: `IndexedDB ${label} failed: ${e instanceof Error ? e.message : String(e)}`,
1753
+ cause: e
1754
+ })
1755
+ });
1756
+ }
1757
+ function upgradeSchema(database, schema, tx) {
1758
+ if (!database.objectStoreNames.contains("_meta")) {
1759
+ database.createObjectStore("_meta");
1760
+ }
1761
+ if (!database.objectStoreNames.contains("events")) {
1762
+ const events = database.createObjectStore("events", { keyPath: "id" });
1763
+ events.createIndex("by-record", ["collection", "recordId"]);
1764
+ }
1765
+ if (!database.objectStoreNames.contains("giftwraps")) {
1766
+ database.createObjectStore("giftwraps", { keyPath: "id" });
1767
+ }
1768
+ const expectedStores = new Set;
1769
+ for (const [, def] of Object.entries(schema)) {
1770
+ const sn = storeName(def.name);
1771
+ expectedStores.add(sn);
1772
+ if (!database.objectStoreNames.contains(sn)) {
1773
+ const store = database.createObjectStore(sn, { keyPath: "id" });
1774
+ for (const idx of def.indices ?? []) {
1775
+ store.createIndex(idx, idx);
1776
+ }
1777
+ } else {
1778
+ const store = tx.objectStore(sn);
1779
+ const existingIndices = new Set(Array.from(store.indexNames));
1780
+ const wantedIndices = new Set(def.indices ?? []);
1781
+ for (const idx of existingIndices) {
1782
+ if (!wantedIndices.has(idx))
1783
+ store.deleteIndex(idx);
1784
+ }
1785
+ for (const idx of wantedIndices) {
1786
+ if (!existingIndices.has(idx))
1787
+ store.createIndex(idx, idx);
1788
+ }
1789
+ }
1790
+ }
1791
+ for (const existing of Array.from(database.objectStoreNames)) {
1792
+ if (existing.startsWith("col_") && !expectedStores.has(existing)) {
1793
+ database.deleteObjectStore(existing);
1794
+ }
1795
+ }
1796
+ tx.objectStore("_meta").put(computeSchemaSig(schema), "schema_sig");
1797
+ }
1798
+ function openIDBStorage(dbName, schema) {
1799
+ return Effect13.gen(function* () {
1800
+ const name = dbName ?? DB_NAME;
1801
+ const schemaSig = computeSchemaSig(schema);
1802
+ const probeDb = yield* Effect13.tryPromise({
1803
+ try: () => openDB(name),
1804
+ catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
1805
+ });
1806
+ const currentVersion = probeDb.version;
1807
+ let needsUpgrade = true;
1808
+ if (probeDb.objectStoreNames.contains("_meta")) {
1809
+ const storedSig = yield* Effect13.tryPromise({
1810
+ try: () => probeDb.get("_meta", "schema_sig"),
1811
+ catch: () => new StorageError({ message: "Failed to read schema meta" })
1812
+ }).pipe(Effect13.catch(() => Effect13.succeed(undefined)));
1813
+ needsUpgrade = storedSig !== schemaSig;
1814
+ }
1815
+ probeDb.close();
1816
+ const db = needsUpgrade ? yield* Effect13.tryPromise({
1817
+ try: () => openDB(name, currentVersion + 1, {
1818
+ upgrade(database, _oldVersion, _newVersion, transaction) {
1819
+ upgradeSchema(database, schema, transaction);
1820
+ }
1821
+ }),
1822
+ catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
1823
+ }) : yield* Effect13.tryPromise({
1824
+ try: () => openDB(name),
1825
+ catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
1826
+ });
1827
+ yield* Effect13.addFinalizer(() => Effect13.sync(() => db.close()));
1828
+ const handle = {
1829
+ putRecord: (collection2, record) => wrap("putRecord", () => db.put(storeName(collection2), record).then(() => {
1830
+ return;
1831
+ })),
1832
+ getRecord: (collection2, id) => wrap("getRecord", () => db.get(storeName(collection2), id)),
1833
+ getAllRecords: (collection2) => wrap("getAllRecords", () => db.getAll(storeName(collection2))),
1834
+ countRecords: (collection2) => wrap("countRecords", () => db.count(storeName(collection2))),
1835
+ clearRecords: (collection2) => wrap("clearRecords", () => db.clear(storeName(collection2))),
1836
+ getByIndex: (collection2, indexName, value) => wrap("getByIndex", () => db.getAllFromIndex(storeName(collection2), indexName, value)),
1837
+ getByIndexRange: (collection2, indexName, range) => wrap("getByIndexRange", () => db.getAllFromIndex(storeName(collection2), indexName, range)),
1838
+ getAllSorted: (collection2, indexName, direction) => wrap("getAllSorted", async () => {
1839
+ const sn = storeName(collection2);
1840
+ const tx = db.transaction(sn, "readonly");
1841
+ const store = tx.objectStore(sn);
1842
+ const index = store.index(indexName);
1843
+ const results = [];
1844
+ let cursor = await index.openCursor(null, direction ?? "next");
1845
+ while (cursor) {
1846
+ results.push(cursor.value);
1847
+ cursor = await cursor.continue();
1807
1848
  }
1808
- yield* rebuild(storage, schemaEntries.map(([, def]) => def.name));
1849
+ return results;
1809
1850
  }),
1810
- sync: () => Effect14.gen(function* () {
1811
- const closed = yield* Ref5.get(closedRef);
1812
- if (closed) {
1813
- return yield* new SyncError({ message: "Database is closed", phase: "init" });
1851
+ putEvent: (event) => wrap("putEvent", () => db.put("events", event).then(() => {
1852
+ return;
1853
+ })),
1854
+ getEvent: (id) => wrap("getEvent", () => db.get("events", id)),
1855
+ getAllEvents: () => wrap("getAllEvents", () => db.getAll("events")),
1856
+ getEventsByRecord: (collection2, recordId) => wrap("getEventsByRecord", () => db.getAllFromIndex("events", "by-record", [collection2, recordId])),
1857
+ putGiftWrap: (gw) => wrap("putGiftWrap", () => db.put("giftwraps", gw).then(() => {
1858
+ return;
1859
+ })),
1860
+ getGiftWrap: (id) => wrap("getGiftWrap", () => db.get("giftwraps", id)),
1861
+ getAllGiftWraps: () => wrap("getAllGiftWraps", () => db.getAll("giftwraps")),
1862
+ deleteGiftWrap: (id) => wrap("deleteGiftWrap", () => db.delete("giftwraps", id).then(() => {
1863
+ return;
1864
+ })),
1865
+ stripGiftWrapBlob: (id) => wrap("stripGiftWrapBlob", async () => {
1866
+ const existing = await db.get("giftwraps", id);
1867
+ if (existing) {
1868
+ const { event: _, ...tombstone } = existing;
1869
+ await db.put("giftwraps", tombstone);
1814
1870
  }
1815
- yield* syncHandle.sync();
1816
1871
  }),
1817
- getSyncStatus: () => syncStatus.get()
1872
+ deleteEvent: (id) => wrap("deleteEvent", () => db.delete("events", id).then(() => {
1873
+ return;
1874
+ })),
1875
+ getMeta: (key) => wrap("getMeta", () => db.get("_meta", key)),
1876
+ putMeta: (key, value) => wrap("putMeta", () => db.put("_meta", value, key).then(() => {
1877
+ return;
1878
+ })),
1879
+ close: () => Effect13.sync(() => db.close())
1818
1880
  };
1819
- return dbHandle;
1881
+ return handle;
1820
1882
  });
1821
1883
  }
1884
+
1885
+ // src/layers/StorageLive.ts
1886
+ var StorageLive = Layer3.effect(Storage, Effect14.gen(function* () {
1887
+ const config = yield* Config;
1888
+ return yield* openIDBStorage(config.dbName, {
1889
+ ...config.schema,
1890
+ _members: membersCollectionDef
1891
+ });
1892
+ }));
1893
+
1894
+ // src/layers/RelayLive.ts
1895
+ import { Layer as Layer4 } from "effect";
1896
+
1897
+ // src/sync/relay.ts
1898
+ import { Effect as Effect15, Option as Option8, Schema as Schema6, ScopedCache, Scope as Scope3 } from "effect";
1899
+ import { Relay as Relay2 } from "nostr-tools/relay";
1900
+ var NegMessageFrameSchema = Schema6.Tuple([
1901
+ Schema6.Literal("NEG-MSG"),
1902
+ Schema6.String,
1903
+ Schema6.String
1904
+ ]);
1905
+ var NegErrorFrameSchema = Schema6.Tuple([Schema6.Literal("NEG-ERR"), Schema6.String, Schema6.String]);
1906
+ var decodeNegFrame = Schema6.decodeUnknownEffect(Schema6.fromJsonString(Schema6.Union([NegMessageFrameSchema, NegErrorFrameSchema])));
1907
+ function parseNegMessageFrame(data) {
1908
+ return Effect15.runSync(decodeNegFrame(data).pipe(Effect15.map(Option8.some), Effect15.orElseSucceed(() => Option8.none())));
1909
+ }
1910
+ function createRelayHandle() {
1911
+ return Effect15.gen(function* () {
1912
+ const relayScope = yield* Effect15.scope;
1913
+ const connections = yield* ScopedCache.make({
1914
+ capacity: 64,
1915
+ lookup: (url) => Effect15.acquireRelease(Effect15.tryPromise({
1916
+ try: () => Relay2.connect(url),
1917
+ catch: (e) => new RelayError({
1918
+ message: `Connect to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
1919
+ url,
1920
+ cause: e
1921
+ })
1922
+ }), (relay) => Effect15.sync(() => {
1923
+ relay.close();
1924
+ }))
1925
+ });
1926
+ const connectedUrls = new Set;
1927
+ const statusListeners = new Set;
1928
+ const notifyStatus = () => {
1929
+ const status = { connectedUrls: [...connectedUrls] };
1930
+ for (const listener of statusListeners)
1931
+ listener(status);
1932
+ };
1933
+ const markConnected = (url) => {
1934
+ if (!connectedUrls.has(url)) {
1935
+ connectedUrls.add(url);
1936
+ notifyStatus();
1937
+ }
1938
+ };
1939
+ const markDisconnected = (url) => {
1940
+ if (connectedUrls.has(url)) {
1941
+ connectedUrls.delete(url);
1942
+ notifyStatus();
1943
+ }
1944
+ };
1945
+ 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)));
1946
+ 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))))));
1947
+ const collectEvents = (url, filters) => withRelay(url, (relay) => Effect15.callback((resume) => {
1948
+ const events = [];
1949
+ let settled = false;
1950
+ let timer;
1951
+ let sub;
1952
+ const cleanup = () => {
1953
+ settled = true;
1954
+ if (timer !== undefined) {
1955
+ clearTimeout(timer);
1956
+ timer = undefined;
1957
+ }
1958
+ sub?.close();
1959
+ sub = undefined;
1960
+ };
1961
+ const fail = (e) => resume(Effect15.fail(new RelayError({
1962
+ message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
1963
+ url,
1964
+ cause: e
1965
+ })));
1966
+ try {
1967
+ sub = relay.subscribe([...filters], {
1968
+ onevent(evt) {
1969
+ if (!settled) {
1970
+ events.push(evt);
1971
+ }
1972
+ },
1973
+ oneose() {
1974
+ if (settled)
1975
+ return;
1976
+ cleanup();
1977
+ resume(Effect15.succeed(events));
1978
+ }
1979
+ });
1980
+ timer = setTimeout(() => {
1981
+ if (settled)
1982
+ return;
1983
+ cleanup();
1984
+ resume(Effect15.succeed(events));
1985
+ }, 1e4);
1986
+ } catch (e) {
1987
+ cleanup();
1988
+ fail(e);
1989
+ }
1990
+ return Effect15.sync(cleanup);
1991
+ }));
1992
+ return {
1993
+ publish: (event, urls) => Effect15.gen(function* () {
1994
+ const results = yield* Effect15.forEach(urls, (url) => Effect15.result(withRelay(url, (relay) => Effect15.tryPromise({
1995
+ try: () => relay.publish(event),
1996
+ catch: (e) => new RelayError({
1997
+ message: `Publish to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
1998
+ url,
1999
+ cause: e
2000
+ })
2001
+ }).pipe(Effect15.timeoutOrElse({
2002
+ duration: "10 seconds",
2003
+ onTimeout: () => Effect15.fail(new RelayError({ message: `Publish to ${url} timed out`, url }))
2004
+ })))), { concurrency: "unbounded" });
2005
+ const failures = results.filter((r) => r._tag === "Failure");
2006
+ if (failures.length === urls.length && urls.length > 0) {
2007
+ return yield* new RelayError({
2008
+ message: `Publish failed on all ${urls.length} relays`
2009
+ });
2010
+ }
2011
+ }),
2012
+ fetchEvents: (ids, url) => Effect15.gen(function* () {
2013
+ if (ids.length === 0)
2014
+ return [];
2015
+ return yield* collectEvents(url, [{ ids }]);
2016
+ }),
2017
+ fetchByFilter: (filter, url) => collectEvents(url, [filter]),
2018
+ subscribe: (filter, url, onEvent) => withRelay(url, (relay) => Effect15.acquireRelease(Effect15.try({
2019
+ try: () => relay.subscribe([filter], {
2020
+ onevent(evt) {
2021
+ onEvent(evt);
2022
+ },
2023
+ oneose() {}
2024
+ }),
2025
+ catch: (e) => new RelayError({
2026
+ message: `Subscribe to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
2027
+ url,
2028
+ cause: e
2029
+ })
2030
+ }), (sub) => Effect15.sync(() => {
2031
+ sub.close();
2032
+ })).pipe(Effect15.provideService(Scope3.Scope, relayScope), Effect15.asVoid)),
2033
+ sendNegMsg: (url, subId, filter, msgHex) => withRelay(url, (relay) => Effect15.callback((resume) => {
2034
+ let settled = false;
2035
+ let timer;
2036
+ let sub;
2037
+ let ws;
2038
+ const cleanup = () => {
2039
+ settled = true;
2040
+ if (timer !== undefined) {
2041
+ clearTimeout(timer);
2042
+ timer = undefined;
2043
+ }
2044
+ sub?.close();
2045
+ sub = undefined;
2046
+ ws?.removeEventListener("message", handler);
2047
+ ws = undefined;
2048
+ };
2049
+ const fail = (e) => resume(Effect15.fail(new RelayError({
2050
+ message: `NIP-77 negotiation with ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
2051
+ url,
2052
+ cause: e
2053
+ })));
2054
+ const handler = (msg) => {
2055
+ if (settled || typeof msg.data !== "string")
2056
+ return;
2057
+ const frameOpt = parseNegMessageFrame(msg.data);
2058
+ if (Option8.isNone(frameOpt) || frameOpt.value[1] !== subId)
2059
+ return;
2060
+ const frame = frameOpt.value;
2061
+ cleanup();
2062
+ if (frame[0] === "NEG-MSG") {
2063
+ resume(Effect15.succeed({
2064
+ msgHex: frame[2],
2065
+ haveIds: [],
2066
+ needIds: []
2067
+ }));
2068
+ return;
2069
+ }
2070
+ fail(new Error(`NEG-ERR: ${frame[2]}`));
2071
+ };
2072
+ try {
2073
+ sub = relay.subscribe([filter], {
2074
+ onevent() {},
2075
+ oneose() {}
2076
+ });
2077
+ ws = relay._ws || relay.ws;
2078
+ if (!ws) {
2079
+ cleanup();
2080
+ fail(new Error("Cannot access relay WebSocket"));
2081
+ return Effect15.succeed(undefined);
2082
+ }
2083
+ timer = setTimeout(() => {
2084
+ if (settled)
2085
+ return;
2086
+ cleanup();
2087
+ fail(new Error("NIP-77 negotiation timeout"));
2088
+ }, 30000);
2089
+ ws.addEventListener("message", handler);
2090
+ ws.send(JSON.stringify(["NEG-OPEN", subId, filter, msgHex]));
2091
+ } catch (e) {
2092
+ cleanup();
2093
+ fail(e);
2094
+ }
2095
+ return Effect15.sync(cleanup);
2096
+ })),
2097
+ closeAll: () => ScopedCache.invalidateAll(connections),
2098
+ getStatus: () => ({ connectedUrls: [...connectedUrls] }),
2099
+ subscribeStatus: (callback) => {
2100
+ statusListeners.add(callback);
2101
+ return () => statusListeners.delete(callback);
2102
+ }
2103
+ };
2104
+ });
2105
+ }
2106
+
2107
+ // src/layers/RelayLive.ts
2108
+ var RelayLive = Layer4.effect(Relay, createRelayHandle());
2109
+
2110
+ // src/layers/GiftWrapLive.ts
2111
+ import { Effect as Effect17, Layer as Layer5 } from "effect";
2112
+
2113
+ // src/sync/gift-wrap.ts
2114
+ import { Effect as Effect16 } from "effect";
2115
+ import { wrapEvent as wrapEvent2, unwrapEvent as unwrapEvent2 } from "nostr-tools/nip59";
2116
+ function createEpochGiftWrapHandle(senderPrivateKey, epochStore) {
2117
+ return {
2118
+ wrap: (rumor) => Effect16.try({
2119
+ try: () => wrapEvent2(rumor, senderPrivateKey, getCurrentPublicKey(epochStore)),
2120
+ catch: (e) => new CryptoError({
2121
+ message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
2122
+ cause: e
2123
+ })
2124
+ }),
2125
+ unwrap: (giftWrap) => Effect16.gen(function* () {
2126
+ const pTag = giftWrap.tags.find((t) => t[0] === "p")?.[1];
2127
+ if (!pTag) {
2128
+ return yield* new CryptoError({ message: "Gift wrap missing #p tag" });
2129
+ }
2130
+ const decKey = getDecryptionKey(epochStore, pTag);
2131
+ if (!decKey) {
2132
+ return yield* new CryptoError({
2133
+ message: `No epoch key for public key ${pTag.slice(0, 8)}...`
2134
+ });
2135
+ }
2136
+ return yield* Effect16.try({
2137
+ try: () => unwrapEvent2(giftWrap, decKey),
2138
+ catch: (e) => new CryptoError({
2139
+ message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
2140
+ cause: e
2141
+ })
2142
+ });
2143
+ })
2144
+ };
2145
+ }
2146
+
2147
+ // src/layers/GiftWrapLive.ts
2148
+ var GiftWrapLive = Layer5.effect(GiftWrap3, Effect17.gen(function* () {
2149
+ const identity = yield* Identity;
2150
+ const epochStore = yield* EpochStore;
2151
+ return createEpochGiftWrapHandle(identity.privateKey, epochStore);
2152
+ }));
2153
+
2154
+ // src/layers/PublishQueueLive.ts
2155
+ import { Effect as Effect19, Layer as Layer6 } from "effect";
2156
+
2157
+ // src/sync/publish-queue.ts
2158
+ import { Effect as Effect18, Ref as Ref4 } from "effect";
2159
+ var META_KEY = "publish_queue";
2160
+ function persist(storage, pending) {
2161
+ return storage.putMeta(META_KEY, [...pending]);
2162
+ }
2163
+ function createPublishQueue(storage, relay) {
2164
+ return Effect18.gen(function* () {
2165
+ const stored = yield* storage.getMeta(META_KEY);
2166
+ const initial = Array.isArray(stored) ? new Set(stored) : new Set;
2167
+ const pendingRef = yield* Ref4.make(initial);
2168
+ const listeners = new Set;
2169
+ const notify = (pending) => {
2170
+ for (const listener of listeners)
2171
+ listener(pending.size);
2172
+ };
2173
+ return {
2174
+ enqueue: (eventId) => Effect18.gen(function* () {
2175
+ const next = yield* Ref4.updateAndGet(pendingRef, (set) => {
2176
+ const n = new Set(set);
2177
+ n.add(eventId);
2178
+ return n;
2179
+ });
2180
+ yield* persist(storage, next);
2181
+ notify(next);
2182
+ }),
2183
+ flush: (relayUrls) => Effect18.gen(function* () {
2184
+ const pending = yield* Ref4.get(pendingRef);
2185
+ if (pending.size === 0)
2186
+ return;
2187
+ const succeeded = new Set;
2188
+ let consecutiveFailures = 0;
2189
+ for (const eventId of pending) {
2190
+ if (consecutiveFailures >= 3)
2191
+ break;
2192
+ const gw = yield* storage.getGiftWrap(eventId);
2193
+ if (!gw || !gw.event) {
2194
+ succeeded.add(eventId);
2195
+ consecutiveFailures = 0;
2196
+ continue;
2197
+ }
2198
+ const result = yield* Effect18.result(relay.publish(gw.event, relayUrls));
2199
+ if (result._tag === "Success") {
2200
+ succeeded.add(eventId);
2201
+ yield* storage.stripGiftWrapBlob(eventId);
2202
+ consecutiveFailures = 0;
2203
+ } else {
2204
+ consecutiveFailures++;
2205
+ }
2206
+ }
2207
+ if (succeeded.size > 0) {
2208
+ const updated = yield* Ref4.updateAndGet(pendingRef, (set) => {
2209
+ const next = new Set(set);
2210
+ for (const id of succeeded) {
2211
+ next.delete(id);
2212
+ }
2213
+ return next;
2214
+ });
2215
+ yield* persist(storage, updated);
2216
+ notify(updated);
2217
+ }
2218
+ }),
2219
+ size: () => Ref4.get(pendingRef).pipe(Effect18.map((s) => s.size)),
2220
+ subscribe: (callback) => {
2221
+ listeners.add(callback);
2222
+ return () => listeners.delete(callback);
2223
+ }
2224
+ };
2225
+ });
2226
+ }
2227
+
2228
+ // src/layers/PublishQueueLive.ts
2229
+ var PublishQueueLive = Layer6.effect(PublishQueue, Effect19.gen(function* () {
2230
+ const storage = yield* Storage;
2231
+ const relay = yield* Relay;
2232
+ return yield* createPublishQueue(storage, relay);
2233
+ }));
2234
+
2235
+ // src/layers/SyncStatusLive.ts
2236
+ import { Layer as Layer7 } from "effect";
2237
+
2238
+ // src/sync/sync-status.ts
2239
+ import { Effect as Effect20, SubscriptionRef } from "effect";
2240
+ function createSyncStatusHandle() {
2241
+ return Effect20.gen(function* () {
2242
+ const ref = yield* SubscriptionRef.make("idle");
2243
+ const listeners = new Set;
2244
+ return {
2245
+ get: () => SubscriptionRef.get(ref),
2246
+ set: (status) => Effect20.gen(function* () {
2247
+ yield* SubscriptionRef.set(ref, status);
2248
+ for (const listener of listeners)
2249
+ listener(status);
2250
+ }),
2251
+ subscribe: (callback) => {
2252
+ listeners.add(callback);
2253
+ return () => listeners.delete(callback);
2254
+ }
2255
+ };
2256
+ });
2257
+ }
2258
+
2259
+ // src/layers/SyncStatusLive.ts
2260
+ var SyncStatusLive = Layer7.effect(SyncStatus, createSyncStatusHandle());
2261
+
2262
+ // src/layers/TablinumLive.ts
2263
+ function reportSyncError(onSyncError, error) {
2264
+ if (!onSyncError)
2265
+ return;
2266
+ onSyncError(error instanceof Error ? error : new Error(String(error)));
2267
+ }
2268
+ function mapMemberRecord(record) {
2269
+ return {
2270
+ id: record.id,
2271
+ addedAt: record.addedAt,
2272
+ addedInEpoch: record.addedInEpoch,
2273
+ ...record.name !== undefined ? { name: record.name } : {},
2274
+ ...record.picture !== undefined ? { picture: record.picture } : {},
2275
+ ...record.about !== undefined ? { about: record.about } : {},
2276
+ ...record.nip05 !== undefined ? { nip05: record.nip05 } : {},
2277
+ ...record.removedAt !== undefined ? { removedAt: record.removedAt } : {},
2278
+ ...record.removedInEpoch !== undefined ? { removedInEpoch: record.removedInEpoch } : {}
2279
+ };
2280
+ }
2281
+ var IdentityWithDeps = IdentityLive.pipe(Layer8.provide(StorageLive));
2282
+ var EpochStoreWithDeps = EpochStoreLive.pipe(Layer8.provide(IdentityWithDeps), Layer8.provide(StorageLive));
2283
+ var GiftWrapWithDeps = GiftWrapLive.pipe(Layer8.provide(IdentityWithDeps), Layer8.provide(EpochStoreWithDeps));
2284
+ var PublishQueueWithDeps = PublishQueueLive.pipe(Layer8.provide(StorageLive), Layer8.provide(RelayLive));
2285
+ var AllServicesLive = Layer8.mergeAll(IdentityWithDeps, EpochStoreWithDeps, StorageLive, RelayLive, GiftWrapWithDeps, PublishQueueWithDeps, SyncStatusLive);
2286
+ var TablinumLive = Layer8.effect(Tablinum, Effect21.gen(function* () {
2287
+ const config = yield* Config;
2288
+ const identity = yield* Identity;
2289
+ const epochStore = yield* EpochStore;
2290
+ const storage = yield* Storage;
2291
+ const relay = yield* Relay;
2292
+ const giftWrap = yield* GiftWrap3;
2293
+ const publishQueue = yield* PublishQueue;
2294
+ const syncStatus = yield* SyncStatus;
2295
+ const scope = yield* Effect21.scope;
2296
+ const pubsub = yield* PubSub2.unbounded();
2297
+ const replayingRef = yield* Ref5.make(false);
2298
+ const closedRef = yield* Ref5.make(false);
2299
+ const watchCtx = { pubsub, replayingRef };
2300
+ const schemaEntries = Object.entries(config.schema);
2301
+ const allSchemaEntries = [...schemaEntries, ["_members", membersCollectionDef]];
2302
+ const knownCollections = new Set(allSchemaEntries.map(([, def]) => def.name));
2303
+ let notifyAuthor;
2304
+ 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);
2305
+ const onWrite = (event) => Effect21.gen(function* () {
2306
+ const content = event.kind === "delete" ? JSON.stringify({ _deleted: true }) : JSON.stringify(event.data);
2307
+ const dTag = `${event.collection}:${event.recordId}`;
2308
+ const wrapResult = yield* Effect21.result(giftWrap.wrap({
2309
+ kind: 1,
2310
+ content,
2311
+ tags: [["d", dTag]],
2312
+ created_at: Math.floor(event.createdAt / 1000)
2313
+ }));
2314
+ if (wrapResult._tag === "Failure") {
2315
+ reportSyncError(config.onSyncError, wrapResult.failure);
2316
+ return;
2317
+ }
2318
+ const gw = wrapResult.success;
2319
+ yield* storage.putGiftWrap({ id: gw.id, eventId: event.id, createdAt: gw.created_at });
2320
+ yield* Effect21.forkIn(Effect21.gen(function* () {
2321
+ const publishResult = yield* Effect21.result(syncHandle.publishLocal({
2322
+ id: gw.id,
2323
+ eventId: event.id,
2324
+ event: gw,
2325
+ createdAt: gw.created_at
2326
+ }));
2327
+ if (publishResult._tag === "Failure") {
2328
+ reportSyncError(config.onSyncError, publishResult.failure);
2329
+ }
2330
+ }), scope);
2331
+ });
2332
+ const knownAuthors = new Set;
2333
+ const putMemberRecord = (record) => Effect21.gen(function* () {
2334
+ const existing = yield* storage.getRecord("_members", record.id);
2335
+ const event = {
2336
+ id: uuidv7(),
2337
+ collection: "_members",
2338
+ recordId: record.id,
2339
+ kind: existing ? "update" : "create",
2340
+ data: record,
2341
+ createdAt: Date.now()
2342
+ };
2343
+ yield* storage.putEvent(event);
2344
+ yield* applyEvent(storage, event);
2345
+ yield* onWrite(event);
2346
+ yield* notifyChange(watchCtx, {
2347
+ collection: "_members",
2348
+ recordId: record.id,
2349
+ kind: existing ? "update" : "create"
2350
+ });
2351
+ config.onMembersChanged?.();
2352
+ });
2353
+ notifyAuthor = (pubkey) => {
2354
+ if (knownAuthors.has(pubkey))
2355
+ return;
2356
+ knownAuthors.add(pubkey);
2357
+ Effect21.runFork(Effect21.gen(function* () {
2358
+ const existing = yield* storage.getRecord("_members", pubkey);
2359
+ if (!existing) {
2360
+ yield* putMemberRecord({
2361
+ id: pubkey,
2362
+ addedAt: Date.now(),
2363
+ addedInEpoch: getCurrentEpoch(epochStore).id
2364
+ });
2365
+ }
2366
+ const profileOpt = yield* fetchAuthorProfile(relay, config.relays, pubkey).pipe(Effect21.catchTag("RelayError", () => Effect21.succeed(Option9.none())));
2367
+ if (Option9.isSome(profileOpt)) {
2368
+ const current = yield* storage.getRecord("_members", pubkey);
2369
+ if (current) {
2370
+ yield* storage.putRecord("_members", {
2371
+ ...current,
2372
+ ...profileOpt.value
2373
+ });
2374
+ yield* notifyChange(watchCtx, {
2375
+ collection: "_members",
2376
+ recordId: pubkey,
2377
+ kind: "update"
2378
+ });
2379
+ config.onMembersChanged?.();
2380
+ }
2381
+ }
2382
+ }).pipe(Effect21.ignore, Effect21.forkIn(scope)));
2383
+ };
2384
+ const handles = new Map;
2385
+ for (const [, def] of allSchemaEntries) {
2386
+ const validator = buildValidator(def.name, def);
2387
+ const partialValidator = buildPartialValidator(def.name, def);
2388
+ const handle = createCollectionHandle(def, storage, watchCtx, validator, partialValidator, uuidv7, onWrite);
2389
+ handles.set(def.name, handle);
2390
+ }
2391
+ yield* syncHandle.startSubscription();
2392
+ const selfMember = yield* storage.getRecord("_members", identity.publicKey);
2393
+ if (!selfMember) {
2394
+ yield* putMemberRecord({
2395
+ id: identity.publicKey,
2396
+ addedAt: Date.now(),
2397
+ addedInEpoch: getCurrentEpoch(epochStore).id
2398
+ });
2399
+ }
2400
+ const ensureOpen = (effect) => Effect21.gen(function* () {
2401
+ if (yield* Ref5.get(closedRef)) {
2402
+ return yield* new StorageError({ message: "Database is closed" });
2403
+ }
2404
+ return yield* effect;
2405
+ });
2406
+ const ensureSyncOpen = (effect) => Effect21.gen(function* () {
2407
+ if (yield* Ref5.get(closedRef)) {
2408
+ return yield* new SyncError({ message: "Database is closed", phase: "init" });
2409
+ }
2410
+ return yield* effect;
2411
+ });
2412
+ const dbHandle = {
2413
+ collection: (name) => {
2414
+ const handle = handles.get(name);
2415
+ if (!handle)
2416
+ throw new Error(`Collection "${name}" not found in schema`);
2417
+ return handle;
2418
+ },
2419
+ publicKey: identity.publicKey,
2420
+ members: handles.get("_members"),
2421
+ exportKey: () => identity.exportKey(),
2422
+ exportInvite: () => ({
2423
+ epochKeys: [...exportEpochKeys(epochStore)],
2424
+ relays: [...config.relays],
2425
+ dbName: config.dbName
2426
+ }),
2427
+ close: () => Effect21.gen(function* () {
2428
+ if (yield* Ref5.get(closedRef))
2429
+ return;
2430
+ yield* Ref5.set(closedRef, true);
2431
+ yield* Scope4.close(scope, Exit.void);
2432
+ }),
2433
+ rebuild: () => ensureOpen(rebuild(storage, allSchemaEntries.map(([, def]) => def.name))),
2434
+ sync: () => ensureSyncOpen(syncHandle.sync()),
2435
+ getSyncStatus: () => syncStatus.get(),
2436
+ subscribeSyncStatus: (callback) => syncStatus.subscribe(callback),
2437
+ pendingCount: () => publishQueue.size(),
2438
+ subscribePendingCount: (callback) => publishQueue.subscribe(callback),
2439
+ getRelayStatus: () => relay.getStatus(),
2440
+ subscribeRelayStatus: (callback) => relay.subscribeStatus(callback),
2441
+ addMember: (pubkey) => ensureOpen(Effect21.gen(function* () {
2442
+ const existing = yield* storage.getRecord("_members", pubkey);
2443
+ if (existing && !existing.removedAt)
2444
+ return;
2445
+ yield* putMemberRecord({
2446
+ id: pubkey,
2447
+ addedAt: Date.now(),
2448
+ addedInEpoch: getCurrentEpoch(epochStore).id,
2449
+ ...existing ? { removedAt: undefined, removedInEpoch: undefined } : {}
2450
+ });
2451
+ })),
2452
+ removeMember: (pubkey) => ensureOpen(Effect21.gen(function* () {
2453
+ const allMembers = yield* storage.getAllRecords("_members");
2454
+ const activeMembers = allMembers.filter((member) => !member.removedAt && member.id !== pubkey);
2455
+ const activePubkeys = activeMembers.map((member) => member.id);
2456
+ const result = createRotation(epochStore, identity.privateKey, identity.publicKey, activePubkeys, [pubkey]);
2457
+ addEpoch(epochStore, result.epoch);
2458
+ epochStore.currentEpochId = result.epoch.id;
2459
+ yield* storage.putMeta("epochs", stringifyEpochStore(epochStore));
2460
+ const memberRecord = yield* storage.getRecord("_members", pubkey);
2461
+ yield* putMemberRecord({
2462
+ ...memberRecord ?? {
2463
+ id: pubkey,
2464
+ addedAt: 0,
2465
+ addedInEpoch: EpochId("epoch-0")
2466
+ },
2467
+ removedAt: Date.now(),
2468
+ removedInEpoch: result.epoch.id
2469
+ });
2470
+ 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 });
2471
+ 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 });
2472
+ yield* syncHandle.addEpochSubscription(result.epoch.publicKey);
2473
+ })),
2474
+ getMembers: () => ensureOpen(Effect21.gen(function* () {
2475
+ const allRecords = yield* storage.getAllRecords("_members");
2476
+ return allRecords.filter((record) => !record._deleted).map(mapMemberRecord);
2477
+ })),
2478
+ setProfile: (profile) => ensureOpen(Effect21.gen(function* () {
2479
+ const existing = yield* storage.getRecord("_members", identity.publicKey);
2480
+ if (!existing) {
2481
+ return yield* new ValidationError({ message: "Current user is not a member" });
2482
+ }
2483
+ yield* putMemberRecord({ ...existing, ...profile });
2484
+ }))
2485
+ };
2486
+ return dbHandle;
2487
+ })).pipe(Layer8.provide(AllServicesLive));
2488
+
2489
+ // src/db/create-tablinum.ts
2490
+ function validateConfig(config) {
2491
+ return Effect22.gen(function* () {
2492
+ if (Object.keys(config.schema).length === 0) {
2493
+ return yield* new ValidationError({
2494
+ message: "Schema must contain at least one collection"
2495
+ });
2496
+ }
2497
+ });
2498
+ }
2499
+ function createTablinum(config) {
2500
+ return Effect22.gen(function* () {
2501
+ yield* validateConfig(config);
2502
+ const runtimeConfig = yield* resolveRuntimeConfig(config);
2503
+ const configValue = {
2504
+ ...runtimeConfig,
2505
+ schema: config.schema,
2506
+ onSyncError: config.onSyncError,
2507
+ onRemoved: config.onRemoved,
2508
+ onMembersChanged: config.onMembersChanged
2509
+ };
2510
+ const configLayer = Layer9.succeed(Config, configValue);
2511
+ const fullLayer = TablinumLive.pipe(Layer9.provide(configLayer));
2512
+ const ctx = yield* Layer9.build(fullLayer);
2513
+ return ServiceMap10.get(ctx, Tablinum);
2514
+ });
2515
+ }
2516
+ // src/db/invite.ts
2517
+ import { Schema as Schema7 } from "effect";
2518
+ var InviteSchema = Schema7.Struct({
2519
+ epochKeys: Schema7.Array(EpochKeyInputSchema),
2520
+ relays: Schema7.Array(Schema7.String),
2521
+ dbName: Schema7.String
2522
+ });
2523
+ var decodeInviteJson = Schema7.decodeUnknownSync(Schema7.UnknownFromJsonString);
2524
+ var decodeInvitePayload = Schema7.decodeUnknownSync(InviteSchema);
2525
+ function encodeInvite(invite) {
2526
+ return btoa(JSON.stringify(invite));
2527
+ }
2528
+ function decodeInvite(encoded) {
2529
+ let raw;
2530
+ try {
2531
+ raw = decodeInviteJson(atob(encoded));
2532
+ } catch {
2533
+ throw new Error("Invalid invite: failed to decode");
2534
+ }
2535
+ try {
2536
+ const invite = decodeInvitePayload(raw);
2537
+ return {
2538
+ epochKeys: invite.epochKeys.map((epoch) => ({
2539
+ epochId: EpochId(epoch.epochId),
2540
+ key: epoch.key
2541
+ })),
2542
+ relays: [...invite.relays],
2543
+ dbName: DatabaseName(invite.dbName)
2544
+ };
2545
+ } catch {
2546
+ throw new Error("Invalid invite: unexpected shape");
2547
+ }
2548
+ }
1822
2549
  export {
1823
2550
  field,
2551
+ encodeInvite,
2552
+ decodeInvite,
1824
2553
  createTablinum,
1825
2554
  collection,
1826
2555
  ValidationError,
2556
+ TablinumLive,
1827
2557
  SyncError,
1828
2558
  StorageError,
1829
2559
  RelayError,
1830
2560
  NotFoundError,
2561
+ EpochId,
2562
+ DatabaseName,
1831
2563
  CryptoError,
1832
2564
  ClosedError
1833
2565
  };