tablinum 0.0.1
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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +571 -0
- package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +498 -0
- package/.context/notes.md +0 -0
- package/.context/plans/add-changesets-to-douala-v4.md +48 -0
- package/.context/plans/dexie-js-style-query-language-for-localstr.md +115 -0
- package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +336 -0
- package/.context/plans/implementation-plan-localstr-v0-2.md +263 -0
- package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +71 -0
- package/.context/plans/revise-localstr-prd-v0-2.md +132 -0
- package/.context/plans/svelte-5-runes-bindings-for-localstr.md +233 -0
- package/.context/todos.md +0 -0
- package/.github/workflows/release.yml +36 -0
- package/.oxlintrc.json +8 -0
- package/README.md +1 -0
- package/bun.lock +705 -0
- package/examples/svelte/bun.lock +261 -0
- package/examples/svelte/package.json +21 -0
- package/examples/svelte/src/app.html +11 -0
- package/examples/svelte/src/lib/db.ts +44 -0
- package/examples/svelte/src/routes/+page.svelte +322 -0
- package/examples/svelte/svelte.config.js +16 -0
- package/examples/svelte/tsconfig.json +6 -0
- package/examples/svelte/vite.config.ts +6 -0
- package/examples/vanilla/app.ts +219 -0
- package/examples/vanilla/index.html +144 -0
- package/examples/vanilla/serve.ts +42 -0
- package/package.json +46 -0
- package/prds/localstr-v0.2.md +221 -0
- package/prek.toml +10 -0
- package/scripts/validate.ts +392 -0
- package/src/crud/collection-handle.ts +189 -0
- package/src/crud/query-builder.ts +414 -0
- package/src/crud/watch.ts +78 -0
- package/src/db/create-localstr.ts +217 -0
- package/src/db/database-handle.ts +16 -0
- package/src/db/identity.ts +49 -0
- package/src/errors.ts +37 -0
- package/src/index.ts +32 -0
- package/src/main.ts +10 -0
- package/src/schema/collection.ts +53 -0
- package/src/schema/field.ts +25 -0
- package/src/schema/types.ts +19 -0
- package/src/schema/validate.ts +111 -0
- package/src/storage/events-store.ts +24 -0
- package/src/storage/giftwraps-store.ts +23 -0
- package/src/storage/idb.ts +244 -0
- package/src/storage/lww.ts +17 -0
- package/src/storage/records-store.ts +76 -0
- package/src/svelte/collection.svelte.ts +87 -0
- package/src/svelte/database.svelte.ts +83 -0
- package/src/svelte/index.svelte.ts +52 -0
- package/src/svelte/live-query.svelte.ts +29 -0
- package/src/svelte/query.svelte.ts +101 -0
- package/src/sync/gift-wrap.ts +33 -0
- package/src/sync/negentropy.ts +83 -0
- package/src/sync/publish-queue.ts +61 -0
- package/src/sync/relay.ts +239 -0
- package/src/sync/sync-service.ts +183 -0
- package/src/sync/sync-status.ts +17 -0
- package/src/utils/uuid.ts +22 -0
- package/src/vendor/negentropy.js +616 -0
- package/tests/db/create-localstr.test.ts +174 -0
- package/tests/db/identity.test.ts +33 -0
- package/tests/main.test.ts +9 -0
- package/tests/schema/collection.test.ts +27 -0
- package/tests/schema/field.test.ts +41 -0
- package/tests/schema/validate.test.ts +85 -0
- package/tests/setup.ts +1 -0
- package/tests/storage/idb.test.ts +144 -0
- package/tests/storage/lww.test.ts +33 -0
- package/tests/sync/gift-wrap.test.ts +56 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Relay } from "nostr-tools/relay";
|
|
3
|
+
import type { NostrEvent } from "nostr-tools/pure";
|
|
4
|
+
import type { Filter } from "nostr-tools/filter";
|
|
5
|
+
import { RelayError } from "../errors.ts";
|
|
6
|
+
|
|
7
|
+
export interface RelayHandle {
|
|
8
|
+
readonly publish: (event: NostrEvent, urls: readonly string[]) => Effect.Effect<void, RelayError>;
|
|
9
|
+
readonly fetchEvents: (
|
|
10
|
+
ids: readonly string[],
|
|
11
|
+
url: string,
|
|
12
|
+
) => Effect.Effect<NostrEvent[], RelayError>;
|
|
13
|
+
readonly fetchByFilter: (filter: Filter, url: string) => Effect.Effect<NostrEvent[], RelayError>;
|
|
14
|
+
readonly subscribe: (
|
|
15
|
+
filter: Filter,
|
|
16
|
+
url: string,
|
|
17
|
+
onEvent: (event: NostrEvent) => void,
|
|
18
|
+
) => Effect.Effect<void, RelayError>;
|
|
19
|
+
readonly sendNegMsg: (
|
|
20
|
+
url: string,
|
|
21
|
+
subId: string,
|
|
22
|
+
filter: Filter,
|
|
23
|
+
msgHex: string,
|
|
24
|
+
) => Effect.Effect<{ msgHex: string | null; haveIds: string[]; needIds: string[] }, RelayError>;
|
|
25
|
+
readonly closeAll: () => Effect.Effect<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRelayHandle(): RelayHandle {
|
|
29
|
+
const connections = new Map<string, Relay>();
|
|
30
|
+
|
|
31
|
+
const getRelay = (url: string): Effect.Effect<Relay, RelayError> =>
|
|
32
|
+
Effect.tryPromise({
|
|
33
|
+
try: async () => {
|
|
34
|
+
const existing = connections.get(url);
|
|
35
|
+
if (existing && (existing as any).connected !== false) {
|
|
36
|
+
return existing;
|
|
37
|
+
}
|
|
38
|
+
// Clean up stale entry
|
|
39
|
+
connections.delete(url);
|
|
40
|
+
const relay = await Relay.connect(url);
|
|
41
|
+
connections.set(url, relay);
|
|
42
|
+
return relay;
|
|
43
|
+
},
|
|
44
|
+
catch: (e) =>
|
|
45
|
+
new RelayError({
|
|
46
|
+
message: `Connect to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
47
|
+
url,
|
|
48
|
+
cause: e,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
publish: (event, urls) =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const errors: Array<{ url: string; error: unknown }> = [];
|
|
56
|
+
for (const url of urls) {
|
|
57
|
+
const result = yield* Effect.result(
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const relay = yield* getRelay(url);
|
|
60
|
+
yield* Effect.tryPromise({
|
|
61
|
+
try: () => relay.publish(event),
|
|
62
|
+
catch: (e) => {
|
|
63
|
+
// Connection may be stale, remove so next attempt reconnects
|
|
64
|
+
connections.delete(url);
|
|
65
|
+
return new RelayError({
|
|
66
|
+
message: `Publish to ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
67
|
+
url,
|
|
68
|
+
cause: e,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
if (result._tag === "Failure") {
|
|
75
|
+
errors.push({ url, error: result });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (errors.length === urls.length && urls.length > 0) {
|
|
79
|
+
return yield* new RelayError({
|
|
80
|
+
message: `Publish failed on all ${urls.length} relays`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
fetchEvents: (ids, url) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
if (ids.length === 0) return [] as NostrEvent[];
|
|
88
|
+
const relay = yield* getRelay(url);
|
|
89
|
+
return yield* Effect.tryPromise({
|
|
90
|
+
try: () =>
|
|
91
|
+
new Promise<NostrEvent[]>((resolve) => {
|
|
92
|
+
const events: NostrEvent[] = [];
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
sub.close();
|
|
95
|
+
resolve(events);
|
|
96
|
+
}, 10000);
|
|
97
|
+
const sub = relay.subscribe([{ ids: ids as string[] }], {
|
|
98
|
+
onevent(evt: NostrEvent) {
|
|
99
|
+
events.push(evt);
|
|
100
|
+
},
|
|
101
|
+
oneose() {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
sub.close();
|
|
104
|
+
resolve(events);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}),
|
|
108
|
+
catch: (e) => {
|
|
109
|
+
connections.delete(url);
|
|
110
|
+
return new RelayError({
|
|
111
|
+
message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
112
|
+
url,
|
|
113
|
+
cause: e,
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}),
|
|
118
|
+
|
|
119
|
+
fetchByFilter: (filter, url) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
const relay = yield* getRelay(url);
|
|
122
|
+
return yield* Effect.tryPromise({
|
|
123
|
+
try: () =>
|
|
124
|
+
new Promise<NostrEvent[]>((resolve) => {
|
|
125
|
+
const events: NostrEvent[] = [];
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
sub.close();
|
|
128
|
+
resolve(events);
|
|
129
|
+
}, 10000);
|
|
130
|
+
const sub = relay.subscribe([filter], {
|
|
131
|
+
onevent(evt: NostrEvent) {
|
|
132
|
+
events.push(evt);
|
|
133
|
+
},
|
|
134
|
+
oneose() {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
sub.close();
|
|
137
|
+
resolve(events);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}),
|
|
141
|
+
catch: (e) => {
|
|
142
|
+
connections.delete(url);
|
|
143
|
+
return new RelayError({
|
|
144
|
+
message: `Fetch from ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
145
|
+
url,
|
|
146
|
+
cause: e,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
subscribe: (filter, url, onEvent) =>
|
|
153
|
+
Effect.gen(function* () {
|
|
154
|
+
const relay = yield* getRelay(url);
|
|
155
|
+
relay.subscribe([filter], {
|
|
156
|
+
onevent(evt: NostrEvent) {
|
|
157
|
+
onEvent(evt);
|
|
158
|
+
},
|
|
159
|
+
oneose() {
|
|
160
|
+
// Initial fetch complete, keep subscription open for real-time
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}),
|
|
164
|
+
|
|
165
|
+
sendNegMsg: (url, subId, filter, msgHex) =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const relay = yield* getRelay(url);
|
|
168
|
+
return yield* Effect.tryPromise({
|
|
169
|
+
try: () =>
|
|
170
|
+
new Promise<{
|
|
171
|
+
msgHex: string | null;
|
|
172
|
+
haveIds: string[];
|
|
173
|
+
needIds: string[];
|
|
174
|
+
}>((resolve, reject) => {
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
reject(new Error("NIP-77 negotiation timeout"));
|
|
177
|
+
}, 30000);
|
|
178
|
+
|
|
179
|
+
const sub = relay.subscribe([filter], {
|
|
180
|
+
onevent() {},
|
|
181
|
+
oneose() {},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Use raw WebSocket for NIP-77
|
|
185
|
+
const ws = (relay as any)._ws || (relay as any).ws;
|
|
186
|
+
if (!ws) {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
sub.close();
|
|
189
|
+
reject(new Error("Cannot access relay WebSocket"));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const handler = (msg: MessageEvent) => {
|
|
194
|
+
try {
|
|
195
|
+
const data = JSON.parse(typeof msg.data === "string" ? msg.data : "");
|
|
196
|
+
if (!Array.isArray(data)) return;
|
|
197
|
+
if (data[0] === "NEG-MSG" && data[1] === subId) {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
sub.close();
|
|
200
|
+
resolve({
|
|
201
|
+
msgHex: data[2] as string,
|
|
202
|
+
haveIds: [],
|
|
203
|
+
needIds: [],
|
|
204
|
+
});
|
|
205
|
+
ws.removeEventListener("message", handler);
|
|
206
|
+
} else if (data[0] === "NEG-ERR" && data[1] === subId) {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
sub.close();
|
|
209
|
+
reject(new Error(`NEG-ERR: ${data[2]}`));
|
|
210
|
+
ws.removeEventListener("message", handler);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// ignore parse errors
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
ws.addEventListener("message", handler);
|
|
218
|
+
ws.send(JSON.stringify(["NEG-OPEN", subId, filter, msgHex]));
|
|
219
|
+
}),
|
|
220
|
+
catch: (e) => {
|
|
221
|
+
connections.delete(url);
|
|
222
|
+
return new RelayError({
|
|
223
|
+
message: `NIP-77 negotiation with ${url} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
224
|
+
url,
|
|
225
|
+
cause: e,
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}),
|
|
230
|
+
|
|
231
|
+
closeAll: () =>
|
|
232
|
+
Effect.sync(() => {
|
|
233
|
+
for (const [url, relay] of connections) {
|
|
234
|
+
relay.close();
|
|
235
|
+
connections.delete(url);
|
|
236
|
+
}
|
|
237
|
+
}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Effect, Ref } from "effect";
|
|
2
|
+
import type { NostrEvent } from "nostr-tools/pure";
|
|
3
|
+
import type { IDBStorageHandle, StoredEvent, StoredGiftWrap } from "../storage/idb.ts";
|
|
4
|
+
import { applyEvent } from "../storage/records-store.ts";
|
|
5
|
+
import type { GiftWrapHandle } from "./gift-wrap.ts";
|
|
6
|
+
import type { RelayHandle } from "./relay.ts";
|
|
7
|
+
import type { PublishQueueHandle } from "./publish-queue.ts";
|
|
8
|
+
import type { SyncStatusHandle } from "./sync-status.ts";
|
|
9
|
+
import { reconcileWithRelay } from "./negentropy.ts";
|
|
10
|
+
import type { WatchContext } from "../crud/watch.ts";
|
|
11
|
+
import { notifyChange, notifyReplayComplete } from "../crud/watch.ts";
|
|
12
|
+
import { CryptoError, RelayError, StorageError, SyncError } from "../errors.ts";
|
|
13
|
+
|
|
14
|
+
export interface SyncHandle {
|
|
15
|
+
readonly sync: () => Effect.Effect<void, SyncError | RelayError | CryptoError | StorageError>;
|
|
16
|
+
readonly publishLocal: (giftWrap: StoredGiftWrap) => Effect.Effect<void>;
|
|
17
|
+
readonly startSubscription: () => Effect.Effect<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createSyncHandle(
|
|
21
|
+
storage: IDBStorageHandle,
|
|
22
|
+
giftWrapHandle: GiftWrapHandle,
|
|
23
|
+
relay: RelayHandle,
|
|
24
|
+
publishQueue: PublishQueueHandle,
|
|
25
|
+
syncStatus: SyncStatusHandle,
|
|
26
|
+
watchCtx: WatchContext,
|
|
27
|
+
relayUrls: readonly string[],
|
|
28
|
+
publicKey: string,
|
|
29
|
+
onSyncError?: ((error: unknown) => void) | undefined,
|
|
30
|
+
): SyncHandle {
|
|
31
|
+
// Process a single remote gift wrap: store, unwrap, apply event
|
|
32
|
+
const processGiftWrap = (
|
|
33
|
+
remoteGw: NostrEvent,
|
|
34
|
+
): Effect.Effect<string | null, StorageError | CryptoError> =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
// Skip if we already have this gift wrap
|
|
37
|
+
const existing = yield* storage.getGiftWrap(remoteGw.id);
|
|
38
|
+
if (existing) return null;
|
|
39
|
+
|
|
40
|
+
// Store gift wrap
|
|
41
|
+
yield* storage.putGiftWrap({
|
|
42
|
+
id: remoteGw.id,
|
|
43
|
+
event: remoteGw as unknown as Record<string, unknown>,
|
|
44
|
+
createdAt: remoteGw.created_at,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Unwrap to get rumor
|
|
48
|
+
const unwrapResult = yield* Effect.result(giftWrapHandle.unwrap(remoteGw));
|
|
49
|
+
if (unwrapResult._tag === "Failure") return null;
|
|
50
|
+
|
|
51
|
+
const rumor = unwrapResult.success;
|
|
52
|
+
|
|
53
|
+
// Parse the rumor content and d-tag
|
|
54
|
+
const dTag = rumor.tags.find((t: string[]) => t[0] === "d")?.[1];
|
|
55
|
+
if (!dTag) return null;
|
|
56
|
+
|
|
57
|
+
const colonIdx = dTag.indexOf(":");
|
|
58
|
+
if (colonIdx === -1) return null;
|
|
59
|
+
|
|
60
|
+
const collectionName = dTag.substring(0, colonIdx);
|
|
61
|
+
const recordId = dTag.substring(colonIdx + 1);
|
|
62
|
+
|
|
63
|
+
let data: Record<string, unknown> | null = null;
|
|
64
|
+
let kind: "create" | "update" | "delete" = "update";
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(rumor.content);
|
|
68
|
+
if (parsed === null || parsed._deleted) {
|
|
69
|
+
kind = "delete";
|
|
70
|
+
} else {
|
|
71
|
+
data = parsed;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const event: StoredEvent = {
|
|
78
|
+
id: rumor.id,
|
|
79
|
+
collection: collectionName,
|
|
80
|
+
recordId,
|
|
81
|
+
kind,
|
|
82
|
+
data,
|
|
83
|
+
createdAt: rumor.created_at * 1000,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
yield* storage.putEvent(event);
|
|
87
|
+
yield* applyEvent(storage, event);
|
|
88
|
+
return collectionName;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
sync: () =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
yield* syncStatus.set("syncing");
|
|
95
|
+
yield* Ref.set(watchCtx.replayingRef, true);
|
|
96
|
+
|
|
97
|
+
const changedCollections = new Set<string>();
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for (const url of relayUrls) {
|
|
101
|
+
const reconcileResult = yield* Effect.result(
|
|
102
|
+
reconcileWithRelay(storage, relay, url, publicKey),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (reconcileResult._tag === "Failure") continue;
|
|
106
|
+
|
|
107
|
+
const { haveIds, needIds } = reconcileResult.success;
|
|
108
|
+
|
|
109
|
+
// Download missing gift wraps
|
|
110
|
+
if (needIds.length > 0) {
|
|
111
|
+
const fetchResult = yield* Effect.result(relay.fetchEvents(needIds, url));
|
|
112
|
+
|
|
113
|
+
if (fetchResult._tag === "Success") {
|
|
114
|
+
for (const remoteGw of fetchResult.success) {
|
|
115
|
+
const result = yield* Effect.result(processGiftWrap(remoteGw));
|
|
116
|
+
if (result._tag === "Success" && result.success) {
|
|
117
|
+
changedCollections.add(result.success);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Upload gift wraps the relay is missing
|
|
124
|
+
if (haveIds.length > 0) {
|
|
125
|
+
for (const id of haveIds) {
|
|
126
|
+
const gw = yield* storage.getGiftWrap(id);
|
|
127
|
+
if (gw) {
|
|
128
|
+
yield* Effect.result(relay.publish(gw.event as unknown as NostrEvent, [url]));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Flush pending publications
|
|
135
|
+
yield* Effect.result(publishQueue.flush(relayUrls));
|
|
136
|
+
} finally {
|
|
137
|
+
yield* notifyReplayComplete(watchCtx, [...changedCollections]);
|
|
138
|
+
yield* syncStatus.set("idle");
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
|
|
142
|
+
publishLocal: (giftWrap) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const result = yield* Effect.result(
|
|
145
|
+
relay.publish(giftWrap.event as unknown as NostrEvent, relayUrls),
|
|
146
|
+
);
|
|
147
|
+
if (result._tag === "Failure") {
|
|
148
|
+
yield* publishQueue.enqueue(giftWrap.id);
|
|
149
|
+
console.error("[localstr:publishLocal] relay error:", result.failure);
|
|
150
|
+
if (onSyncError) onSyncError(result.failure);
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
|
|
154
|
+
startSubscription: () =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
for (const url of relayUrls) {
|
|
157
|
+
const subResult = yield* Effect.result(
|
|
158
|
+
relay.subscribe({ kinds: [1059], "#p": [publicKey] }, url, (evt: NostrEvent) => {
|
|
159
|
+
// Process incoming gift wrap in a fire-and-forget fiber
|
|
160
|
+
Effect.runFork(
|
|
161
|
+
Effect.gen(function* () {
|
|
162
|
+
const result = yield* Effect.result(processGiftWrap(evt));
|
|
163
|
+
if (result._tag === "Success" && result.success) {
|
|
164
|
+
yield* notifyChange(watchCtx, {
|
|
165
|
+
collection: result.success,
|
|
166
|
+
recordId: "",
|
|
167
|
+
kind: "create",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
if (subResult._tag === "Failure") {
|
|
175
|
+
console.error("[localstr:subscribe] failed for", url, subResult.failure);
|
|
176
|
+
if (onSyncError) onSyncError(subResult.failure);
|
|
177
|
+
} else {
|
|
178
|
+
console.log("[localstr:subscribe] listening on", url);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Effect, SubscriptionRef } from "effect";
|
|
2
|
+
import type { SyncStatus } from "../db/database-handle.ts";
|
|
3
|
+
|
|
4
|
+
export interface SyncStatusHandle {
|
|
5
|
+
readonly get: () => Effect.Effect<SyncStatus>;
|
|
6
|
+
readonly set: (status: SyncStatus) => Effect.Effect<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createSyncStatusHandle(): Effect.Effect<SyncStatusHandle> {
|
|
10
|
+
return Effect.gen(function* () {
|
|
11
|
+
const ref = yield* SubscriptionRef.make<SyncStatus>("idle");
|
|
12
|
+
return {
|
|
13
|
+
get: () => SubscriptionRef.get(ref),
|
|
14
|
+
set: (status: SyncStatus) => SubscriptionRef.set(ref, status),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Generate a UUIDv7 (time-sortable) using crypto.getRandomValues. */
|
|
2
|
+
export function uuidv7(): string {
|
|
3
|
+
const now = Date.now();
|
|
4
|
+
const bytes = new Uint8Array(16);
|
|
5
|
+
crypto.getRandomValues(bytes);
|
|
6
|
+
|
|
7
|
+
// Timestamp: 48 bits in bytes 0-5
|
|
8
|
+
bytes[0] = (now / 2 ** 40) & 0xff;
|
|
9
|
+
bytes[1] = (now / 2 ** 32) & 0xff;
|
|
10
|
+
bytes[2] = (now / 2 ** 24) & 0xff;
|
|
11
|
+
bytes[3] = (now / 2 ** 16) & 0xff;
|
|
12
|
+
bytes[4] = (now / 2 ** 8) & 0xff;
|
|
13
|
+
bytes[5] = now & 0xff;
|
|
14
|
+
|
|
15
|
+
// Version 7: set bits 0111 in byte 6 high nibble
|
|
16
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x70;
|
|
17
|
+
// Variant 10: set bits 10 in byte 8 high 2 bits
|
|
18
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
19
|
+
|
|
20
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
21
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
22
|
+
}
|