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,42 @@
1
+ import { $ } from "bun";
2
+
3
+ const dir = "examples/vanilla";
4
+
5
+ // Bundle app.ts for the browser
6
+ console.log("Bundling...");
7
+ const result = await Bun.build({
8
+ entrypoints: [`${dir}/app.ts`],
9
+ outdir: dir,
10
+ target: "browser",
11
+ format: "esm",
12
+ minify: false,
13
+ sourcemap: "inline",
14
+ });
15
+
16
+ if (!result.success) {
17
+ console.error("Build failed:");
18
+ for (const log of result.logs) {
19
+ console.error(log);
20
+ }
21
+ process.exit(1);
22
+ }
23
+
24
+ console.log(`Bundle created at ${dir}/app.js`);
25
+
26
+ // Serve
27
+ const server = Bun.serve({
28
+ port: 3000,
29
+ async fetch(req) {
30
+ const url = new URL(req.url);
31
+ let path = url.pathname;
32
+ if (path === "/") path = "/index.html";
33
+
34
+ const file = Bun.file(`${dir}${path}`);
35
+ if (await file.exists()) {
36
+ return new Response(file);
37
+ }
38
+ return new Response("Not found", { status: 404 });
39
+ },
40
+ });
41
+
42
+ console.log(`Demo running at http://localhost:${server.port}`);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "tablinum",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "bun run src/main.ts",
7
+ "lint": "oxlint",
8
+ "format": "oxfmt --write .",
9
+ "format:check": "oxfmt --check .",
10
+ "test": "bun run vitest run",
11
+ "test:watch": "bun run vitest",
12
+ "validate": "bun run scripts/validate.ts",
13
+ "demo": "bun run examples/vanilla/serve.ts",
14
+ "demo:svelte": "cd examples/svelte && bun run dev",
15
+ "changeset": "changeset",
16
+ "version": "changeset version",
17
+ "release": "changeset publish"
18
+ },
19
+ "dependencies": {
20
+ "@noble/curves": "^2.0.1",
21
+ "@noble/hashes": "^2.0.1",
22
+ "effect": "4.0.0-beta.28",
23
+ "idb": "^8.0.3",
24
+ "nostr-tools": "^2.23.3"
25
+ },
26
+ "devDependencies": {
27
+ "@changesets/cli": "^2.30.0",
28
+ "@effect/vitest": "4.0.0-beta.28",
29
+ "@j178/prek": "latest",
30
+ "@types/bun": "^1.3.10",
31
+ "fake-indexeddb": "^6.2.5",
32
+ "oxfmt": "latest",
33
+ "oxlint": "^1.51.0",
34
+ "svelte": "^5.0.0",
35
+ "typescript": "^5.7.0",
36
+ "vitest": "^3.0.0"
37
+ },
38
+ "peerDependencies": {
39
+ "svelte": "^5.0.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "svelte": {
43
+ "optional": true
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,221 @@
1
+ [PRD]
2
+
3
+ # PRD: localstr v0.2
4
+
5
+ ## Overview
6
+
7
+ `localstr` is a browser-first local-first sync library for structured application data.
8
+ It gives developers a typed Effect API for defining collections, writing records, querying local
9
+ data, and syncing that data across the same user's devices through Nostr relays.
10
+
11
+ For v0.2, the scope is intentionally narrow:
12
+
13
+ - local-first browser storage
14
+ - typed schema and Effect-based query/mutation API
15
+ - single-user sync across devices
16
+ - privacy-preserving encrypted relay replication via NIP-59 gift wrapping
17
+ - efficient set reconciliation via NIP-77 negentropy
18
+
19
+ Collaboration, shared collections, invite flows, multi-user membership, and framework-specific
20
+ bindings are explicitly out of scope for this phase.
21
+
22
+ The core developer experience should be:
23
+
24
+ 1. Define a schema once.
25
+ 2. Initialize the library.
26
+ 3. Read and write data locally using the Effect API.
27
+ 4. Call sync when needed.
28
+ 5. Let the library handle event creation, gift wrapping, relay I/O, and replay.
29
+
30
+ ## Goals
31
+
32
+ - Provide a typed Effect API for local-first application data in the browser.
33
+ - Keep the UI read path entirely local via IndexedDB.
34
+ - Replicate data across the same user's devices through Nostr relays.
35
+ - Treat relays as untrusted: hide application data structure and content, while reducing metadata leakage via NIP-59 gift wrapping.
36
+ - Use NIP-77 negentropy for efficient, timestamp-independent set reconciliation during sync.
37
+ - Make the library usable in a small browser app by developers familiar with Effect.
38
+ - Establish a clean technical foundation for future collaboration support and framework bindings.
39
+
40
+ ## Quality Gates
41
+
42
+ These commands must pass for every user story:
43
+
44
+ - `bun run validate`
45
+
46
+ ## User Stories
47
+
48
+ ### US-001: Define typed collections
49
+
50
+ **Description:** As a developer, I want to define collections and fields once so that TypeScript types and runtime validation stay aligned.
51
+
52
+ **Acceptance Criteria:**
53
+
54
+ - [ ] Add schema builders for `collection()` and `field.*()` in the public API.
55
+ - [ ] Support `string`, `number`, `boolean`, `json`, `optional`, and array field variants for v0.2.
56
+ - [ ] Infer record types from the schema without requiring a separate interface.
57
+ - [ ] Reject invalid schema field definitions at initialization time.
58
+ - [ ] Document which field types support simple equality filtering in v0.2 and which do not.
59
+
60
+ ### US-002: Initialize a localstr database
61
+
62
+ **Description:** As a developer, I want to create a database instance with schema and relay configuration so that my app can read and write local-first data.
63
+
64
+ **Acceptance Criteria:**
65
+
66
+ - [ ] Add `createLocalstr` as the primary entrypoint, returning an `Effect` that requires a `Scope`.
67
+ - [ ] Require a schema and at least one relay URL.
68
+ - [ ] Generate a Nostr private key by default when one is not supplied.
69
+ - [ ] Accept a developer-supplied secret key or signer as an advanced option.
70
+ - [ ] Expose `exportKey()` on the database handle so the developer can back up or transfer the generated key to another device.
71
+ - [ ] Expose a stable database handle with collection, sync, and lifecycle APIs.
72
+ - [ ] Expose `close()` on the database handle to release IndexedDB connections and clean up active subscriptions. Further operations after close should fail with a typed error.
73
+
74
+ ### US-003: Persist records locally and materialize query state
75
+
76
+ **Description:** As a developer, I want writes to commit locally first so that my app remains fast and usable offline.
77
+
78
+ **Acceptance Criteria:**
79
+
80
+ - [ ] Maintain three IndexedDB stores: `giftwraps` (full gift wrap events for publication, relay fanout, and negentropy), `events` (decrypted rumors as source of truth), and `records` (materialized LWW-resolved state for queries).
81
+ - [ ] On local write, store the decrypted rumor in `events`, the exact serialized gift wrap in `giftwraps`, and the LWW-resolved record in `records`.
82
+ - [ ] Treat the `events` store as the source of truth for replay and rebuild.
83
+ - [ ] Provide rebuild logic that clears `records` and regenerates materialized state by replaying all events with LWW resolution.
84
+ - [ ] Expose `rebuild()` on the database handle to replay all events and regenerate the `records` store from scratch.
85
+ - [ ] Do not claim local at-rest encryption for the materialized query store in v0.2.
86
+
87
+ ### US-004: Read, write, update, and delete records through a typed API
88
+
89
+ **Description:** As a developer, I want a small typed CRUD API so that I can use localstr like a normal browser database.
90
+
91
+ **Acceptance Criteria:**
92
+
93
+ - [ ] Implement `add()`, `update()`, `delete()`, `get()`, `first()`, `count()`, and `watch()` for collections.
94
+ - [ ] One-shot mutation and query methods return `Effect` values with typed error channels.
95
+ - [ ] `watch()` returns an `Effect.Stream` that emits initial results followed by subsequent local changes.
96
+ - [ ] During sync replay, `watch()` batches changes and emits once after replay completes rather than per-event.
97
+ - [ ] Validate write payloads against the schema before local commit.
98
+ - [ ] Generate UUIDv7 record IDs for inserts.
99
+ - [ ] Represent deletes as tombstone records in the event stream rather than a separate Nostr deletion event kind.
100
+ - [ ] Exclude tombstoned records from normal query results.
101
+
102
+ ### US-005: Query local data with simple v0.2 constraints
103
+
104
+ **Description:** As a developer, I want a minimal local query API so that I can filter records without learning IndexedDB details.
105
+
106
+ **Acceptance Criteria:**
107
+
108
+ - [ ] Support `.where().equals()` on supported scalar fields using local evaluation against the `records` store.
109
+ - [ ] Execute normal application queries against the `records` IndexedDB store only.
110
+ - [ ] Restrict relay-side filtering to event metadata needed during sync, not application query execution.
111
+ - [ ] Reject unsupported query patterns with typed errors in the Effect error channel.
112
+ - [ ] Leave indexed query planning, range filters, sorting, and compound query optimization out of scope for v0.2.
113
+ - [ ] Provide live subscriptions via `watch()` that emit initial results and subsequent local changes as an `Effect.Stream`.
114
+
115
+ ### US-006: Sync encrypted events across the same user's devices
116
+
117
+ **Description:** As a developer, I want local writes to replicate through relays so that users can use the same data on multiple devices.
118
+
119
+ **Acceptance Criteria:**
120
+
121
+ - [ ] Convert writes into signed Nostr rumor events using a deterministic `d` tag formatted as `{collectionName}:{recordId}`.
122
+ - [ ] Wrap each rumor using NIP-59 gift wrapping: rumor (unsigned) → NIP-44-encrypted seal (signed by real author) → NIP-44-encrypted gift wrap (signed by random disposable key, randomized timestamp).
123
+ - [ ] Encrypt gift wraps to the author's own public key (self-encryption for single-user sync).
124
+ - [ ] Publish gift wraps to relays asynchronously without blocking the local commit path.
125
+ - [ ] Implement `sync()` using NIP-77 negentropy set reconciliation over gift wrap event IDs. No timestamp-based `since` filters.
126
+ - [ ] On sync, fetch missing gift wraps, unwrap (decrypt gift wrap → decrypt seal → extract rumor), store the exact gift wrap in `giftwraps`, store the rumor in `events`, then LWW-resolve into `records`.
127
+ - [ ] Resolve conflicts using last-write-wins by client `created_at` timestamp. Break ties by lowest event ID (lexicographic).
128
+ - [ ] Document that concurrent offline writes from devices with skewed clocks may resolve unintuitively in v0.2.
129
+ - [ ] Send the exact locally-stored gift wraps that the relay is missing (bidirectional sync).
130
+
131
+ ### US-007: Support offline operation and retry relay publication
132
+
133
+ **Description:** As a developer, I want offline-safe writes so that my app continues working when the network is unavailable.
134
+
135
+ **Acceptance Criteria:**
136
+
137
+ - [ ] Commit writes locally even when relay publication fails.
138
+ - [ ] Queue failed gift wrap publications for retry using the exact stored gift wrap event.
139
+ - [ ] Flush queued publications when the browser returns online or when sync is triggered manually.
140
+ - [ ] Expose `getSyncStatus()` returning an `Effect` that reports whether the database is currently `idle` or `syncing`.
141
+ - [ ] Surface sync and relay failures separately from local validation/storage failures via distinct error types in the Effect error channel.
142
+ - [ ] Keep local reads available when no relay is reachable.
143
+
144
+ ## Functional Requirements
145
+
146
+ 1. `FR-1`: The system must allow a developer to define a schema made of named collections and typed fields.
147
+ 2. `FR-2`: The system must infer TypeScript record types from the declared schema.
148
+ 3. `FR-3`: The system must initialize in a browser environment with IndexedDB and relay configuration.
149
+ 4. `FR-4`: The system must generate a Nostr private key when the developer does not supply one.
150
+ 5. `FR-5`: The system must allow the developer to supply either a secret key or a signer instead of the generated key.
151
+ 6. `FR-6`: The system must expose `exportKey()` to allow the developer to back up and transfer the identity to another device.
152
+ 7. `FR-7`: The system must maintain three IndexedDB stores: `giftwraps` (full gift wrap events for publication, relay fanout, and negentropy), `events` (decrypted rumors as source of truth), and `records` (materialized LWW-resolved query state).
153
+ 8. `FR-8`: The system must validate writes against the schema before committing them locally.
154
+ 9. `FR-9`: The system must support typed collection CRUD operations returning `Effect` values with typed error channels.
155
+ 10. `FR-10`: The system must represent record deletion as a tombstone in the event stream.
156
+ 11. `FR-11`: The system must provide minimal local query operations against the `records` store, including equality filtering on supported scalar fields.
157
+ 12. `FR-12`: The system must provide reactive subscriptions as `Effect.Stream` values for local query result changes.
158
+ 13. `FR-13`: The system must wrap outbound events using NIP-59 gift wrapping (rumor → seal → gift wrap) with NIP-44 encryption at each layer.
159
+ 14. `FR-14`: The system must sign seals with the active identity and gift wraps with a random disposable key.
160
+ 15. `FR-15`: The system must sync using NIP-77 negentropy set reconciliation over gift wrap event IDs.
161
+ 16. `FR-16`: The system must unwrap, verify, decrypt, deduplicate, and replay remote gift wraps during sync.
162
+ 17. `FR-17`: The system must resolve conflicts using last-write-wins by `created_at`, ties broken by lowest event ID.
163
+ 18. `FR-18`: The system must queue failed gift wrap publications and retry them later.
164
+ 19. `FR-19`: The system must expose `getSyncStatus()` to application code as an `Effect` reporting whether the database is currently `idle` or `syncing`.
165
+ 20. `FR-20`: The system must surface typed errors via the Effect error channel, including at least `ValidationError`, `StorageError`, `CryptoError`, `RelayError`, `SyncError`, and `NotFoundError`.
166
+ 21. `FR-21`: The system must expose `close()` to release IndexedDB connections and clean up active subscriptions.
167
+ 22. `FR-22`: The system must generate UUIDv7 record IDs and use `{collectionName}:{recordId}` as the deterministic `d` tag (client-side only, encrypted inside gift wraps).
168
+ 23. `FR-23`: The system must expose `rebuild()` to regenerate the materialized `records` store by replaying all events with LWW resolution.
169
+
170
+ ## Non-Goals
171
+
172
+ - Multi-user collaboration.
173
+ - Shared collection invites or join flows.
174
+ - Membership tracking.
175
+ - Member revocation or key rotation.
176
+ - Full-text search.
177
+ - Cross-collection joins.
178
+ - Multi-record transactions.
179
+ - Node.js, Bun, or server-side storage adapters.
180
+ - Password-derived identity as the default or only identity mode.
181
+ - Promise-based public API (reserved for future framework bindings).
182
+ - Framework-specific bindings (Svelte, React, etc.) — these are separate future packages.
183
+ - Schema migration.
184
+ - KV store.
185
+ - IndexedDB quota management or compaction.
186
+
187
+ ## Technical Considerations
188
+
189
+ - Effect is the public API for one-shot operations. Streaming APIs such as `watch()` return `Effect.Stream` values with typed error channels. Framework bindings (e.g., `@localstr/svelte`, `@localstr/react`) will translate Effect streams into framework-native reactivity as separate packages.
190
+ - v0.2 uses IndexedDB as the only supported storage backend, with three stores: `giftwraps`, `events`, and `records`.
191
+ - The `events` store (decrypted rumors) is authoritative; the `records` store (materialized view) is rebuildable from events via LWW replay.
192
+ - The `giftwraps` store holds the full serialized gift wrap events so the same exact event can be retried, replicated to multiple relays, and later sent to newly-added relays without changing event IDs.
193
+ - Query execution is local-only against the `records` store. v0.2 supports basic equality filtering only; relay filtering is a sync concern, not an application query concern.
194
+ - NIP-59 gift wrapping provides strong confidentiality for application data: the relay sees only the gift wrap (kind 1059) signed by a random disposable key with a randomized timestamp. The relay cannot read collection names, record IDs, or application payloads, but it can still observe recipient pubkey, traffic volume, and network arrival time. The gift wrap's `p` tag addresses the author's own public key for self-sync filtering.
195
+ - NIP-44 is used within NIP-59 for encryption at the seal and gift wrap layers.
196
+ - NIP-77 negentropy provides efficient set reconciliation that works regardless of timestamps. First sync (empty local state) degrades gracefully to a full download. Incremental syncs transfer only the difference. Cross-relay deduplication is handled naturally.
197
+ - Because NIP-59 randomizes timestamps and uses disposable keys, relay-side replaceable event deduplication is not possible. Each mutation is wrapped once and the resulting exact gift wrap is stored locally and published to all relays. Relays still store all versions across distinct mutations. LWW resolution happens client-side after decryption.
198
+ - Record IDs are client-generated UUIDv7s (time-sortable). The `d` tag for a record's event is `{collectionName}:{recordId}`, visible only inside the encrypted rumor.
199
+ - Conflict resolution is last-write-wins by client `created_at` timestamp, ties broken by lowest event ID (lexicographic). This is intentionally simple for v0.2 and may resolve concurrent offline writes unintuitively if device clocks are skewed.
200
+ - The root identity is a generated Nostr private key stored in IndexedDB, exportable via `exportKey()` for recovery and reuse on another device. If IndexedDB is cleared, the key is lost unless exported.
201
+ - A developer-supplied key or signer remains an advanced path for apps that want tighter identity control.
202
+ - Delete behavior is handled through replayable tombstones so rebuilds remain deterministic.
203
+ - v0.2 does not support schema migration. Adding new optional fields is safe — replay will populate them as `undefined`. Adding required fields, removing fields, or changing field types requires the developer to manually clear and rebuild the local database.
204
+ - The library does not manage IndexedDB quota. If the browser evicts storage, the local database is lost and must be rebuilt from relays via `sync()`. Applications storing large datasets should request persistent storage via `navigator.storage.persist()`.
205
+ - The NIP-77 negentropy implementation should depend on the external `negentropy` package (reference implementation by the NIP-77 author) rather than bundling a custom implementation.
206
+
207
+ ## Success Metrics
208
+
209
+ - A developer can define a schema and perform typed local CRUD using the Effect API in a browser app with no direct Nostr usage.
210
+ - Data written on one device can be synced and read on a second device using an exported secret key.
211
+ - Local queries continue to work while offline.
212
+ - Relay failures do not block local writes.
213
+ - Relays cannot read application data structure or content, but still observe recipient pubkey, traffic volume, and network arrival time.
214
+ - The implementation passes `bun run validate`.
215
+
216
+ ## Resolved Questions
217
+
218
+ - **`rebuild()` in public API?** Yes. Exposed as `db.rebuild()` returning an `Effect`. Useful as an escape hatch for corrupted state, schema changes, and testing.
219
+ - **Negentropy performance ceiling?** Not a concern for v0.2. The algorithm is O(n log n) and the reference JS implementation handles hundreds of thousands of items. The bottleneck would be IndexedDB reads, not the algorithm. Revisit if real usage shows issues.
220
+ - **Bundle or depend on external negentropy?** Depend on the external `negentropy` package by Doug Hoyte (NIP-77 author). It's the reference implementation, small, and actively maintained.
221
+ [/PRD]
package/prek.toml ADDED
@@ -0,0 +1,10 @@
1
+ [[repos]]
2
+ repo = "local"
3
+
4
+ [[repos.hooks]]
5
+ id = "validate"
6
+ name = "Run validation"
7
+ entry = "bun run scripts/validate.ts"
8
+ language = "system"
9
+ always_run = true
10
+ pass_filenames = false