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,336 @@
1
+ # Dexie.js-style Query Language with Per-Collection IDB Stores
2
+
3
+ ## Context
4
+
5
+ localstr currently only supports `where(field).equals(value)` with no sorting, range queries, or pagination. All collections share a single IDB `records` object store with nested `data.*` fields, making real indices impossible. We're migrating to per-collection IDB object stores with native indices, enabling a rich chainable query API inspired by Dexie.js.
6
+
7
+ ## Target API
8
+
9
+ ```typescript
10
+ // Schema with indices
11
+ const todos =
12
+ yield *
13
+ collection(
14
+ "todos",
15
+ {
16
+ title: field.string(),
17
+ done: field.boolean(),
18
+ priority: field.number(),
19
+ },
20
+ { indices: ["done", "priority"] },
21
+ );
22
+
23
+ // Range queries — single yield* at the terminal only
24
+ yield * col.where("priority").above(3).get();
25
+ yield * col.where("priority").between(1, 5).get();
26
+ yield * col.where("title").startsWith("Buy").get();
27
+
28
+ // Sorting & pagination
29
+ yield * col.orderBy("priority").get();
30
+ yield * col.orderBy("priority").reverse().get();
31
+ yield * col.orderBy("priority").offset(10).limit(5).get();
32
+
33
+ // Chained filter + sort
34
+ yield * col.where("done").equals(false).sortBy("priority").get();
35
+ ```
36
+
37
+ **Key design choice:** `where()` and `orderBy()` are synchronous — they return builder objects directly, not Effects. The entire chain before a terminal (`.get()`, `.first()`, etc.) is pure data describing a query plan. Only the terminal returns an `Effect`. Field validation is deferred to execution time. TypeScript catches field name typos at compile time anyway.
38
+
39
+ **Effect v4 note:** Uses `effect@4.0.0-beta.28`. All code follows v4 patterns: `Data.TaggedError("Tag")<{ fields }>`, `Effect.gen(function* () { ... })` (no adapter), `yield* new ErrorClass(...)` to fail. Plan follows these same conventions.
40
+
41
+ ---
42
+
43
+ ## Step 1: Schema — Add indices to `CollectionDef`
44
+
45
+ **File: `src/schema/collection.ts`**
46
+
47
+ - Add optional third parameter: `options?: { indices?: ReadonlyArray<string & keyof F> }`
48
+ - Add `indices` field to `CollectionDef` interface (default `[]`)
49
+ - Validate each index key exists in `fields` and is not `json` or `array` type
50
+ - Backwards compatible — existing two-arg calls still work
51
+
52
+ **File: `src/schema/types.ts`**
53
+
54
+ - Add `IndexedFields<C>` utility type (for future type-level enforcement)
55
+
56
+ ---
57
+
58
+ ## Step 2: Storage — Per-collection IDB object stores
59
+
60
+ **File: `src/storage/idb.ts`**
61
+
62
+ This is the biggest change. Replace the shared `records` store with per-collection stores.
63
+
64
+ ### New `StoredRecord` shape (flattened)
65
+
66
+ ```typescript
67
+ // Before: { id, collection, data: { title, done, ... }, deleted, updatedAt }
68
+ // After: { id, _deleted, _updatedAt, title, done, ... }
69
+ ```
70
+
71
+ Drop the `collection` and `data` wrapper — fields live at top level. `_deleted` and `_updatedAt` use underscore prefix to avoid conflicts (already reserved in schema validation).
72
+
73
+ ### Schema-aware `openDB`
74
+
75
+ `openIDBStorage` needs the schema config passed in so it can create per-collection object stores and indices:
76
+
77
+ ```typescript
78
+ export function openIDBStorage(
79
+ dbName: string | undefined,
80
+ schema: SchemaConfig, // NEW: needed to create stores + indices
81
+ ): Effect.Effect<IDBStorageHandle, StorageError, Scope.Scope>;
82
+ ```
83
+
84
+ **Version strategy:** Compute a deterministic version number from the schema. Hash the sorted collection names + their sorted index declarations → produce a numeric version. When schema changes, version bumps, triggering `onupgradeneeded`.
85
+
86
+ **`upgrade` handler:**
87
+
88
+ - For each collection in schema, create object store `col_{name}` with keyPath `"id"` if it doesn't exist
89
+ - Create IDB indices for each declared index field (keyPath is the field name directly since data is flattened)
90
+ - Remove old stores/indices that no longer exist in schema
91
+ - Keep `events` and `giftwraps` stores unchanged
92
+ - If old shared `records` store exists, migrate data out then delete it
93
+
94
+ ### Updated `IDBStorageHandle` interface
95
+
96
+ ```typescript
97
+ // Records — now collection-scoped with flat shape
98
+ readonly putRecord: (collection: string, record: Record<string, unknown>) => Effect.Effect<void, StorageError>;
99
+ readonly getRecord: (collection: string, id: string) => Effect.Effect<Record<string, unknown> | undefined, StorageError>;
100
+ readonly getAllRecords: (collection: string) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
101
+ readonly countRecords: (collection: string) => Effect.Effect<number, StorageError>;
102
+ readonly clearRecords: (collection: string) => Effect.Effect<void, StorageError>;
103
+
104
+ // NEW: index-based queries
105
+ readonly getByIndex: (collection: string, indexName: string, value: IDBValidKey) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
106
+ readonly getByIndexRange: (collection: string, indexName: string, range: IDBKeyRange) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
107
+ readonly getAllSorted: (collection: string, indexName: string, direction?: "next" | "prev") => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
108
+ ```
109
+
110
+ The store name for a collection is `col_{collectionName}`.
111
+
112
+ ### Events and giftwraps stores — unchanged
113
+
114
+ These stay as shared stores. No changes needed.
115
+
116
+ ---
117
+
118
+ ## Step 3: Update `records-store.ts` — Flattened record shape
119
+
120
+ **File: `src/storage/records-store.ts`**
121
+
122
+ Update `applyEvent` to write flattened records:
123
+
124
+ ```typescript
125
+ // Before
126
+ const record: StoredRecord = {
127
+ id: event.recordId,
128
+ collection: event.collection,
129
+ data: event.data ?? {},
130
+ deleted: event.kind === "delete",
131
+ updatedAt: event.createdAt,
132
+ };
133
+ yield * storage.putRecord(record);
134
+
135
+ // After
136
+ const record = {
137
+ id: event.recordId,
138
+ _deleted: event.kind === "delete",
139
+ _updatedAt: event.createdAt,
140
+ ...(event.data ?? {}),
141
+ };
142
+ yield * storage.putRecord(event.collection, record);
143
+ ```
144
+
145
+ Update `rebuild` similarly — it now calls `storage.clearRecords(collection)` per collection and writes flattened records.
146
+
147
+ The `rebuild` function needs access to collection names. Options:
148
+
149
+ - Accept `collections: string[]` parameter
150
+ - Or clear each collection store encountered in events
151
+
152
+ Simplest: accept `collections: string[]` from caller (create-localstr already knows all collection names).
153
+
154
+ ---
155
+
156
+ ## Step 4: Query Builder — Rich chainable API
157
+
158
+ **File: `src/crud/query-builder.ts`** (major rewrite)
159
+
160
+ ### Internal query plan
161
+
162
+ ```typescript
163
+ interface QueryPlan {
164
+ filters: Array<(record: Record<string, unknown>) => boolean>;
165
+ orderBy?: { field: string; direction: "asc" | "desc" };
166
+ offset?: number;
167
+ limit?: number;
168
+ // Index hint for IDB-accelerated queries
169
+ indexQuery?: { field: string; range: IDBKeyRange | IDBValidKey; type: "value" | "range" };
170
+ }
171
+ ```
172
+
173
+ ### `WhereClause<T>` — returned by `col.where(field)`
174
+
175
+ ```
176
+ equals(value) → QueryBuilder<T>
177
+ above(value) / aboveOrEqual(value) → QueryBuilder<T>
178
+ below(value) / belowOrEqual(value) → QueryBuilder<T>
179
+ between(lower, upper) → QueryBuilder<T>
180
+ startsWith(prefix) → QueryBuilder<T>
181
+ anyOf(values) / noneOf(values) → QueryBuilder<T>
182
+ ```
183
+
184
+ Each method sets both a filter predicate AND an index hint (if the field is indexed). For `startsWith`, the IDB range is `IDBKeyRange.bound(prefix, prefix + '\uffff')`.
185
+
186
+ ### `QueryBuilder<T>` — chainable
187
+
188
+ ```
189
+ and(fn) → QueryBuilder<T> // JS predicate
190
+ sortBy(field) → QueryBuilder<T> // set orderBy
191
+ reverse() → QueryBuilder<T> // flip direction
192
+ offset(n) / limit(n) → QueryBuilder<T>
193
+ get() → Effect<T[], StorageError> // terminal: all matching records
194
+ first() → Effect<T | null, StorageError> // terminal
195
+ count() → Effect<number, StorageError> // terminal
196
+ watch() → Stream<T[], StorageError> // terminal
197
+ ```
198
+
199
+ ### `OrderByBuilder<T>` — returned by `col.orderBy(field)`
200
+
201
+ Same chain/terminal methods as QueryBuilder minus `and/sortBy`:
202
+
203
+ ```
204
+ reverse() → OrderByBuilder<T>
205
+ offset(n) / limit(n) → OrderByBuilder<T>
206
+ get() → Effect<T[], StorageError> // terminal
207
+ first() → Effect<T | null, StorageError> // terminal
208
+ count() → Effect<number, StorageError> // terminal
209
+ watch() → Stream<T[], StorageError> // terminal
210
+ ```
211
+
212
+ ### Execution strategy
213
+
214
+ The `executeQuery` function:
215
+
216
+ 1. If there's an `indexQuery` hint → use `storage.getByIndex` or `storage.getByIndexRange`
217
+ 2. Otherwise → `storage.getAllRecords`
218
+ 3. Filter `_deleted` records
219
+ 4. Apply remaining filter predicates
220
+ 5. Sort (if `orderBy` set and not already sorted by IDB)
221
+ 6. Apply offset/limit
222
+ 7. Map to user-facing record type (strip `_deleted`, `_updatedAt`)
223
+
224
+ ### Watch implementation
225
+
226
+ `QueryBuilder.watch()` builds a stream that re-executes the full query on each PubSub change event, same pattern as current `watchCollection` but self-contained (no changes to `watch.ts` needed).
227
+
228
+ ---
229
+
230
+ ## Step 5: Update `collection-handle.ts`
231
+
232
+ **File: `src/crud/collection-handle.ts`**
233
+
234
+ - Add `orderBy(field)` method to `CollectionHandle` interface
235
+ - Both `where()` and `orderBy()` return builders directly (synchronous, not wrapped in Effect)
236
+ - Field validation deferred to terminal execution — errors surface as `ValidationError` in the Effect
237
+ - This is a **breaking change** to `where()` signature (was `Effect<WhereClause>`, now just `WhereClause`)
238
+ - Update `mapRecord` — now maps from flat `Record<string, unknown>` instead of `StoredRecord`:
239
+ ```typescript
240
+ // Before: { id: record.id, ...record.data }
241
+ // After: strip _deleted, _updatedAt from flat record
242
+ function mapRecord(record: Record<string, unknown>): InferRecord<C> {
243
+ const { _deleted, _updatedAt, ...fields } = record;
244
+ return fields as InferRecord<C>;
245
+ }
246
+ ```
247
+ - Update all CRUD methods (`add`, `update`, `delete`, `get`, `first`, `count`) to use new `putRecord(collection, record)` signature and flat record shape
248
+ - Pass `CollectionDef` (with indices) to query builder so it knows which fields are indexed
249
+
250
+ ---
251
+
252
+ ## Step 6: Update `create-localstr.ts`
253
+
254
+ **File: `src/db/create-localstr.ts`**
255
+
256
+ - Pass `config.schema` to `openIDBStorage(config.dbName, config.schema)` so it can create per-collection stores
257
+ - Pass collection names to `rebuild()`: `rebuildRecords(storage, Object.keys(config.schema))`
258
+ - No other changes needed — sync, identity, watch infra all stay the same
259
+
260
+ ---
261
+
262
+ ## Step 7: Update exports
263
+
264
+ **File: `src/index.ts`**
265
+
266
+ - Export `QueryBuilder`, `OrderByBuilder` types
267
+ - Keep `QueryExecutor` as deprecated alias → `QueryBuilder`
268
+
269
+ ---
270
+
271
+ ## Step 8: Update demo
272
+
273
+ **File: `demo/app.ts`**
274
+
275
+ - Add indices to collection definition
276
+ - Add examples using `orderBy`, `above`, `between`, `limit`
277
+
278
+ ---
279
+
280
+ ## Files changed (in order)
281
+
282
+ | # | File | Nature of change |
283
+ | --- | ------------------------------- | ------------------------------------------------------- |
284
+ | 1 | `src/schema/collection.ts` | Add indices option |
285
+ | 2 | `src/schema/types.ts` | Add IndexedFields type |
286
+ | 3 | `src/storage/idb.ts` | Per-collection stores, index queries, schema-aware open |
287
+ | 4 | `src/storage/records-store.ts` | Flat record shape, collection-aware rebuild |
288
+ | 5 | `src/crud/query-builder.ts` | Full rewrite: WhereClause, QueryBuilder, OrderByBuilder |
289
+ | 6 | `src/crud/collection-handle.ts` | Add orderBy, update where, flat record mapping |
290
+ | 7 | `src/crud/watch.ts` | Minor: update filter signature for flat records |
291
+ | 8 | `src/db/create-localstr.ts` | Pass schema to openIDBStorage |
292
+ | 9 | `src/index.ts` | Export new types |
293
+ | 10 | `demo/app.ts` | Demo new query API |
294
+
295
+ **Files NOT changed:** `src/schema/field.ts`, `src/schema/validate.ts`, `src/storage/lww.ts`, `src/sync/*`, `src/errors.ts`, `src/db/database-handle.ts`
296
+
297
+ Note: Sync code (`sync-service.ts`, `records-store.ts`) creates `StoredEvent` objects and calls `applyEvent` → `storage.putRecord`. Since we're updating `applyEvent` to write flat records with the new `putRecord(collection, record)` signature, sync continues to work without changes to sync-service.ts itself.
298
+
299
+ ---
300
+
301
+ ## Version bump strategy
302
+
303
+ Compute IDB version deterministically from schema:
304
+
305
+ ```typescript
306
+ function schemaVersion(schema: SchemaConfig): number {
307
+ const sig = Object.entries(schema)
308
+ .sort(([a], [b]) => a.localeCompare(b))
309
+ .map(([name, def]) => {
310
+ const indices = (def.indices ?? []).slice().sort().join(",");
311
+ return `${name}:${indices}`;
312
+ })
313
+ .join("|");
314
+ // Simple hash → positive integer
315
+ let hash = 1;
316
+ for (let i = 0; i < sig.length; i++) {
317
+ hash = (hash * 31 + sig.charCodeAt(i)) | 0;
318
+ }
319
+ return Math.abs(hash) + 1; // IDB versions must be >= 1
320
+ }
321
+ ```
322
+
323
+ The `upgrade` handler uses `database.objectStoreNames` to detect what exists and creates/removes stores+indices as needed.
324
+
325
+ ---
326
+
327
+ ## Verification
328
+
329
+ 1. `bun run check` — typecheck passes
330
+ 2. Run demo app — CRUD still works, queries work
331
+ 3. Test new queries in demo: `orderBy("priority")`, `where("priority").above(2)`, `where("title").startsWith("B")`
332
+ 4. Test sync still works: add items, sync, verify they appear
333
+ 5. Test rebuild: `db.rebuild()` reconstructs records correctly
334
+ 6. Verify `where("done").equals(false).get()` works
335
+ 7. Note: `where()` signature changed from `Effect<WhereClause>` to `WhereClause` — demo code updated accordingly
336
+ 8. Note: `col.get(id)` is by-ID lookup (single record), `col.where(...).get()` is query result (array)
@@ -0,0 +1,263 @@
1
+ # Implementation Plan: localstr v0.2
2
+
3
+ ## Context
4
+
5
+ localstr is a browser-first local-first sync library that gives developers a typed Effect API for defining collections, performing CRUD, and syncing data across devices through Nostr relays. The project scaffold exists (Effect 4.0.0-beta.28, Bun, vitest, oxlint) but no library code has been written yet. This plan implements all 7 user stories (US-001 through US-007) and 23 functional requirements from `prds/localstr-v0.2.md`.
6
+
7
+ ## Dependencies to Install
8
+
9
+ **Runtime:**
10
+
11
+ - `idb` — Promise-based IndexedDB wrapper
12
+ - `nostr-tools` — Nostr event creation, signing, NIP-44, NIP-59
13
+ - `@noble/hashes` — SHA-256, etc. (peer dep of nostr-tools)
14
+ - `@noble/curves` — secp256k1 (peer dep of nostr-tools)
15
+ - negentropy from `github:hoytech/negentropy` — NIP-77 set reconciliation
16
+
17
+ **Dev:**
18
+
19
+ - `fake-indexeddb` — IndexedDB polyfill for vitest
20
+
21
+ **Config changes:**
22
+
23
+ - `tsconfig.json`: add `"DOM"` to `lib` for IndexedDB + crypto types
24
+ - `vitest.config.ts`: add `setupFiles: ["tests/setup.ts"]`
25
+
26
+ ## File Structure
27
+
28
+ ```
29
+ src/
30
+ index.ts -- Public API barrel
31
+ errors.ts -- All Data.TaggedError definitions
32
+ schema/
33
+ field.ts -- field.* builders + FieldDef types
34
+ collection.ts -- collection() builder
35
+ types.ts -- InferRecord<C> type utilities
36
+ validate.ts -- Runtime validation via Effect Schema
37
+ utils/
38
+ uuid.ts -- UUIDv7 generation
39
+ storage/
40
+ idb.ts -- IDBStorage Effect service
41
+ events-store.ts -- Events store operations
42
+ records-store.ts -- Records store + LWW materialization
43
+ giftwraps-store.ts -- Giftwraps store operations
44
+ lww.ts -- LWW resolution (pure function)
45
+ crud/
46
+ collection-handle.ts -- CollectionHandle: add/update/delete/get/first/count/watch
47
+ query-builder.ts -- .where().equals() chain
48
+ watch.ts -- watch() via PubSub + Stream
49
+ db/
50
+ create-localstr.ts -- createLocalstr() entrypoint
51
+ database-handle.ts -- DatabaseHandle interface
52
+ identity.ts -- Key generation + exportKey()
53
+ sync/
54
+ gift-wrap.ts -- NIP-59 wrap/unwrap using nostr-tools
55
+ relay.ts -- WebSocket relay I/O + NIP-77 messages
56
+ negentropy.ts -- NIP-77 set reconciliation
57
+ publish-queue.ts -- Offline retry queue
58
+ sync-service.ts -- Main sync orchestrator
59
+ sync-status.ts -- SyncStatus type + SubscriptionRef
60
+ tests/
61
+ setup.ts -- fake-indexeddb/auto polyfill
62
+ schema/
63
+ field.test.ts
64
+ collection.test.ts
65
+ validate.test.ts
66
+ storage/
67
+ idb.test.ts
68
+ lww.test.ts
69
+ crud/
70
+ collection-handle.test.ts
71
+ query-builder.test.ts
72
+ watch.test.ts
73
+ db/
74
+ create-localstr.test.ts
75
+ identity.test.ts
76
+ sync/
77
+ gift-wrap.test.ts
78
+ sync-service.test.ts
79
+ ```
80
+
81
+ ## Effect v4 Patterns
82
+
83
+ Services use `ServiceMap.Service`:
84
+
85
+ ```typescript
86
+ class IDBStorage extends ServiceMap.Service<IDBStorage, IDBStorageShape>()("IDBStorage") {}
87
+ ```
88
+
89
+ Errors use `Data.TaggedError`:
90
+
91
+ ```typescript
92
+ class ValidationError extends Data.TaggedError("ValidationError")<{ readonly message: string }> {}
93
+ ```
94
+
95
+ Layers use `Layer.effect(ServiceTag)(Effect.gen(...))`.
96
+
97
+ ## Implementation Order
98
+
99
+ ### Phase 1: Foundation (no I/O)
100
+
101
+ **Step 1: `src/errors.ts`**
102
+
103
+ - Define: `ValidationError`, `StorageError`, `CryptoError`, `RelayError`, `SyncError`, `NotFoundError`, `ClosedError`
104
+ - All extend `Data.TaggedError` with descriptive fields
105
+
106
+ **Step 2: `src/schema/field.ts` + `src/schema/collection.ts` + `src/schema/types.ts`**
107
+
108
+ - `field.string()`, `field.number()`, `field.boolean()`, `field.json()` return `FieldDef<T>`
109
+ - `.optional()` and `.array()` modifiers via builder pattern
110
+ - `collection(fields)` returns `CollectionDef<F>`
111
+ - `InferRecord<C>` infers TS type from collection def (auto-injects `id: string`)
112
+ - Reject empty fields, reserved names (`id`, `_deleted`, `_createdAt`, `_updatedAt`) at init
113
+
114
+ **Step 3: `src/schema/validate.ts`**
115
+
116
+ - Build `Schema.Struct` from `CollectionDef` at init time
117
+ - Map FieldDef kinds to Effect Schema types
118
+ - Return validator function: `(input: unknown) => Effect<T, ValidationError>`
119
+
120
+ **Step 4: `src/utils/uuid.ts`**
121
+
122
+ - UUIDv7 implementation (~25 lines using `crypto.getRandomValues`)
123
+
124
+ **Step 5: `src/storage/lww.ts`**
125
+
126
+ - Pure function `resolveWinner(existing, incoming)`: compare `createdAt`, tie-break by lowest event ID lexicographically
127
+
128
+ ### Phase 2: Storage Layer
129
+
130
+ **Step 6: Install deps, config changes**
131
+
132
+ - `bun add idb` + `bun add -d fake-indexeddb`
133
+ - Add `"DOM"` to tsconfig lib
134
+ - Create `tests/setup.ts` with `import "fake-indexeddb/auto"`
135
+ - Update `vitest.config.ts` with setupFiles
136
+
137
+ **Step 7: `src/storage/idb.ts`**
138
+
139
+ - `IDBStorage` Effect service wrapping `idb`'s `openDB`
140
+ - Three object stores: `giftwraps` (key: event id), `events` (key: event id), `records` (key: `[collection, id]`)
141
+ - Scope finalizer to close DB connection
142
+ - Methods: putRecord, getRecord, getAllRecords, deleteRecord, putEvent, getAllEvents, clearRecords, countRecords, close
143
+
144
+ **Step 8: `src/storage/events-store.ts` + `src/storage/records-store.ts` + `src/storage/giftwraps-store.ts`**
145
+
146
+ - Higher-level operations on each store
147
+ - `applyEvent`: LWW-resolve incoming event against records store
148
+ - `rebuild()`: clear records, replay all events with LWW
149
+
150
+ ### Phase 3: CRUD & Query
151
+
152
+ **Step 9: `src/crud/watch.ts`**
153
+
154
+ - `PubSub<ChangeEvent>` for change notification (one per DB instance)
155
+ - `ChangeEvent = { collection, recordId, kind }`
156
+ - `watch()` returns `Stream` — emits initial results, then re-queries on relevant changes
157
+ - Batching during sync replay via `Ref<boolean>` replaying flag
158
+
159
+ **Step 10: `src/crud/query-builder.ts`**
160
+
161
+ - `.where(field).equals(value)` captures a `QuerySpec`
162
+ - Execution scans records store, filters in-memory, excludes tombstones
163
+ - Reject `json`/`array` fields with `ValidationError`
164
+ - Returns `QueryExecutor` with `get()`, `first()`, `count()`, `watch()`
165
+
166
+ **Step 11: `src/crud/collection-handle.ts`**
167
+
168
+ - `add(data)`: validate → generate UUIDv7 → create event → store in events + records → notify PubSub → return id
169
+ - `update(id, partial)`: get existing → merge → validate → create event → store → notify
170
+ - `delete(id)`: verify exists → create tombstone event → store → notify
171
+ - `get(id)`: read from records store, fail with NotFoundError if missing/deleted
172
+ - `first(query?)`, `count(query?)`: scan records with optional filter
173
+ - `watch(query?)`: delegate to watch.ts
174
+
175
+ ### Phase 4: Database Handle
176
+
177
+ **Step 12: `src/db/identity.ts`**
178
+
179
+ - Generate 32-byte random key via `crypto.getRandomValues`
180
+ - Accept optional supplied key
181
+ - `exportKey()` returns hex-encoded private key
182
+ - Derive pubkey using `@noble/curves` secp256k1
183
+
184
+ **Step 13: `src/db/database-handle.ts` + `src/db/create-localstr.ts`**
185
+
186
+ - `DatabaseHandle<S>`: `collection(name)`, `exportKey()`, `close()`, `rebuild()`, `sync()`, `getSyncStatus()`
187
+ - `createLocalstr(config)`: validate config (non-empty relays, valid schema) → generate key → open IDB → build validators → create PubSub → build collection handles → register Scope finalizer → return handle
188
+ - `close()` sets `Ref<boolean>` closed flag; all subsequent ops fail with `ClosedError`
189
+
190
+ ### Phase 5: Sync Layer
191
+
192
+ **Step 14: Install Nostr deps**
193
+
194
+ - `bun add nostr-tools @noble/hashes @noble/curves`
195
+ - Install negentropy: `bun add github:hoytech/negentropy` (or vendor the ~400-line JS file)
196
+
197
+ **Step 15: `src/sync/gift-wrap.ts`**
198
+
199
+ - `wrap(rumor)`: rumor → NIP-44-encrypted seal (signed by author) → NIP-44-encrypted gift wrap (signed by random disposable key, randomized timestamp)
200
+ - `unwrap(giftWrap)`: decrypt gift wrap → decrypt seal → extract rumor
201
+ - Self-encryption: encrypt to own pubkey
202
+ - Uses `nostr-tools/nip59` and `nostr-tools/nip44`
203
+
204
+ **Step 16: `src/sync/relay.ts`**
205
+
206
+ - Raw WebSocket management with `Effect.acquireRelease`
207
+ - `publish(event, urls)`: send `["EVENT", event]` to each relay
208
+ - `fetchEvents(ids, url)`: send `["REQ", ...]` with id filter
209
+ - NIP-77 messages: `negentropyOpen`, `negentropySend`, `negentropyClose` for `NEG-OPEN`/`NEG-MSG`/`NEG-CLOSE`
210
+
211
+ **Step 17: `src/sync/negentropy.ts`**
212
+
213
+ - Load all local gift wrap IDs from giftwraps store
214
+ - Build `NegentropyStorageVector`, insert each, seal
215
+ - Run reconciliation rounds via relay NIP-77 messages
216
+ - Return `{ haveIds, needIds }`
217
+
218
+ **Step 18: `src/sync/publish-queue.ts`**
219
+
220
+ - In-memory queue (backed by `Ref<Set<string>>`) of pending gift wrap event IDs
221
+ - `enqueue(giftWrap)`: add to pending set
222
+ - `flush()`: read events from giftwraps store, publish, remove successful ones
223
+ - Auto-flush on browser `online` event
224
+
225
+ **Step 19: `src/sync/sync-status.ts`**
226
+
227
+ - `SyncStatus = "idle" | "syncing"` via `SubscriptionRef`
228
+ - `getSyncStatus()` reads the ref
229
+
230
+ **Step 20: `src/sync/sync-service.ts`**
231
+
232
+ - `sync()`: set status → for each relay: negentropy reconcile → fetch missing → unwrap → store → LWW resolve → upload missing → flush queue → set idle
233
+ - `publishLocal(giftWrap)`: fire-and-forget publish via `Effect.fork`, enqueue on failure
234
+ - Wrap in `Effect.ensuring` to guarantee status reset on failure
235
+ - Signal replay start/end for watch() batching
236
+
237
+ ### Phase 6: Public API & Integration
238
+
239
+ **Step 21: `src/index.ts`**
240
+
241
+ - Export: `createLocalstr`, `collection`, `field`, type `InferRecord`, type `DatabaseHandle`, type `CollectionHandle`, all error types
242
+
243
+ **Step 22: Wire sync into CRUD**
244
+
245
+ - On local write: create rumor → gift wrap → store in giftwraps → publishLocal (async)
246
+ - On sync receive: store giftwrap → unwrap → store event → LWW resolve → notify watch
247
+
248
+ **Step 23: Integration tests + `bun run validate`**
249
+
250
+ ## Key Design Decisions
251
+
252
+ 1. **Schema builders are plain descriptors, not Effect Schemas** — Effect Schema is derived internally for validation. Keeps the developer API simple.
253
+ 2. **IDBStorage is the only Effect Service in core** — CRUD/query are plain functions receiving storage. Avoids over-engineering DI.
254
+ 3. **PubSub for change notification** — subscribers filter by collection. watch() emits full result sets (not deltas).
255
+ 4. **Tombstone deletes** — delete events stored in events store; records marked `_deleted: true`; excluded from queries; deterministic on rebuild.
256
+ 5. **Raw WebSocket for relays** — nostr-tools Relay class doesn't support NIP-77 custom messages.
257
+ 6. **No `idb` if too heavy** — fallback to raw IndexedDB API wrapped in Effect.tryPromise if the `idb` package causes issues.
258
+
259
+ ## Verification
260
+
261
+ 1. `bun run validate` passes (oxfmt + oxlint + vitest)
262
+ 2. Tests cover: schema building + type inference, runtime validation, IDB CRUD, LWW resolution, watch() reactivity + batching, query filtering, identity key gen/export, gift wrap round-trip, full create→add→get→update→delete→close lifecycle
263
+ 3. Integration test: create DB → add records → query → delete → rebuild → verify state
@@ -0,0 +1,71 @@
1
+ # Project Init: Effect v4 + Bun + Oxlint/oxfmt + Vitest
2
+
3
+ ## Context
4
+
5
+ Setting up a fresh TypeScript project ("douala-v4" / "localstr") using Effect v4 beta, Bun as runtime/package manager, Oxlint+oxfmt for linting/formatting, and Vitest for testing (with `@effect/vitest`).
6
+
7
+ ## Files to Create
8
+
9
+ ### 1. `package.json`
10
+
11
+ - `"type": "module"`
12
+ - Dependencies: `effect@4.0.0-beta.28`
13
+ - Dev deps: `@effect/vitest@4.0.0-beta.28`, `oxlint@^1.51.0`, `oxfmt@latest`, `typescript@^5.7.0`, `vitest@^3.0.0`, `@j178/prek@latest`
14
+ - Scripts: `dev`, `lint`, `format`, `format:check`, `test`, `test:watch`, `validate`
15
+
16
+ ### 2. `tsconfig.json`
17
+
18
+ - `strict: true` (required by Effect)
19
+ - `target/module: ES2022`, `moduleResolution: bundler`
20
+ - `exactOptionalPropertyTypes: true`, `noEmit: true`, `skipLibCheck: true`
21
+
22
+ ### 3. `vitest.config.ts`
23
+
24
+ - Minimal config, test files in `tests/**/*.test.ts`
25
+
26
+ ### 4. `.oxlintrc.json`
27
+
28
+ - Minimal rules, `$schema` pointing to node_modules schema
29
+
30
+ ### 5. `.gitignore`
31
+
32
+ - `node_modules/`, `dist/`, `*.tsbuildinfo`
33
+
34
+ ### 6. `src/main.ts`
35
+
36
+ - Minimal Effect program to verify setup works
37
+
38
+ ### 7. `tests/main.test.ts`
39
+
40
+ - Minimal test using `@effect/vitest` to verify test setup
41
+
42
+ ### 8. `prek.toml`
43
+
44
+ - Pre-commit hook config using prek (https://prek.j178.dev/)
45
+ - Single local hook that runs `bun run scripts/validate.ts` on commit
46
+ - Uses `language = "system"` since bun is already in PATH
47
+
48
+ ### 9. `scripts/validate.ts`
49
+
50
+ - Runs `bun run test`, `bun run lint`, and `bun run format:check` sequentially
51
+ - Exits with non-zero if any step fails
52
+
53
+ ## Execution Order
54
+
55
+ 1. Create `package.json`
56
+ 2. Run `bun install`
57
+ 3. Create `tsconfig.json`, `vitest.config.ts`, `.oxlintrc.json`, `.gitignore`
58
+ 4. Create `src/main.ts`, `tests/main.test.ts`, `scripts/validate.ts`
59
+ 5. Create `prek.toml` and run `bunx prek install` to install the git hook
60
+ 6. Verify: `bun run src/main.ts` runs
61
+ 7. Verify: `bun run test` passes
62
+ 8. Verify: `bun run lint` works
63
+ 9. Verify: `bun run format:check` works (if oxfmt installs successfully — it's beta)
64
+ 10. Verify: `bunx prek run validate` runs the hook
65
+
66
+ ## Notes
67
+
68
+ - Vitest is the right choice: `@effect/vitest` provides Effect-native test helpers with synchronized beta versions
69
+ - oxfmt reached beta in Feb 2026 and is viable, but if it causes issues we can skip it
70
+ - Pin exact Effect beta versions (not `@beta` tag) for reproducibility
71
+ - Use `bun run vitest` not `bun test` (the latter invokes Bun's built-in test runner)