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,132 @@
1
+ # Plan: Revise localstr PRD v0.2
2
+
3
+ ## Context
4
+
5
+ The current PRD (`prds/localstr-v0.2.md`) has been critiqued and several design decisions have been made through discussion. The goal is to produce a revised PRD that incorporates all agreed-upon changes. This is a document revision task — no code changes.
6
+
7
+ ## Decisions Made
8
+
9
+ 1. **Effect as the core public API** — The library exports only an Effect API. Framework bindings (Svelte, React, etc.) come later as separate packages. No Promise wrapper in the core.
10
+
11
+ 2. **NIP-59 (Gift Wrap) + NIP-77 (Negentropy) from day one** — Full privacy stack. Events are triple-wrapped (rumor → seal → gift wrap) with randomized timestamps and disposable sender keys. Sync uses negentropy set reconciliation instead of timestamp-based `since` filters.
12
+
13
+ 3. **LWW conflict resolution** — Last-write-wins by `created_at`, ties broken by lowest event ID (lexicographic).
14
+
15
+ 4. **`d` tag = `{collectionName}:{recordId}`** with UUIDv7 record IDs. This is client-side only (relay never sees it due to NIP-59 encryption).
16
+
17
+ 5. **Remove KV API** — Cut from v0.2 entirely.
18
+
19
+ 6. **No schema migration in v0.2** — New optional fields are safe. Required field changes require manual rebuild. Document this as a known limitation.
20
+
21
+ 7. **Full query API** — Ship all query operators: `where().equals()`, `above()`, `below()`, `between()`, `anyOf()`, `sortBy()`, `limit()`, `offset()`.
22
+
23
+ 8. **NIP-77 for sync/dedup** — Negentropy handles cross-relay deduplication naturally. No timestamp-based sync checkpoints.
24
+
25
+ 9. **Storage limits** — Document that the library doesn't manage IndexedDB quota. Suggest `navigator.storage.persist()`. No compaction in v0.2.
26
+
27
+ 10. **`db.close()` lifecycle** — Add to the API for releasing IndexedDB connections and cleaning up subscriptions.
28
+
29
+ 11. **Identity: `exportKey()` + import via config** — Include key export/import in v0.2 since it's required for the cross-device sync success metric.
30
+
31
+ 12. **`watch()` returns an `Effect.Stream`** — Emits initial results then subsequent changes. During sync replay, batch changes and emit once after replay completes.
32
+
33
+ ## Key Changes to the PRD
34
+
35
+ ### Structural changes
36
+
37
+ - **Remove US-008** (hiding Effect from public API) — replaced by the opposite: Effect IS the public API
38
+ - **Add US for key export/import** or fold into US-002
39
+ - **Remove KV from US-002** database handle description
40
+ - **Add `close()` to US-002** acceptance criteria
41
+
42
+ ### US-004 (CRUD) changes
43
+
44
+ - `watch()` returns `Effect.Stream<ReadonlyArray<Record>>`
45
+ - Specify that all methods return `Effect` values with typed error channels
46
+
47
+ ### US-005 (Query) changes
48
+
49
+ - Full query API (no tiering)
50
+ - `watch()` batches changes during sync replay
51
+
52
+ ### US-006 (Sync) changes — Major rewrite
53
+
54
+ - Replace "encrypt event payloads" with NIP-59 gift wrap (3-layer: rumor → seal → gift wrap)
55
+ - Replace "parameterized replaceable event kind" with gift-wrapped events (kind 1059)
56
+ - Replace "deterministic `d` tag" — still deterministic but client-side only, invisible to relay
57
+ - Replace "scoped checkpoints" with NIP-77 negentropy set reconciliation
58
+ - Add: "LWW by `created_at`, ties broken by lowest event ID"
59
+ - Add: relay stores every version (no relay-side deduplication)
60
+ - Add: client-side dedup and latest-version resolution after decryption
61
+
62
+ ### US-007 (Offline) changes
63
+
64
+ - Queue refers to gift-wrapped events, not raw events
65
+
66
+ ### US-008 → Delete
67
+
68
+ - The old "conventional public error model" story needs rewriting since errors are now in Effect's error channel
69
+ - Fold error types into a new story or into Technical Considerations
70
+
71
+ ### New/revised Functional Requirements
72
+
73
+ - Remove FR-20 (hide Nostr from CRUD API) — still true but reframe: the Effect API is the primary API, framework bindings hide everything
74
+ - Add FR for NIP-59 gift wrapping
75
+ - Add FR for NIP-77 negentropy sync
76
+ - Add FR for LWW conflict resolution
77
+ - Add FR for key export
78
+ - Add FR for `close()` lifecycle
79
+ - Remove any mention of KV
80
+
81
+ ### Technical Considerations updates
82
+
83
+ - Effect is the public API; framework bindings are separate packages
84
+ - NIP-59 for encryption (not NIP-44 directly — NIP-44 is used _within_ NIP-59)
85
+ - NIP-77 for sync (not `since`-based filtering)
86
+ - UUIDv7 for record IDs
87
+ - `d` tag format: `{collectionName}:{recordId}` (client-side only)
88
+ - No schema migration; new optional fields safe, other changes require rebuild
89
+ - No IndexedDB quota management; recommend `navigator.storage.persist()`
90
+ - Relay is untrusted; learns almost nothing about data structure
91
+
92
+ ### Non-Goals updates
93
+
94
+ - Add: Promise-based public API (that's for framework bindings)
95
+ - Add: Schema migration
96
+ - Add: KV store
97
+ - Add: IndexedDB quota management / compaction
98
+ - Keep existing non-goals
99
+
100
+ ### Open Questions updates
101
+
102
+ - Remove resolved questions (event kind → kind 1059 gift wraps, export/import → yes, autoSync → manual only, KV → cut, Effect adapter → Effect is the core API)
103
+ - New open questions:
104
+ - Should there be a `rebuild()` method that replays all local events to regenerate materialized state?
105
+ - What's the maximum reasonable event count before negentropy reconciliation becomes slow in a browser?
106
+ - Should the library include a NIP-77-compatible negentropy implementation or depend on an external one?
107
+
108
+ ## IndexedDB Storage Architecture
109
+
110
+ Three stores in IndexedDB:
111
+
112
+ | Store | Contents | Purpose |
113
+ | ----------- | ------------------------------------------------------------------- | ------------------------------------------- |
114
+ | `giftwraps` | Gift wrap event IDs only (not full encrypted blobs) | Negentropy fingerprint computation for sync |
115
+ | `events` | Decrypted rumors (real events with `d` tags, `created_at`, content) | Source of truth, replay, rebuild |
116
+ | `records` | Materialized current state per `d` tag (LWW winner) | Query performance |
117
+
118
+ - On local write: create rumor + gift wrap. Store gift wrap ID in `giftwraps`, decrypted rumor in `events`, LWW-resolved state in `records`. Publish gift wrap to relay async.
119
+ - On sync: negentropy over `giftwraps` IDs → fetch missing gift wraps → decrypt → store rumor in `events` + gift wrap ID in `giftwraps` → LWW resolve into `records`.
120
+ - On rebuild: clear `records`, replay all `events` applying LWW, regenerate `records`.
121
+ - `giftwraps` grows with every write (one per mutation). `records` stays at one entry per unique `d` tag.
122
+
123
+ ## File to modify
124
+
125
+ - `prds/localstr-v0.2.md` — full rewrite incorporating all changes above
126
+
127
+ ## Verification
128
+
129
+ - Read through the revised PRD and confirm all 12 decisions are reflected
130
+ - Confirm no references to KV, Promise wrappers, NIP-04, `since`-based sync, or "hide Effect"
131
+ - Confirm NIP-59, NIP-77, LWW, UUIDv7, `close()`, `exportKey()` are all present
132
+ - Run `bun run validate` to ensure no code breakage (though this is a doc-only change)
@@ -0,0 +1,233 @@
1
+ # Svelte 5 Runes Bindings for localstr
2
+
3
+ ## Context
4
+
5
+ localstr has a fully Effect-based API where all operations return `Effect` and reactive streams return `Stream`. This is powerful but requires Effect knowledge to use. We want Svelte 5 bindings that wrap this API into idiomatic Svelte runes classes — `$state` for reactive data, Promises for CRUD, and auto-cleanup via `$effect`.
6
+
7
+ ## API Design
8
+
9
+ ```svelte
10
+ <script>
11
+ import { createLocalstr, field, collection } from 'localstr/svelte'
12
+
13
+ const todos = collection("todos", {
14
+ title: field.string(),
15
+ done: field.boolean(),
16
+ priority: field.number(),
17
+ }, { indices: ["done", "priority"] })
18
+
19
+ const db = await createLocalstr({
20
+ schema: { todos },
21
+ relays: ["wss://relay.example.com"],
22
+ })
23
+
24
+ const col = db.collection("todos")
25
+ // col.items → $state, auto-watched, all records (live)
26
+ // col.status → $state, 'idle' | 'syncing'
27
+ // col.error → $state, last error or null
28
+
29
+ const incomplete = col.where("done").equals(false).live()
30
+ // incomplete.items → $state, filtered live query
31
+
32
+ // CRUD — returns Promises, sets col.error on failure
33
+ const id = await col.add({ title: "Buy milk", done: false, priority: 1 })
34
+ await col.update(id, { done: true })
35
+ await col.delete(id)
36
+ const todo = await col.get(id)
37
+ </script>
38
+
39
+ {#if col.error}<p class="error">{col.error.message}</p>{/if}
40
+ {#each incomplete.items as todo}
41
+ <p>{todo.title}</p>
42
+ {/each}
43
+ ```
44
+
45
+ ## Files to Create
46
+
47
+ ### `src/svelte/index.svelte.ts` — Main entry point
48
+
49
+ - Re-exports `field`, `collection` from core (unchanged)
50
+ - Exports wrapped `createLocalstr` that returns `Promise<Database>`
51
+ - Exports `Database`, `Collection`, `LiveQuery` classes
52
+
53
+ ### `src/svelte/database.svelte.ts` — Database class
54
+
55
+ ```typescript
56
+ class Database<S extends SchemaConfig> {
57
+ #handle: DatabaseHandle<S>
58
+ #scope: Scope.CloseableScope
59
+ #fiber: Fiber<...> | null // for sync status polling
60
+
61
+ status = $state<SyncStatus>('idle')
62
+ error = $state<Error | null>(null)
63
+
64
+ collection<K extends keyof S>(name: K): Collection<S[K]>
65
+ exportKey(): string
66
+ async close(): Promise<void> // closes scope + handle
67
+ async sync(): Promise<void>
68
+ async rebuild(): Promise<void>
69
+ }
70
+ ```
71
+
72
+ ### `src/svelte/collection.svelte.ts` — Collection class
73
+
74
+ ```typescript
75
+ class Collection<C extends CollectionDef> {
76
+ #handle: CollectionHandle<C>;
77
+ #cleanup: (() => void) | null; // fiber interrupt for watch stream
78
+
79
+ items = $state<ReadonlyArray<InferRecord<C>>>([]);
80
+ error = $state<Error | null>(null);
81
+
82
+ // CRUD — Promise-based, sets this.error on failure
83
+ async add(data): Promise<string>;
84
+ async update(id, data): Promise<void>;
85
+ async delete(id): Promise<void>;
86
+ async get(id): Promise<InferRecord<C>>;
87
+ async first(): Promise<InferRecord<C> | null>;
88
+ async count(): Promise<number>;
89
+
90
+ // Query builders — return Svelte-wrapped builders with .live()
91
+ where(field): SvelteWhereClause<InferRecord<C>>;
92
+ orderBy(field): SvelteOrderByBuilder<InferRecord<C>>;
93
+ }
94
+ ```
95
+
96
+ ### `src/svelte/live-query.svelte.ts` — LiveQuery class
97
+
98
+ ```typescript
99
+ class LiveQuery<T> {
100
+ items = $state<ReadonlyArray<T>>([]);
101
+ #cleanup: (() => void) | null;
102
+
103
+ constructor(stream: Stream<ReadonlyArray<T>>) {
104
+ // Fork stream processing, update items on each emission
105
+ // Return cleanup function that interrupts the fiber
106
+ }
107
+
108
+ destroy(): void; // manual cleanup if needed
109
+ }
110
+ ```
111
+
112
+ ### `src/svelte/query.svelte.ts` — Svelte query builder wrappers
113
+
114
+ Thin wrappers over the Effect query builders that add `.live()` terminal:
115
+
116
+ ```typescript
117
+ interface SvelteQueryBuilder<T> {
118
+ and(fn): SvelteQueryBuilder<T>;
119
+ sortBy(field): SvelteQueryBuilder<T>;
120
+ reverse(): SvelteQueryBuilder<T>;
121
+ offset(n): SvelteQueryBuilder<T>;
122
+ limit(n): SvelteQueryBuilder<T>;
123
+ get(): Promise<ReadonlyArray<T>>; // Effect.runPromise
124
+ first(): Promise<T | null>;
125
+ count(): Promise<number>;
126
+ live(): LiveQuery<T>; // NEW: returns reactive LiveQuery
127
+ }
128
+
129
+ interface SvelteWhereClause<T> {
130
+ equals(value): SvelteQueryBuilder<T>;
131
+ above(value): SvelteQueryBuilder<T>;
132
+ // ... all WhereClause methods, returning SvelteQueryBuilder
133
+ }
134
+
135
+ interface SvelteOrderByBuilder<T> {
136
+ reverse(): SvelteOrderByBuilder<T>;
137
+ offset(n): SvelteOrderByBuilder<T>;
138
+ limit(n): SvelteOrderByBuilder<T>;
139
+ get(): Promise<ReadonlyArray<T>>;
140
+ first(): Promise<T | null>;
141
+ count(): Promise<number>;
142
+ live(): LiveQuery<T>;
143
+ }
144
+ ```
145
+
146
+ ## Key Implementation Details
147
+
148
+ ### Effect Scope Management
149
+
150
+ `createLocalstr` returns `Effect<DatabaseHandle, ..., Scope.Scope>`. The scope must stay alive until `close()` is called:
151
+
152
+ ```typescript
153
+ // In createLocalstr wrapper:
154
+ const scope = Effect.runSync(Scope.make());
155
+ const handle = await Effect.runPromise(
156
+ coreCreateLocalstr(config).pipe(Effect.provideService(Scope.Scope, scope)),
157
+ );
158
+ // scope is stored in Database, closed on db.close()
159
+ ```
160
+
161
+ ### Stream → $state Bridging
162
+
163
+ For auto-watching `items` and `LiveQuery`:
164
+
165
+ ```typescript
166
+ // Fork a fiber that processes the stream
167
+ const fiber = Effect.runFork(
168
+ stream.pipe(
169
+ Stream.runForEach((records) =>
170
+ Effect.sync(() => {
171
+ this.items = records;
172
+ }),
173
+ ),
174
+ ),
175
+ );
176
+ // Store fiber reference for cleanup
177
+ this.#cleanup = () => Effect.runFork(Fiber.interrupt(fiber));
178
+ ```
179
+
180
+ ### Collection Auto-Watch Lifecycle
181
+
182
+ - Watch stream starts in the constructor (items immediately populated)
183
+ - Fiber is stored for cleanup
184
+ - `Database.close()` interrupts all collection fibers
185
+
186
+ ### LiveQuery Auto-Dispose
187
+
188
+ LiveQuery uses `$effect` cleanup in Svelte 5. When the component unmounts, `$effect` teardown interrupts the stream fiber. Since LiveQuery is created inside component `<script>`, Svelte tracks it.
189
+
190
+ However, `$effect` cleanup only works if the LiveQuery is created inside a tracking context. For safety, LiveQuery also exposes a `.destroy()` method for manual cleanup, and Database.close() cleans up all active queries.
191
+
192
+ ### Error Handling
193
+
194
+ CRUD methods catch Effect failures and:
195
+
196
+ 1. Set `collection.error = extractedError`
197
+ 2. Re-throw as a plain Error (so `await` callers can also catch)
198
+
199
+ This gives both template-friendly `{#if col.error}` and traditional `try/catch`.
200
+
201
+ ### Sync Status
202
+
203
+ Database polls `getSyncStatus()` periodically or listens to sync events to update `database.status` and propagate to collections.
204
+
205
+ ## Files to Modify
206
+
207
+ ### `src/index.ts`
208
+
209
+ Add svelte re-export:
210
+
211
+ ```typescript
212
+ // Svelte bindings
213
+ export * from "./svelte/index.svelte.ts";
214
+ ```
215
+
216
+ Actually — better to keep it as a separate import path. Users import from `localstr/svelte` or `./svelte/index.svelte.ts`. No changes to `src/index.ts` needed.
217
+
218
+ ## Implementation Order
219
+
220
+ 1. `src/svelte/live-query.svelte.ts` — LiveQuery class (standalone, testable)
221
+ 2. `src/svelte/query.svelte.ts` — SvelteWhereClause/SvelteQueryBuilder/SvelteOrderByBuilder wrappers
222
+ 3. `src/svelte/collection.svelte.ts` — Collection class with auto-watch + CRUD
223
+ 4. `src/svelte/database.svelte.ts` — Database class with scope management
224
+ 5. `src/svelte/index.svelte.ts` — Entry point with createLocalstr wrapper + re-exports
225
+ 6. Update `package.json` exports map if needed for `localstr/svelte` path
226
+
227
+ ## Verification
228
+
229
+ 1. TypeScript compilation: `bun run tsc --noEmit` (ensure .svelte.ts files compile)
230
+ 2. Create a demo Svelte component using the new API
231
+ 3. Test that Effect scope is properly managed (no leaked fibers)
232
+ 4. Test LiveQuery cleanup on destroy
233
+ 5. Test error propagation to $state
File without changes
@@ -0,0 +1,36 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ concurrency: ${{ github.workflow }}-${{ github.ref }}
9
+
10
+ jobs:
11
+ release:
12
+ name: Release
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: write
16
+ pull-requests: write
17
+ id-token: write
18
+ steps:
19
+ - name: Checkout
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Setup Bun
23
+ uses: oven-sh/setup-bun@v2
24
+
25
+ - name: Install dependencies
26
+ run: bun install --frozen-lockfile
27
+
28
+ - name: Create Release Pull Request or Publish
29
+ id: changesets
30
+ uses: changesets/action@v1
31
+ with:
32
+ version: bun run version
33
+ publish: bun run release
34
+ env:
35
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36
+ NPM_CONFIG_PROVENANCE: true
package/.oxlintrc.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "rules": {
4
+ "no-unused-vars": "warn",
5
+ "eqeqeq": "error"
6
+ },
7
+ "ignorePatterns": ["node_modules", "dist"]
8
+ }
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # localstr