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,414 @@
1
+ import { Effect, Ref, Stream } from "effect";
2
+ import type { IDBStorageHandle } from "../storage/idb.ts";
3
+ import type { StorageError, ValidationError } from "../errors.ts";
4
+ import { ValidationError as VE } from "../errors.ts";
5
+ import type { CollectionDef, CollectionFields } from "../schema/collection.ts";
6
+ import type { WatchContext } from "./watch.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Query plan (internal)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ interface QueryPlan {
13
+ readonly fieldName?: string | undefined;
14
+ readonly filters: Array<(record: Record<string, unknown>) => boolean>;
15
+ readonly orderBy?: { field: string; direction: "asc" | "desc" } | undefined;
16
+ readonly offset?: number | undefined;
17
+ readonly limit?: number | undefined;
18
+ readonly indexQuery?:
19
+ | { field: string; range: IDBKeyRange | IDBValidKey; type: "value" | "range" }
20
+ | undefined;
21
+ }
22
+
23
+ function emptyPlan(): QueryPlan {
24
+ return { filters: [] };
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Query context (shared deps for execution)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface QueryContext {
32
+ readonly storage: IDBStorageHandle;
33
+ readonly watchCtx: WatchContext;
34
+ readonly collectionName: string;
35
+ readonly def: CollectionDef<CollectionFields>;
36
+ readonly mapRecord: (record: Record<string, unknown>) => unknown;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Execution
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function executeQuery<T>(
44
+ ctx: QueryContext,
45
+ plan: QueryPlan,
46
+ ): Effect.Effect<ReadonlyArray<T>, StorageError | ValidationError> {
47
+ return Effect.gen(function* () {
48
+ // Validate field references
49
+ if (plan.fieldName) {
50
+ const fieldDef = ctx.def.fields[plan.fieldName];
51
+ if (!fieldDef) {
52
+ return yield* new VE({
53
+ message: `Unknown field "${plan.fieldName}" in collection "${ctx.collectionName}"`,
54
+ field: plan.fieldName,
55
+ });
56
+ }
57
+ if (fieldDef.kind === "json" || fieldDef.isArray) {
58
+ return yield* new VE({
59
+ message: `Field "${plan.fieldName}" does not support filtering (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`,
60
+ field: plan.fieldName,
61
+ });
62
+ }
63
+ }
64
+
65
+ // Fetch records — use index if available
66
+ let results: Record<string, unknown>[];
67
+ if (plan.indexQuery && ctx.def.indices.includes(plan.indexQuery.field)) {
68
+ if (plan.indexQuery.type === "value") {
69
+ results = [
70
+ ...(yield* ctx.storage.getByIndex(
71
+ ctx.collectionName,
72
+ plan.indexQuery.field,
73
+ plan.indexQuery.range as IDBValidKey,
74
+ )),
75
+ ];
76
+ } else {
77
+ results = [
78
+ ...(yield* ctx.storage.getByIndexRange(
79
+ ctx.collectionName,
80
+ plan.indexQuery.field,
81
+ plan.indexQuery.range as IDBKeyRange,
82
+ )),
83
+ ];
84
+ }
85
+ } else if (
86
+ plan.orderBy &&
87
+ ctx.def.indices.includes(plan.orderBy.field) &&
88
+ plan.filters.length === 0
89
+ ) {
90
+ // Use index for sorting when no filters need to be applied
91
+ results = [
92
+ ...(yield* ctx.storage.getAllSorted(
93
+ ctx.collectionName,
94
+ plan.orderBy.field,
95
+ plan.orderBy.direction === "desc" ? "prev" : "next",
96
+ )),
97
+ ];
98
+ } else {
99
+ results = [...(yield* ctx.storage.getAllRecords(ctx.collectionName))];
100
+ }
101
+
102
+ // Filter deleted records
103
+ results = results.filter((r) => !r._deleted);
104
+
105
+ // Apply filter predicates
106
+ for (const f of plan.filters) {
107
+ results = results.filter(f);
108
+ }
109
+
110
+ // Sort (if not already sorted by IDB)
111
+ if (plan.orderBy) {
112
+ const alreadySorted =
113
+ ctx.def.indices.includes(plan.orderBy.field) &&
114
+ plan.filters.length === 0 &&
115
+ !plan.indexQuery;
116
+ if (!alreadySorted) {
117
+ const { field, direction } = plan.orderBy;
118
+ results = results.sort((a, b) => {
119
+ const va = a[field] as string | number | boolean;
120
+ const vb = b[field] as string | number | boolean;
121
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
122
+ return direction === "desc" ? -cmp : cmp;
123
+ });
124
+ }
125
+ }
126
+
127
+ // Offset
128
+ if (plan.offset) {
129
+ results = results.slice(plan.offset);
130
+ }
131
+
132
+ // Limit
133
+ if (plan.limit !== null && plan.limit !== undefined) {
134
+ results = results.slice(0, plan.limit);
135
+ }
136
+
137
+ // Map to user-facing type
138
+ return results.map(ctx.mapRecord) as ReadonlyArray<T>;
139
+ });
140
+ }
141
+
142
+ function watchQuery<T>(
143
+ ctx: QueryContext,
144
+ plan: QueryPlan,
145
+ ): Stream.Stream<ReadonlyArray<T>, StorageError | ValidationError> {
146
+ const query = () => executeQuery<T>(ctx, plan);
147
+
148
+ const changes = Stream.fromPubSub(ctx.watchCtx.pubsub).pipe(
149
+ Stream.filter((event) => event.collection === ctx.collectionName),
150
+ Stream.mapEffect(() =>
151
+ Effect.gen(function* () {
152
+ const replaying = yield* Ref.get(ctx.watchCtx.replayingRef);
153
+ if (replaying) return undefined;
154
+ return yield* query();
155
+ }),
156
+ ),
157
+ Stream.filter((result): result is ReadonlyArray<T> => result !== undefined),
158
+ );
159
+
160
+ return Stream.unwrap(
161
+ Effect.gen(function* () {
162
+ const initial = yield* query();
163
+ return Stream.concat(Stream.make(initial), changes);
164
+ }),
165
+ );
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Public interfaces
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface WhereClause<T> {
173
+ readonly equals: (value: string | number | boolean) => QueryBuilder<T>;
174
+ readonly above: (value: number) => QueryBuilder<T>;
175
+ readonly aboveOrEqual: (value: number) => QueryBuilder<T>;
176
+ readonly below: (value: number) => QueryBuilder<T>;
177
+ readonly belowOrEqual: (value: number) => QueryBuilder<T>;
178
+ readonly between: (
179
+ lower: number,
180
+ upper: number,
181
+ options?: { includeLower?: boolean; includeUpper?: boolean },
182
+ ) => QueryBuilder<T>;
183
+ readonly startsWith: (prefix: string) => QueryBuilder<T>;
184
+ readonly anyOf: (values: ReadonlyArray<string | number | boolean>) => QueryBuilder<T>;
185
+ readonly noneOf: (values: ReadonlyArray<string | number | boolean>) => QueryBuilder<T>;
186
+ }
187
+
188
+ export interface QueryBuilder<T> {
189
+ readonly and: (fn: (item: T) => boolean) => QueryBuilder<T>;
190
+ readonly sortBy: (field: string) => QueryBuilder<T>;
191
+ readonly reverse: () => QueryBuilder<T>;
192
+ readonly offset: (n: number) => QueryBuilder<T>;
193
+ readonly limit: (n: number) => QueryBuilder<T>;
194
+ readonly get: () => Effect.Effect<ReadonlyArray<T>, StorageError | ValidationError>;
195
+ readonly first: () => Effect.Effect<T | null, StorageError | ValidationError>;
196
+ readonly count: () => Effect.Effect<number, StorageError | ValidationError>;
197
+ readonly watch: () => Stream.Stream<ReadonlyArray<T>, StorageError | ValidationError>;
198
+ }
199
+
200
+ export interface OrderByBuilder<T> {
201
+ readonly reverse: () => OrderByBuilder<T>;
202
+ readonly offset: (n: number) => OrderByBuilder<T>;
203
+ readonly limit: (n: number) => OrderByBuilder<T>;
204
+ readonly get: () => Effect.Effect<ReadonlyArray<T>, StorageError | ValidationError>;
205
+ readonly first: () => Effect.Effect<T | null, StorageError | ValidationError>;
206
+ readonly count: () => Effect.Effect<number, StorageError | ValidationError>;
207
+ readonly watch: () => Stream.Stream<ReadonlyArray<T>, StorageError | ValidationError>;
208
+ }
209
+
210
+ // Keep for backwards compat
211
+ export type QueryExecutor<T> = QueryBuilder<T>;
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Builders
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function makeQueryBuilder<T>(ctx: QueryContext, plan: QueryPlan): QueryBuilder<T> {
218
+ return {
219
+ and: (fn) =>
220
+ makeQueryBuilder(ctx, {
221
+ ...plan,
222
+ filters: [...plan.filters, (r) => fn(ctx.mapRecord(r) as T)],
223
+ }),
224
+ sortBy: (field) =>
225
+ makeQueryBuilder(ctx, {
226
+ ...plan,
227
+ orderBy: { field, direction: plan.orderBy?.direction ?? "asc" },
228
+ }),
229
+ reverse: () =>
230
+ makeQueryBuilder(ctx, {
231
+ ...plan,
232
+ orderBy: {
233
+ field: plan.orderBy?.field ?? "id",
234
+ direction: plan.orderBy?.direction === "desc" ? "asc" : "desc",
235
+ },
236
+ }),
237
+ offset: (n) => makeQueryBuilder(ctx, { ...plan, offset: n }),
238
+ limit: (n) => makeQueryBuilder(ctx, { ...plan, limit: n }),
239
+ get: () => executeQuery<T>(ctx, plan),
240
+ first: () =>
241
+ Effect.gen(function* () {
242
+ const limitedPlan = { ...plan, limit: 1 };
243
+ const results = yield* executeQuery<T>(ctx, limitedPlan);
244
+ return results[0] ?? null;
245
+ }),
246
+ count: () =>
247
+ Effect.gen(function* () {
248
+ const results = yield* executeQuery<T>(ctx, plan);
249
+ return results.length;
250
+ }),
251
+ watch: () => watchQuery<T>(ctx, plan),
252
+ };
253
+ }
254
+
255
+ function makeOrderByBuilder<T>(ctx: QueryContext, plan: QueryPlan): OrderByBuilder<T> {
256
+ return {
257
+ reverse: () =>
258
+ makeOrderByBuilder(ctx, {
259
+ ...plan,
260
+ orderBy: {
261
+ field: plan.orderBy!.field,
262
+ direction: plan.orderBy!.direction === "desc" ? "asc" : "desc",
263
+ },
264
+ }),
265
+ offset: (n) => makeOrderByBuilder(ctx, { ...plan, offset: n }),
266
+ limit: (n) => makeOrderByBuilder(ctx, { ...plan, limit: n }),
267
+ get: () => executeQuery<T>(ctx, plan),
268
+ first: () =>
269
+ Effect.gen(function* () {
270
+ const limitedPlan = { ...plan, limit: 1 };
271
+ const results = yield* executeQuery<T>(ctx, limitedPlan);
272
+ return results[0] ?? null;
273
+ }),
274
+ count: () =>
275
+ Effect.gen(function* () {
276
+ const results = yield* executeQuery<T>(ctx, plan);
277
+ return results.length;
278
+ }),
279
+ watch: () => watchQuery<T>(ctx, plan),
280
+ };
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Factory functions (called by CollectionHandle)
285
+ // ---------------------------------------------------------------------------
286
+
287
+ export function createWhereClause<T>(
288
+ storage: IDBStorageHandle,
289
+ watchCtx: WatchContext,
290
+ collectionName: string,
291
+ def: CollectionDef<CollectionFields>,
292
+ fieldName: string,
293
+ mapRecord: (record: Record<string, unknown>) => T,
294
+ ): WhereClause<T> {
295
+ const ctx: QueryContext = { storage, watchCtx, collectionName, def, mapRecord };
296
+ const fieldDef = def.fields[fieldName];
297
+ // IDB only supports string/number/Date/Array keys — not booleans
298
+ const isIndexed =
299
+ def.indices.includes(fieldName) &&
300
+ fieldDef !== null &&
301
+ fieldDef !== undefined &&
302
+ fieldDef.kind !== "boolean";
303
+
304
+ const withFilter = (
305
+ filterFn: (record: Record<string, unknown>) => boolean,
306
+ indexQuery?: QueryPlan["indexQuery"],
307
+ ): QueryBuilder<T> => {
308
+ const plan: QueryPlan = {
309
+ ...emptyPlan(),
310
+ fieldName,
311
+ filters: [filterFn],
312
+ indexQuery,
313
+ };
314
+ return makeQueryBuilder(ctx, plan);
315
+ };
316
+
317
+ return {
318
+ equals: (value) =>
319
+ withFilter(
320
+ (r) => r[fieldName] === value,
321
+ isIndexed ? { field: fieldName, range: value as IDBValidKey, type: "value" } : undefined,
322
+ ),
323
+
324
+ above: (value) =>
325
+ withFilter(
326
+ (r) => (r[fieldName] as number) > value,
327
+ isIndexed
328
+ ? { field: fieldName, range: IDBKeyRange.lowerBound(value, true), type: "range" }
329
+ : undefined,
330
+ ),
331
+
332
+ aboveOrEqual: (value) =>
333
+ withFilter(
334
+ (r) => (r[fieldName] as number) >= value,
335
+ isIndexed
336
+ ? { field: fieldName, range: IDBKeyRange.lowerBound(value, false), type: "range" }
337
+ : undefined,
338
+ ),
339
+
340
+ below: (value) =>
341
+ withFilter(
342
+ (r) => (r[fieldName] as number) < value,
343
+ isIndexed
344
+ ? { field: fieldName, range: IDBKeyRange.upperBound(value, true), type: "range" }
345
+ : undefined,
346
+ ),
347
+
348
+ belowOrEqual: (value) =>
349
+ withFilter(
350
+ (r) => (r[fieldName] as number) <= value,
351
+ isIndexed
352
+ ? { field: fieldName, range: IDBKeyRange.upperBound(value, false), type: "range" }
353
+ : undefined,
354
+ ),
355
+
356
+ between: (lower, upper, options) => {
357
+ const includeLower = options?.includeLower ?? true;
358
+ const includeUpper = options?.includeUpper ?? true;
359
+ return withFilter(
360
+ (r) => {
361
+ const v = r[fieldName] as number;
362
+ const aboveLower = includeLower ? v >= lower : v > lower;
363
+ const belowUpper = includeUpper ? v <= upper : v < upper;
364
+ return aboveLower && belowUpper;
365
+ },
366
+ isIndexed
367
+ ? {
368
+ field: fieldName,
369
+ range: IDBKeyRange.bound(lower, upper, !includeLower, !includeUpper),
370
+ type: "range",
371
+ }
372
+ : undefined,
373
+ );
374
+ },
375
+
376
+ startsWith: (prefix) =>
377
+ withFilter(
378
+ (r) => typeof r[fieldName] === "string" && (r[fieldName] as string).startsWith(prefix),
379
+ isIndexed
380
+ ? {
381
+ field: fieldName,
382
+ range: IDBKeyRange.bound(prefix, prefix + "\uffff", false, false),
383
+ type: "range",
384
+ }
385
+ : undefined,
386
+ ),
387
+
388
+ anyOf: (values) => {
389
+ const set = new Set(values);
390
+ return withFilter((r) => set.has(r[fieldName] as string | number | boolean));
391
+ },
392
+
393
+ noneOf: (values) => {
394
+ const set = new Set(values);
395
+ return withFilter((r) => !set.has(r[fieldName] as string | number | boolean));
396
+ },
397
+ };
398
+ }
399
+
400
+ export function createOrderByBuilder<T>(
401
+ storage: IDBStorageHandle,
402
+ watchCtx: WatchContext,
403
+ collectionName: string,
404
+ def: CollectionDef<CollectionFields>,
405
+ fieldName: string,
406
+ mapRecord: (record: Record<string, unknown>) => T,
407
+ ): OrderByBuilder<T> {
408
+ const ctx: QueryContext = { storage, watchCtx, collectionName, def, mapRecord };
409
+ const plan: QueryPlan = {
410
+ ...emptyPlan(),
411
+ orderBy: { field: fieldName, direction: "asc" },
412
+ };
413
+ return makeOrderByBuilder(ctx, plan);
414
+ }
@@ -0,0 +1,78 @@
1
+ import { Effect, PubSub, Ref, Stream } from "effect";
2
+ import type { IDBStorageHandle } from "../storage/idb.ts";
3
+ import type { StorageError } from "../errors.ts";
4
+
5
+ export interface ChangeEvent {
6
+ readonly collection: string;
7
+ readonly recordId: string;
8
+ readonly kind: "create" | "update" | "delete";
9
+ }
10
+
11
+ export interface WatchContext {
12
+ readonly pubsub: PubSub.PubSub<ChangeEvent>;
13
+ readonly replayingRef: Ref.Ref<boolean>;
14
+ }
15
+
16
+ /**
17
+ * Create a reactive Stream that emits the current result set whenever it changes.
18
+ */
19
+ export function watchCollection<T>(
20
+ ctx: WatchContext,
21
+ storage: IDBStorageHandle,
22
+ collectionName: string,
23
+ filter?: (record: Record<string, unknown>) => boolean,
24
+ mapRecord?: (record: Record<string, unknown>) => T,
25
+ ): Stream.Stream<ReadonlyArray<T>, StorageError> {
26
+ const query = (): Effect.Effect<ReadonlyArray<T>, StorageError> =>
27
+ Effect.gen(function* () {
28
+ const all = yield* storage.getAllRecords(collectionName);
29
+ const filtered = all.filter((r) => !r._deleted && (filter ? filter(r) : true));
30
+ return mapRecord ? filtered.map(mapRecord) : (filtered as unknown as ReadonlyArray<T>);
31
+ });
32
+
33
+ const changes = Stream.fromPubSub(ctx.pubsub).pipe(
34
+ Stream.filter((event) => event.collection === collectionName),
35
+ Stream.mapEffect(() =>
36
+ Effect.gen(function* () {
37
+ const replaying = yield* Ref.get(ctx.replayingRef);
38
+ if (replaying) return undefined;
39
+ return yield* query();
40
+ }),
41
+ ),
42
+ Stream.filter((result): result is ReadonlyArray<T> => result !== undefined),
43
+ );
44
+
45
+ return Stream.unwrap(
46
+ Effect.gen(function* () {
47
+ const initial = yield* query();
48
+ return Stream.concat(Stream.make(initial), changes);
49
+ }),
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Notify subscribers of a change.
55
+ */
56
+ export function notifyChange(ctx: WatchContext, event: ChangeEvent): Effect.Effect<void> {
57
+ return PubSub.publish(ctx.pubsub, event).pipe(Effect.asVoid);
58
+ }
59
+
60
+ /**
61
+ * Emit a replay-complete notification for all collections that changed.
62
+ * Call after sync replay to trigger batched watch updates.
63
+ */
64
+ export function notifyReplayComplete(
65
+ ctx: WatchContext,
66
+ collections: ReadonlyArray<string>,
67
+ ): Effect.Effect<void> {
68
+ return Effect.gen(function* () {
69
+ yield* Ref.set(ctx.replayingRef, false);
70
+ for (const collection of collections) {
71
+ yield* notifyChange(ctx, {
72
+ collection,
73
+ recordId: "",
74
+ kind: "update",
75
+ });
76
+ }
77
+ });
78
+ }
@@ -0,0 +1,217 @@
1
+ import { Effect, PubSub, Ref, Scope } from "effect";
2
+ import type { CollectionFields } from "../schema/collection.ts";
3
+ import type { CollectionDef } from "../schema/collection.ts";
4
+ import type { SchemaConfig } from "../schema/types.ts";
5
+ import { buildValidator, buildPartialValidator } from "../schema/validate.ts";
6
+ import { openIDBStorage } from "../storage/idb.ts";
7
+ import { rebuild as rebuildRecords } from "../storage/records-store.ts";
8
+ import {
9
+ createCollectionHandle,
10
+ type CollectionHandle,
11
+ type OnWriteCallback,
12
+ } from "../crud/collection-handle.ts";
13
+ import type { ChangeEvent } from "../crud/watch.ts";
14
+ import { createIdentity } from "./identity.ts";
15
+ import type { DatabaseHandle } from "./database-handle.ts";
16
+ import { createGiftWrapHandle } from "../sync/gift-wrap.ts";
17
+ import { createRelayHandle } from "../sync/relay.ts";
18
+ import { createPublishQueue } from "../sync/publish-queue.ts";
19
+ import { createSyncStatusHandle } from "../sync/sync-status.ts";
20
+ import { createSyncHandle } from "../sync/sync-service.ts";
21
+ import { CryptoError, StorageError, SyncError, ValidationError } from "../errors.ts";
22
+ import { uuidv7 } from "../utils/uuid.ts";
23
+
24
+ export interface LocalstrConfig<S extends SchemaConfig> {
25
+ readonly schema: S;
26
+ readonly relays: readonly string[];
27
+ readonly privateKey?: Uint8Array | undefined;
28
+ readonly dbName?: string | undefined;
29
+ readonly onSyncError?: ((error: Error) => void) | undefined;
30
+ }
31
+
32
+ export function createLocalstr<S extends SchemaConfig>(
33
+ config: LocalstrConfig<S>,
34
+ ): Effect.Effect<DatabaseHandle<S>, ValidationError | StorageError | CryptoError, Scope.Scope> {
35
+ return Effect.gen(function* () {
36
+ if (!config.relays || config.relays.length === 0) {
37
+ return yield* new ValidationError({
38
+ message: "At least one relay URL is required",
39
+ });
40
+ }
41
+
42
+ const schemaEntries = Object.entries(config.schema);
43
+ if (schemaEntries.length === 0) {
44
+ return yield* new ValidationError({
45
+ message: "Schema must contain at least one collection",
46
+ });
47
+ }
48
+
49
+ // Resolve private key: supplied > persisted > generate new
50
+ let resolvedKey = config.privateKey;
51
+ const storageKeyName = `localstr-key-${config.dbName ?? "localstr"}`;
52
+ if (!resolvedKey && typeof globalThis.localStorage !== "undefined") {
53
+ const saved = globalThis.localStorage.getItem(storageKeyName);
54
+ if (saved && saved.length === 64) {
55
+ const bytes = new Uint8Array(32);
56
+ for (let i = 0; i < 32; i++) {
57
+ bytes[i] = parseInt(saved.slice(i * 2, i * 2 + 2), 16);
58
+ }
59
+ resolvedKey = bytes;
60
+ }
61
+ }
62
+
63
+ const identity = yield* createIdentity(resolvedKey);
64
+
65
+ // Persist the key for next session
66
+ if (typeof globalThis.localStorage !== "undefined") {
67
+ globalThis.localStorage.setItem(storageKeyName, identity.exportKey());
68
+ }
69
+
70
+ const storage = yield* openIDBStorage(config.dbName, config.schema);
71
+
72
+ // Watch infrastructure
73
+ const pubsub = yield* PubSub.unbounded<ChangeEvent>();
74
+ const replayingRef = yield* Ref.make(false);
75
+ const watchCtx = { pubsub, replayingRef };
76
+ const closedRef = yield* Ref.make(false);
77
+
78
+ // Sync infrastructure
79
+ const giftWrapHandle = createGiftWrapHandle(identity.privateKey, identity.publicKey);
80
+ const relayHandle = createRelayHandle();
81
+ const publishQueue = yield* createPublishQueue(storage, relayHandle);
82
+ const syncStatus = yield* createSyncStatusHandle();
83
+ const syncHandle = createSyncHandle(
84
+ storage,
85
+ giftWrapHandle,
86
+ relayHandle,
87
+ publishQueue,
88
+ syncStatus,
89
+ watchCtx,
90
+ config.relays,
91
+ identity.publicKey,
92
+ config.onSyncError,
93
+ );
94
+
95
+ // On local write: create gift wrap and publish asynchronously
96
+ const onWrite: OnWriteCallback = (event) =>
97
+ Effect.gen(function* () {
98
+ console.log("[localstr:onWrite]", event.kind, event.collection, event.recordId);
99
+ const content =
100
+ event.kind === "delete" ? JSON.stringify({ _deleted: true }) : JSON.stringify(event.data);
101
+ const dTag = `${event.collection}:${event.recordId}`;
102
+
103
+ const wrapResult = yield* Effect.result(
104
+ giftWrapHandle.wrap({
105
+ kind: 1,
106
+ content,
107
+ tags: [["d", dTag]],
108
+ created_at: Math.floor(event.createdAt / 1000),
109
+ }),
110
+ );
111
+
112
+ if (wrapResult._tag === "Success") {
113
+ const gw = wrapResult.success;
114
+ console.log(
115
+ "[localstr:onWrite] gift wrap created:",
116
+ gw.id,
117
+ "kind:",
118
+ gw.kind,
119
+ "tags:",
120
+ JSON.stringify(gw.tags),
121
+ );
122
+ yield* storage.putGiftWrap({
123
+ id: gw.id,
124
+ event: gw as unknown as Record<string, unknown>,
125
+ createdAt: gw.created_at,
126
+ });
127
+ console.log("[localstr:onWrite] gift wrap stored, publishing...");
128
+ const publishEffect = Effect.gen(function* () {
129
+ const pubResult = yield* Effect.result(
130
+ syncHandle.publishLocal({
131
+ id: gw.id,
132
+ event: gw as unknown as Record<string, unknown>,
133
+ createdAt: gw.created_at,
134
+ }),
135
+ );
136
+ if (pubResult._tag === "Failure") {
137
+ const err = pubResult.failure;
138
+ console.error("[localstr:publish] failed:", err);
139
+ if (config.onSyncError) config.onSyncError(err);
140
+ } else {
141
+ console.log("[localstr:publish] success");
142
+ }
143
+ });
144
+ yield* Effect.forkDetach(publishEffect);
145
+ } else {
146
+ const err = wrapResult.failure;
147
+ console.error("[localstr:onWrite] wrap failed:", err);
148
+ if (config.onSyncError) config.onSyncError(err);
149
+ }
150
+ });
151
+
152
+ // Build collection handles
153
+ const handles = new Map<string, CollectionHandle<CollectionDef<CollectionFields>>>();
154
+
155
+ for (const [, def] of schemaEntries) {
156
+ const validator = buildValidator(def.name, def);
157
+ const partialValidator = buildPartialValidator(def.name, def);
158
+ const handle = createCollectionHandle(
159
+ def,
160
+ storage,
161
+ watchCtx,
162
+ validator,
163
+ partialValidator,
164
+ uuidv7,
165
+ onWrite,
166
+ );
167
+ handles.set(def.name, handle as CollectionHandle<CollectionDef<CollectionFields>>);
168
+ }
169
+
170
+ // Start real-time subscription to incoming gift wraps
171
+ yield* syncHandle.startSubscription();
172
+
173
+ const dbHandle: DatabaseHandle<S> = {
174
+ collection: <K extends string & keyof S>(name: K) => {
175
+ const handle = handles.get(name);
176
+ if (!handle) {
177
+ throw new Error(`Collection "${name}" not found in schema`);
178
+ }
179
+ return handle as unknown as CollectionHandle<S[K]>;
180
+ },
181
+
182
+ exportKey: () => identity.exportKey(),
183
+
184
+ close: () =>
185
+ Effect.gen(function* () {
186
+ yield* Ref.set(closedRef, true);
187
+ yield* relayHandle.closeAll();
188
+ yield* storage.close();
189
+ }),
190
+
191
+ rebuild: () =>
192
+ Effect.gen(function* () {
193
+ const closed = yield* Ref.get(closedRef);
194
+ if (closed) {
195
+ return yield* new StorageError({ message: "Database is closed" });
196
+ }
197
+ yield* rebuildRecords(
198
+ storage,
199
+ schemaEntries.map(([, def]) => def.name),
200
+ );
201
+ }),
202
+
203
+ sync: () =>
204
+ Effect.gen(function* () {
205
+ const closed = yield* Ref.get(closedRef);
206
+ if (closed) {
207
+ return yield* new SyncError({ message: "Database is closed", phase: "init" });
208
+ }
209
+ yield* syncHandle.sync();
210
+ }),
211
+
212
+ getSyncStatus: () => syncStatus.get(),
213
+ };
214
+
215
+ return dbHandle;
216
+ });
217
+ }