tablinum 0.0.1 → 0.1.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/README.md +77 -1
- package/dist/crud/collection-handle.d.ts +21 -0
- package/dist/crud/query-builder.d.ts +42 -0
- package/dist/crud/watch.d.ts +25 -0
- package/dist/db/create-tablinum.d.ts +12 -0
- package/dist/db/database-handle.d.ts +13 -0
- package/dist/db/identity.d.ts +8 -0
- package/dist/errors.d.ts +58 -0
- package/{src/index.ts → dist/index.d.ts} +4 -24
- package/dist/index.js +1833 -0
- package/dist/main.d.ts +1 -0
- package/dist/schema/collection.d.ts +12 -0
- package/dist/schema/field.d.ts +17 -0
- package/{src/schema/types.ts → dist/schema/types.d.ts} +5 -10
- package/dist/schema/validate.d.ts +13 -0
- package/dist/storage/events-store.d.ts +6 -0
- package/dist/storage/giftwraps-store.d.ts +6 -0
- package/dist/storage/idb.d.ts +35 -0
- package/dist/storage/lww.d.ts +10 -0
- package/dist/storage/records-store.d.ts +12 -0
- package/dist/svelte/collection.svelte.d.ts +20 -0
- package/dist/svelte/database.svelte.d.ts +15 -0
- package/dist/svelte/index.svelte.d.ts +16 -0
- package/dist/svelte/index.svelte.js +2050 -0
- package/dist/svelte/live-query.svelte.d.ts +8 -0
- package/dist/svelte/query.svelte.d.ts +39 -0
- package/dist/sync/gift-wrap.d.ts +9 -0
- package/dist/sync/negentropy.d.ts +9 -0
- package/dist/sync/publish-queue.d.ts +10 -0
- package/dist/sync/relay.d.ts +17 -0
- package/dist/sync/sync-service.d.ts +14 -0
- package/dist/sync/sync-status.d.ts +7 -0
- package/dist/utils/uuid.d.ts +2 -0
- package/package.json +22 -1
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +0 -571
- package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +0 -498
- package/.context/notes.md +0 -0
- package/.context/plans/add-changesets-to-douala-v4.md +0 -48
- package/.context/plans/dexie-js-style-query-language-for-localstr.md +0 -115
- package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +0 -336
- package/.context/plans/implementation-plan-localstr-v0-2.md +0 -263
- package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +0 -71
- package/.context/plans/revise-localstr-prd-v0-2.md +0 -132
- package/.context/plans/svelte-5-runes-bindings-for-localstr.md +0 -233
- package/.context/todos.md +0 -0
- package/.github/workflows/release.yml +0 -36
- package/.oxlintrc.json +0 -8
- package/bun.lock +0 -705
- package/examples/svelte/bun.lock +0 -261
- package/examples/svelte/package.json +0 -21
- package/examples/svelte/src/app.html +0 -11
- package/examples/svelte/src/lib/db.ts +0 -44
- package/examples/svelte/src/routes/+page.svelte +0 -322
- package/examples/svelte/svelte.config.js +0 -16
- package/examples/svelte/tsconfig.json +0 -6
- package/examples/svelte/vite.config.ts +0 -6
- package/examples/vanilla/app.ts +0 -219
- package/examples/vanilla/index.html +0 -144
- package/examples/vanilla/serve.ts +0 -42
- package/prds/localstr-v0.2.md +0 -221
- package/prek.toml +0 -10
- package/scripts/validate.ts +0 -392
- package/src/crud/collection-handle.ts +0 -189
- package/src/crud/query-builder.ts +0 -414
- package/src/crud/watch.ts +0 -78
- package/src/db/create-localstr.ts +0 -217
- package/src/db/database-handle.ts +0 -16
- package/src/db/identity.ts +0 -49
- package/src/errors.ts +0 -37
- package/src/main.ts +0 -10
- package/src/schema/collection.ts +0 -53
- package/src/schema/field.ts +0 -25
- package/src/schema/validate.ts +0 -111
- package/src/storage/events-store.ts +0 -24
- package/src/storage/giftwraps-store.ts +0 -23
- package/src/storage/idb.ts +0 -244
- package/src/storage/lww.ts +0 -17
- package/src/storage/records-store.ts +0 -76
- package/src/svelte/collection.svelte.ts +0 -87
- package/src/svelte/database.svelte.ts +0 -83
- package/src/svelte/index.svelte.ts +0 -52
- package/src/svelte/live-query.svelte.ts +0 -29
- package/src/svelte/query.svelte.ts +0 -101
- package/src/sync/gift-wrap.ts +0 -33
- package/src/sync/negentropy.ts +0 -83
- package/src/sync/publish-queue.ts +0 -61
- package/src/sync/relay.ts +0 -239
- package/src/sync/sync-service.ts +0 -183
- package/src/sync/sync-status.ts +0 -17
- package/src/utils/uuid.ts +0 -22
- package/src/vendor/negentropy.js +0 -616
- package/tests/db/create-localstr.test.ts +0 -174
- package/tests/db/identity.test.ts +0 -33
- package/tests/main.test.ts +0 -9
- package/tests/schema/collection.test.ts +0 -27
- package/tests/schema/field.test.ts +0 -41
- package/tests/schema/validate.test.ts +0 -85
- package/tests/setup.ts +0 -1
- package/tests/storage/idb.test.ts +0 -144
- package/tests/storage/lww.test.ts +0 -33
- package/tests/sync/gift-wrap.test.ts +0 -56
- package/tsconfig.json +0 -18
- package/vitest.config.ts +0 -8
package/src/sync/gift-wrap.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
import { wrapEvent, unwrapEvent, type Rumor } from "nostr-tools/nip59";
|
|
3
|
-
import type { NostrEvent, UnsignedEvent } from "nostr-tools/pure";
|
|
4
|
-
import { CryptoError } from "../errors.ts";
|
|
5
|
-
|
|
6
|
-
export interface GiftWrapHandle {
|
|
7
|
-
readonly wrap: (rumor: Partial<UnsignedEvent>) => Effect.Effect<NostrEvent, CryptoError>;
|
|
8
|
-
readonly unwrap: (giftWrap: NostrEvent) => Effect.Effect<Rumor, CryptoError>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function createGiftWrapHandle(privateKey: Uint8Array, publicKey: string): GiftWrapHandle {
|
|
12
|
-
return {
|
|
13
|
-
wrap: (rumor) =>
|
|
14
|
-
Effect.try({
|
|
15
|
-
try: () => wrapEvent(rumor, privateKey, publicKey),
|
|
16
|
-
catch: (e) =>
|
|
17
|
-
new CryptoError({
|
|
18
|
-
message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
19
|
-
cause: e,
|
|
20
|
-
}),
|
|
21
|
-
}),
|
|
22
|
-
|
|
23
|
-
unwrap: (giftWrap) =>
|
|
24
|
-
Effect.try({
|
|
25
|
-
try: () => unwrapEvent(giftWrap, privateKey),
|
|
26
|
-
catch: (e) =>
|
|
27
|
-
new CryptoError({
|
|
28
|
-
message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
29
|
-
cause: e,
|
|
30
|
-
}),
|
|
31
|
-
}),
|
|
32
|
-
};
|
|
33
|
-
}
|
package/src/sync/negentropy.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
// @ts-expect-error -- vendored JS without types
|
|
3
|
-
import { Negentropy, NegentropyStorageVector } from "../vendor/negentropy.js";
|
|
4
|
-
import type { IDBStorageHandle } from "../storage/idb.ts";
|
|
5
|
-
import type { RelayHandle } from "./relay.ts";
|
|
6
|
-
import type { Filter } from "nostr-tools/filter";
|
|
7
|
-
import { SyncError, RelayError, StorageError } from "../errors.ts";
|
|
8
|
-
|
|
9
|
-
export interface ReconcileResult {
|
|
10
|
-
readonly haveIds: string[];
|
|
11
|
-
readonly needIds: string[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function hexToBytes(hex: string): Uint8Array {
|
|
15
|
-
const bytes = new Uint8Array(hex.length / 2);
|
|
16
|
-
for (let i = 0; i < hex.length; i += 2) {
|
|
17
|
-
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
18
|
-
}
|
|
19
|
-
return bytes;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function reconcileWithRelay(
|
|
23
|
-
storage: IDBStorageHandle,
|
|
24
|
-
relay: RelayHandle,
|
|
25
|
-
relayUrl: string,
|
|
26
|
-
publicKey: string,
|
|
27
|
-
): Effect.Effect<ReconcileResult, SyncError | RelayError | StorageError> {
|
|
28
|
-
return Effect.gen(function* () {
|
|
29
|
-
const allGiftWraps = yield* storage.getAllGiftWraps();
|
|
30
|
-
|
|
31
|
-
const storageVector = new NegentropyStorageVector();
|
|
32
|
-
for (const gw of allGiftWraps) {
|
|
33
|
-
storageVector.insert(gw.createdAt, hexToBytes(gw.id));
|
|
34
|
-
}
|
|
35
|
-
storageVector.seal();
|
|
36
|
-
|
|
37
|
-
const neg = new Negentropy(storageVector, 0);
|
|
38
|
-
|
|
39
|
-
const filter: Filter = {
|
|
40
|
-
kinds: [1059],
|
|
41
|
-
"#p": [publicKey],
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const allHaveIds: string[] = [];
|
|
45
|
-
const allNeedIds: string[] = [];
|
|
46
|
-
const subId = `neg-${Date.now()}`;
|
|
47
|
-
|
|
48
|
-
const initialMsg: string = yield* Effect.tryPromise({
|
|
49
|
-
try: () => neg.initiate(),
|
|
50
|
-
catch: (e) =>
|
|
51
|
-
new SyncError({
|
|
52
|
-
message: `Negentropy initiate failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
53
|
-
phase: "negotiate",
|
|
54
|
-
cause: e,
|
|
55
|
-
}),
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
let currentMsg: string | null = initialMsg;
|
|
59
|
-
|
|
60
|
-
while (currentMsg !== null) {
|
|
61
|
-
const response = yield* relay.sendNegMsg(relayUrl, subId, filter, currentMsg);
|
|
62
|
-
|
|
63
|
-
if (response.msgHex === null) break;
|
|
64
|
-
|
|
65
|
-
const reconcileResult: [string | null, string[], string[]] = yield* Effect.tryPromise({
|
|
66
|
-
try: () => neg.reconcile(response.msgHex),
|
|
67
|
-
catch: (e) =>
|
|
68
|
-
new SyncError({
|
|
69
|
-
message: `Negentropy reconcile failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
70
|
-
phase: "negotiate",
|
|
71
|
-
cause: e,
|
|
72
|
-
}),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const [nextMsg, haveIds, needIds] = reconcileResult;
|
|
76
|
-
for (const id of haveIds) allHaveIds.push(id);
|
|
77
|
-
for (const id of needIds) allNeedIds.push(id);
|
|
78
|
-
currentMsg = nextMsg;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return { haveIds: allHaveIds, needIds: allNeedIds };
|
|
82
|
-
});
|
|
83
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { Effect, Ref } from "effect";
|
|
2
|
-
import type { IDBStorageHandle } from "../storage/idb.ts";
|
|
3
|
-
import type { RelayHandle } from "./relay.ts";
|
|
4
|
-
import type { NostrEvent } from "nostr-tools/pure";
|
|
5
|
-
import type { RelayError, StorageError } from "../errors.ts";
|
|
6
|
-
|
|
7
|
-
export interface PublishQueueHandle {
|
|
8
|
-
readonly enqueue: (eventId: string) => Effect.Effect<void>;
|
|
9
|
-
readonly flush: (relayUrls: readonly string[]) => Effect.Effect<void, RelayError | StorageError>;
|
|
10
|
-
readonly size: () => Effect.Effect<number>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function createPublishQueue(
|
|
14
|
-
storage: IDBStorageHandle,
|
|
15
|
-
relay: RelayHandle,
|
|
16
|
-
): Effect.Effect<PublishQueueHandle> {
|
|
17
|
-
return Effect.gen(function* () {
|
|
18
|
-
const pendingRef = yield* Ref.make<Set<string>>(new Set());
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
enqueue: (eventId) =>
|
|
22
|
-
Ref.update(pendingRef, (set) => {
|
|
23
|
-
const next = new Set(set);
|
|
24
|
-
next.add(eventId);
|
|
25
|
-
return next;
|
|
26
|
-
}),
|
|
27
|
-
|
|
28
|
-
flush: (relayUrls) =>
|
|
29
|
-
Effect.gen(function* () {
|
|
30
|
-
const pending = yield* Ref.get(pendingRef);
|
|
31
|
-
if (pending.size === 0) return;
|
|
32
|
-
|
|
33
|
-
const succeeded = new Set<string>();
|
|
34
|
-
|
|
35
|
-
for (const eventId of pending) {
|
|
36
|
-
const gw = yield* storage.getGiftWrap(eventId);
|
|
37
|
-
if (!gw) {
|
|
38
|
-
succeeded.add(eventId);
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
const result = yield* Effect.result(
|
|
42
|
-
relay.publish(gw.event as unknown as NostrEvent, relayUrls),
|
|
43
|
-
);
|
|
44
|
-
if (result._tag === "Success") {
|
|
45
|
-
succeeded.add(eventId);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
yield* Ref.update(pendingRef, (set) => {
|
|
50
|
-
const next = new Set(set);
|
|
51
|
-
for (const id of succeeded) {
|
|
52
|
-
next.delete(id);
|
|
53
|
-
}
|
|
54
|
-
return next;
|
|
55
|
-
});
|
|
56
|
-
}),
|
|
57
|
-
|
|
58
|
-
size: () => Ref.get(pendingRef).pipe(Effect.map((s) => s.size)),
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
}
|
package/src/sync/relay.ts
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
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
|
-
}
|
package/src/sync/sync-service.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
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
|
-
}
|
package/src/sync/sync-status.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
}
|
package/src/utils/uuid.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
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
|
-
}
|