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,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
|
+
}
|