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