tablinum 0.1.3 → 0.4.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.
- package/README.md +134 -85
- package/dist/brands.d.ts +5 -0
- package/dist/crud/collection-handle.d.ts +8 -6
- package/dist/crud/query-builder.d.ts +4 -13
- package/dist/crud/watch.d.ts +0 -10
- package/dist/db/create-tablinum.d.ts +7 -0
- package/dist/db/database-handle.d.ts +23 -1
- package/dist/db/epoch.d.ts +48 -0
- package/dist/db/invite.d.ts +8 -0
- package/dist/db/key-rotation.d.ts +24 -0
- package/dist/db/members.d.ts +24 -0
- package/dist/db/runtime-config.d.ts +16 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +1744 -842
- package/dist/layers/EpochStoreLive.d.ts +6 -0
- package/dist/layers/GiftWrapLive.d.ts +5 -0
- package/dist/layers/IdentityLive.d.ts +5 -0
- package/dist/layers/PublishQueueLive.d.ts +5 -0
- package/dist/layers/RelayLive.d.ts +3 -0
- package/dist/layers/StorageLive.d.ts +4 -0
- package/dist/layers/SyncStatusLive.d.ts +3 -0
- package/dist/layers/TablinumLive.d.ts +5 -0
- package/dist/layers/index.d.ts +8 -0
- package/dist/schema/collection.d.ts +2 -0
- package/dist/schema/field.d.ts +7 -2
- package/dist/schema/types.d.ts +0 -4
- package/dist/services/Config.d.ts +16 -0
- package/dist/services/EpochStore.d.ts +6 -0
- package/dist/services/GiftWrap.d.ts +6 -0
- package/dist/services/Identity.d.ts +6 -0
- package/dist/services/PublishQueue.d.ts +6 -0
- package/dist/services/Relay.d.ts +6 -0
- package/dist/services/Storage.d.ts +6 -0
- package/dist/services/Sync.d.ts +6 -0
- package/dist/services/SyncStatus.d.ts +6 -0
- package/dist/services/Tablinum.d.ts +7 -0
- package/dist/services/index.d.ts +10 -0
- package/dist/storage/idb.d.ts +13 -3
- package/dist/storage/lww.d.ts +0 -5
- package/dist/storage/records-store.d.ts +1 -7
- package/dist/svelte/collection.svelte.d.ts +10 -6
- package/dist/svelte/deferred.d.ts +7 -0
- package/dist/svelte/index.svelte.d.ts +6 -6
- package/dist/svelte/index.svelte.js +2116 -1084
- package/dist/svelte/query.svelte.d.ts +6 -6
- package/dist/svelte/tablinum.svelte.d.ts +36 -0
- package/dist/sync/gift-wrap.d.ts +6 -2
- package/dist/sync/negentropy.d.ts +1 -1
- package/dist/sync/publish-queue.d.ts +3 -2
- package/dist/sync/relay.d.ts +9 -2
- package/dist/sync/sync-service.d.ts +5 -2
- package/dist/sync/sync-status.d.ts +1 -0
- package/dist/utils/diff.d.ts +2 -0
- package/dist/utils/uuid.d.ts +0 -1
- package/package.json +10 -7
- package/dist/main.d.ts +0 -1
- package/dist/storage/events-store.d.ts +0 -6
- package/dist/storage/giftwraps-store.d.ts +0 -6
- package/dist/svelte/database.svelte.d.ts +0 -15
- package/dist/svelte/live-query.svelte.d.ts +0 -8
package/dist/index.js
CHANGED
|
@@ -7,19 +7,20 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
// src/schema/field.ts
|
|
10
|
-
function make(kind, isOptional, isArray) {
|
|
11
|
-
return { _tag: "FieldDef", kind, isOptional, isArray };
|
|
10
|
+
function make(kind, isOptional, isArray, fields) {
|
|
11
|
+
return { _tag: "FieldDef", kind, isOptional, isArray, ...fields !== undefined && { fields } };
|
|
12
12
|
}
|
|
13
13
|
var field = {
|
|
14
14
|
string: () => make("string", false, false),
|
|
15
15
|
number: () => make("number", false, false),
|
|
16
16
|
boolean: () => make("boolean", false, false),
|
|
17
17
|
json: () => make("json", false, false),
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
object: (fields) => make("object", false, false, fields),
|
|
19
|
+
optional: (inner) => make(inner.kind, true, inner.isArray, inner.fields),
|
|
20
|
+
array: (inner) => make(inner.kind, inner.isOptional, true, inner.fields)
|
|
20
21
|
};
|
|
21
22
|
// src/schema/collection.ts
|
|
22
|
-
var RESERVED_NAMES = new Set(["id", "
|
|
23
|
+
var RESERVED_NAMES = new Set(["id", "_d", "_u", "_e", "_a"]);
|
|
23
24
|
function collection(name, fields, options) {
|
|
24
25
|
if (!name || name.trim().length === 0) {
|
|
25
26
|
throw new Error("Collection name must not be empty");
|
|
@@ -40,19 +41,23 @@ function collection(name, fields, options) {
|
|
|
40
41
|
if (!fieldDef) {
|
|
41
42
|
throw new Error(`Index field "${idx}" does not exist in collection "${name}"`);
|
|
42
43
|
}
|
|
43
|
-
if (fieldDef.kind === "json" || fieldDef.isArray) {
|
|
44
|
+
if (fieldDef.kind === "json" || fieldDef.kind === "object" || fieldDef.isArray) {
|
|
44
45
|
throw new Error(`Field "${idx}" cannot be indexed (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`);
|
|
45
46
|
}
|
|
46
47
|
indices.push(idx);
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
const eventRetention = options?.eventRetention ?? 1;
|
|
51
|
+
if (eventRetention < 0 || !Number.isInteger(eventRetention)) {
|
|
52
|
+
throw new Error(`eventRetention must be a non-negative integer, got ${eventRetention}`);
|
|
53
|
+
}
|
|
54
|
+
return { _tag: "CollectionDef", name, fields, indices, eventRetention };
|
|
50
55
|
}
|
|
51
56
|
// src/db/create-tablinum.ts
|
|
52
|
-
import { Effect as
|
|
57
|
+
import { Effect as Effect22, Layer as Layer9, ServiceMap as ServiceMap10 } from "effect";
|
|
53
58
|
|
|
54
|
-
// src/
|
|
55
|
-
import { Effect, Schema } from "effect";
|
|
59
|
+
// src/db/runtime-config.ts
|
|
60
|
+
import { Effect, Schema as Schema2 } from "effect";
|
|
56
61
|
|
|
57
62
|
// src/errors.ts
|
|
58
63
|
import { Data } from "effect";
|
|
@@ -78,210 +83,177 @@ class NotFoundError extends Data.TaggedError("NotFoundError") {
|
|
|
78
83
|
class ClosedError extends Data.TaggedError("ClosedError") {
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
// src/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
// src/db/epoch.ts
|
|
87
|
+
import { Option, Schema } from "effect";
|
|
88
|
+
import { getPublicKey } from "nostr-tools/pure";
|
|
89
|
+
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
|
|
90
|
+
|
|
91
|
+
// src/brands.ts
|
|
92
|
+
import { Brand } from "effect";
|
|
93
|
+
var EpochId = Brand.nominal();
|
|
94
|
+
var DatabaseName = Brand.nominal();
|
|
95
|
+
|
|
96
|
+
// src/db/epoch.ts
|
|
97
|
+
var HexKeySchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/i));
|
|
98
|
+
var EpochKeyInputSchema = Schema.Struct({
|
|
99
|
+
epochId: Schema.String,
|
|
100
|
+
key: HexKeySchema
|
|
101
|
+
});
|
|
102
|
+
var PersistedEpochSchema = Schema.Struct({
|
|
103
|
+
id: Schema.String,
|
|
104
|
+
privateKey: HexKeySchema,
|
|
105
|
+
createdBy: Schema.String,
|
|
106
|
+
parentEpoch: Schema.optionalKey(Schema.String)
|
|
107
|
+
});
|
|
108
|
+
var PersistedEpochStoreSchema = Schema.Struct({
|
|
109
|
+
epochs: Schema.Array(PersistedEpochSchema),
|
|
110
|
+
currentEpochId: Schema.String
|
|
111
|
+
});
|
|
112
|
+
var decodePersistedEpochStore = Schema.decodeUnknownSync(Schema.fromJsonString(PersistedEpochStoreSchema));
|
|
113
|
+
function createEpochKey(id, privateKeyHex, createdBy, parentEpoch) {
|
|
114
|
+
const publicKey = getPublicKey(hexToBytes(privateKeyHex));
|
|
115
|
+
const base = { id, privateKey: privateKeyHex, publicKey, createdBy };
|
|
116
|
+
return parentEpoch !== undefined ? { ...base, parentEpoch } : base;
|
|
117
|
+
}
|
|
118
|
+
function createEpochStore(initialEpoch) {
|
|
119
|
+
const epochs = new Map;
|
|
120
|
+
const keysByPublicKey = new Map;
|
|
121
|
+
epochs.set(initialEpoch.id, initialEpoch);
|
|
122
|
+
keysByPublicKey.set(initialEpoch.publicKey, hexToBytes(initialEpoch.privateKey));
|
|
123
|
+
return { epochs, keysByPublicKey, currentEpochId: initialEpoch.id };
|
|
124
|
+
}
|
|
125
|
+
function addEpoch(store, epoch) {
|
|
126
|
+
store.epochs.set(epoch.id, epoch);
|
|
127
|
+
store.keysByPublicKey.set(epoch.publicKey, hexToBytes(epoch.privateKey));
|
|
128
|
+
}
|
|
129
|
+
function hydrateEpochStore(snapshot) {
|
|
130
|
+
const [firstEpoch, ...remainingEpochs] = snapshot.epochs.map((epoch) => createEpochKey(EpochId(epoch.id), epoch.privateKey, epoch.createdBy, epoch.parentEpoch !== undefined ? EpochId(epoch.parentEpoch) : undefined));
|
|
131
|
+
if (!firstEpoch) {
|
|
132
|
+
throw new Error("Epoch snapshot must contain at least one epoch");
|
|
97
133
|
}
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
const store = createEpochStore(firstEpoch);
|
|
135
|
+
for (const epoch of remainingEpochs) {
|
|
136
|
+
addEpoch(store, epoch);
|
|
100
137
|
}
|
|
101
|
-
|
|
102
|
-
|
|
138
|
+
store.currentEpochId = EpochId(snapshot.currentEpochId);
|
|
139
|
+
return store;
|
|
140
|
+
}
|
|
141
|
+
function createEpochStoreFromInputs(epochKeys, options = {}) {
|
|
142
|
+
if (epochKeys.length === 0) {
|
|
143
|
+
throw new Error("Epoch input must contain at least one key");
|
|
103
144
|
}
|
|
104
|
-
|
|
145
|
+
const createdBy = options.createdBy ?? "";
|
|
146
|
+
const epochs = epochKeys.map((epochKey, index) => createEpochKey(epochKey.epochId, epochKey.key, createdBy, index > 0 ? epochKeys[index - 1].epochId : undefined));
|
|
147
|
+
const store = createEpochStore(epochs[0]);
|
|
148
|
+
for (let i = 1;i < epochs.length; i++) {
|
|
149
|
+
addEpoch(store, epochs[i]);
|
|
150
|
+
}
|
|
151
|
+
store.currentEpochId = epochs[epochs.length - 1].id;
|
|
152
|
+
return store;
|
|
105
153
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
154
|
+
function getCurrentEpoch(store) {
|
|
155
|
+
return store.epochs.get(store.currentEpochId);
|
|
156
|
+
}
|
|
157
|
+
function getCurrentPublicKey(store) {
|
|
158
|
+
return getCurrentEpoch(store).publicKey;
|
|
159
|
+
}
|
|
160
|
+
function getAllPublicKeys(store) {
|
|
161
|
+
return Array.from(store.keysByPublicKey.keys());
|
|
162
|
+
}
|
|
163
|
+
function getDecryptionKey(store, publicKey) {
|
|
164
|
+
return store.keysByPublicKey.get(publicKey);
|
|
165
|
+
}
|
|
166
|
+
function exportEpochKeys(store) {
|
|
167
|
+
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 }));
|
|
168
|
+
}
|
|
169
|
+
function serializeEpochStore(store) {
|
|
170
|
+
return {
|
|
171
|
+
epochs: Array.from(store.epochs.values()).map((epoch) => ({
|
|
172
|
+
id: epoch.id,
|
|
173
|
+
privateKey: epoch.privateKey,
|
|
174
|
+
createdBy: epoch.createdBy,
|
|
175
|
+
...epoch.parentEpoch !== undefined ? { parentEpoch: epoch.parentEpoch } : {}
|
|
176
|
+
})),
|
|
177
|
+
currentEpochId: store.currentEpochId
|
|
109
178
|
};
|
|
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
179
|
}
|
|
126
|
-
function
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
});
|
|
180
|
+
function stringifyEpochStore(store) {
|
|
181
|
+
return JSON.stringify(serializeEpochStore(store));
|
|
182
|
+
}
|
|
183
|
+
function deserializeEpochStore(raw) {
|
|
184
|
+
try {
|
|
185
|
+
return Option.some(hydrateEpochStore(decodePersistedEpochStore(raw)));
|
|
186
|
+
} catch {
|
|
187
|
+
return Option.none();
|
|
188
|
+
}
|
|
158
189
|
}
|
|
159
190
|
|
|
160
|
-
// src/
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
// src/db/runtime-config.ts
|
|
192
|
+
var PrivateKeySchema = Schema2.Uint8Array.check(Schema2.isMinLength(32), Schema2.isMaxLength(32));
|
|
193
|
+
var RuntimeConfigSchema = Schema2.Struct({
|
|
194
|
+
relays: Schema2.NonEmptyArray(Schema2.String),
|
|
195
|
+
dbName: Schema2.optional(Schema2.String),
|
|
196
|
+
privateKey: Schema2.optional(PrivateKeySchema),
|
|
197
|
+
epochKeys: Schema2.optional(Schema2.Array(EpochKeyInputSchema))
|
|
198
|
+
});
|
|
199
|
+
function resolveRuntimeConfig(source) {
|
|
200
|
+
return Schema2.decodeUnknownEffect(RuntimeConfigSchema)(source).pipe(Effect.map((config) => ({
|
|
201
|
+
relays: [...config.relays],
|
|
202
|
+
privateKey: config.privateKey,
|
|
203
|
+
epochKeys: config.epochKeys?.map((ek) => ({ epochId: EpochId(ek.epochId), key: ek.key })),
|
|
204
|
+
dbName: DatabaseName(config.dbName ?? "tablinum")
|
|
205
|
+
})), Effect.mapError((error) => new ValidationError({
|
|
206
|
+
message: `Invalid Tablinum configuration: ${error.message}`
|
|
207
|
+
})));
|
|
166
208
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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;
|
|
209
|
+
|
|
210
|
+
// src/services/Config.ts
|
|
211
|
+
import { ServiceMap } from "effect";
|
|
212
|
+
|
|
213
|
+
class Config extends ServiceMap.Service()("tablinum/Config") {
|
|
177
214
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
215
|
+
|
|
216
|
+
// src/services/Tablinum.ts
|
|
217
|
+
import { ServiceMap as ServiceMap2 } from "effect";
|
|
218
|
+
|
|
219
|
+
class Tablinum extends ServiceMap2.Service()("tablinum/Tablinum") {
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/layers/TablinumLive.ts
|
|
223
|
+
import { Effect as Effect21, Exit, Layer as Layer8, Option as Option9, PubSub as PubSub2, Ref as Ref5, Scope as Scope4 } from "effect";
|
|
224
|
+
|
|
225
|
+
// src/crud/watch.ts
|
|
226
|
+
import { Effect as Effect2, PubSub, Ref, Stream } from "effect";
|
|
227
|
+
function watchCollection(ctx, storage, collectionName, filter, mapRecord) {
|
|
228
|
+
const query = () => Effect2.map(storage.getAllRecords(collectionName), (all) => {
|
|
229
|
+
const filtered = all.filter((r) => !r._d && (filter ? filter(r) : true));
|
|
230
|
+
return mapRecord ? filtered.map(mapRecord) : filtered;
|
|
185
231
|
});
|
|
232
|
+
const changes = Stream.fromPubSub(ctx.pubsub).pipe(Stream.filter((event) => event.collection === collectionName), Stream.mapEffect(() => Effect2.gen(function* () {
|
|
233
|
+
const replaying = yield* Ref.get(ctx.replayingRef);
|
|
234
|
+
if (replaying)
|
|
235
|
+
return;
|
|
236
|
+
return yield* query();
|
|
237
|
+
})), Stream.filter((result) => result !== undefined));
|
|
238
|
+
return Stream.unwrap(Effect2.gen(function* () {
|
|
239
|
+
yield* Effect2.sleep(0);
|
|
240
|
+
const initial = yield* query();
|
|
241
|
+
return Stream.concat(Stream.make(initial), changes);
|
|
242
|
+
}));
|
|
186
243
|
}
|
|
187
|
-
function
|
|
244
|
+
function notifyChange(ctx, event) {
|
|
245
|
+
return PubSub.publish(ctx.pubsub, event).pipe(Effect2.asVoid);
|
|
246
|
+
}
|
|
247
|
+
function notifyReplayComplete(ctx, collections) {
|
|
188
248
|
return Effect2.gen(function* () {
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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;
|
|
249
|
+
yield* Ref.set(ctx.replayingRef, false);
|
|
250
|
+
for (const collection2 of collections) {
|
|
251
|
+
yield* notifyChange(ctx, {
|
|
252
|
+
collection: collection2,
|
|
253
|
+
recordId: "",
|
|
254
|
+
kind: "update"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
285
257
|
});
|
|
286
258
|
}
|
|
287
259
|
|
|
@@ -299,30 +271,78 @@ function resolveWinner(existing, incoming) {
|
|
|
299
271
|
return incoming.id < existing.id ? incoming : existing;
|
|
300
272
|
}
|
|
301
273
|
|
|
274
|
+
// src/utils/diff.ts
|
|
275
|
+
function isPlainObject(value) {
|
|
276
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
277
|
+
}
|
|
278
|
+
function deepDiff(before, after) {
|
|
279
|
+
const result = {};
|
|
280
|
+
let hasChanges = false;
|
|
281
|
+
for (const key of Object.keys(after)) {
|
|
282
|
+
const a = before[key];
|
|
283
|
+
const b = after[key];
|
|
284
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
285
|
+
const nested = deepDiff(a, b);
|
|
286
|
+
if (nested !== null) {
|
|
287
|
+
result[key] = nested;
|
|
288
|
+
hasChanges = true;
|
|
289
|
+
}
|
|
290
|
+
} else if (Array.isArray(a) && Array.isArray(b)) {
|
|
291
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
292
|
+
result[key] = b;
|
|
293
|
+
hasChanges = true;
|
|
294
|
+
}
|
|
295
|
+
} else if (a !== b) {
|
|
296
|
+
result[key] = b;
|
|
297
|
+
hasChanges = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return hasChanges ? result : null;
|
|
301
|
+
}
|
|
302
|
+
function deepMerge(target, source) {
|
|
303
|
+
const result = { ...target };
|
|
304
|
+
for (const key of Object.keys(source)) {
|
|
305
|
+
const t = target[key];
|
|
306
|
+
const s = source[key];
|
|
307
|
+
if (isPlainObject(t) && isPlainObject(s)) {
|
|
308
|
+
result[key] = deepMerge(t, s);
|
|
309
|
+
} else {
|
|
310
|
+
result[key] = s;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
302
316
|
// src/storage/records-store.ts
|
|
303
317
|
function buildRecord(event) {
|
|
304
318
|
return {
|
|
305
319
|
id: event.recordId,
|
|
306
|
-
|
|
307
|
-
|
|
320
|
+
_d: event.kind === "d",
|
|
321
|
+
_u: event.createdAt,
|
|
322
|
+
_e: event.id,
|
|
323
|
+
_a: event.author,
|
|
308
324
|
...event.data ?? {}
|
|
309
325
|
};
|
|
310
326
|
}
|
|
311
327
|
function applyEvent(storage, event) {
|
|
312
328
|
return Effect3.gen(function* () {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
329
|
+
const existing = yield* storage.getRecord(event.collection, event.recordId);
|
|
330
|
+
if (existing) {
|
|
331
|
+
const existingMeta = {
|
|
332
|
+
id: existing._e,
|
|
333
|
+
createdAt: existing._u
|
|
334
|
+
};
|
|
335
|
+
const incomingMeta = { id: event.id, createdAt: event.createdAt };
|
|
336
|
+
const winner = resolveWinner(existingMeta, incomingMeta);
|
|
337
|
+
if (winner.id !== event.id)
|
|
338
|
+
return false;
|
|
319
339
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
340
|
+
if (existing && event.kind === "u") {
|
|
341
|
+
yield* storage.putRecord(event.collection, deepMerge(existing, buildRecord(event)));
|
|
342
|
+
} else {
|
|
323
343
|
yield* storage.putRecord(event.collection, buildRecord(event));
|
|
324
344
|
}
|
|
325
|
-
return
|
|
345
|
+
return true;
|
|
326
346
|
});
|
|
327
347
|
}
|
|
328
348
|
function rebuild(storage, collections) {
|
|
@@ -331,23 +351,108 @@ function rebuild(storage, collections) {
|
|
|
331
351
|
yield* storage.clearRecords(col);
|
|
332
352
|
}
|
|
333
353
|
const allEvents = yield* storage.getAllEvents();
|
|
334
|
-
const sorted = [...allEvents].sort((a, b) => a.createdAt - b.createdAt);
|
|
335
|
-
const winners = new Map;
|
|
354
|
+
const sorted = [...allEvents].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1));
|
|
336
355
|
for (const event of sorted) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
356
|
+
if (event.data === null && event.kind !== "d")
|
|
357
|
+
continue;
|
|
358
|
+
yield* applyEvent(storage, event);
|
|
340
359
|
}
|
|
341
|
-
|
|
342
|
-
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/schema/validate.ts
|
|
364
|
+
import { Effect as Effect4, Schema as Schema3 } from "effect";
|
|
365
|
+
function fieldDefToSchema(fd) {
|
|
366
|
+
let base;
|
|
367
|
+
switch (fd.kind) {
|
|
368
|
+
case "string":
|
|
369
|
+
base = Schema3.String;
|
|
370
|
+
break;
|
|
371
|
+
case "number":
|
|
372
|
+
base = Schema3.Number;
|
|
373
|
+
break;
|
|
374
|
+
case "boolean":
|
|
375
|
+
base = Schema3.Boolean;
|
|
376
|
+
break;
|
|
377
|
+
case "json":
|
|
378
|
+
base = Schema3.Unknown;
|
|
379
|
+
break;
|
|
380
|
+
case "object": {
|
|
381
|
+
const nested = {};
|
|
382
|
+
for (const [k, v] of Object.entries(fd.fields)) {
|
|
383
|
+
nested[k] = fieldDefToSchema(v);
|
|
384
|
+
}
|
|
385
|
+
base = Schema3.Struct(nested);
|
|
386
|
+
break;
|
|
343
387
|
}
|
|
388
|
+
}
|
|
389
|
+
if (fd.isArray) {
|
|
390
|
+
base = Schema3.Array(base);
|
|
391
|
+
}
|
|
392
|
+
if (fd.isOptional) {
|
|
393
|
+
base = Schema3.UndefinedOr(base);
|
|
394
|
+
}
|
|
395
|
+
return base;
|
|
396
|
+
}
|
|
397
|
+
function buildStructSchema(def, options = {}) {
|
|
398
|
+
const schemaFields = {};
|
|
399
|
+
if (options.includeId) {
|
|
400
|
+
schemaFields.id = Schema3.String;
|
|
401
|
+
}
|
|
402
|
+
for (const [name, fieldDef] of Object.entries(def.fields)) {
|
|
403
|
+
const fieldSchema = fieldDefToSchema(fieldDef);
|
|
404
|
+
schemaFields[name] = options.allOptional ? Schema3.optionalKey(fieldSchema) : fieldSchema;
|
|
405
|
+
}
|
|
406
|
+
return Schema3.Struct(schemaFields);
|
|
407
|
+
}
|
|
408
|
+
function buildValidator(collectionName, def) {
|
|
409
|
+
const decode = Schema3.decodeUnknownEffect(buildStructSchema(def, { includeId: true }));
|
|
410
|
+
return (input) => decode(input).pipe(Effect4.map((result) => result), Effect4.mapError((e) => new ValidationError({
|
|
411
|
+
message: `Validation failed for collection "${collectionName}": ${e.message}`
|
|
412
|
+
})));
|
|
413
|
+
}
|
|
414
|
+
function buildPartialValidator(collectionName, def) {
|
|
415
|
+
const decode = Schema3.decodeUnknownEffect(buildStructSchema(def, { allOptional: true }));
|
|
416
|
+
return (input) => Effect4.gen(function* () {
|
|
417
|
+
if (typeof input !== "object" || input === null) {
|
|
418
|
+
return yield* new ValidationError({
|
|
419
|
+
message: `Validation failed for collection "${collectionName}": expected an object`
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const record = input;
|
|
423
|
+
const unknownField = Object.keys(record).find((key) => !Object.hasOwn(def.fields, key));
|
|
424
|
+
if (unknownField !== undefined) {
|
|
425
|
+
return yield* new ValidationError({
|
|
426
|
+
message: `Unknown field "${unknownField}" in collection "${collectionName}"`,
|
|
427
|
+
field: unknownField
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return yield* decode(record).pipe(Effect4.map((result) => result), Effect4.mapError((e) => new ValidationError({
|
|
431
|
+
message: `Validation failed for collection "${collectionName}": ${e.message}`
|
|
432
|
+
})));
|
|
344
433
|
});
|
|
345
434
|
}
|
|
346
435
|
|
|
347
436
|
// src/crud/collection-handle.ts
|
|
348
|
-
import { Effect as Effect6 } from "effect";
|
|
437
|
+
import { Effect as Effect6, Option as Option3 } from "effect";
|
|
349
438
|
|
|
350
439
|
// src/utils/uuid.ts
|
|
440
|
+
var alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
441
|
+
function toBase64url(bytes) {
|
|
442
|
+
let result = "";
|
|
443
|
+
for (let i = 0;i < bytes.length; i += 3) {
|
|
444
|
+
const b0 = bytes[i];
|
|
445
|
+
const b1 = bytes[i + 1] ?? 0;
|
|
446
|
+
const b2 = bytes[i + 2] ?? 0;
|
|
447
|
+
result += alphabet[b0 >> 2];
|
|
448
|
+
result += alphabet[(b0 & 3) << 4 | b1 >> 4];
|
|
449
|
+
if (i + 1 < bytes.length)
|
|
450
|
+
result += alphabet[(b1 & 15) << 2 | b2 >> 6];
|
|
451
|
+
if (i + 2 < bytes.length)
|
|
452
|
+
result += alphabet[b2 & 63];
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
351
456
|
function uuidv7() {
|
|
352
457
|
const now = Date.now();
|
|
353
458
|
const bytes = new Uint8Array(16);
|
|
@@ -360,47 +465,11 @@ function uuidv7() {
|
|
|
360
465
|
bytes[5] = now & 255;
|
|
361
466
|
bytes[6] = bytes[6] & 15 | 112;
|
|
362
467
|
bytes[8] = bytes[8] & 63 | 128;
|
|
363
|
-
|
|
364
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
365
|
-
}
|
|
366
|
-
|
|
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
|
-
});
|
|
468
|
+
return toBase64url(bytes);
|
|
400
469
|
}
|
|
401
470
|
|
|
402
471
|
// src/crud/query-builder.ts
|
|
403
|
-
import { Effect as Effect5, Ref as Ref2, Stream as Stream2 } from "effect";
|
|
472
|
+
import { Effect as Effect5, Option as Option2, Ref as Ref2, Stream as Stream2 } from "effect";
|
|
404
473
|
function emptyPlan() {
|
|
405
474
|
return { filters: [] };
|
|
406
475
|
}
|
|
@@ -414,7 +483,7 @@ function executeQuery(ctx, plan) {
|
|
|
414
483
|
field: plan.fieldName
|
|
415
484
|
});
|
|
416
485
|
}
|
|
417
|
-
if (fieldDef.kind === "json" || fieldDef.isArray) {
|
|
486
|
+
if (fieldDef.kind === "json" || fieldDef.kind === "object" || fieldDef.isArray) {
|
|
418
487
|
return yield* new ValidationError({
|
|
419
488
|
message: `Field "${plan.fieldName}" does not support filtering (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`,
|
|
420
489
|
field: plan.fieldName
|
|
@@ -439,7 +508,7 @@ function executeQuery(ctx, plan) {
|
|
|
439
508
|
} else {
|
|
440
509
|
results = [...yield* ctx.storage.getAllRecords(ctx.collectionName)];
|
|
441
510
|
}
|
|
442
|
-
results = results.filter((r) => !r.
|
|
511
|
+
results = results.filter((r) => !r._d);
|
|
443
512
|
for (const f of plan.filters) {
|
|
444
513
|
results = results.filter(f);
|
|
445
514
|
}
|
|
@@ -497,39 +566,8 @@ function makeQueryBuilder(ctx, plan) {
|
|
|
497
566
|
offset: (n) => makeQueryBuilder(ctx, { ...plan, offset: n }),
|
|
498
567
|
limit: (n) => makeQueryBuilder(ctx, { ...plan, limit: n }),
|
|
499
568
|
get: () => executeQuery(ctx, plan),
|
|
500
|
-
first: () => Effect5.
|
|
501
|
-
|
|
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
|
-
}),
|
|
569
|
+
first: () => Effect5.map(executeQuery(ctx, { ...plan, limit: 1 }), (results) => results.length > 0 ? Option2.some(results[0]) : Option2.none()),
|
|
570
|
+
count: () => Effect5.map(executeQuery(ctx, plan), (results) => results.length),
|
|
533
571
|
watch: () => watchQuery(ctx, plan)
|
|
534
572
|
};
|
|
535
573
|
}
|
|
@@ -587,16 +625,76 @@ function createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName,
|
|
|
587
625
|
...emptyPlan(),
|
|
588
626
|
orderBy: { field: fieldName, direction: "asc" }
|
|
589
627
|
};
|
|
590
|
-
return
|
|
628
|
+
return makeQueryBuilder(ctx, plan);
|
|
591
629
|
}
|
|
592
630
|
|
|
593
631
|
// src/crud/collection-handle.ts
|
|
632
|
+
var KIND_FULL = { c: "create", u: "update", d: "delete" };
|
|
633
|
+
function sortChronologically(events) {
|
|
634
|
+
return [...events].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1));
|
|
635
|
+
}
|
|
636
|
+
function replayState(recordId, events, stopAtId) {
|
|
637
|
+
let state = null;
|
|
638
|
+
for (const e of events) {
|
|
639
|
+
if (e.kind === "d") {
|
|
640
|
+
state = null;
|
|
641
|
+
} else if (e.data !== null) {
|
|
642
|
+
if (state === null) {
|
|
643
|
+
state = { id: recordId, ...e.data };
|
|
644
|
+
} else {
|
|
645
|
+
state = deepMerge(state, e.data);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (stopAtId !== undefined && e.id === stopAtId) {
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return state;
|
|
653
|
+
}
|
|
654
|
+
function promoteToSnapshot(storage, collection2, recordId, target, allSorted) {
|
|
655
|
+
return Effect6.gen(function* () {
|
|
656
|
+
const chronological = sortChronologically(allSorted);
|
|
657
|
+
const state = replayState(recordId, chronological, target.id);
|
|
658
|
+
if (state) {
|
|
659
|
+
yield* storage.putEvent({ ...target, data: state });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
function pruneEvents(storage, collection2, recordId, retention) {
|
|
664
|
+
return Effect6.gen(function* () {
|
|
665
|
+
const events = yield* storage.getEventsByRecord(collection2, recordId);
|
|
666
|
+
if (events.length <= retention)
|
|
667
|
+
return;
|
|
668
|
+
const sorted = [...events].sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1));
|
|
669
|
+
const retained = sorted.slice(0, retention);
|
|
670
|
+
const toStrip = sorted.slice(retention);
|
|
671
|
+
const oldestRetained = retained[retained.length - 1];
|
|
672
|
+
if (retention > 0 && oldestRetained?.kind === "u" && oldestRetained.data !== null) {
|
|
673
|
+
yield* promoteToSnapshot(storage, collection2, recordId, oldestRetained, sorted);
|
|
674
|
+
}
|
|
675
|
+
for (const e of toStrip) {
|
|
676
|
+
if (e.data !== null)
|
|
677
|
+
yield* storage.stripEventData(e.id);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
594
681
|
function mapRecord(record) {
|
|
595
|
-
const {
|
|
682
|
+
const { _d, _u, _a, _e, ...fields } = record;
|
|
596
683
|
return fields;
|
|
597
684
|
}
|
|
598
|
-
function createCollectionHandle(def, storage, watchCtx, validator, partialValidator, makeEventId, onWrite) {
|
|
685
|
+
function createCollectionHandle(def, storage, watchCtx, validator, partialValidator, makeEventId, localAuthor, onWrite) {
|
|
599
686
|
const collectionName = def.name;
|
|
687
|
+
const commitEvent = (event) => Effect6.gen(function* () {
|
|
688
|
+
yield* storage.putEvent(event);
|
|
689
|
+
yield* applyEvent(storage, event);
|
|
690
|
+
if (onWrite)
|
|
691
|
+
yield* onWrite(event);
|
|
692
|
+
yield* notifyChange(watchCtx, {
|
|
693
|
+
collection: collectionName,
|
|
694
|
+
recordId: event.recordId,
|
|
695
|
+
kind: KIND_FULL[event.kind]
|
|
696
|
+
});
|
|
697
|
+
});
|
|
600
698
|
const handle = {
|
|
601
699
|
add: (data) => Effect6.gen(function* () {
|
|
602
700
|
const id = uuidv7();
|
|
@@ -606,54 +704,42 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
|
|
|
606
704
|
id: makeEventId(),
|
|
607
705
|
collection: collectionName,
|
|
608
706
|
recordId: id,
|
|
609
|
-
kind: "
|
|
707
|
+
kind: "c",
|
|
610
708
|
data: fullRecord,
|
|
611
|
-
createdAt: Date.now()
|
|
709
|
+
createdAt: Date.now(),
|
|
710
|
+
author: localAuthor
|
|
612
711
|
};
|
|
613
|
-
yield*
|
|
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
|
-
});
|
|
712
|
+
yield* commitEvent(event);
|
|
622
713
|
return id;
|
|
623
714
|
}),
|
|
624
715
|
update: (id, data) => Effect6.gen(function* () {
|
|
625
716
|
const existing = yield* storage.getRecord(collectionName, id);
|
|
626
|
-
if (!existing || existing.
|
|
717
|
+
if (!existing || existing._d) {
|
|
627
718
|
return yield* new NotFoundError({
|
|
628
719
|
collection: collectionName,
|
|
629
720
|
id
|
|
630
721
|
});
|
|
631
722
|
}
|
|
632
723
|
yield* partialValidator(data);
|
|
633
|
-
const {
|
|
724
|
+
const { _d, _u, _a, _e, ...existingFields } = existing;
|
|
634
725
|
const merged = { ...existingFields, ...data, id };
|
|
635
726
|
yield* validator(merged);
|
|
727
|
+
const diff = deepDiff(existingFields, merged);
|
|
636
728
|
const event = {
|
|
637
729
|
id: makeEventId(),
|
|
638
730
|
collection: collectionName,
|
|
639
731
|
recordId: id,
|
|
640
|
-
kind: "
|
|
641
|
-
data:
|
|
642
|
-
createdAt: Date.now()
|
|
732
|
+
kind: "u",
|
|
733
|
+
data: diff ?? { id },
|
|
734
|
+
createdAt: Date.now(),
|
|
735
|
+
author: localAuthor
|
|
643
736
|
};
|
|
644
|
-
yield*
|
|
645
|
-
yield*
|
|
646
|
-
if (onWrite)
|
|
647
|
-
yield* onWrite(event);
|
|
648
|
-
yield* notifyChange(watchCtx, {
|
|
649
|
-
collection: collectionName,
|
|
650
|
-
recordId: id,
|
|
651
|
-
kind: "update"
|
|
652
|
-
});
|
|
737
|
+
yield* commitEvent(event);
|
|
738
|
+
yield* pruneEvents(storage, collectionName, id, def.eventRetention);
|
|
653
739
|
}),
|
|
654
740
|
delete: (id) => Effect6.gen(function* () {
|
|
655
741
|
const existing = yield* storage.getRecord(collectionName, id);
|
|
656
|
-
if (!existing || existing.
|
|
742
|
+
if (!existing || existing._d) {
|
|
657
743
|
return yield* new NotFoundError({
|
|
658
744
|
collection: collectionName,
|
|
659
745
|
id
|
|
@@ -663,23 +749,42 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
|
|
|
663
749
|
id: makeEventId(),
|
|
664
750
|
collection: collectionName,
|
|
665
751
|
recordId: id,
|
|
666
|
-
kind: "
|
|
752
|
+
kind: "d",
|
|
667
753
|
data: null,
|
|
668
|
-
createdAt: Date.now()
|
|
754
|
+
createdAt: Date.now(),
|
|
755
|
+
author: localAuthor
|
|
669
756
|
};
|
|
670
|
-
yield*
|
|
671
|
-
yield*
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
yield*
|
|
757
|
+
yield* commitEvent(event);
|
|
758
|
+
yield* pruneEvents(storage, collectionName, id, def.eventRetention);
|
|
759
|
+
}),
|
|
760
|
+
undo: (id) => Effect6.gen(function* () {
|
|
761
|
+
const existing = yield* storage.getRecord(collectionName, id);
|
|
762
|
+
if (!existing) {
|
|
763
|
+
return yield* new NotFoundError({ collection: collectionName, id });
|
|
764
|
+
}
|
|
765
|
+
const events = sortChronologically(yield* storage.getEventsByRecord(collectionName, id));
|
|
766
|
+
if (events.length < 2) {
|
|
767
|
+
return yield* new NotFoundError({ collection: collectionName, id });
|
|
768
|
+
}
|
|
769
|
+
const state = replayState(id, events.slice(0, -1));
|
|
770
|
+
if (!state) {
|
|
771
|
+
return yield* new NotFoundError({ collection: collectionName, id });
|
|
772
|
+
}
|
|
773
|
+
const event = {
|
|
774
|
+
id: makeEventId(),
|
|
675
775
|
collection: collectionName,
|
|
676
776
|
recordId: id,
|
|
677
|
-
kind: "
|
|
678
|
-
|
|
777
|
+
kind: "u",
|
|
778
|
+
data: state,
|
|
779
|
+
createdAt: Date.now(),
|
|
780
|
+
author: localAuthor
|
|
781
|
+
};
|
|
782
|
+
yield* commitEvent(event);
|
|
783
|
+
yield* pruneEvents(storage, collectionName, id, def.eventRetention);
|
|
679
784
|
}),
|
|
680
785
|
get: (id) => Effect6.gen(function* () {
|
|
681
786
|
const record = yield* storage.getRecord(collectionName, id);
|
|
682
|
-
if (!record || record.
|
|
787
|
+
if (!record || record._d) {
|
|
683
788
|
return yield* new NotFoundError({
|
|
684
789
|
collection: collectionName,
|
|
685
790
|
id
|
|
@@ -687,15 +792,11 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
|
|
|
687
792
|
}
|
|
688
793
|
return mapRecord(record);
|
|
689
794
|
}),
|
|
690
|
-
first: () => Effect6.
|
|
691
|
-
const
|
|
692
|
-
|
|
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;
|
|
795
|
+
first: () => Effect6.map(storage.getAllRecords(collectionName), (all) => {
|
|
796
|
+
const found = all.find((r) => !r._d);
|
|
797
|
+
return found ? Option3.some(mapRecord(found)) : Option3.none();
|
|
698
798
|
}),
|
|
799
|
+
count: () => Effect6.map(storage.getAllRecords(collectionName), (all) => all.filter((r) => !r._d).length),
|
|
699
800
|
watch: () => watchCollection(watchCtx, storage, collectionName, undefined, mapRecord),
|
|
700
801
|
where: (fieldName) => createWhereClause(storage, watchCtx, collectionName, def, fieldName, mapRecord),
|
|
701
802
|
orderBy: (fieldName) => createOrderByBuilder(storage, watchCtx, collectionName, def, fieldName, mapRecord)
|
|
@@ -703,306 +804,13 @@ function createCollectionHandle(def, storage, watchCtx, validator, partialValida
|
|
|
703
804
|
return handle;
|
|
704
805
|
}
|
|
705
806
|
|
|
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
807
|
// src/sync/sync-service.ts
|
|
1002
|
-
import { Effect as
|
|
808
|
+
import { Effect as Effect8, Option as Option5, Ref as Ref3, Schedule } from "effect";
|
|
809
|
+
import { unwrapEvent } from "nostr-tools/nip59";
|
|
810
|
+
import { GiftWrap as GiftWrap2 } from "nostr-tools/kinds";
|
|
1003
811
|
|
|
1004
812
|
// src/sync/negentropy.ts
|
|
1005
|
-
import { Effect as
|
|
813
|
+
import { Effect as Effect7 } from "effect";
|
|
1006
814
|
|
|
1007
815
|
// src/vendor/negentropy.js
|
|
1008
816
|
var PROTOCOL_VERSION = 97;
|
|
@@ -1522,30 +1330,25 @@ function itemCompare(a, b) {
|
|
|
1522
1330
|
}
|
|
1523
1331
|
|
|
1524
1332
|
// src/sync/negentropy.ts
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
}
|
|
1530
|
-
return bytes;
|
|
1531
|
-
}
|
|
1532
|
-
function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
|
|
1533
|
-
return Effect12.gen(function* () {
|
|
1333
|
+
import { hexToBytes as hexToBytes2 } from "@noble/hashes/utils.js";
|
|
1334
|
+
import { GiftWrap } from "nostr-tools/kinds";
|
|
1335
|
+
function reconcileWithRelay(storage, relay, relayUrl, publicKeys) {
|
|
1336
|
+
return Effect7.gen(function* () {
|
|
1534
1337
|
const allGiftWraps = yield* storage.getAllGiftWraps();
|
|
1535
1338
|
const storageVector = new NegentropyStorageVector;
|
|
1536
1339
|
for (const gw of allGiftWraps) {
|
|
1537
|
-
storageVector.insert(gw.createdAt,
|
|
1340
|
+
storageVector.insert(gw.createdAt, hexToBytes2(gw.id));
|
|
1538
1341
|
}
|
|
1539
1342
|
storageVector.seal();
|
|
1540
1343
|
const neg = new Negentropy(storageVector, 0);
|
|
1541
1344
|
const filter = {
|
|
1542
|
-
kinds: [
|
|
1543
|
-
"#p": [
|
|
1345
|
+
kinds: [GiftWrap],
|
|
1346
|
+
"#p": Array.isArray(publicKeys) ? publicKeys : [publicKeys]
|
|
1544
1347
|
};
|
|
1545
1348
|
const allHaveIds = [];
|
|
1546
1349
|
const allNeedIds = [];
|
|
1547
1350
|
const subId = `neg-${Date.now()}`;
|
|
1548
|
-
const initialMsg = yield*
|
|
1351
|
+
const initialMsg = yield* Effect7.try({
|
|
1549
1352
|
try: () => neg.initiate(),
|
|
1550
1353
|
catch: (e) => new SyncError({
|
|
1551
1354
|
message: `Negentropy initiate failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -1558,7 +1361,7 @@ function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
|
|
|
1558
1361
|
const response = yield* relay.sendNegMsg(relayUrl, subId, filter, currentMsg);
|
|
1559
1362
|
if (response.msgHex === null)
|
|
1560
1363
|
break;
|
|
1561
|
-
const reconcileResult = yield*
|
|
1364
|
+
const reconcileResult = yield* Effect7.try({
|
|
1562
1365
|
try: () => neg.reconcile(response.msgHex),
|
|
1563
1366
|
catch: (e) => new SyncError({
|
|
1564
1367
|
message: `Negentropy reconcile failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -1577,257 +1380,1356 @@ function reconcileWithRelay(storage, relay, relayUrl, publicKey) {
|
|
|
1577
1380
|
});
|
|
1578
1381
|
}
|
|
1579
1382
|
|
|
1383
|
+
// src/db/key-rotation.ts
|
|
1384
|
+
import { Option as Option4, Schema as Schema4 } from "effect";
|
|
1385
|
+
import { generateSecretKey } from "nostr-tools/pure";
|
|
1386
|
+
import { wrapEvent } from "nostr-tools/nip59";
|
|
1387
|
+
var HexKeySchema2 = Schema4.String.check(Schema4.isPattern(/^[0-9a-f]{64}$/i));
|
|
1388
|
+
var RotationDataSchema = Schema4.Struct({
|
|
1389
|
+
_rotation: Schema4.Literal(true),
|
|
1390
|
+
epochId: Schema4.String,
|
|
1391
|
+
epochKey: HexKeySchema2,
|
|
1392
|
+
parentEpoch: Schema4.String,
|
|
1393
|
+
removedMembers: Schema4.Array(Schema4.String)
|
|
1394
|
+
});
|
|
1395
|
+
var RemovalNoticeSchema = Schema4.Struct({
|
|
1396
|
+
_removed: Schema4.Literal(true),
|
|
1397
|
+
epochId: Schema4.String,
|
|
1398
|
+
removedBy: Schema4.String
|
|
1399
|
+
});
|
|
1400
|
+
var decodeRotationData = Schema4.decodeUnknownSync(Schema4.fromJsonString(RotationDataSchema));
|
|
1401
|
+
var decodeRemovalNotice = Schema4.decodeUnknownSync(Schema4.fromJsonString(RemovalNoticeSchema));
|
|
1402
|
+
function createRotation(epochStore, senderPrivateKey, senderPublicKey, remainingMemberPubkeys, removedMemberPubkeys) {
|
|
1403
|
+
const newSk = generateSecretKey();
|
|
1404
|
+
const newKeyHex = bytesToHex(newSk);
|
|
1405
|
+
const currentEpoch = getCurrentEpoch(epochStore);
|
|
1406
|
+
const epochId = EpochId(uuidv7());
|
|
1407
|
+
const epoch = createEpochKey(epochId, newKeyHex, senderPublicKey, currentEpoch.id);
|
|
1408
|
+
const rotationData = {
|
|
1409
|
+
_rotation: true,
|
|
1410
|
+
epochId,
|
|
1411
|
+
epochKey: newKeyHex,
|
|
1412
|
+
parentEpoch: currentEpoch.id,
|
|
1413
|
+
removedMembers: removedMemberPubkeys
|
|
1414
|
+
};
|
|
1415
|
+
const rumor = {
|
|
1416
|
+
kind: 1,
|
|
1417
|
+
content: JSON.stringify(rotationData),
|
|
1418
|
+
tags: [["d", `_system:rotation:${epochId}`]],
|
|
1419
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
1420
|
+
};
|
|
1421
|
+
const wrappedEvents = [];
|
|
1422
|
+
for (const memberPubkey of remainingMemberPubkeys) {
|
|
1423
|
+
if (memberPubkey === senderPublicKey)
|
|
1424
|
+
continue;
|
|
1425
|
+
const wrapped = wrapEvent(rumor, senderPrivateKey, memberPubkey);
|
|
1426
|
+
wrappedEvents.push(wrapped);
|
|
1427
|
+
}
|
|
1428
|
+
const removalData = {
|
|
1429
|
+
_removed: true,
|
|
1430
|
+
epochId,
|
|
1431
|
+
removedBy: senderPublicKey
|
|
1432
|
+
};
|
|
1433
|
+
const removalRumor = {
|
|
1434
|
+
kind: 1,
|
|
1435
|
+
content: JSON.stringify(removalData),
|
|
1436
|
+
tags: [["d", `_system:removed:${epochId}`]],
|
|
1437
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
1438
|
+
};
|
|
1439
|
+
const removalNotices = [];
|
|
1440
|
+
for (const removedPubkey of removedMemberPubkeys) {
|
|
1441
|
+
const wrapped = wrapEvent(removalRumor, senderPrivateKey, removedPubkey);
|
|
1442
|
+
removalNotices.push(wrapped);
|
|
1443
|
+
}
|
|
1444
|
+
return { epoch, wrappedEvents, removalNotices };
|
|
1445
|
+
}
|
|
1446
|
+
function parseRotationEvent(content, dTag) {
|
|
1447
|
+
if (!dTag.startsWith("_system:rotation:"))
|
|
1448
|
+
return Option4.none();
|
|
1449
|
+
try {
|
|
1450
|
+
return Option4.some(decodeRotationData(content));
|
|
1451
|
+
} catch {
|
|
1452
|
+
return Option4.none();
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
function parseRemovalNotice(content, dTag) {
|
|
1456
|
+
if (!dTag.startsWith("_system:removed:"))
|
|
1457
|
+
return Option4.none();
|
|
1458
|
+
try {
|
|
1459
|
+
return Option4.some(decodeRemovalNotice(content));
|
|
1460
|
+
} catch {
|
|
1461
|
+
return Option4.none();
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1580
1465
|
// src/sync/sync-service.ts
|
|
1581
|
-
function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls,
|
|
1582
|
-
const
|
|
1466
|
+
function createSyncHandle(storage, giftWrapHandle, relay, publishQueue, syncStatus, watchCtx, relayUrls, knownCollections, epochStore, personalPrivateKey, personalPublicKey, scope, onSyncError, onNewAuthor, onRemoved, onMembersChanged) {
|
|
1467
|
+
const getSubscriptionPubKeys = () => {
|
|
1468
|
+
return getAllPublicKeys(epochStore);
|
|
1469
|
+
};
|
|
1470
|
+
const notifyCollectionUpdated = (collection2) => notifyChange(watchCtx, {
|
|
1471
|
+
collection: collection2,
|
|
1472
|
+
recordId: "",
|
|
1473
|
+
kind: "create"
|
|
1474
|
+
});
|
|
1475
|
+
const forkHandled = (effect) => {
|
|
1476
|
+
Effect8.runFork(effect.pipe(Effect8.tapError((e) => Effect8.sync(() => onSyncError?.(e))), Effect8.ignore, Effect8.forkIn(scope)));
|
|
1477
|
+
};
|
|
1478
|
+
let autoFlushActive = false;
|
|
1479
|
+
const autoFlushEffect = Effect8.gen(function* () {
|
|
1480
|
+
const size = yield* publishQueue.size();
|
|
1481
|
+
if (size === 0)
|
|
1482
|
+
return;
|
|
1483
|
+
yield* syncStatus.set("syncing");
|
|
1484
|
+
yield* publishQueue.flush(relayUrls);
|
|
1485
|
+
const remaining = yield* publishQueue.size();
|
|
1486
|
+
if (remaining > 0)
|
|
1487
|
+
yield* Effect8.fail("pending");
|
|
1488
|
+
}).pipe(Effect8.ensuring(syncStatus.set("idle")), Effect8.retry({ schedule: Schedule.exponential(5000).pipe(Schedule.jittered), times: 10 }), Effect8.ignore);
|
|
1489
|
+
const scheduleAutoFlush = () => {
|
|
1490
|
+
if (autoFlushActive)
|
|
1491
|
+
return;
|
|
1492
|
+
autoFlushActive = true;
|
|
1493
|
+
forkHandled(autoFlushEffect.pipe(Effect8.ensuring(Effect8.sync(() => {
|
|
1494
|
+
autoFlushActive = false;
|
|
1495
|
+
}))));
|
|
1496
|
+
};
|
|
1497
|
+
const shouldRejectWrite = (authorPubkey) => Effect8.gen(function* () {
|
|
1498
|
+
const memberRecord = yield* storage.getRecord("_members", authorPubkey);
|
|
1499
|
+
if (!memberRecord)
|
|
1500
|
+
return false;
|
|
1501
|
+
return !!memberRecord.removedAt;
|
|
1502
|
+
});
|
|
1503
|
+
const processGiftWrap = (remoteGw) => Effect8.gen(function* () {
|
|
1583
1504
|
const existing = yield* storage.getGiftWrap(remoteGw.id);
|
|
1584
1505
|
if (existing)
|
|
1585
1506
|
return null;
|
|
1586
|
-
yield*
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
createdAt: remoteGw.created_at
|
|
1590
|
-
});
|
|
1591
|
-
const unwrapResult = yield* Effect13.result(giftWrapHandle.unwrap(remoteGw));
|
|
1592
|
-
if (unwrapResult._tag === "Failure")
|
|
1507
|
+
const unwrapResult = yield* Effect8.result(giftWrapHandle.unwrap(remoteGw));
|
|
1508
|
+
if (unwrapResult._tag === "Failure") {
|
|
1509
|
+
yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
|
|
1593
1510
|
return null;
|
|
1511
|
+
}
|
|
1594
1512
|
const rumor = unwrapResult.success;
|
|
1595
1513
|
const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
|
|
1596
|
-
if (!dTag)
|
|
1514
|
+
if (!dTag) {
|
|
1515
|
+
yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
|
|
1597
1516
|
return null;
|
|
1517
|
+
}
|
|
1598
1518
|
const colonIdx = dTag.indexOf(":");
|
|
1599
|
-
if (colonIdx === -1)
|
|
1519
|
+
if (colonIdx === -1) {
|
|
1520
|
+
yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
|
|
1600
1521
|
return null;
|
|
1522
|
+
}
|
|
1601
1523
|
const collectionName = dTag.substring(0, colonIdx);
|
|
1602
1524
|
const recordId = dTag.substring(colonIdx + 1);
|
|
1525
|
+
const retention = knownCollections.get(collectionName);
|
|
1526
|
+
if (retention === undefined) {
|
|
1527
|
+
yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
if (rumor.pubkey) {
|
|
1531
|
+
const reject = yield* shouldRejectWrite(rumor.pubkey);
|
|
1532
|
+
if (reject) {
|
|
1533
|
+
yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1603
1537
|
let data = null;
|
|
1604
|
-
let kind = "
|
|
1605
|
-
try
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
} else {
|
|
1610
|
-
data = parsed;
|
|
1538
|
+
let kind = "u";
|
|
1539
|
+
const parsed = yield* Effect8.try({
|
|
1540
|
+
try: () => JSON.parse(rumor.content),
|
|
1541
|
+
catch: () => {
|
|
1542
|
+
return;
|
|
1611
1543
|
}
|
|
1612
|
-
}
|
|
1544
|
+
}).pipe(Effect8.orElseSucceed(() => {
|
|
1545
|
+
return;
|
|
1546
|
+
}));
|
|
1547
|
+
if (parsed === undefined) {
|
|
1548
|
+
yield* storage.putGiftWrap({ id: remoteGw.id, createdAt: remoteGw.created_at });
|
|
1613
1549
|
return null;
|
|
1614
1550
|
}
|
|
1551
|
+
if (parsed === null || parsed._deleted) {
|
|
1552
|
+
kind = "d";
|
|
1553
|
+
} else {
|
|
1554
|
+
data = parsed;
|
|
1555
|
+
}
|
|
1556
|
+
const author = rumor.pubkey || undefined;
|
|
1615
1557
|
const event = {
|
|
1616
1558
|
id: rumor.id,
|
|
1617
1559
|
collection: collectionName,
|
|
1618
1560
|
recordId,
|
|
1619
1561
|
kind,
|
|
1620
1562
|
data,
|
|
1621
|
-
createdAt: rumor.created_at * 1000
|
|
1563
|
+
createdAt: rumor.created_at * 1000,
|
|
1564
|
+
author
|
|
1622
1565
|
};
|
|
1566
|
+
yield* storage.putGiftWrap({
|
|
1567
|
+
id: remoteGw.id,
|
|
1568
|
+
createdAt: remoteGw.created_at
|
|
1569
|
+
});
|
|
1623
1570
|
yield* storage.putEvent(event);
|
|
1624
|
-
yield* applyEvent(storage, event);
|
|
1571
|
+
const didApply = yield* applyEvent(storage, event);
|
|
1572
|
+
if (didApply && (kind === "u" || kind === "d")) {
|
|
1573
|
+
yield* pruneEvents(storage, collectionName, recordId, retention);
|
|
1574
|
+
}
|
|
1575
|
+
if (author && onNewAuthor) {
|
|
1576
|
+
onNewAuthor(author);
|
|
1577
|
+
}
|
|
1625
1578
|
return collectionName;
|
|
1626
1579
|
});
|
|
1627
|
-
|
|
1628
|
-
|
|
1580
|
+
const processRealtimeGiftWrap = (remoteGw) => Effect8.gen(function* () {
|
|
1581
|
+
const collection2 = yield* processGiftWrap(remoteGw).pipe(Effect8.orElseSucceed(() => null));
|
|
1582
|
+
if (collection2) {
|
|
1583
|
+
yield* notifyCollectionUpdated(collection2);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
const processRotationGiftWrap = (remoteGw) => Effect8.gen(function* () {
|
|
1587
|
+
const unwrapResult = yield* Effect8.result(Effect8.try({
|
|
1588
|
+
try: () => unwrapEvent(remoteGw, personalPrivateKey),
|
|
1589
|
+
catch: (e) => new CryptoError({
|
|
1590
|
+
message: `Rotation unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1591
|
+
cause: e
|
|
1592
|
+
})
|
|
1593
|
+
}));
|
|
1594
|
+
if (unwrapResult._tag === "Failure")
|
|
1595
|
+
return false;
|
|
1596
|
+
const rumor = unwrapResult.success;
|
|
1597
|
+
const dTag = rumor.tags.find((t) => t[0] === "d")?.[1];
|
|
1598
|
+
if (!dTag)
|
|
1599
|
+
return false;
|
|
1600
|
+
const removalNoticeOpt = parseRemovalNotice(rumor.content, dTag);
|
|
1601
|
+
if (Option5.isSome(removalNoticeOpt)) {
|
|
1602
|
+
if (onRemoved)
|
|
1603
|
+
onRemoved(removalNoticeOpt.value);
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
const rotationDataOpt = parseRotationEvent(rumor.content, dTag);
|
|
1607
|
+
if (Option5.isNone(rotationDataOpt))
|
|
1608
|
+
return false;
|
|
1609
|
+
const rotationData = rotationDataOpt.value;
|
|
1610
|
+
if (epochStore.epochs.has(rotationData.epochId))
|
|
1611
|
+
return false;
|
|
1612
|
+
const epoch = createEpochKey(rotationData.epochId, rotationData.epochKey, rumor.pubkey || "", rotationData.parentEpoch);
|
|
1613
|
+
addEpoch(epochStore, epoch);
|
|
1614
|
+
epochStore.currentEpochId = epoch.id;
|
|
1615
|
+
let membersChanged = false;
|
|
1616
|
+
for (const removedPubkey of rotationData.removedMembers) {
|
|
1617
|
+
const memberRecord = yield* storage.getRecord("_members", removedPubkey);
|
|
1618
|
+
if (memberRecord && !memberRecord.removedAt) {
|
|
1619
|
+
yield* storage.putRecord("_members", {
|
|
1620
|
+
...memberRecord,
|
|
1621
|
+
removedAt: Date.now(),
|
|
1622
|
+
removedInEpoch: epoch.id
|
|
1623
|
+
});
|
|
1624
|
+
yield* notifyChange(watchCtx, {
|
|
1625
|
+
collection: "_members",
|
|
1626
|
+
recordId: removedPubkey,
|
|
1627
|
+
kind: "update"
|
|
1628
|
+
});
|
|
1629
|
+
membersChanged = true;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
if (membersChanged && onMembersChanged)
|
|
1633
|
+
onMembersChanged();
|
|
1634
|
+
yield* handle.addEpochSubscription(epoch.publicKey);
|
|
1635
|
+
return true;
|
|
1636
|
+
});
|
|
1637
|
+
const subscribeAcrossRelays = (filter, onEvent) => Effect8.forEach(relayUrls, (url) => Effect8.gen(function* () {
|
|
1638
|
+
yield* relay.subscribe(filter, url, (event) => {
|
|
1639
|
+
forkHandled(onEvent(event));
|
|
1640
|
+
}).pipe(Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
|
|
1641
|
+
}), { discard: true });
|
|
1642
|
+
const syncRelay = (url, pubKeys, changedCollections) => Effect8.gen(function* () {
|
|
1643
|
+
const reconcileResult = yield* Effect8.result(reconcileWithRelay(storage, relay, url, Array.from(pubKeys)));
|
|
1644
|
+
if (reconcileResult._tag === "Failure") {
|
|
1645
|
+
onSyncError?.(reconcileResult.failure);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const { haveIds, needIds } = reconcileResult.success;
|
|
1649
|
+
if (needIds.length > 0) {
|
|
1650
|
+
const fetched = yield* relay.fetchEvents(needIds, url).pipe(Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.orElseSucceed(() => []));
|
|
1651
|
+
const sorted = [...fetched].sort((a, b) => a.created_at - b.created_at);
|
|
1652
|
+
yield* Effect8.forEach(sorted, (remoteGw) => Effect8.gen(function* () {
|
|
1653
|
+
const collection2 = yield* processGiftWrap(remoteGw).pipe(Effect8.orElseSucceed(() => null));
|
|
1654
|
+
if (collection2)
|
|
1655
|
+
changedCollections.add(collection2);
|
|
1656
|
+
}), { discard: true });
|
|
1657
|
+
}
|
|
1658
|
+
if (haveIds.length > 0) {
|
|
1659
|
+
yield* Effect8.forEach(haveIds, (id) => Effect8.gen(function* () {
|
|
1660
|
+
const gw = yield* storage.getGiftWrap(id);
|
|
1661
|
+
if (!gw?.event)
|
|
1662
|
+
return;
|
|
1663
|
+
yield* relay.publish(gw.event, [url]).pipe(Effect8.andThen(storage.stripGiftWrapBlob(id)), Effect8.tapError((err) => Effect8.sync(() => onSyncError?.(err))), Effect8.ignore);
|
|
1664
|
+
}), { discard: true });
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
const handle = {
|
|
1668
|
+
sync: () => Effect8.gen(function* () {
|
|
1629
1669
|
yield* syncStatus.set("syncing");
|
|
1630
|
-
yield*
|
|
1670
|
+
yield* Ref3.set(watchCtx.replayingRef, true);
|
|
1631
1671
|
const changedCollections = new Set;
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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 {
|
|
1672
|
+
yield* Effect8.gen(function* () {
|
|
1673
|
+
const pubKeys = getSubscriptionPubKeys();
|
|
1674
|
+
yield* Effect8.forEach(relayUrls, (url) => syncRelay(url, pubKeys, changedCollections), {
|
|
1675
|
+
discard: true
|
|
1676
|
+
});
|
|
1677
|
+
yield* publishQueue.flush(relayUrls).pipe(Effect8.ignore);
|
|
1678
|
+
}).pipe(Effect8.ensuring(Effect8.gen(function* () {
|
|
1660
1679
|
yield* notifyReplayComplete(watchCtx, [...changedCollections]);
|
|
1661
1680
|
yield* syncStatus.set("idle");
|
|
1662
|
-
}
|
|
1681
|
+
})));
|
|
1663
1682
|
}),
|
|
1664
|
-
publishLocal: (giftWrap) =>
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
console.error("[tablinum:publishLocal] relay error:", result.failure);
|
|
1669
|
-
if (onSyncError)
|
|
1670
|
-
onSyncError(result.failure);
|
|
1671
|
-
}
|
|
1683
|
+
publishLocal: (giftWrap) => Effect8.gen(function* () {
|
|
1684
|
+
if (!giftWrap.event)
|
|
1685
|
+
return;
|
|
1686
|
+
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
1687
|
}),
|
|
1673
|
-
startSubscription: () =>
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
-
}
|
|
1688
|
+
startSubscription: () => Effect8.gen(function* () {
|
|
1689
|
+
const pubKeys = getSubscriptionPubKeys();
|
|
1690
|
+
yield* subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": pubKeys }, processRealtimeGiftWrap);
|
|
1691
|
+
if (!pubKeys.includes(personalPublicKey)) {
|
|
1692
|
+
yield* subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": [personalPublicKey] }, (event) => Effect8.result(processRotationGiftWrap(event)).pipe(Effect8.asVoid));
|
|
1694
1693
|
}
|
|
1695
|
-
})
|
|
1694
|
+
}),
|
|
1695
|
+
addEpochSubscription: (publicKey) => subscribeAcrossRelays({ kinds: [GiftWrap2], "#p": [publicKey] }, processRealtimeGiftWrap)
|
|
1696
1696
|
};
|
|
1697
|
+
forkHandled(publishQueue.size().pipe(Effect8.flatMap((size) => Effect8.sync(() => {
|
|
1698
|
+
if (size > 0)
|
|
1699
|
+
scheduleAutoFlush();
|
|
1700
|
+
}))));
|
|
1701
|
+
return handle;
|
|
1697
1702
|
}
|
|
1698
1703
|
|
|
1699
|
-
// src/db/
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1704
|
+
// src/db/members.ts
|
|
1705
|
+
import { Effect as Effect9, Option as Option6, Schema as Schema5 } from "effect";
|
|
1706
|
+
var optionalString = {
|
|
1707
|
+
_tag: "FieldDef",
|
|
1708
|
+
kind: "string",
|
|
1709
|
+
isOptional: true,
|
|
1710
|
+
isArray: false
|
|
1711
|
+
};
|
|
1712
|
+
var optionalNumber = {
|
|
1713
|
+
_tag: "FieldDef",
|
|
1714
|
+
kind: "number",
|
|
1715
|
+
isOptional: true,
|
|
1716
|
+
isArray: false
|
|
1717
|
+
};
|
|
1718
|
+
var requiredNumber = {
|
|
1719
|
+
_tag: "FieldDef",
|
|
1720
|
+
kind: "number",
|
|
1721
|
+
isOptional: false,
|
|
1722
|
+
isArray: false
|
|
1723
|
+
};
|
|
1724
|
+
var requiredString = {
|
|
1725
|
+
_tag: "FieldDef",
|
|
1726
|
+
kind: "string",
|
|
1727
|
+
isOptional: false,
|
|
1728
|
+
isArray: false
|
|
1729
|
+
};
|
|
1730
|
+
var membersCollectionDef = {
|
|
1731
|
+
_tag: "CollectionDef",
|
|
1732
|
+
name: "_members",
|
|
1733
|
+
fields: {
|
|
1734
|
+
name: optionalString,
|
|
1735
|
+
picture: optionalString,
|
|
1736
|
+
about: optionalString,
|
|
1737
|
+
nip05: optionalString,
|
|
1738
|
+
addedAt: requiredNumber,
|
|
1739
|
+
addedInEpoch: requiredString,
|
|
1740
|
+
removedAt: optionalNumber,
|
|
1741
|
+
removedInEpoch: optionalString
|
|
1742
|
+
},
|
|
1743
|
+
indices: [],
|
|
1744
|
+
eventRetention: 1
|
|
1745
|
+
};
|
|
1746
|
+
var AuthorProfileSchema = Schema5.Struct({
|
|
1747
|
+
name: Schema5.optionalKey(Schema5.String),
|
|
1748
|
+
picture: Schema5.optionalKey(Schema5.String),
|
|
1749
|
+
about: Schema5.optionalKey(Schema5.String),
|
|
1750
|
+
nip05: Schema5.optionalKey(Schema5.String)
|
|
1751
|
+
});
|
|
1752
|
+
var decodeAuthorProfile = Schema5.decodeUnknownEffect(Schema5.fromJsonString(AuthorProfileSchema));
|
|
1753
|
+
function fetchAuthorProfile(relay, relayUrls, pubkey) {
|
|
1754
|
+
return Effect9.gen(function* () {
|
|
1755
|
+
for (const url of relayUrls) {
|
|
1756
|
+
const result = yield* Effect9.result(relay.fetchByFilter({ kinds: [0], authors: [pubkey], limit: 1 }, url));
|
|
1757
|
+
if (result._tag === "Success" && result.success.length > 0) {
|
|
1758
|
+
return yield* decodeAuthorProfile(result.success[0].content).pipe(Effect9.map(Option6.some), Effect9.orElseSucceed(() => Option6.none()));
|
|
1759
|
+
}
|
|
1706
1760
|
}
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1761
|
+
return Option6.none();
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// src/services/Identity.ts
|
|
1766
|
+
import { ServiceMap as ServiceMap3 } from "effect";
|
|
1767
|
+
|
|
1768
|
+
class Identity extends ServiceMap3.Service()("tablinum/Identity") {
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// src/services/EpochStore.ts
|
|
1772
|
+
import { ServiceMap as ServiceMap4 } from "effect";
|
|
1773
|
+
|
|
1774
|
+
class EpochStore extends ServiceMap4.Service()("tablinum/EpochStore") {
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// src/services/Storage.ts
|
|
1778
|
+
import { ServiceMap as ServiceMap5 } from "effect";
|
|
1779
|
+
|
|
1780
|
+
class Storage extends ServiceMap5.Service()("tablinum/Storage") {
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// src/services/Relay.ts
|
|
1784
|
+
import { ServiceMap as ServiceMap6 } from "effect";
|
|
1785
|
+
|
|
1786
|
+
class Relay extends ServiceMap6.Service()("tablinum/Relay") {
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// src/services/GiftWrap.ts
|
|
1790
|
+
import { ServiceMap as ServiceMap7 } from "effect";
|
|
1791
|
+
|
|
1792
|
+
class GiftWrap3 extends ServiceMap7.Service()("tablinum/GiftWrap") {
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// src/services/PublishQueue.ts
|
|
1796
|
+
import { ServiceMap as ServiceMap8 } from "effect";
|
|
1797
|
+
|
|
1798
|
+
class PublishQueue extends ServiceMap8.Service()("tablinum/PublishQueue") {
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// src/services/SyncStatus.ts
|
|
1802
|
+
import { ServiceMap as ServiceMap9 } from "effect";
|
|
1803
|
+
|
|
1804
|
+
class SyncStatus extends ServiceMap9.Service()("tablinum/SyncStatus") {
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// src/layers/IdentityLive.ts
|
|
1808
|
+
import { Effect as Effect11, Layer } from "effect";
|
|
1809
|
+
import { hexToBytes as hexToBytes3 } from "@noble/hashes/utils.js";
|
|
1810
|
+
|
|
1811
|
+
// src/db/identity.ts
|
|
1812
|
+
import { Effect as Effect10 } from "effect";
|
|
1813
|
+
import { getPublicKey as getPublicKey2 } from "nostr-tools/pure";
|
|
1814
|
+
import { bytesToHex as bytesToHex2 } from "@noble/hashes/utils.js";
|
|
1815
|
+
function createIdentity(suppliedKey) {
|
|
1816
|
+
return Effect10.gen(function* () {
|
|
1817
|
+
let privateKey;
|
|
1818
|
+
if (suppliedKey) {
|
|
1819
|
+
if (suppliedKey.length !== 32) {
|
|
1820
|
+
return yield* new CryptoError({
|
|
1821
|
+
message: `Private key must be 32 bytes, got ${suppliedKey.length}`
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
privateKey = suppliedKey;
|
|
1825
|
+
} else {
|
|
1826
|
+
privateKey = new Uint8Array(32);
|
|
1827
|
+
crypto.getRandomValues(privateKey);
|
|
1712
1828
|
}
|
|
1713
|
-
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1829
|
+
const privateKeyHex = bytesToHex2(privateKey);
|
|
1830
|
+
const publicKey = yield* Effect10.try({
|
|
1831
|
+
try: () => getPublicKey2(privateKey),
|
|
1832
|
+
catch: (e) => new CryptoError({
|
|
1833
|
+
message: `Failed to derive public key: ${e instanceof Error ? e.message : String(e)}`,
|
|
1834
|
+
cause: e
|
|
1835
|
+
})
|
|
1836
|
+
});
|
|
1837
|
+
return {
|
|
1838
|
+
privateKey,
|
|
1839
|
+
publicKey,
|
|
1840
|
+
exportKey: () => privateKeyHex
|
|
1841
|
+
};
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// src/layers/IdentityLive.ts
|
|
1846
|
+
var IdentityLive = Layer.effect(Identity, Effect11.gen(function* () {
|
|
1847
|
+
const config = yield* Config;
|
|
1848
|
+
const storage = yield* Storage;
|
|
1849
|
+
const idbKey = yield* storage.getMeta("identity_key");
|
|
1850
|
+
const resolvedKey = config.privateKey ?? (typeof idbKey === "string" && idbKey.length === 64 ? hexToBytes3(idbKey) : undefined);
|
|
1851
|
+
const identity = yield* createIdentity(resolvedKey);
|
|
1852
|
+
yield* storage.putMeta("identity_key", identity.exportKey());
|
|
1853
|
+
return identity;
|
|
1854
|
+
}));
|
|
1855
|
+
|
|
1856
|
+
// src/layers/EpochStoreLive.ts
|
|
1857
|
+
import { Effect as Effect12, Layer as Layer2, Option as Option7 } from "effect";
|
|
1858
|
+
import { generateSecretKey as generateSecretKey2 } from "nostr-tools/pure";
|
|
1859
|
+
import { bytesToHex as bytesToHex3 } from "@noble/hashes/utils.js";
|
|
1860
|
+
var EpochStoreLive = Layer2.effect(EpochStore, Effect12.gen(function* () {
|
|
1861
|
+
const config = yield* Config;
|
|
1862
|
+
const identity = yield* Identity;
|
|
1863
|
+
const storage = yield* Storage;
|
|
1864
|
+
const idbRaw = yield* storage.getMeta("epochs");
|
|
1865
|
+
if (typeof idbRaw === "string") {
|
|
1866
|
+
const idbStore = deserializeEpochStore(idbRaw);
|
|
1867
|
+
if (Option7.isSome(idbStore)) {
|
|
1868
|
+
return idbStore.value;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
if (config.epochKeys && config.epochKeys.length > 0) {
|
|
1872
|
+
const store2 = createEpochStoreFromInputs(config.epochKeys);
|
|
1873
|
+
yield* storage.putMeta("epochs", stringifyEpochStore(store2));
|
|
1874
|
+
return store2;
|
|
1875
|
+
}
|
|
1876
|
+
const store = createEpochStoreFromInputs([{ epochId: EpochId("epoch-0"), key: bytesToHex3(generateSecretKey2()) }], { createdBy: identity.publicKey });
|
|
1877
|
+
yield* storage.putMeta("epochs", stringifyEpochStore(store));
|
|
1878
|
+
return store;
|
|
1879
|
+
}));
|
|
1880
|
+
|
|
1881
|
+
// src/layers/StorageLive.ts
|
|
1882
|
+
import { Effect as Effect14, Layer as Layer3 } from "effect";
|
|
1883
|
+
|
|
1884
|
+
// src/storage/idb.ts
|
|
1885
|
+
import { Effect as Effect13 } from "effect";
|
|
1886
|
+
import { openDB } from "idb";
|
|
1887
|
+
var DB_NAME = "tablinum";
|
|
1888
|
+
function storeName(collection2) {
|
|
1889
|
+
return `col_${collection2}`;
|
|
1890
|
+
}
|
|
1891
|
+
function computeSchemaSig(schema) {
|
|
1892
|
+
return Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)).map(([name, def]) => {
|
|
1893
|
+
const indices = [...def.indices ?? []].sort().join(",");
|
|
1894
|
+
return `${name}:${indices}`;
|
|
1895
|
+
}).join("|");
|
|
1896
|
+
}
|
|
1897
|
+
function wrap(label, fn) {
|
|
1898
|
+
return Effect13.tryPromise({
|
|
1899
|
+
try: fn,
|
|
1900
|
+
catch: (e) => new StorageError({
|
|
1901
|
+
message: `IndexedDB ${label} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1902
|
+
cause: e
|
|
1903
|
+
})
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
function upgradeSchema(database, schema, tx) {
|
|
1907
|
+
if (!database.objectStoreNames.contains("_meta")) {
|
|
1908
|
+
database.createObjectStore("_meta");
|
|
1909
|
+
}
|
|
1910
|
+
if (!database.objectStoreNames.contains("events")) {
|
|
1911
|
+
const events = database.createObjectStore("events", { keyPath: "id" });
|
|
1912
|
+
events.createIndex("by-record", ["collection", "recordId"]);
|
|
1913
|
+
}
|
|
1914
|
+
if (!database.objectStoreNames.contains("giftwraps")) {
|
|
1915
|
+
database.createObjectStore("giftwraps", { keyPath: "id" });
|
|
1916
|
+
}
|
|
1917
|
+
const expectedStores = new Set;
|
|
1918
|
+
for (const [, def] of Object.entries(schema)) {
|
|
1919
|
+
const sn = storeName(def.name);
|
|
1920
|
+
expectedStores.add(sn);
|
|
1921
|
+
if (!database.objectStoreNames.contains(sn)) {
|
|
1922
|
+
const store = database.createObjectStore(sn, { keyPath: "id" });
|
|
1923
|
+
for (const idx of def.indices ?? []) {
|
|
1924
|
+
store.createIndex(idx, idx);
|
|
1925
|
+
}
|
|
1926
|
+
} else {
|
|
1927
|
+
const store = tx.objectStore(sn);
|
|
1928
|
+
const existingIndices = new Set(Array.from(store.indexNames));
|
|
1929
|
+
const wantedIndices = new Set(def.indices ?? []);
|
|
1930
|
+
for (const idx of existingIndices) {
|
|
1931
|
+
if (!wantedIndices.has(idx))
|
|
1932
|
+
store.deleteIndex(idx);
|
|
1933
|
+
}
|
|
1934
|
+
for (const idx of wantedIndices) {
|
|
1935
|
+
if (!existingIndices.has(idx))
|
|
1936
|
+
store.createIndex(idx, idx);
|
|
1723
1937
|
}
|
|
1724
1938
|
}
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
const
|
|
1736
|
-
const
|
|
1737
|
-
const
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1939
|
+
}
|
|
1940
|
+
for (const existing of Array.from(database.objectStoreNames)) {
|
|
1941
|
+
if (existing.startsWith("col_") && !expectedStores.has(existing)) {
|
|
1942
|
+
database.deleteObjectStore(existing);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
tx.objectStore("_meta").put(computeSchemaSig(schema), "schema_sig");
|
|
1946
|
+
}
|
|
1947
|
+
function openIDBStorage(dbName, schema) {
|
|
1948
|
+
return Effect13.gen(function* () {
|
|
1949
|
+
const name = dbName ?? DB_NAME;
|
|
1950
|
+
const schemaSig = computeSchemaSig(schema);
|
|
1951
|
+
const probeDb = yield* Effect13.tryPromise({
|
|
1952
|
+
try: () => openDB(name),
|
|
1953
|
+
catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
|
|
1954
|
+
});
|
|
1955
|
+
const currentVersion = probeDb.version;
|
|
1956
|
+
let needsUpgrade = true;
|
|
1957
|
+
if (probeDb.objectStoreNames.contains("_meta")) {
|
|
1958
|
+
const storedSig = yield* Effect13.tryPromise({
|
|
1959
|
+
try: () => probeDb.get("_meta", "schema_sig"),
|
|
1960
|
+
catch: () => new StorageError({ message: "Failed to read schema meta" })
|
|
1961
|
+
}).pipe(Effect13.catch(() => Effect13.succeed(undefined)));
|
|
1962
|
+
needsUpgrade = storedSig !== schemaSig;
|
|
1963
|
+
}
|
|
1964
|
+
probeDb.close();
|
|
1965
|
+
const db = needsUpgrade ? yield* Effect13.tryPromise({
|
|
1966
|
+
try: () => openDB(name, currentVersion + 1, {
|
|
1967
|
+
upgrade(database, _oldVersion, _newVersion, transaction) {
|
|
1968
|
+
upgradeSchema(database, schema, transaction);
|
|
1969
|
+
}
|
|
1970
|
+
}),
|
|
1971
|
+
catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
|
|
1972
|
+
}) : yield* Effect13.tryPromise({
|
|
1973
|
+
try: () => openDB(name),
|
|
1974
|
+
catch: (e) => new StorageError({ message: "Failed to open IndexedDB", cause: e })
|
|
1975
|
+
});
|
|
1976
|
+
yield* Effect13.addFinalizer(() => Effect13.sync(() => db.close()));
|
|
1977
|
+
const handle = {
|
|
1978
|
+
putRecord: (collection2, record) => wrap("putRecord", () => db.put(storeName(collection2), record).then(() => {
|
|
1979
|
+
return;
|
|
1980
|
+
})),
|
|
1981
|
+
getRecord: (collection2, id) => wrap("getRecord", () => db.get(storeName(collection2), id)),
|
|
1982
|
+
getAllRecords: (collection2) => wrap("getAllRecords", () => db.getAll(storeName(collection2))),
|
|
1983
|
+
countRecords: (collection2) => wrap("countRecords", () => db.count(storeName(collection2))),
|
|
1984
|
+
clearRecords: (collection2) => wrap("clearRecords", () => db.clear(storeName(collection2))),
|
|
1985
|
+
getByIndex: (collection2, indexName, value) => wrap("getByIndex", () => db.getAllFromIndex(storeName(collection2), indexName, value)),
|
|
1986
|
+
getByIndexRange: (collection2, indexName, range) => wrap("getByIndexRange", () => db.getAllFromIndex(storeName(collection2), indexName, range)),
|
|
1987
|
+
getAllSorted: (collection2, indexName, direction) => wrap("getAllSorted", async () => {
|
|
1988
|
+
const sn = storeName(collection2);
|
|
1989
|
+
const tx = db.transaction(sn, "readonly");
|
|
1990
|
+
const store = tx.objectStore(sn);
|
|
1991
|
+
const index = store.index(indexName);
|
|
1992
|
+
const results = [];
|
|
1993
|
+
let cursor = await index.openCursor(null, direction ?? "next");
|
|
1994
|
+
while (cursor) {
|
|
1995
|
+
results.push(cursor.value);
|
|
1996
|
+
cursor = await cursor.continue();
|
|
1997
|
+
}
|
|
1998
|
+
return results;
|
|
1999
|
+
}),
|
|
2000
|
+
putEvent: (event) => wrap("putEvent", () => db.put("events", event).then(() => {
|
|
2001
|
+
return;
|
|
2002
|
+
})),
|
|
2003
|
+
getEvent: (id) => wrap("getEvent", () => db.get("events", id)),
|
|
2004
|
+
getAllEvents: () => wrap("getAllEvents", () => db.getAll("events")),
|
|
2005
|
+
getEventsByRecord: (collection2, recordId) => wrap("getEventsByRecord", () => db.getAllFromIndex("events", "by-record", [collection2, recordId])),
|
|
2006
|
+
putGiftWrap: (gw) => wrap("putGiftWrap", () => db.put("giftwraps", gw).then(() => {
|
|
2007
|
+
return;
|
|
2008
|
+
})),
|
|
2009
|
+
getGiftWrap: (id) => wrap("getGiftWrap", () => db.get("giftwraps", id)),
|
|
2010
|
+
getAllGiftWraps: () => wrap("getAllGiftWraps", () => db.getAll("giftwraps")),
|
|
2011
|
+
deleteGiftWrap: (id) => wrap("deleteGiftWrap", () => db.delete("giftwraps", id).then(() => {
|
|
2012
|
+
return;
|
|
2013
|
+
})),
|
|
2014
|
+
stripGiftWrapBlob: (id) => wrap("stripGiftWrapBlob", async () => {
|
|
2015
|
+
const existing = await db.get("giftwraps", id);
|
|
2016
|
+
if (existing) {
|
|
2017
|
+
await db.put("giftwraps", { id: existing.id, createdAt: existing.createdAt });
|
|
2018
|
+
}
|
|
2019
|
+
}),
|
|
2020
|
+
deleteEvent: (id) => wrap("deleteEvent", () => db.delete("events", id).then(() => {
|
|
2021
|
+
return;
|
|
2022
|
+
})),
|
|
2023
|
+
stripEventData: (id) => wrap("stripEventData", async () => {
|
|
2024
|
+
const existing = await db.get("events", id);
|
|
2025
|
+
if (existing) {
|
|
2026
|
+
await db.put("events", { ...existing, data: null });
|
|
2027
|
+
}
|
|
2028
|
+
}),
|
|
2029
|
+
getMeta: (key) => wrap("getMeta", () => db.get("_meta", key)),
|
|
2030
|
+
putMeta: (key, value) => wrap("putMeta", () => db.put("_meta", value, key).then(() => {
|
|
2031
|
+
return;
|
|
2032
|
+
})),
|
|
2033
|
+
close: () => Effect13.sync(() => db.close())
|
|
2034
|
+
};
|
|
2035
|
+
return handle;
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/layers/StorageLive.ts
|
|
2040
|
+
var StorageLive = Layer3.effect(Storage, Effect14.gen(function* () {
|
|
2041
|
+
const config = yield* Config;
|
|
2042
|
+
return yield* openIDBStorage(config.dbName, {
|
|
2043
|
+
...config.schema,
|
|
2044
|
+
_members: membersCollectionDef
|
|
2045
|
+
});
|
|
2046
|
+
}));
|
|
2047
|
+
|
|
2048
|
+
// src/layers/RelayLive.ts
|
|
2049
|
+
import { Layer as Layer4 } from "effect";
|
|
2050
|
+
|
|
2051
|
+
// src/sync/relay.ts
|
|
2052
|
+
import { Effect as Effect15, Option as Option8, Schema as Schema6, ScopedCache, Scope as Scope3 } from "effect";
|
|
2053
|
+
import { Relay as Relay2 } from "nostr-tools/relay";
|
|
2054
|
+
var NegMessageFrameSchema = Schema6.Tuple([
|
|
2055
|
+
Schema6.Literal("NEG-MSG"),
|
|
2056
|
+
Schema6.String,
|
|
2057
|
+
Schema6.String
|
|
2058
|
+
]);
|
|
2059
|
+
var NegErrorFrameSchema = Schema6.Tuple([Schema6.Literal("NEG-ERR"), Schema6.String, Schema6.String]);
|
|
2060
|
+
var decodeNegFrame = Schema6.decodeUnknownEffect(Schema6.fromJsonString(Schema6.Union([NegMessageFrameSchema, NegErrorFrameSchema])));
|
|
2061
|
+
function parseNegMessageFrame(data) {
|
|
2062
|
+
return Effect15.runSync(decodeNegFrame(data).pipe(Effect15.map(Option8.some), Effect15.orElseSucceed(() => Option8.none())));
|
|
2063
|
+
}
|
|
2064
|
+
function createRelayHandle() {
|
|
2065
|
+
return Effect15.gen(function* () {
|
|
2066
|
+
const relayScope = yield* Effect15.scope;
|
|
2067
|
+
const connections = yield* ScopedCache.make({
|
|
2068
|
+
capacity: 64,
|
|
2069
|
+
lookup: (url) => Effect15.acquireRelease(Effect15.tryPromise({
|
|
2070
|
+
try: () => Relay2.connect(url),
|
|
2071
|
+
catch: (e) => new RelayError({
|
|
2072
|
+
message: `Connect to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2073
|
+
url,
|
|
2074
|
+
cause: e
|
|
2075
|
+
})
|
|
2076
|
+
}), (relay) => Effect15.sync(() => {
|
|
2077
|
+
relay.close();
|
|
2078
|
+
}))
|
|
2079
|
+
});
|
|
2080
|
+
const connectedUrls = new Set;
|
|
2081
|
+
const statusListeners = new Set;
|
|
2082
|
+
const notifyStatus = () => {
|
|
2083
|
+
const status = { connectedUrls: [...connectedUrls] };
|
|
2084
|
+
for (const listener of statusListeners)
|
|
2085
|
+
listener(status);
|
|
2086
|
+
};
|
|
2087
|
+
const markConnected = (url) => {
|
|
2088
|
+
if (!connectedUrls.has(url)) {
|
|
2089
|
+
connectedUrls.add(url);
|
|
2090
|
+
notifyStatus();
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
const markDisconnected = (url) => {
|
|
2094
|
+
if (connectedUrls.has(url)) {
|
|
2095
|
+
connectedUrls.delete(url);
|
|
2096
|
+
notifyStatus();
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
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)));
|
|
2100
|
+
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))))));
|
|
2101
|
+
const collectEvents = (url, filters) => withRelay(url, (relay) => Effect15.callback((resume) => {
|
|
2102
|
+
const events = [];
|
|
2103
|
+
let settled = false;
|
|
2104
|
+
let timer;
|
|
2105
|
+
let sub;
|
|
2106
|
+
const cleanup = () => {
|
|
2107
|
+
settled = true;
|
|
2108
|
+
if (timer !== undefined) {
|
|
2109
|
+
clearTimeout(timer);
|
|
2110
|
+
timer = undefined;
|
|
2111
|
+
}
|
|
2112
|
+
sub?.close();
|
|
2113
|
+
sub = undefined;
|
|
2114
|
+
};
|
|
2115
|
+
const fail = (e) => resume(Effect15.fail(new RelayError({
|
|
2116
|
+
message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2117
|
+
url,
|
|
2118
|
+
cause: e
|
|
2119
|
+
})));
|
|
2120
|
+
try {
|
|
2121
|
+
sub = relay.subscribe([...filters], {
|
|
2122
|
+
onevent(evt) {
|
|
2123
|
+
if (!settled) {
|
|
2124
|
+
events.push(evt);
|
|
2125
|
+
}
|
|
2126
|
+
},
|
|
2127
|
+
oneose() {
|
|
2128
|
+
if (settled)
|
|
2129
|
+
return;
|
|
2130
|
+
cleanup();
|
|
2131
|
+
resume(Effect15.succeed(events));
|
|
1771
2132
|
}
|
|
1772
2133
|
});
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
2134
|
+
timer = setTimeout(() => {
|
|
2135
|
+
if (settled)
|
|
2136
|
+
return;
|
|
2137
|
+
cleanup();
|
|
2138
|
+
resume(Effect15.succeed(events));
|
|
2139
|
+
}, 1e4);
|
|
2140
|
+
} catch (e) {
|
|
2141
|
+
cleanup();
|
|
2142
|
+
fail(e);
|
|
1779
2143
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2144
|
+
return Effect15.sync(cleanup);
|
|
2145
|
+
}));
|
|
2146
|
+
return {
|
|
2147
|
+
publish: (event, urls) => Effect15.gen(function* () {
|
|
2148
|
+
const results = yield* Effect15.forEach(urls, (url) => Effect15.result(withRelay(url, (relay) => Effect15.tryPromise({
|
|
2149
|
+
try: () => relay.publish(event),
|
|
2150
|
+
catch: (e) => new RelayError({
|
|
2151
|
+
message: `Publish to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2152
|
+
url,
|
|
2153
|
+
cause: e
|
|
2154
|
+
})
|
|
2155
|
+
}).pipe(Effect15.timeoutOrElse({
|
|
2156
|
+
duration: "10 seconds",
|
|
2157
|
+
onTimeout: () => Effect15.fail(new RelayError({ message: `Publish to ${url} timed out`, url }))
|
|
2158
|
+
})))), { concurrency: "unbounded" });
|
|
2159
|
+
const failures = results.filter((r) => r._tag === "Failure");
|
|
2160
|
+
if (failures.length === urls.length && urls.length > 0) {
|
|
2161
|
+
return yield* new RelayError({
|
|
2162
|
+
message: `Publish failed on all ${urls.length} relays`
|
|
2163
|
+
});
|
|
1794
2164
|
}
|
|
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
2165
|
}),
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2166
|
+
fetchEvents: (ids, url) => Effect15.gen(function* () {
|
|
2167
|
+
if (ids.length === 0)
|
|
2168
|
+
return [];
|
|
2169
|
+
return yield* collectEvents(url, [{ ids }]);
|
|
2170
|
+
}),
|
|
2171
|
+
fetchByFilter: (filter, url) => collectEvents(url, [filter]),
|
|
2172
|
+
subscribe: (filter, url, onEvent) => withRelay(url, (relay) => Effect15.acquireRelease(Effect15.try({
|
|
2173
|
+
try: () => relay.subscribe([filter], {
|
|
2174
|
+
onevent(evt) {
|
|
2175
|
+
onEvent(evt);
|
|
2176
|
+
},
|
|
2177
|
+
oneose() {}
|
|
2178
|
+
}),
|
|
2179
|
+
catch: (e) => new RelayError({
|
|
2180
|
+
message: `Subscribe to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2181
|
+
url,
|
|
2182
|
+
cause: e
|
|
2183
|
+
})
|
|
2184
|
+
}), (sub) => Effect15.sync(() => {
|
|
2185
|
+
sub.close();
|
|
2186
|
+
})).pipe(Effect15.provideService(Scope3.Scope, relayScope), Effect15.asVoid)),
|
|
2187
|
+
sendNegMsg: (url, subId, filter, msgHex) => withRelay(url, (relay) => Effect15.callback((resume) => {
|
|
2188
|
+
let settled = false;
|
|
2189
|
+
let timer;
|
|
2190
|
+
let sub;
|
|
2191
|
+
let ws;
|
|
2192
|
+
const cleanup = () => {
|
|
2193
|
+
settled = true;
|
|
2194
|
+
if (timer !== undefined) {
|
|
2195
|
+
clearTimeout(timer);
|
|
2196
|
+
timer = undefined;
|
|
2197
|
+
}
|
|
2198
|
+
sub?.close();
|
|
2199
|
+
sub = undefined;
|
|
2200
|
+
ws?.removeEventListener("message", handler);
|
|
2201
|
+
ws = undefined;
|
|
2202
|
+
};
|
|
2203
|
+
const fail = (e) => resume(Effect15.fail(new RelayError({
|
|
2204
|
+
message: `NIP-77 negotiation with ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2205
|
+
url,
|
|
2206
|
+
cause: e
|
|
2207
|
+
})));
|
|
2208
|
+
const handler = (msg) => {
|
|
2209
|
+
if (settled || typeof msg.data !== "string")
|
|
2210
|
+
return;
|
|
2211
|
+
const frameOpt = parseNegMessageFrame(msg.data);
|
|
2212
|
+
if (Option8.isNone(frameOpt) || frameOpt.value[1] !== subId)
|
|
2213
|
+
return;
|
|
2214
|
+
const frame = frameOpt.value;
|
|
2215
|
+
cleanup();
|
|
2216
|
+
if (frame[0] === "NEG-MSG") {
|
|
2217
|
+
resume(Effect15.succeed({
|
|
2218
|
+
msgHex: frame[2],
|
|
2219
|
+
haveIds: [],
|
|
2220
|
+
needIds: []
|
|
2221
|
+
}));
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
fail(new Error(`NEG-ERR: ${frame[2]}`));
|
|
2225
|
+
};
|
|
2226
|
+
try {
|
|
2227
|
+
sub = relay.subscribe([filter], {
|
|
2228
|
+
onevent() {},
|
|
2229
|
+
oneose() {}
|
|
2230
|
+
});
|
|
2231
|
+
ws = relay._ws || relay.ws;
|
|
2232
|
+
if (!ws) {
|
|
2233
|
+
cleanup();
|
|
2234
|
+
fail(new Error("Cannot access relay WebSocket"));
|
|
2235
|
+
return Effect15.succeed(undefined);
|
|
2236
|
+
}
|
|
2237
|
+
timer = setTimeout(() => {
|
|
2238
|
+
if (settled)
|
|
2239
|
+
return;
|
|
2240
|
+
cleanup();
|
|
2241
|
+
fail(new Error("NIP-77 negotiation timeout"));
|
|
2242
|
+
}, 30000);
|
|
2243
|
+
ws.addEventListener("message", handler);
|
|
2244
|
+
ws.send(JSON.stringify(["NEG-OPEN", subId, filter, msgHex]));
|
|
2245
|
+
} catch (e) {
|
|
2246
|
+
cleanup();
|
|
2247
|
+
fail(e);
|
|
1807
2248
|
}
|
|
1808
|
-
|
|
2249
|
+
return Effect15.sync(cleanup);
|
|
2250
|
+
})),
|
|
2251
|
+
closeAll: () => ScopedCache.invalidateAll(connections),
|
|
2252
|
+
getStatus: () => ({ connectedUrls: [...connectedUrls] }),
|
|
2253
|
+
subscribeStatus: (callback) => {
|
|
2254
|
+
statusListeners.add(callback);
|
|
2255
|
+
return () => statusListeners.delete(callback);
|
|
2256
|
+
}
|
|
2257
|
+
};
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// src/layers/RelayLive.ts
|
|
2262
|
+
var RelayLive = Layer4.effect(Relay, createRelayHandle());
|
|
2263
|
+
|
|
2264
|
+
// src/layers/GiftWrapLive.ts
|
|
2265
|
+
import { Effect as Effect17, Layer as Layer5 } from "effect";
|
|
2266
|
+
|
|
2267
|
+
// src/sync/gift-wrap.ts
|
|
2268
|
+
import { Effect as Effect16 } from "effect";
|
|
2269
|
+
import { wrapEvent as wrapEvent2, unwrapEvent as unwrapEvent2 } from "nostr-tools/nip59";
|
|
2270
|
+
function createEpochGiftWrapHandle(senderPrivateKey, epochStore) {
|
|
2271
|
+
return {
|
|
2272
|
+
wrap: (rumor) => Effect16.try({
|
|
2273
|
+
try: () => wrapEvent2(rumor, senderPrivateKey, getCurrentPublicKey(epochStore)),
|
|
2274
|
+
catch: (e) => new CryptoError({
|
|
2275
|
+
message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2276
|
+
cause: e
|
|
2277
|
+
})
|
|
2278
|
+
}),
|
|
2279
|
+
unwrap: (giftWrap) => Effect16.gen(function* () {
|
|
2280
|
+
const pTag = giftWrap.tags.find((t) => t[0] === "p")?.[1];
|
|
2281
|
+
if (!pTag) {
|
|
2282
|
+
return yield* new CryptoError({ message: "Gift wrap missing #p tag" });
|
|
2283
|
+
}
|
|
2284
|
+
const decKey = getDecryptionKey(epochStore, pTag);
|
|
2285
|
+
if (!decKey) {
|
|
2286
|
+
return yield* new CryptoError({
|
|
2287
|
+
message: `No epoch key for public key ${pTag.slice(0, 8)}...`
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
return yield* Effect16.try({
|
|
2291
|
+
try: () => unwrapEvent2(giftWrap, decKey),
|
|
2292
|
+
catch: (e) => new CryptoError({
|
|
2293
|
+
message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
2294
|
+
cause: e
|
|
2295
|
+
})
|
|
2296
|
+
});
|
|
2297
|
+
})
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// src/layers/GiftWrapLive.ts
|
|
2302
|
+
var GiftWrapLive = Layer5.effect(GiftWrap3, Effect17.gen(function* () {
|
|
2303
|
+
const identity = yield* Identity;
|
|
2304
|
+
const epochStore = yield* EpochStore;
|
|
2305
|
+
return createEpochGiftWrapHandle(identity.privateKey, epochStore);
|
|
2306
|
+
}));
|
|
2307
|
+
|
|
2308
|
+
// src/layers/PublishQueueLive.ts
|
|
2309
|
+
import { Effect as Effect19, Layer as Layer6 } from "effect";
|
|
2310
|
+
|
|
2311
|
+
// src/sync/publish-queue.ts
|
|
2312
|
+
import { Effect as Effect18, Ref as Ref4 } from "effect";
|
|
2313
|
+
var META_KEY = "publish_queue";
|
|
2314
|
+
function persist(storage, pending) {
|
|
2315
|
+
return storage.putMeta(META_KEY, [...pending]);
|
|
2316
|
+
}
|
|
2317
|
+
function createPublishQueue(storage, relay) {
|
|
2318
|
+
return Effect18.gen(function* () {
|
|
2319
|
+
const stored = yield* storage.getMeta(META_KEY);
|
|
2320
|
+
const initial = Array.isArray(stored) ? new Set(stored) : new Set;
|
|
2321
|
+
const pendingRef = yield* Ref4.make(initial);
|
|
2322
|
+
const listeners = new Set;
|
|
2323
|
+
const notify = (pending) => {
|
|
2324
|
+
for (const listener of listeners)
|
|
2325
|
+
listener(pending.size);
|
|
2326
|
+
};
|
|
2327
|
+
return {
|
|
2328
|
+
enqueue: (eventId) => Effect18.gen(function* () {
|
|
2329
|
+
const next = yield* Ref4.updateAndGet(pendingRef, (set) => {
|
|
2330
|
+
const n = new Set(set);
|
|
2331
|
+
n.add(eventId);
|
|
2332
|
+
return n;
|
|
2333
|
+
});
|
|
2334
|
+
yield* persist(storage, next);
|
|
2335
|
+
notify(next);
|
|
1809
2336
|
}),
|
|
1810
|
-
|
|
1811
|
-
const
|
|
1812
|
-
if (
|
|
1813
|
-
return
|
|
2337
|
+
flush: (relayUrls) => Effect18.gen(function* () {
|
|
2338
|
+
const pending = yield* Ref4.get(pendingRef);
|
|
2339
|
+
if (pending.size === 0)
|
|
2340
|
+
return;
|
|
2341
|
+
const succeeded = new Set;
|
|
2342
|
+
let consecutiveFailures = 0;
|
|
2343
|
+
for (const eventId of pending) {
|
|
2344
|
+
if (consecutiveFailures >= 3)
|
|
2345
|
+
break;
|
|
2346
|
+
const gw = yield* storage.getGiftWrap(eventId);
|
|
2347
|
+
if (!gw || !gw.event) {
|
|
2348
|
+
succeeded.add(eventId);
|
|
2349
|
+
consecutiveFailures = 0;
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
const result = yield* Effect18.result(relay.publish(gw.event, relayUrls));
|
|
2353
|
+
if (result._tag === "Success") {
|
|
2354
|
+
succeeded.add(eventId);
|
|
2355
|
+
yield* storage.stripGiftWrapBlob(eventId);
|
|
2356
|
+
consecutiveFailures = 0;
|
|
2357
|
+
} else {
|
|
2358
|
+
consecutiveFailures++;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
if (succeeded.size > 0) {
|
|
2362
|
+
const updated = yield* Ref4.updateAndGet(pendingRef, (set) => {
|
|
2363
|
+
const next = new Set(set);
|
|
2364
|
+
for (const id of succeeded) {
|
|
2365
|
+
next.delete(id);
|
|
2366
|
+
}
|
|
2367
|
+
return next;
|
|
2368
|
+
});
|
|
2369
|
+
yield* persist(storage, updated);
|
|
2370
|
+
notify(updated);
|
|
1814
2371
|
}
|
|
1815
|
-
yield* syncHandle.sync();
|
|
1816
2372
|
}),
|
|
1817
|
-
|
|
2373
|
+
size: () => Ref4.get(pendingRef).pipe(Effect18.map((s) => s.size)),
|
|
2374
|
+
subscribe: (callback) => {
|
|
2375
|
+
listeners.add(callback);
|
|
2376
|
+
return () => listeners.delete(callback);
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// src/layers/PublishQueueLive.ts
|
|
2383
|
+
var PublishQueueLive = Layer6.effect(PublishQueue, Effect19.gen(function* () {
|
|
2384
|
+
const storage = yield* Storage;
|
|
2385
|
+
const relay = yield* Relay;
|
|
2386
|
+
return yield* createPublishQueue(storage, relay);
|
|
2387
|
+
}));
|
|
2388
|
+
|
|
2389
|
+
// src/layers/SyncStatusLive.ts
|
|
2390
|
+
import { Layer as Layer7 } from "effect";
|
|
2391
|
+
|
|
2392
|
+
// src/sync/sync-status.ts
|
|
2393
|
+
import { Effect as Effect20, SubscriptionRef } from "effect";
|
|
2394
|
+
function createSyncStatusHandle() {
|
|
2395
|
+
return Effect20.gen(function* () {
|
|
2396
|
+
const ref = yield* SubscriptionRef.make("idle");
|
|
2397
|
+
const listeners = new Set;
|
|
2398
|
+
return {
|
|
2399
|
+
get: () => SubscriptionRef.get(ref),
|
|
2400
|
+
set: (status) => Effect20.gen(function* () {
|
|
2401
|
+
yield* SubscriptionRef.set(ref, status);
|
|
2402
|
+
for (const listener of listeners)
|
|
2403
|
+
listener(status);
|
|
2404
|
+
}),
|
|
2405
|
+
subscribe: (callback) => {
|
|
2406
|
+
listeners.add(callback);
|
|
2407
|
+
return () => listeners.delete(callback);
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// src/layers/SyncStatusLive.ts
|
|
2414
|
+
var SyncStatusLive = Layer7.effect(SyncStatus, createSyncStatusHandle());
|
|
2415
|
+
|
|
2416
|
+
// src/layers/TablinumLive.ts
|
|
2417
|
+
function reportSyncError(onSyncError, error) {
|
|
2418
|
+
if (!onSyncError)
|
|
2419
|
+
return;
|
|
2420
|
+
onSyncError(error instanceof Error ? error : new Error(String(error)));
|
|
2421
|
+
}
|
|
2422
|
+
function mapMemberRecord(record) {
|
|
2423
|
+
return {
|
|
2424
|
+
id: record.id,
|
|
2425
|
+
addedAt: record.addedAt,
|
|
2426
|
+
addedInEpoch: record.addedInEpoch,
|
|
2427
|
+
...record.name !== undefined ? { name: record.name } : {},
|
|
2428
|
+
...record.picture !== undefined ? { picture: record.picture } : {},
|
|
2429
|
+
...record.about !== undefined ? { about: record.about } : {},
|
|
2430
|
+
...record.nip05 !== undefined ? { nip05: record.nip05 } : {},
|
|
2431
|
+
...record.removedAt !== undefined ? { removedAt: record.removedAt } : {},
|
|
2432
|
+
...record.removedInEpoch !== undefined ? { removedInEpoch: record.removedInEpoch } : {}
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
var IdentityWithDeps = IdentityLive.pipe(Layer8.provide(StorageLive));
|
|
2436
|
+
var EpochStoreWithDeps = EpochStoreLive.pipe(Layer8.provide(IdentityWithDeps), Layer8.provide(StorageLive));
|
|
2437
|
+
var GiftWrapWithDeps = GiftWrapLive.pipe(Layer8.provide(IdentityWithDeps), Layer8.provide(EpochStoreWithDeps));
|
|
2438
|
+
var PublishQueueWithDeps = PublishQueueLive.pipe(Layer8.provide(StorageLive), Layer8.provide(RelayLive));
|
|
2439
|
+
var AllServicesLive = Layer8.mergeAll(IdentityWithDeps, EpochStoreWithDeps, StorageLive, RelayLive, GiftWrapWithDeps, PublishQueueWithDeps, SyncStatusLive);
|
|
2440
|
+
var TablinumLive = Layer8.effect(Tablinum, Effect21.gen(function* () {
|
|
2441
|
+
const config = yield* Config;
|
|
2442
|
+
const identity = yield* Identity;
|
|
2443
|
+
const epochStore = yield* EpochStore;
|
|
2444
|
+
const storage = yield* Storage;
|
|
2445
|
+
const relay = yield* Relay;
|
|
2446
|
+
const giftWrap = yield* GiftWrap3;
|
|
2447
|
+
const publishQueue = yield* PublishQueue;
|
|
2448
|
+
const syncStatus = yield* SyncStatus;
|
|
2449
|
+
const scope = yield* Effect21.scope;
|
|
2450
|
+
const pubsub = yield* PubSub2.unbounded();
|
|
2451
|
+
const replayingRef = yield* Ref5.make(false);
|
|
2452
|
+
const closedRef = yield* Ref5.make(false);
|
|
2453
|
+
const watchCtx = { pubsub, replayingRef };
|
|
2454
|
+
const schemaEntries = Object.entries(config.schema);
|
|
2455
|
+
const allSchemaEntries = [...schemaEntries, ["_members", membersCollectionDef]];
|
|
2456
|
+
const knownCollections = new Map(allSchemaEntries.map(([, def]) => [def.name, def.eventRetention]));
|
|
2457
|
+
let notifyAuthor;
|
|
2458
|
+
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);
|
|
2459
|
+
const onWrite = (event) => Effect21.gen(function* () {
|
|
2460
|
+
const content = event.kind === "d" ? JSON.stringify(null) : JSON.stringify(event.data);
|
|
2461
|
+
const dTag = `${event.collection}:${event.recordId}`;
|
|
2462
|
+
const wrapResult = yield* Effect21.result(giftWrap.wrap({
|
|
2463
|
+
kind: 1,
|
|
2464
|
+
content,
|
|
2465
|
+
tags: [["d", dTag]],
|
|
2466
|
+
created_at: Math.floor(event.createdAt / 1000)
|
|
2467
|
+
}));
|
|
2468
|
+
if (wrapResult._tag === "Failure") {
|
|
2469
|
+
reportSyncError(config.onSyncError, wrapResult.failure);
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
const gw = wrapResult.success;
|
|
2473
|
+
yield* storage.putGiftWrap({ id: gw.id, createdAt: gw.created_at });
|
|
2474
|
+
yield* Effect21.forkIn(Effect21.gen(function* () {
|
|
2475
|
+
const publishResult = yield* Effect21.result(syncHandle.publishLocal({
|
|
2476
|
+
id: gw.id,
|
|
2477
|
+
event: gw,
|
|
2478
|
+
createdAt: gw.created_at
|
|
2479
|
+
}));
|
|
2480
|
+
if (publishResult._tag === "Failure") {
|
|
2481
|
+
reportSyncError(config.onSyncError, publishResult.failure);
|
|
2482
|
+
}
|
|
2483
|
+
}), scope);
|
|
2484
|
+
});
|
|
2485
|
+
const knownAuthors = new Set;
|
|
2486
|
+
const putMemberRecord = (record) => Effect21.gen(function* () {
|
|
2487
|
+
const existing = yield* storage.getRecord("_members", record.id);
|
|
2488
|
+
const event = {
|
|
2489
|
+
id: uuidv7(),
|
|
2490
|
+
collection: "_members",
|
|
2491
|
+
recordId: record.id,
|
|
2492
|
+
kind: existing ? "u" : "c",
|
|
2493
|
+
data: record,
|
|
2494
|
+
createdAt: Date.now(),
|
|
2495
|
+
author: identity.publicKey
|
|
1818
2496
|
};
|
|
1819
|
-
|
|
2497
|
+
yield* storage.putEvent(event);
|
|
2498
|
+
yield* applyEvent(storage, event);
|
|
2499
|
+
yield* onWrite(event);
|
|
2500
|
+
yield* notifyChange(watchCtx, {
|
|
2501
|
+
collection: "_members",
|
|
2502
|
+
recordId: record.id,
|
|
2503
|
+
kind: existing ? "update" : "create"
|
|
2504
|
+
});
|
|
2505
|
+
config.onMembersChanged?.();
|
|
1820
2506
|
});
|
|
2507
|
+
notifyAuthor = (pubkey) => {
|
|
2508
|
+
if (knownAuthors.has(pubkey))
|
|
2509
|
+
return;
|
|
2510
|
+
knownAuthors.add(pubkey);
|
|
2511
|
+
Effect21.runFork(Effect21.gen(function* () {
|
|
2512
|
+
const existing = yield* storage.getRecord("_members", pubkey);
|
|
2513
|
+
if (!existing) {
|
|
2514
|
+
yield* putMemberRecord({
|
|
2515
|
+
id: pubkey,
|
|
2516
|
+
addedAt: Date.now(),
|
|
2517
|
+
addedInEpoch: getCurrentEpoch(epochStore).id
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
const profileOpt = yield* fetchAuthorProfile(relay, config.relays, pubkey).pipe(Effect21.catchTag("RelayError", () => Effect21.succeed(Option9.none())));
|
|
2521
|
+
if (Option9.isSome(profileOpt)) {
|
|
2522
|
+
const current = yield* storage.getRecord("_members", pubkey);
|
|
2523
|
+
if (current) {
|
|
2524
|
+
yield* storage.putRecord("_members", {
|
|
2525
|
+
...current,
|
|
2526
|
+
...profileOpt.value
|
|
2527
|
+
});
|
|
2528
|
+
yield* notifyChange(watchCtx, {
|
|
2529
|
+
collection: "_members",
|
|
2530
|
+
recordId: pubkey,
|
|
2531
|
+
kind: "update"
|
|
2532
|
+
});
|
|
2533
|
+
config.onMembersChanged?.();
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}).pipe(Effect21.ignore, Effect21.forkIn(scope)));
|
|
2537
|
+
};
|
|
2538
|
+
const handles = new Map;
|
|
2539
|
+
for (const [, def] of allSchemaEntries) {
|
|
2540
|
+
const validator = buildValidator(def.name, def);
|
|
2541
|
+
const partialValidator = buildPartialValidator(def.name, def);
|
|
2542
|
+
const handle = createCollectionHandle(def, storage, watchCtx, validator, partialValidator, uuidv7, identity.publicKey, onWrite);
|
|
2543
|
+
handles.set(def.name, handle);
|
|
2544
|
+
}
|
|
2545
|
+
yield* syncHandle.startSubscription();
|
|
2546
|
+
const selfMember = yield* storage.getRecord("_members", identity.publicKey);
|
|
2547
|
+
if (!selfMember) {
|
|
2548
|
+
yield* putMemberRecord({
|
|
2549
|
+
id: identity.publicKey,
|
|
2550
|
+
addedAt: Date.now(),
|
|
2551
|
+
addedInEpoch: getCurrentEpoch(epochStore).id
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
const ensureOpen = (effect) => Effect21.gen(function* () {
|
|
2555
|
+
if (yield* Ref5.get(closedRef)) {
|
|
2556
|
+
return yield* new StorageError({ message: "Database is closed" });
|
|
2557
|
+
}
|
|
2558
|
+
return yield* effect;
|
|
2559
|
+
});
|
|
2560
|
+
const ensureSyncOpen = (effect) => Effect21.gen(function* () {
|
|
2561
|
+
if (yield* Ref5.get(closedRef)) {
|
|
2562
|
+
return yield* new SyncError({ message: "Database is closed", phase: "init" });
|
|
2563
|
+
}
|
|
2564
|
+
return yield* effect;
|
|
2565
|
+
});
|
|
2566
|
+
const dbHandle = {
|
|
2567
|
+
collection: (name) => {
|
|
2568
|
+
const handle = handles.get(name);
|
|
2569
|
+
if (!handle)
|
|
2570
|
+
throw new Error(`Collection "${name}" not found in schema`);
|
|
2571
|
+
return handle;
|
|
2572
|
+
},
|
|
2573
|
+
publicKey: identity.publicKey,
|
|
2574
|
+
members: handles.get("_members"),
|
|
2575
|
+
exportKey: () => identity.exportKey(),
|
|
2576
|
+
exportInvite: () => ({
|
|
2577
|
+
epochKeys: [...exportEpochKeys(epochStore)],
|
|
2578
|
+
relays: [...config.relays],
|
|
2579
|
+
dbName: config.dbName
|
|
2580
|
+
}),
|
|
2581
|
+
close: () => Effect21.gen(function* () {
|
|
2582
|
+
if (yield* Ref5.get(closedRef))
|
|
2583
|
+
return;
|
|
2584
|
+
yield* Ref5.set(closedRef, true);
|
|
2585
|
+
yield* Scope4.close(scope, Exit.void);
|
|
2586
|
+
}),
|
|
2587
|
+
rebuild: () => ensureOpen(rebuild(storage, allSchemaEntries.map(([, def]) => def.name))),
|
|
2588
|
+
sync: () => ensureSyncOpen(syncHandle.sync()),
|
|
2589
|
+
getSyncStatus: () => syncStatus.get(),
|
|
2590
|
+
subscribeSyncStatus: (callback) => syncStatus.subscribe(callback),
|
|
2591
|
+
pendingCount: () => publishQueue.size(),
|
|
2592
|
+
subscribePendingCount: (callback) => publishQueue.subscribe(callback),
|
|
2593
|
+
getRelayStatus: () => relay.getStatus(),
|
|
2594
|
+
subscribeRelayStatus: (callback) => relay.subscribeStatus(callback),
|
|
2595
|
+
addMember: (pubkey) => ensureOpen(Effect21.gen(function* () {
|
|
2596
|
+
const existing = yield* storage.getRecord("_members", pubkey);
|
|
2597
|
+
if (existing && !existing.removedAt)
|
|
2598
|
+
return;
|
|
2599
|
+
yield* putMemberRecord({
|
|
2600
|
+
id: pubkey,
|
|
2601
|
+
addedAt: Date.now(),
|
|
2602
|
+
addedInEpoch: getCurrentEpoch(epochStore).id,
|
|
2603
|
+
...existing ? { removedAt: undefined, removedInEpoch: undefined } : {}
|
|
2604
|
+
});
|
|
2605
|
+
})),
|
|
2606
|
+
removeMember: (pubkey) => ensureOpen(Effect21.gen(function* () {
|
|
2607
|
+
const allMembers = yield* storage.getAllRecords("_members");
|
|
2608
|
+
const activeMembers = allMembers.filter((member) => !member.removedAt && member.id !== pubkey);
|
|
2609
|
+
const activePubkeys = activeMembers.map((member) => member.id);
|
|
2610
|
+
const result = createRotation(epochStore, identity.privateKey, identity.publicKey, activePubkeys, [pubkey]);
|
|
2611
|
+
addEpoch(epochStore, result.epoch);
|
|
2612
|
+
epochStore.currentEpochId = result.epoch.id;
|
|
2613
|
+
yield* storage.putMeta("epochs", stringifyEpochStore(epochStore));
|
|
2614
|
+
const memberRecord = yield* storage.getRecord("_members", pubkey);
|
|
2615
|
+
yield* putMemberRecord({
|
|
2616
|
+
...memberRecord ?? {
|
|
2617
|
+
id: pubkey,
|
|
2618
|
+
addedAt: 0,
|
|
2619
|
+
addedInEpoch: EpochId("epoch-0")
|
|
2620
|
+
},
|
|
2621
|
+
removedAt: Date.now(),
|
|
2622
|
+
removedInEpoch: result.epoch.id
|
|
2623
|
+
});
|
|
2624
|
+
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 });
|
|
2625
|
+
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 });
|
|
2626
|
+
yield* syncHandle.addEpochSubscription(result.epoch.publicKey);
|
|
2627
|
+
})),
|
|
2628
|
+
getMembers: () => ensureOpen(Effect21.gen(function* () {
|
|
2629
|
+
const allRecords = yield* storage.getAllRecords("_members");
|
|
2630
|
+
return allRecords.filter((record) => !record._d).map(mapMemberRecord);
|
|
2631
|
+
})),
|
|
2632
|
+
getProfile: () => ensureOpen(Effect21.gen(function* () {
|
|
2633
|
+
const record = yield* storage.getRecord("_members", identity.publicKey);
|
|
2634
|
+
if (!record)
|
|
2635
|
+
return {};
|
|
2636
|
+
const profile = {};
|
|
2637
|
+
if (record.name !== undefined)
|
|
2638
|
+
profile.name = record.name;
|
|
2639
|
+
if (record.picture !== undefined)
|
|
2640
|
+
profile.picture = record.picture;
|
|
2641
|
+
if (record.about !== undefined)
|
|
2642
|
+
profile.about = record.about;
|
|
2643
|
+
if (record.nip05 !== undefined)
|
|
2644
|
+
profile.nip05 = record.nip05;
|
|
2645
|
+
return profile;
|
|
2646
|
+
})),
|
|
2647
|
+
setProfile: (profile) => ensureOpen(Effect21.gen(function* () {
|
|
2648
|
+
const existing = yield* storage.getRecord("_members", identity.publicKey);
|
|
2649
|
+
if (!existing) {
|
|
2650
|
+
return yield* new ValidationError({ message: "Current user is not a member" });
|
|
2651
|
+
}
|
|
2652
|
+
const { _d, _u, _a, _e, ...memberFields } = existing;
|
|
2653
|
+
yield* putMemberRecord({ ...memberFields, ...profile });
|
|
2654
|
+
}))
|
|
2655
|
+
};
|
|
2656
|
+
return dbHandle;
|
|
2657
|
+
})).pipe(Layer8.provide(AllServicesLive));
|
|
2658
|
+
|
|
2659
|
+
// src/db/create-tablinum.ts
|
|
2660
|
+
function validateConfig(config) {
|
|
2661
|
+
return Effect22.gen(function* () {
|
|
2662
|
+
if (Object.keys(config.schema).length === 0) {
|
|
2663
|
+
return yield* new ValidationError({
|
|
2664
|
+
message: "Schema must contain at least one collection"
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
function createTablinum(config) {
|
|
2670
|
+
return Effect22.gen(function* () {
|
|
2671
|
+
yield* validateConfig(config);
|
|
2672
|
+
const runtimeConfig = yield* resolveRuntimeConfig(config);
|
|
2673
|
+
const configValue = {
|
|
2674
|
+
...runtimeConfig,
|
|
2675
|
+
schema: config.schema,
|
|
2676
|
+
onSyncError: config.onSyncError,
|
|
2677
|
+
onRemoved: config.onRemoved,
|
|
2678
|
+
onMembersChanged: config.onMembersChanged
|
|
2679
|
+
};
|
|
2680
|
+
const configLayer = Layer9.succeed(Config, configValue);
|
|
2681
|
+
const fullLayer = TablinumLive.pipe(Layer9.provide(configLayer));
|
|
2682
|
+
const ctx = yield* Layer9.build(fullLayer);
|
|
2683
|
+
return ServiceMap10.get(ctx, Tablinum);
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
// src/db/invite.ts
|
|
2687
|
+
import { Schema as Schema7 } from "effect";
|
|
2688
|
+
var InviteSchema = Schema7.Struct({
|
|
2689
|
+
epochKeys: Schema7.Array(EpochKeyInputSchema),
|
|
2690
|
+
relays: Schema7.Array(Schema7.String),
|
|
2691
|
+
dbName: Schema7.String
|
|
2692
|
+
});
|
|
2693
|
+
var decodeInviteJson = Schema7.decodeUnknownSync(Schema7.UnknownFromJsonString);
|
|
2694
|
+
var decodeInvitePayload = Schema7.decodeUnknownSync(InviteSchema);
|
|
2695
|
+
function encodeInvite(invite) {
|
|
2696
|
+
return btoa(JSON.stringify(invite));
|
|
2697
|
+
}
|
|
2698
|
+
function decodeInvite(encoded) {
|
|
2699
|
+
let raw;
|
|
2700
|
+
try {
|
|
2701
|
+
raw = decodeInviteJson(atob(encoded));
|
|
2702
|
+
} catch {
|
|
2703
|
+
throw new Error("Invalid invite: failed to decode");
|
|
2704
|
+
}
|
|
2705
|
+
try {
|
|
2706
|
+
const invite = decodeInvitePayload(raw);
|
|
2707
|
+
return {
|
|
2708
|
+
epochKeys: invite.epochKeys.map((epoch) => ({
|
|
2709
|
+
epochId: EpochId(epoch.epochId),
|
|
2710
|
+
key: epoch.key
|
|
2711
|
+
})),
|
|
2712
|
+
relays: [...invite.relays],
|
|
2713
|
+
dbName: DatabaseName(invite.dbName)
|
|
2714
|
+
};
|
|
2715
|
+
} catch {
|
|
2716
|
+
throw new Error("Invalid invite: unexpected shape");
|
|
2717
|
+
}
|
|
1821
2718
|
}
|
|
1822
2719
|
export {
|
|
1823
2720
|
field,
|
|
2721
|
+
encodeInvite,
|
|
2722
|
+
decodeInvite,
|
|
1824
2723
|
createTablinum,
|
|
1825
2724
|
collection,
|
|
1826
2725
|
ValidationError,
|
|
2726
|
+
TablinumLive,
|
|
1827
2727
|
SyncError,
|
|
1828
2728
|
StorageError,
|
|
1829
2729
|
RelayError,
|
|
1830
2730
|
NotFoundError,
|
|
2731
|
+
EpochId,
|
|
2732
|
+
DatabaseName,
|
|
1831
2733
|
CryptoError,
|
|
1832
2734
|
ClosedError
|
|
1833
2735
|
};
|