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.
Files changed (75) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +571 -0
  4. package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +498 -0
  5. package/.context/notes.md +0 -0
  6. package/.context/plans/add-changesets-to-douala-v4.md +48 -0
  7. package/.context/plans/dexie-js-style-query-language-for-localstr.md +115 -0
  8. package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +336 -0
  9. package/.context/plans/implementation-plan-localstr-v0-2.md +263 -0
  10. package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +71 -0
  11. package/.context/plans/revise-localstr-prd-v0-2.md +132 -0
  12. package/.context/plans/svelte-5-runes-bindings-for-localstr.md +233 -0
  13. package/.context/todos.md +0 -0
  14. package/.github/workflows/release.yml +36 -0
  15. package/.oxlintrc.json +8 -0
  16. package/README.md +1 -0
  17. package/bun.lock +705 -0
  18. package/examples/svelte/bun.lock +261 -0
  19. package/examples/svelte/package.json +21 -0
  20. package/examples/svelte/src/app.html +11 -0
  21. package/examples/svelte/src/lib/db.ts +44 -0
  22. package/examples/svelte/src/routes/+page.svelte +322 -0
  23. package/examples/svelte/svelte.config.js +16 -0
  24. package/examples/svelte/tsconfig.json +6 -0
  25. package/examples/svelte/vite.config.ts +6 -0
  26. package/examples/vanilla/app.ts +219 -0
  27. package/examples/vanilla/index.html +144 -0
  28. package/examples/vanilla/serve.ts +42 -0
  29. package/package.json +46 -0
  30. package/prds/localstr-v0.2.md +221 -0
  31. package/prek.toml +10 -0
  32. package/scripts/validate.ts +392 -0
  33. package/src/crud/collection-handle.ts +189 -0
  34. package/src/crud/query-builder.ts +414 -0
  35. package/src/crud/watch.ts +78 -0
  36. package/src/db/create-localstr.ts +217 -0
  37. package/src/db/database-handle.ts +16 -0
  38. package/src/db/identity.ts +49 -0
  39. package/src/errors.ts +37 -0
  40. package/src/index.ts +32 -0
  41. package/src/main.ts +10 -0
  42. package/src/schema/collection.ts +53 -0
  43. package/src/schema/field.ts +25 -0
  44. package/src/schema/types.ts +19 -0
  45. package/src/schema/validate.ts +111 -0
  46. package/src/storage/events-store.ts +24 -0
  47. package/src/storage/giftwraps-store.ts +23 -0
  48. package/src/storage/idb.ts +244 -0
  49. package/src/storage/lww.ts +17 -0
  50. package/src/storage/records-store.ts +76 -0
  51. package/src/svelte/collection.svelte.ts +87 -0
  52. package/src/svelte/database.svelte.ts +83 -0
  53. package/src/svelte/index.svelte.ts +52 -0
  54. package/src/svelte/live-query.svelte.ts +29 -0
  55. package/src/svelte/query.svelte.ts +101 -0
  56. package/src/sync/gift-wrap.ts +33 -0
  57. package/src/sync/negentropy.ts +83 -0
  58. package/src/sync/publish-queue.ts +61 -0
  59. package/src/sync/relay.ts +239 -0
  60. package/src/sync/sync-service.ts +183 -0
  61. package/src/sync/sync-status.ts +17 -0
  62. package/src/utils/uuid.ts +22 -0
  63. package/src/vendor/negentropy.js +616 -0
  64. package/tests/db/create-localstr.test.ts +174 -0
  65. package/tests/db/identity.test.ts +33 -0
  66. package/tests/main.test.ts +9 -0
  67. package/tests/schema/collection.test.ts +27 -0
  68. package/tests/schema/field.test.ts +41 -0
  69. package/tests/schema/validate.test.ts +85 -0
  70. package/tests/setup.ts +1 -0
  71. package/tests/storage/idb.test.ts +144 -0
  72. package/tests/storage/lww.test.ts +33 -0
  73. package/tests/sync/gift-wrap.test.ts +56 -0
  74. package/tsconfig.json +18 -0
  75. 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
+ }