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