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
package/src/storage/idb.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import { Effect, Scope } from "effect";
|
|
2
|
-
import { openDB, type IDBPDatabase } from "idb";
|
|
3
|
-
import { StorageError } from "../errors.ts";
|
|
4
|
-
import type { SchemaConfig } from "../schema/types.ts";
|
|
5
|
-
|
|
6
|
-
export interface StoredEvent {
|
|
7
|
-
readonly id: string;
|
|
8
|
-
readonly collection: string;
|
|
9
|
-
readonly recordId: string;
|
|
10
|
-
readonly kind: "create" | "update" | "delete";
|
|
11
|
-
readonly data: Record<string, unknown> | null;
|
|
12
|
-
readonly createdAt: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface StoredGiftWrap {
|
|
16
|
-
readonly id: string;
|
|
17
|
-
readonly event: Record<string, unknown>;
|
|
18
|
-
readonly createdAt: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface IDBStorageHandle {
|
|
22
|
-
// Records — per-collection stores with flat shape
|
|
23
|
-
readonly putRecord: (
|
|
24
|
-
collection: string,
|
|
25
|
-
record: Record<string, unknown>,
|
|
26
|
-
) => Effect.Effect<void, StorageError>;
|
|
27
|
-
readonly getRecord: (
|
|
28
|
-
collection: string,
|
|
29
|
-
id: string,
|
|
30
|
-
) => Effect.Effect<Record<string, unknown> | undefined, StorageError>;
|
|
31
|
-
readonly getAllRecords: (
|
|
32
|
-
collection: string,
|
|
33
|
-
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
|
|
34
|
-
readonly countRecords: (collection: string) => Effect.Effect<number, StorageError>;
|
|
35
|
-
readonly clearRecords: (collection: string) => Effect.Effect<void, StorageError>;
|
|
36
|
-
|
|
37
|
-
// Index-based queries
|
|
38
|
-
readonly getByIndex: (
|
|
39
|
-
collection: string,
|
|
40
|
-
indexName: string,
|
|
41
|
-
value: IDBValidKey,
|
|
42
|
-
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
|
|
43
|
-
readonly getByIndexRange: (
|
|
44
|
-
collection: string,
|
|
45
|
-
indexName: string,
|
|
46
|
-
range: IDBKeyRange,
|
|
47
|
-
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
|
|
48
|
-
readonly getAllSorted: (
|
|
49
|
-
collection: string,
|
|
50
|
-
indexName: string,
|
|
51
|
-
direction?: "next" | "prev",
|
|
52
|
-
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, StorageError>;
|
|
53
|
-
|
|
54
|
-
// Events
|
|
55
|
-
readonly putEvent: (event: StoredEvent) => Effect.Effect<void, StorageError>;
|
|
56
|
-
readonly getEvent: (id: string) => Effect.Effect<StoredEvent | undefined, StorageError>;
|
|
57
|
-
readonly getAllEvents: () => Effect.Effect<ReadonlyArray<StoredEvent>, StorageError>;
|
|
58
|
-
readonly getEventsByRecord: (
|
|
59
|
-
collection: string,
|
|
60
|
-
recordId: string,
|
|
61
|
-
) => Effect.Effect<ReadonlyArray<StoredEvent>, StorageError>;
|
|
62
|
-
|
|
63
|
-
// Gift wraps
|
|
64
|
-
readonly putGiftWrap: (gw: StoredGiftWrap) => Effect.Effect<void, StorageError>;
|
|
65
|
-
readonly getGiftWrap: (id: string) => Effect.Effect<StoredGiftWrap | undefined, StorageError>;
|
|
66
|
-
readonly getAllGiftWraps: () => Effect.Effect<ReadonlyArray<StoredGiftWrap>, StorageError>;
|
|
67
|
-
|
|
68
|
-
readonly close: () => Effect.Effect<void>;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const DB_NAME = "localstr";
|
|
72
|
-
|
|
73
|
-
function storeName(collection: string): string {
|
|
74
|
-
return `col_${collection}`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function schemaVersion(schema: SchemaConfig): number {
|
|
78
|
-
const sig = Object.entries(schema)
|
|
79
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
80
|
-
.map(([name, def]) => {
|
|
81
|
-
const indices = [...(def.indices ?? [])].sort().join(",");
|
|
82
|
-
return `${name}:${indices}`;
|
|
83
|
-
})
|
|
84
|
-
.join("|");
|
|
85
|
-
let hash = 1;
|
|
86
|
-
for (let i = 0; i < sig.length; i++) {
|
|
87
|
-
hash = (hash * 31 + sig.charCodeAt(i)) | 0;
|
|
88
|
-
}
|
|
89
|
-
return Math.abs(hash) + 1;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function wrap<T>(label: string, fn: () => Promise<T>): Effect.Effect<T, StorageError> {
|
|
93
|
-
return Effect.tryPromise({
|
|
94
|
-
try: fn,
|
|
95
|
-
catch: (e) =>
|
|
96
|
-
new StorageError({
|
|
97
|
-
message: `IndexedDB ${label} failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
98
|
-
cause: e,
|
|
99
|
-
}),
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function openIDBStorage(
|
|
104
|
-
dbName: string | undefined,
|
|
105
|
-
schema: SchemaConfig,
|
|
106
|
-
): Effect.Effect<IDBStorageHandle, StorageError, Scope.Scope> {
|
|
107
|
-
return Effect.gen(function* () {
|
|
108
|
-
const name = dbName ?? DB_NAME;
|
|
109
|
-
const version = schemaVersion(schema);
|
|
110
|
-
|
|
111
|
-
const db: IDBPDatabase = yield* Effect.tryPromise({
|
|
112
|
-
try: () =>
|
|
113
|
-
openDB(name, version, {
|
|
114
|
-
upgrade(database) {
|
|
115
|
-
// Create events store if missing
|
|
116
|
-
if (!database.objectStoreNames.contains("events")) {
|
|
117
|
-
const events = database.createObjectStore("events", {
|
|
118
|
-
keyPath: "id",
|
|
119
|
-
});
|
|
120
|
-
events.createIndex("by-record", ["collection", "recordId"]);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Create giftwraps store if missing
|
|
124
|
-
if (!database.objectStoreNames.contains("giftwraps")) {
|
|
125
|
-
database.createObjectStore("giftwraps", {
|
|
126
|
-
keyPath: "id",
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Remove old shared records store if it exists
|
|
131
|
-
if (database.objectStoreNames.contains("records")) {
|
|
132
|
-
database.deleteObjectStore("records");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Determine which collection stores should exist
|
|
136
|
-
const expectedStores = new Set<string>();
|
|
137
|
-
for (const [, def] of Object.entries(schema)) {
|
|
138
|
-
const sn = storeName(def.name);
|
|
139
|
-
expectedStores.add(sn);
|
|
140
|
-
|
|
141
|
-
if (!database.objectStoreNames.contains(sn)) {
|
|
142
|
-
// Create new collection store
|
|
143
|
-
const store = database.createObjectStore(sn, { keyPath: "id" });
|
|
144
|
-
for (const idx of def.indices ?? []) {
|
|
145
|
-
store.createIndex(idx, idx);
|
|
146
|
-
}
|
|
147
|
-
} else {
|
|
148
|
-
// Update indices on existing store
|
|
149
|
-
const tx = (database as any).transaction;
|
|
150
|
-
// During upgrade, we can access stores via transaction
|
|
151
|
-
// The idb library handles this through the upgrade callback
|
|
152
|
-
const store = (tx as IDBTransaction).objectStore(sn);
|
|
153
|
-
const existingIndices = new Set(Array.from(store.indexNames));
|
|
154
|
-
const wantedIndices = new Set(def.indices ?? []);
|
|
155
|
-
|
|
156
|
-
// Remove stale indices
|
|
157
|
-
for (const idx of existingIndices) {
|
|
158
|
-
if (!wantedIndices.has(idx)) {
|
|
159
|
-
store.deleteIndex(idx);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
// Add new indices
|
|
163
|
-
for (const idx of wantedIndices) {
|
|
164
|
-
if (!existingIndices.has(idx as string)) {
|
|
165
|
-
store.createIndex(idx as string, idx as string);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Remove collection stores no longer in schema
|
|
172
|
-
const allStores = Array.from(database.objectStoreNames);
|
|
173
|
-
for (const existing of allStores) {
|
|
174
|
-
if (existing.startsWith("col_") && !expectedStores.has(existing)) {
|
|
175
|
-
database.deleteObjectStore(existing);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
}),
|
|
180
|
-
catch: (e) =>
|
|
181
|
-
new StorageError({
|
|
182
|
-
message: "Failed to open IndexedDB",
|
|
183
|
-
cause: e,
|
|
184
|
-
}),
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
yield* Effect.addFinalizer(() => Effect.sync(() => db.close()));
|
|
188
|
-
|
|
189
|
-
const handle: IDBStorageHandle = {
|
|
190
|
-
putRecord: (collection, record) =>
|
|
191
|
-
wrap("putRecord", () => db.put(storeName(collection), record).then(() => undefined)),
|
|
192
|
-
|
|
193
|
-
getRecord: (collection, id) => wrap("getRecord", () => db.get(storeName(collection), id)),
|
|
194
|
-
|
|
195
|
-
getAllRecords: (collection) => wrap("getAllRecords", () => db.getAll(storeName(collection))),
|
|
196
|
-
|
|
197
|
-
countRecords: (collection) => wrap("countRecords", () => db.count(storeName(collection))),
|
|
198
|
-
|
|
199
|
-
clearRecords: (collection) => wrap("clearRecords", () => db.clear(storeName(collection))),
|
|
200
|
-
|
|
201
|
-
getByIndex: (collection, indexName, value) =>
|
|
202
|
-
wrap("getByIndex", () => db.getAllFromIndex(storeName(collection), indexName, value)),
|
|
203
|
-
|
|
204
|
-
getByIndexRange: (collection, indexName, range) =>
|
|
205
|
-
wrap("getByIndexRange", () => db.getAllFromIndex(storeName(collection), indexName, range)),
|
|
206
|
-
|
|
207
|
-
getAllSorted: (collection, indexName, direction) =>
|
|
208
|
-
wrap("getAllSorted", async () => {
|
|
209
|
-
const sn = storeName(collection);
|
|
210
|
-
const tx = db.transaction(sn, "readonly");
|
|
211
|
-
const store = tx.objectStore(sn);
|
|
212
|
-
const index = store.index(indexName);
|
|
213
|
-
const results: Record<string, unknown>[] = [];
|
|
214
|
-
let cursor = await index.openCursor(null, direction ?? "next");
|
|
215
|
-
while (cursor) {
|
|
216
|
-
results.push(cursor.value as Record<string, unknown>);
|
|
217
|
-
cursor = await cursor.continue();
|
|
218
|
-
}
|
|
219
|
-
return results;
|
|
220
|
-
}),
|
|
221
|
-
|
|
222
|
-
putEvent: (event) => wrap("putEvent", () => db.put("events", event).then(() => undefined)),
|
|
223
|
-
|
|
224
|
-
getEvent: (id) => wrap("getEvent", () => db.get("events", id)),
|
|
225
|
-
|
|
226
|
-
getAllEvents: () => wrap("getAllEvents", () => db.getAll("events")),
|
|
227
|
-
|
|
228
|
-
getEventsByRecord: (collection, recordId) =>
|
|
229
|
-
wrap("getEventsByRecord", () =>
|
|
230
|
-
db.getAllFromIndex("events", "by-record", [collection, recordId]),
|
|
231
|
-
),
|
|
232
|
-
|
|
233
|
-
putGiftWrap: (gw) => wrap("putGiftWrap", () => db.put("giftwraps", gw).then(() => undefined)),
|
|
234
|
-
|
|
235
|
-
getGiftWrap: (id) => wrap("getGiftWrap", () => db.get("giftwraps", id)),
|
|
236
|
-
|
|
237
|
-
getAllGiftWraps: () => wrap("getAllGiftWraps", () => db.getAll("giftwraps")),
|
|
238
|
-
|
|
239
|
-
close: () => Effect.sync(() => db.close()),
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
return handle;
|
|
243
|
-
});
|
|
244
|
-
}
|
package/src/storage/lww.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
export interface EventMeta {
|
|
2
|
-
readonly id: string;
|
|
3
|
-
readonly createdAt: number;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Last-write-wins resolution.
|
|
8
|
-
* Higher `createdAt` wins. Ties broken by lowest event ID (lexicographic).
|
|
9
|
-
* Returns the winner.
|
|
10
|
-
*/
|
|
11
|
-
export function resolveWinner<T extends EventMeta>(existing: T | null, incoming: T): T {
|
|
12
|
-
if (existing === null) return incoming;
|
|
13
|
-
if (incoming.createdAt > existing.createdAt) return incoming;
|
|
14
|
-
if (incoming.createdAt < existing.createdAt) return existing;
|
|
15
|
-
// Tie: lowest event ID wins
|
|
16
|
-
return incoming.id < existing.id ? incoming : existing;
|
|
17
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
import type { IDBStorageHandle, StoredEvent } from "./idb.ts";
|
|
3
|
-
import { resolveWinner } from "./lww.ts";
|
|
4
|
-
import type { StorageError } from "../errors.ts";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Build a flat record from an event for storage in per-collection IDB stores.
|
|
8
|
-
*/
|
|
9
|
-
function buildRecord(event: StoredEvent): Record<string, unknown> {
|
|
10
|
-
return {
|
|
11
|
-
id: event.recordId,
|
|
12
|
-
_deleted: event.kind === "delete",
|
|
13
|
-
_updatedAt: event.createdAt,
|
|
14
|
-
...(event.data ?? {}),
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Apply an event to the records store using LWW resolution.
|
|
20
|
-
* Returns true if the incoming event won and the record was updated.
|
|
21
|
-
*/
|
|
22
|
-
export function applyEvent(
|
|
23
|
-
storage: IDBStorageHandle,
|
|
24
|
-
event: StoredEvent,
|
|
25
|
-
): Effect.Effect<boolean, StorageError> {
|
|
26
|
-
return Effect.gen(function* () {
|
|
27
|
-
const existingEvents = yield* storage.getEventsByRecord(event.collection, event.recordId);
|
|
28
|
-
|
|
29
|
-
// Find the current winning event for this record
|
|
30
|
-
let currentWinner: StoredEvent | null = null;
|
|
31
|
-
for (const e of existingEvents) {
|
|
32
|
-
if (e.id === event.id) continue; // Skip the event we're applying
|
|
33
|
-
currentWinner = resolveWinner(currentWinner, e);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const winner = resolveWinner(currentWinner, event);
|
|
37
|
-
const incomingWon = winner.id === event.id;
|
|
38
|
-
|
|
39
|
-
if (incomingWon) {
|
|
40
|
-
yield* storage.putRecord(event.collection, buildRecord(event));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return incomingWon;
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Rebuild the records store by clearing it and replaying all events.
|
|
49
|
-
*/
|
|
50
|
-
export function rebuild(
|
|
51
|
-
storage: IDBStorageHandle,
|
|
52
|
-
collections: ReadonlyArray<string>,
|
|
53
|
-
): Effect.Effect<void, StorageError> {
|
|
54
|
-
return Effect.gen(function* () {
|
|
55
|
-
for (const col of collections) {
|
|
56
|
-
yield* storage.clearRecords(col);
|
|
57
|
-
}
|
|
58
|
-
const allEvents = yield* storage.getAllEvents();
|
|
59
|
-
|
|
60
|
-
// Sort by createdAt for deterministic replay
|
|
61
|
-
const sorted = [...allEvents].sort((a, b) => a.createdAt - b.createdAt);
|
|
62
|
-
|
|
63
|
-
// Group events by record and resolve each
|
|
64
|
-
const winners = new Map<string, StoredEvent>();
|
|
65
|
-
for (const event of sorted) {
|
|
66
|
-
const key = `${event.collection}:${event.recordId}`;
|
|
67
|
-
const current = winners.get(key) ?? null;
|
|
68
|
-
winners.set(key, resolveWinner(current, event));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Write winning records
|
|
72
|
-
for (const event of winners.values()) {
|
|
73
|
-
yield* storage.putRecord(event.collection, buildRecord(event));
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { Effect, Fiber, Stream } from "effect";
|
|
2
|
-
import type { CollectionDef, CollectionFields } from "../schema/collection.ts";
|
|
3
|
-
import type { InferRecord } from "../schema/types.ts";
|
|
4
|
-
import type { CollectionHandle } from "../crud/collection-handle.ts";
|
|
5
|
-
import { LiveQuery } from "./live-query.svelte.ts";
|
|
6
|
-
import {
|
|
7
|
-
wrapWhereClause,
|
|
8
|
-
wrapOrderByBuilder,
|
|
9
|
-
type SvelteWhereClause,
|
|
10
|
-
type SvelteOrderByBuilder,
|
|
11
|
-
} from "./query.svelte.ts";
|
|
12
|
-
|
|
13
|
-
export class Collection<C extends CollectionDef<CollectionFields>> {
|
|
14
|
-
items = $state<ReadonlyArray<InferRecord<C>>>([]);
|
|
15
|
-
error = $state<Error | null>(null);
|
|
16
|
-
|
|
17
|
-
#handle: CollectionHandle<C>;
|
|
18
|
-
#watchFiber: Fiber.Fiber<void, unknown> | null = null;
|
|
19
|
-
#liveQueries: Set<LiveQuery<unknown>> = new Set();
|
|
20
|
-
|
|
21
|
-
constructor(handle: CollectionHandle<C>) {
|
|
22
|
-
this.#handle = handle;
|
|
23
|
-
|
|
24
|
-
// Auto-watch: subscribe to all records, surface errors to this.error
|
|
25
|
-
const watchEffect = Stream.runForEach(handle.watch(), (records) =>
|
|
26
|
-
Effect.sync(() => {
|
|
27
|
-
this.items = records;
|
|
28
|
-
}),
|
|
29
|
-
).pipe(
|
|
30
|
-
Effect.catch((e) =>
|
|
31
|
-
Effect.sync(() => {
|
|
32
|
-
this.error = e instanceof Error ? e : new Error(String(e));
|
|
33
|
-
}),
|
|
34
|
-
),
|
|
35
|
-
);
|
|
36
|
-
this.#watchFiber = Effect.runFork(watchEffect);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
#run = async <R>(effect: Effect.Effect<R, unknown>): Promise<R> => {
|
|
40
|
-
try {
|
|
41
|
-
this.error = null;
|
|
42
|
-
return await Effect.runPromise(effect);
|
|
43
|
-
} catch (e) {
|
|
44
|
-
this.error = e instanceof Error ? e : new Error(String(e));
|
|
45
|
-
throw this.error;
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
#onLive = (lq: LiveQuery<unknown>): void => {
|
|
50
|
-
this.#liveQueries.add(lq);
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
add = (data: Omit<InferRecord<C>, "id">): Promise<string> => this.#run(this.#handle.add(data));
|
|
54
|
-
|
|
55
|
-
update = (id: string, data: Partial<Omit<InferRecord<C>, "id">>): Promise<void> =>
|
|
56
|
-
this.#run(this.#handle.update(id, data));
|
|
57
|
-
|
|
58
|
-
delete = (id: string): Promise<void> => this.#run(this.#handle.delete(id));
|
|
59
|
-
|
|
60
|
-
get = (id: string): Promise<InferRecord<C>> => this.#run(this.#handle.get(id));
|
|
61
|
-
|
|
62
|
-
first = (): Promise<InferRecord<C> | null> => this.#run(this.#handle.first());
|
|
63
|
-
|
|
64
|
-
count = (): Promise<number> => this.#run(this.#handle.count());
|
|
65
|
-
|
|
66
|
-
where = (field: string & keyof Omit<InferRecord<C>, "id">): SvelteWhereClause<InferRecord<C>> => {
|
|
67
|
-
return wrapWhereClause(this.#handle.where(field), this.#onLive);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
orderBy = (
|
|
71
|
-
field: string & keyof Omit<InferRecord<C>, "id">,
|
|
72
|
-
): SvelteOrderByBuilder<InferRecord<C>> => {
|
|
73
|
-
return wrapOrderByBuilder(this.#handle.orderBy(field), this.#onLive);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
/** @internal Called by Database.close() */
|
|
77
|
-
_destroy(): void {
|
|
78
|
-
if (this.#watchFiber) {
|
|
79
|
-
Effect.runFork(Fiber.interrupt(this.#watchFiber));
|
|
80
|
-
this.#watchFiber = null;
|
|
81
|
-
}
|
|
82
|
-
for (const lq of this.#liveQueries) {
|
|
83
|
-
lq.destroy();
|
|
84
|
-
}
|
|
85
|
-
this.#liveQueries.clear();
|
|
86
|
-
}
|
|
87
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { Effect, Scope, Exit } from "effect";
|
|
2
|
-
import type { SchemaConfig } from "../schema/types.ts";
|
|
3
|
-
import type { CollectionDef, CollectionFields } from "../schema/collection.ts";
|
|
4
|
-
import type { DatabaseHandle, SyncStatus } from "../db/database-handle.ts";
|
|
5
|
-
import { Collection } from "./collection.svelte.ts";
|
|
6
|
-
|
|
7
|
-
export class Database<S extends SchemaConfig> {
|
|
8
|
-
status = $state<SyncStatus>("idle");
|
|
9
|
-
error = $state<Error | null>(null);
|
|
10
|
-
|
|
11
|
-
#handle: DatabaseHandle<S>;
|
|
12
|
-
#scope: Scope.Closeable;
|
|
13
|
-
#collections = new Map<string, Collection<CollectionDef<CollectionFields>>>();
|
|
14
|
-
#statusInterval: ReturnType<typeof setInterval> | null = null;
|
|
15
|
-
#closed = false;
|
|
16
|
-
|
|
17
|
-
constructor(handle: DatabaseHandle<S>, scope: Scope.Closeable) {
|
|
18
|
-
this.#handle = handle;
|
|
19
|
-
this.#scope = scope;
|
|
20
|
-
|
|
21
|
-
// Poll sync status
|
|
22
|
-
this.#statusInterval = setInterval(() => {
|
|
23
|
-
if (this.#closed) return;
|
|
24
|
-
Effect.runPromise(this.#handle.getSyncStatus())
|
|
25
|
-
.then((s) => {
|
|
26
|
-
this.status = s;
|
|
27
|
-
})
|
|
28
|
-
.catch(() => {
|
|
29
|
-
/* database may be closed, ignore */
|
|
30
|
-
});
|
|
31
|
-
}, 1000);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
collection<K extends string & keyof S>(name: K): Collection<S[K]> {
|
|
35
|
-
let col = this.#collections.get(name);
|
|
36
|
-
if (!col) {
|
|
37
|
-
const handle = this.#handle.collection(name);
|
|
38
|
-
col = new Collection(handle) as Collection<CollectionDef<CollectionFields>>;
|
|
39
|
-
this.#collections.set(name, col);
|
|
40
|
-
}
|
|
41
|
-
return col as unknown as Collection<S[K]>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
exportKey(): string {
|
|
45
|
-
return this.#handle.exportKey();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
close = async (): Promise<void> => {
|
|
49
|
-
if (this.#closed) return;
|
|
50
|
-
this.#closed = true;
|
|
51
|
-
|
|
52
|
-
if (this.#statusInterval) {
|
|
53
|
-
clearInterval(this.#statusInterval);
|
|
54
|
-
this.#statusInterval = null;
|
|
55
|
-
}
|
|
56
|
-
for (const col of this.#collections.values()) {
|
|
57
|
-
col._destroy();
|
|
58
|
-
}
|
|
59
|
-
this.#collections.clear();
|
|
60
|
-
await Effect.runPromise(this.#handle.close());
|
|
61
|
-
await Effect.runPromise(Scope.close(this.#scope, Exit.void));
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
sync = async (): Promise<void> => {
|
|
65
|
-
try {
|
|
66
|
-
this.error = null;
|
|
67
|
-
await Effect.runPromise(this.#handle.sync());
|
|
68
|
-
} catch (e) {
|
|
69
|
-
this.error = e instanceof Error ? e : new Error(String(e));
|
|
70
|
-
throw this.error;
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
rebuild = async (): Promise<void> => {
|
|
75
|
-
try {
|
|
76
|
-
this.error = null;
|
|
77
|
-
await Effect.runPromise(this.#handle.rebuild());
|
|
78
|
-
} catch (e) {
|
|
79
|
-
this.error = e instanceof Error ? e : new Error(String(e));
|
|
80
|
-
throw this.error;
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { Effect, Exit, Scope } from "effect";
|
|
2
|
-
import type { SchemaConfig } from "../schema/types.ts";
|
|
3
|
-
import {
|
|
4
|
-
createLocalstr as coreCreateLocalstr,
|
|
5
|
-
type LocalstrConfig,
|
|
6
|
-
} from "../db/create-localstr.ts";
|
|
7
|
-
import { Database } from "./database.svelte.ts";
|
|
8
|
-
|
|
9
|
-
// Re-export schema utilities (unchanged)
|
|
10
|
-
export { field } from "../schema/field.ts";
|
|
11
|
-
export { collection } from "../schema/collection.ts";
|
|
12
|
-
export type { CollectionDef, CollectionFields } from "../schema/collection.ts";
|
|
13
|
-
export type { FieldDef, FieldKind } from "../schema/field.ts";
|
|
14
|
-
export type { InferRecord, SchemaConfig } from "../schema/types.ts";
|
|
15
|
-
export type { LocalstrConfig } from "../db/create-localstr.ts";
|
|
16
|
-
export type { SyncStatus } from "../db/database-handle.ts";
|
|
17
|
-
|
|
18
|
-
// Re-export errors
|
|
19
|
-
export {
|
|
20
|
-
ValidationError,
|
|
21
|
-
StorageError,
|
|
22
|
-
CryptoError,
|
|
23
|
-
RelayError,
|
|
24
|
-
SyncError,
|
|
25
|
-
NotFoundError,
|
|
26
|
-
ClosedError,
|
|
27
|
-
} from "../errors.ts";
|
|
28
|
-
|
|
29
|
-
// Re-export Svelte classes
|
|
30
|
-
export { Database } from "./database.svelte.ts";
|
|
31
|
-
export { Collection } from "./collection.svelte.ts";
|
|
32
|
-
export { LiveQuery } from "./live-query.svelte.ts";
|
|
33
|
-
export type {
|
|
34
|
-
SvelteQueryBuilder,
|
|
35
|
-
SvelteWhereClause,
|
|
36
|
-
SvelteOrderByBuilder,
|
|
37
|
-
} from "./query.svelte.ts";
|
|
38
|
-
|
|
39
|
-
export async function createLocalstr<S extends SchemaConfig>(
|
|
40
|
-
config: LocalstrConfig<S>,
|
|
41
|
-
): Promise<Database<S>> {
|
|
42
|
-
const scope = Effect.runSync(Scope.make());
|
|
43
|
-
try {
|
|
44
|
-
const handle = await Effect.runPromise(
|
|
45
|
-
coreCreateLocalstr(config).pipe(Effect.provideService(Scope.Scope, scope)),
|
|
46
|
-
);
|
|
47
|
-
return new Database(handle, scope) as Database<S>;
|
|
48
|
-
} catch (e) {
|
|
49
|
-
await Effect.runPromise(Scope.close(scope, Exit.fail(e)));
|
|
50
|
-
throw e;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { Effect, Fiber, Stream } from "effect";
|
|
2
|
-
|
|
3
|
-
export class LiveQuery<T> {
|
|
4
|
-
items = $state<ReadonlyArray<T>>([]);
|
|
5
|
-
error = $state<Error | null>(null);
|
|
6
|
-
#fiber: Fiber.Fiber<void, unknown> | null = null;
|
|
7
|
-
|
|
8
|
-
constructor(stream: Stream.Stream<ReadonlyArray<T>, unknown>) {
|
|
9
|
-
const effect = Stream.runForEach(stream, (records) =>
|
|
10
|
-
Effect.sync(() => {
|
|
11
|
-
this.items = records;
|
|
12
|
-
}),
|
|
13
|
-
).pipe(
|
|
14
|
-
Effect.catch((e) =>
|
|
15
|
-
Effect.sync(() => {
|
|
16
|
-
this.error = e instanceof Error ? e : new Error(String(e));
|
|
17
|
-
}),
|
|
18
|
-
),
|
|
19
|
-
);
|
|
20
|
-
this.#fiber = Effect.runFork(effect);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
destroy(): void {
|
|
24
|
-
if (this.#fiber) {
|
|
25
|
-
Effect.runFork(Fiber.interrupt(this.#fiber));
|
|
26
|
-
this.#fiber = null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
import type { WhereClause, QueryBuilder, OrderByBuilder } from "../crud/query-builder.ts";
|
|
3
|
-
import { LiveQuery } from "./live-query.svelte.ts";
|
|
4
|
-
|
|
5
|
-
export type OnLiveCallback = (lq: LiveQuery<unknown>) => void;
|
|
6
|
-
|
|
7
|
-
export interface SvelteQueryBuilder<T> {
|
|
8
|
-
readonly and: (fn: (item: T) => boolean) => SvelteQueryBuilder<T>;
|
|
9
|
-
readonly sortBy: (field: string) => SvelteQueryBuilder<T>;
|
|
10
|
-
readonly reverse: () => SvelteQueryBuilder<T>;
|
|
11
|
-
readonly offset: (n: number) => SvelteQueryBuilder<T>;
|
|
12
|
-
readonly limit: (n: number) => SvelteQueryBuilder<T>;
|
|
13
|
-
readonly get: () => Promise<ReadonlyArray<T>>;
|
|
14
|
-
readonly first: () => Promise<T | null>;
|
|
15
|
-
readonly count: () => Promise<number>;
|
|
16
|
-
readonly live: () => LiveQuery<T>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SvelteWhereClause<T> {
|
|
20
|
-
readonly equals: (value: string | number | boolean) => SvelteQueryBuilder<T>;
|
|
21
|
-
readonly above: (value: number) => SvelteQueryBuilder<T>;
|
|
22
|
-
readonly aboveOrEqual: (value: number) => SvelteQueryBuilder<T>;
|
|
23
|
-
readonly below: (value: number) => SvelteQueryBuilder<T>;
|
|
24
|
-
readonly belowOrEqual: (value: number) => SvelteQueryBuilder<T>;
|
|
25
|
-
readonly between: (
|
|
26
|
-
lower: number,
|
|
27
|
-
upper: number,
|
|
28
|
-
options?: { includeLower?: boolean; includeUpper?: boolean },
|
|
29
|
-
) => SvelteQueryBuilder<T>;
|
|
30
|
-
readonly startsWith: (prefix: string) => SvelteQueryBuilder<T>;
|
|
31
|
-
readonly anyOf: (values: ReadonlyArray<string | number | boolean>) => SvelteQueryBuilder<T>;
|
|
32
|
-
readonly noneOf: (values: ReadonlyArray<string | number | boolean>) => SvelteQueryBuilder<T>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface SvelteOrderByBuilder<T> {
|
|
36
|
-
readonly reverse: () => SvelteOrderByBuilder<T>;
|
|
37
|
-
readonly offset: (n: number) => SvelteOrderByBuilder<T>;
|
|
38
|
-
readonly limit: (n: number) => SvelteOrderByBuilder<T>;
|
|
39
|
-
readonly get: () => Promise<ReadonlyArray<T>>;
|
|
40
|
-
readonly first: () => Promise<T | null>;
|
|
41
|
-
readonly count: () => Promise<number>;
|
|
42
|
-
readonly live: () => LiveQuery<T>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function wrapQueryBuilder<T>(
|
|
46
|
-
builder: QueryBuilder<T>,
|
|
47
|
-
onLive?: OnLiveCallback,
|
|
48
|
-
): SvelteQueryBuilder<T> {
|
|
49
|
-
return {
|
|
50
|
-
and: (fn) => wrapQueryBuilder(builder.and(fn), onLive),
|
|
51
|
-
sortBy: (field) => wrapQueryBuilder(builder.sortBy(field), onLive),
|
|
52
|
-
reverse: () => wrapQueryBuilder(builder.reverse(), onLive),
|
|
53
|
-
offset: (n) => wrapQueryBuilder(builder.offset(n), onLive),
|
|
54
|
-
limit: (n) => wrapQueryBuilder(builder.limit(n), onLive),
|
|
55
|
-
get: () => Effect.runPromise(builder.get()),
|
|
56
|
-
first: () => Effect.runPromise(builder.first()),
|
|
57
|
-
count: () => Effect.runPromise(builder.count()),
|
|
58
|
-
live: () => {
|
|
59
|
-
const lq = new LiveQuery(builder.watch());
|
|
60
|
-
onLive?.(lq as LiveQuery<unknown>);
|
|
61
|
-
return lq;
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function wrapWhereClause<T>(
|
|
67
|
-
clause: WhereClause<T>,
|
|
68
|
-
onLive?: OnLiveCallback,
|
|
69
|
-
): SvelteWhereClause<T> {
|
|
70
|
-
return {
|
|
71
|
-
equals: (value) => wrapQueryBuilder(clause.equals(value), onLive),
|
|
72
|
-
above: (value) => wrapQueryBuilder(clause.above(value), onLive),
|
|
73
|
-
aboveOrEqual: (value) => wrapQueryBuilder(clause.aboveOrEqual(value), onLive),
|
|
74
|
-
below: (value) => wrapQueryBuilder(clause.below(value), onLive),
|
|
75
|
-
belowOrEqual: (value) => wrapQueryBuilder(clause.belowOrEqual(value), onLive),
|
|
76
|
-
between: (lower, upper, options) =>
|
|
77
|
-
wrapQueryBuilder(clause.between(lower, upper, options), onLive),
|
|
78
|
-
startsWith: (prefix) => wrapQueryBuilder(clause.startsWith(prefix), onLive),
|
|
79
|
-
anyOf: (values) => wrapQueryBuilder(clause.anyOf(values), onLive),
|
|
80
|
-
noneOf: (values) => wrapQueryBuilder(clause.noneOf(values), onLive),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function wrapOrderByBuilder<T>(
|
|
85
|
-
builder: OrderByBuilder<T>,
|
|
86
|
-
onLive?: OnLiveCallback,
|
|
87
|
-
): SvelteOrderByBuilder<T> {
|
|
88
|
-
return {
|
|
89
|
-
reverse: () => wrapOrderByBuilder(builder.reverse(), onLive),
|
|
90
|
-
offset: (n) => wrapOrderByBuilder(builder.offset(n), onLive),
|
|
91
|
-
limit: (n) => wrapOrderByBuilder(builder.limit(n), onLive),
|
|
92
|
-
get: () => Effect.runPromise(builder.get()),
|
|
93
|
-
first: () => Effect.runPromise(builder.first()),
|
|
94
|
-
count: () => Effect.runPromise(builder.count()),
|
|
95
|
-
live: () => {
|
|
96
|
-
const lq = new LiveQuery(builder.watch());
|
|
97
|
-
onLive?.(lq as LiveQuery<unknown>);
|
|
98
|
-
return lq;
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|