tablinum 0.0.1 → 0.1.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/README.md +77 -1
- package/dist/crud/collection-handle.d.ts +21 -0
- package/dist/crud/query-builder.d.ts +42 -0
- package/dist/crud/watch.d.ts +25 -0
- package/dist/db/create-tablinum.d.ts +12 -0
- package/dist/db/database-handle.d.ts +13 -0
- package/dist/db/identity.d.ts +8 -0
- package/dist/errors.d.ts +58 -0
- package/{src/index.ts → dist/index.d.ts} +4 -24
- package/dist/index.js +1833 -0
- package/dist/main.d.ts +1 -0
- package/dist/schema/collection.d.ts +12 -0
- package/dist/schema/field.d.ts +17 -0
- package/{src/schema/types.ts → dist/schema/types.d.ts} +5 -10
- package/dist/schema/validate.d.ts +13 -0
- package/dist/storage/events-store.d.ts +6 -0
- package/dist/storage/giftwraps-store.d.ts +6 -0
- package/dist/storage/idb.d.ts +35 -0
- package/dist/storage/lww.d.ts +10 -0
- package/dist/storage/records-store.d.ts +12 -0
- package/dist/svelte/collection.svelte.d.ts +20 -0
- package/dist/svelte/database.svelte.d.ts +15 -0
- package/dist/svelte/index.svelte.d.ts +16 -0
- package/dist/svelte/index.svelte.js +2050 -0
- package/dist/svelte/live-query.svelte.d.ts +8 -0
- package/dist/svelte/query.svelte.d.ts +39 -0
- package/dist/sync/gift-wrap.d.ts +9 -0
- package/dist/sync/negentropy.d.ts +9 -0
- package/dist/sync/publish-queue.d.ts +10 -0
- package/dist/sync/relay.d.ts +17 -0
- package/dist/sync/sync-service.d.ts +14 -0
- package/dist/sync/sync-status.d.ts +7 -0
- package/dist/utils/uuid.d.ts +2 -0
- package/package.json +22 -1
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +0 -571
- package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +0 -498
- package/.context/notes.md +0 -0
- package/.context/plans/add-changesets-to-douala-v4.md +0 -48
- package/.context/plans/dexie-js-style-query-language-for-localstr.md +0 -115
- package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +0 -336
- package/.context/plans/implementation-plan-localstr-v0-2.md +0 -263
- package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +0 -71
- package/.context/plans/revise-localstr-prd-v0-2.md +0 -132
- package/.context/plans/svelte-5-runes-bindings-for-localstr.md +0 -233
- package/.context/todos.md +0 -0
- package/.github/workflows/release.yml +0 -36
- package/.oxlintrc.json +0 -8
- package/bun.lock +0 -705
- package/examples/svelte/bun.lock +0 -261
- package/examples/svelte/package.json +0 -21
- package/examples/svelte/src/app.html +0 -11
- package/examples/svelte/src/lib/db.ts +0 -44
- package/examples/svelte/src/routes/+page.svelte +0 -322
- package/examples/svelte/svelte.config.js +0 -16
- package/examples/svelte/tsconfig.json +0 -6
- package/examples/svelte/vite.config.ts +0 -6
- package/examples/vanilla/app.ts +0 -219
- package/examples/vanilla/index.html +0 -144
- package/examples/vanilla/serve.ts +0 -42
- package/prds/localstr-v0.2.md +0 -221
- package/prek.toml +0 -10
- package/scripts/validate.ts +0 -392
- package/src/crud/collection-handle.ts +0 -189
- package/src/crud/query-builder.ts +0 -414
- package/src/crud/watch.ts +0 -78
- package/src/db/create-localstr.ts +0 -217
- package/src/db/database-handle.ts +0 -16
- package/src/db/identity.ts +0 -49
- package/src/errors.ts +0 -37
- package/src/main.ts +0 -10
- package/src/schema/collection.ts +0 -53
- package/src/schema/field.ts +0 -25
- package/src/schema/validate.ts +0 -111
- package/src/storage/events-store.ts +0 -24
- package/src/storage/giftwraps-store.ts +0 -23
- package/src/storage/idb.ts +0 -244
- package/src/storage/lww.ts +0 -17
- package/src/storage/records-store.ts +0 -76
- package/src/svelte/collection.svelte.ts +0 -87
- package/src/svelte/database.svelte.ts +0 -83
- package/src/svelte/index.svelte.ts +0 -52
- package/src/svelte/live-query.svelte.ts +0 -29
- package/src/svelte/query.svelte.ts +0 -101
- package/src/sync/gift-wrap.ts +0 -33
- package/src/sync/negentropy.ts +0 -83
- package/src/sync/publish-queue.ts +0 -61
- package/src/sync/relay.ts +0 -239
- package/src/sync/sync-service.ts +0 -183
- package/src/sync/sync-status.ts +0 -17
- package/src/utils/uuid.ts +0 -22
- package/src/vendor/negentropy.js +0 -616
- package/tests/db/create-localstr.test.ts +0 -174
- package/tests/db/identity.test.ts +0 -33
- package/tests/main.test.ts +0 -9
- package/tests/schema/collection.test.ts +0 -27
- package/tests/schema/field.test.ts +0 -41
- package/tests/schema/validate.test.ts +0 -85
- package/tests/setup.ts +0 -1
- package/tests/storage/idb.test.ts +0 -144
- package/tests/storage/lww.test.ts +0 -33
- package/tests/sync/gift-wrap.test.ts +0 -56
- package/tsconfig.json +0 -18
- package/vitest.config.ts +0 -8
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { Effect, Stream } from "effect";
|
|
2
|
-
import type { CollectionDef, CollectionFields } from "../schema/collection.ts";
|
|
3
|
-
import type { InferRecord } from "../schema/types.ts";
|
|
4
|
-
import type { RecordValidator, PartialValidator } from "../schema/validate.ts";
|
|
5
|
-
import type { IDBStorageHandle, StoredEvent } from "../storage/idb.ts";
|
|
6
|
-
import { applyEvent } from "../storage/records-store.ts";
|
|
7
|
-
import { NotFoundError, StorageError, ValidationError } from "../errors.ts";
|
|
8
|
-
import { uuidv7 } from "../utils/uuid.ts";
|
|
9
|
-
import type { WatchContext } from "./watch.ts";
|
|
10
|
-
import { notifyChange, watchCollection } from "./watch.ts";
|
|
11
|
-
import type { WhereClause, OrderByBuilder } from "./query-builder.ts";
|
|
12
|
-
import { createWhereClause, createOrderByBuilder } from "./query-builder.ts";
|
|
13
|
-
|
|
14
|
-
export interface CollectionHandle<C extends CollectionDef<CollectionFields>> {
|
|
15
|
-
readonly add: (
|
|
16
|
-
data: Omit<InferRecord<C>, "id">,
|
|
17
|
-
) => Effect.Effect<string, ValidationError | StorageError>;
|
|
18
|
-
readonly update: (
|
|
19
|
-
id: string,
|
|
20
|
-
data: Partial<Omit<InferRecord<C>, "id">>,
|
|
21
|
-
) => Effect.Effect<void, ValidationError | StorageError | NotFoundError>;
|
|
22
|
-
readonly delete: (id: string) => Effect.Effect<void, StorageError | NotFoundError>;
|
|
23
|
-
readonly get: (id: string) => Effect.Effect<InferRecord<C>, StorageError | NotFoundError>;
|
|
24
|
-
readonly first: () => Effect.Effect<InferRecord<C> | null, StorageError>;
|
|
25
|
-
readonly count: () => Effect.Effect<number, StorageError>;
|
|
26
|
-
readonly watch: () => Stream.Stream<ReadonlyArray<InferRecord<C>>, StorageError>;
|
|
27
|
-
readonly where: (field: string & keyof Omit<InferRecord<C>, "id">) => WhereClause<InferRecord<C>>;
|
|
28
|
-
readonly orderBy: (
|
|
29
|
-
field: string & keyof Omit<InferRecord<C>, "id">,
|
|
30
|
-
) => OrderByBuilder<InferRecord<C>>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function mapRecord<C extends CollectionDef<CollectionFields>>(
|
|
34
|
-
record: Record<string, unknown>,
|
|
35
|
-
): InferRecord<C> {
|
|
36
|
-
const { _deleted, _updatedAt, ...fields } = record;
|
|
37
|
-
return fields as InferRecord<C>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export type OnWriteCallback = (event: StoredEvent) => Effect.Effect<void>;
|
|
41
|
-
|
|
42
|
-
export function createCollectionHandle<C extends CollectionDef<CollectionFields>>(
|
|
43
|
-
def: C,
|
|
44
|
-
storage: IDBStorageHandle,
|
|
45
|
-
watchCtx: WatchContext,
|
|
46
|
-
validator: RecordValidator<C["fields"]>,
|
|
47
|
-
partialValidator: PartialValidator<C["fields"]>,
|
|
48
|
-
makeEventId: () => string,
|
|
49
|
-
onWrite?: OnWriteCallback,
|
|
50
|
-
): CollectionHandle<C> {
|
|
51
|
-
const collectionName = def.name;
|
|
52
|
-
|
|
53
|
-
const handle: CollectionHandle<C> = {
|
|
54
|
-
add: (data) =>
|
|
55
|
-
Effect.gen(function* () {
|
|
56
|
-
const id = uuidv7();
|
|
57
|
-
const fullRecord = { id, ...data };
|
|
58
|
-
yield* validator(fullRecord);
|
|
59
|
-
|
|
60
|
-
const event: StoredEvent = {
|
|
61
|
-
id: makeEventId(),
|
|
62
|
-
collection: collectionName,
|
|
63
|
-
recordId: id,
|
|
64
|
-
kind: "create",
|
|
65
|
-
data: fullRecord as unknown as Record<string, unknown>,
|
|
66
|
-
createdAt: Date.now(),
|
|
67
|
-
};
|
|
68
|
-
yield* storage.putEvent(event);
|
|
69
|
-
yield* applyEvent(storage, event);
|
|
70
|
-
if (onWrite) yield* onWrite(event);
|
|
71
|
-
yield* notifyChange(watchCtx, {
|
|
72
|
-
collection: collectionName,
|
|
73
|
-
recordId: id,
|
|
74
|
-
kind: "create",
|
|
75
|
-
});
|
|
76
|
-
return id;
|
|
77
|
-
}),
|
|
78
|
-
|
|
79
|
-
update: (id, data) =>
|
|
80
|
-
Effect.gen(function* () {
|
|
81
|
-
const existing = yield* storage.getRecord(collectionName, id);
|
|
82
|
-
if (!existing || existing._deleted) {
|
|
83
|
-
return yield* new NotFoundError({
|
|
84
|
-
collection: collectionName,
|
|
85
|
-
id,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
yield* partialValidator(data);
|
|
89
|
-
const { _deleted, _updatedAt, ...existingFields } = existing;
|
|
90
|
-
const merged = { ...existingFields, ...data, id };
|
|
91
|
-
yield* validator(merged);
|
|
92
|
-
|
|
93
|
-
const event: StoredEvent = {
|
|
94
|
-
id: makeEventId(),
|
|
95
|
-
collection: collectionName,
|
|
96
|
-
recordId: id,
|
|
97
|
-
kind: "update",
|
|
98
|
-
data: merged as Record<string, unknown>,
|
|
99
|
-
createdAt: Date.now(),
|
|
100
|
-
};
|
|
101
|
-
yield* storage.putEvent(event);
|
|
102
|
-
yield* applyEvent(storage, event);
|
|
103
|
-
if (onWrite) yield* onWrite(event);
|
|
104
|
-
yield* notifyChange(watchCtx, {
|
|
105
|
-
collection: collectionName,
|
|
106
|
-
recordId: id,
|
|
107
|
-
kind: "update",
|
|
108
|
-
});
|
|
109
|
-
}),
|
|
110
|
-
|
|
111
|
-
delete: (id) =>
|
|
112
|
-
Effect.gen(function* () {
|
|
113
|
-
const existing = yield* storage.getRecord(collectionName, id);
|
|
114
|
-
if (!existing || existing._deleted) {
|
|
115
|
-
return yield* new NotFoundError({
|
|
116
|
-
collection: collectionName,
|
|
117
|
-
id,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const event: StoredEvent = {
|
|
122
|
-
id: makeEventId(),
|
|
123
|
-
collection: collectionName,
|
|
124
|
-
recordId: id,
|
|
125
|
-
kind: "delete",
|
|
126
|
-
data: null,
|
|
127
|
-
createdAt: Date.now(),
|
|
128
|
-
};
|
|
129
|
-
yield* storage.putEvent(event);
|
|
130
|
-
yield* applyEvent(storage, event);
|
|
131
|
-
if (onWrite) yield* onWrite(event);
|
|
132
|
-
yield* notifyChange(watchCtx, {
|
|
133
|
-
collection: collectionName,
|
|
134
|
-
recordId: id,
|
|
135
|
-
kind: "delete",
|
|
136
|
-
});
|
|
137
|
-
}),
|
|
138
|
-
|
|
139
|
-
get: (id) =>
|
|
140
|
-
Effect.gen(function* () {
|
|
141
|
-
const record = yield* storage.getRecord(collectionName, id);
|
|
142
|
-
if (!record || record._deleted) {
|
|
143
|
-
return yield* new NotFoundError({
|
|
144
|
-
collection: collectionName,
|
|
145
|
-
id,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
return mapRecord<C>(record);
|
|
149
|
-
}),
|
|
150
|
-
|
|
151
|
-
first: () =>
|
|
152
|
-
Effect.gen(function* () {
|
|
153
|
-
const all = yield* storage.getAllRecords(collectionName);
|
|
154
|
-
const found = all.find((r) => !r._deleted);
|
|
155
|
-
return found ? mapRecord<C>(found) : null;
|
|
156
|
-
}),
|
|
157
|
-
|
|
158
|
-
count: () =>
|
|
159
|
-
Effect.gen(function* () {
|
|
160
|
-
const all = yield* storage.getAllRecords(collectionName);
|
|
161
|
-
return all.filter((r) => !r._deleted).length;
|
|
162
|
-
}),
|
|
163
|
-
|
|
164
|
-
watch: () =>
|
|
165
|
-
watchCollection<InferRecord<C>>(watchCtx, storage, collectionName, undefined, mapRecord),
|
|
166
|
-
|
|
167
|
-
where: (fieldName) =>
|
|
168
|
-
createWhereClause<InferRecord<C>>(
|
|
169
|
-
storage,
|
|
170
|
-
watchCtx,
|
|
171
|
-
collectionName,
|
|
172
|
-
def,
|
|
173
|
-
fieldName,
|
|
174
|
-
mapRecord,
|
|
175
|
-
),
|
|
176
|
-
|
|
177
|
-
orderBy: (fieldName) =>
|
|
178
|
-
createOrderByBuilder<InferRecord<C>>(
|
|
179
|
-
storage,
|
|
180
|
-
watchCtx,
|
|
181
|
-
collectionName,
|
|
182
|
-
def,
|
|
183
|
-
fieldName,
|
|
184
|
-
mapRecord,
|
|
185
|
-
),
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
return handle;
|
|
189
|
-
}
|
|
@@ -1,414 +0,0 @@
|
|
|
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
|
-
}
|
package/src/crud/watch.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|