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,76 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { wrapEvent, unwrapEvent, type Rumor } from "nostr-tools/nip59";
|
|
3
|
+
import type { NostrEvent, UnsignedEvent } from "nostr-tools/pure";
|
|
4
|
+
import { CryptoError } from "../errors.ts";
|
|
5
|
+
|
|
6
|
+
export interface GiftWrapHandle {
|
|
7
|
+
readonly wrap: (rumor: Partial<UnsignedEvent>) => Effect.Effect<NostrEvent, CryptoError>;
|
|
8
|
+
readonly unwrap: (giftWrap: NostrEvent) => Effect.Effect<Rumor, CryptoError>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createGiftWrapHandle(privateKey: Uint8Array, publicKey: string): GiftWrapHandle {
|
|
12
|
+
return {
|
|
13
|
+
wrap: (rumor) =>
|
|
14
|
+
Effect.try({
|
|
15
|
+
try: () => wrapEvent(rumor, privateKey, publicKey),
|
|
16
|
+
catch: (e) =>
|
|
17
|
+
new CryptoError({
|
|
18
|
+
message: `Gift wrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
19
|
+
cause: e,
|
|
20
|
+
}),
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
unwrap: (giftWrap) =>
|
|
24
|
+
Effect.try({
|
|
25
|
+
try: () => unwrapEvent(giftWrap, privateKey),
|
|
26
|
+
catch: (e) =>
|
|
27
|
+
new CryptoError({
|
|
28
|
+
message: `Gift unwrap failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
29
|
+
cause: e,
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
// @ts-expect-error -- vendored JS without types
|
|
3
|
+
import { Negentropy, NegentropyStorageVector } from "../vendor/negentropy.js";
|
|
4
|
+
import type { IDBStorageHandle } from "../storage/idb.ts";
|
|
5
|
+
import type { RelayHandle } from "./relay.ts";
|
|
6
|
+
import type { Filter } from "nostr-tools/filter";
|
|
7
|
+
import { SyncError, RelayError, StorageError } from "../errors.ts";
|
|
8
|
+
|
|
9
|
+
export interface ReconcileResult {
|
|
10
|
+
readonly haveIds: string[];
|
|
11
|
+
readonly needIds: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
15
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
16
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
17
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
18
|
+
}
|
|
19
|
+
return bytes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function reconcileWithRelay(
|
|
23
|
+
storage: IDBStorageHandle,
|
|
24
|
+
relay: RelayHandle,
|
|
25
|
+
relayUrl: string,
|
|
26
|
+
publicKey: string,
|
|
27
|
+
): Effect.Effect<ReconcileResult, SyncError | RelayError | StorageError> {
|
|
28
|
+
return Effect.gen(function* () {
|
|
29
|
+
const allGiftWraps = yield* storage.getAllGiftWraps();
|
|
30
|
+
|
|
31
|
+
const storageVector = new NegentropyStorageVector();
|
|
32
|
+
for (const gw of allGiftWraps) {
|
|
33
|
+
storageVector.insert(gw.createdAt, hexToBytes(gw.id));
|
|
34
|
+
}
|
|
35
|
+
storageVector.seal();
|
|
36
|
+
|
|
37
|
+
const neg = new Negentropy(storageVector, 0);
|
|
38
|
+
|
|
39
|
+
const filter: Filter = {
|
|
40
|
+
kinds: [1059],
|
|
41
|
+
"#p": [publicKey],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const allHaveIds: string[] = [];
|
|
45
|
+
const allNeedIds: string[] = [];
|
|
46
|
+
const subId = `neg-${Date.now()}`;
|
|
47
|
+
|
|
48
|
+
const initialMsg: string = yield* Effect.tryPromise({
|
|
49
|
+
try: () => neg.initiate(),
|
|
50
|
+
catch: (e) =>
|
|
51
|
+
new SyncError({
|
|
52
|
+
message: `Negentropy initiate failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
53
|
+
phase: "negotiate",
|
|
54
|
+
cause: e,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let currentMsg: string | null = initialMsg;
|
|
59
|
+
|
|
60
|
+
while (currentMsg !== null) {
|
|
61
|
+
const response = yield* relay.sendNegMsg(relayUrl, subId, filter, currentMsg);
|
|
62
|
+
|
|
63
|
+
if (response.msgHex === null) break;
|
|
64
|
+
|
|
65
|
+
const reconcileResult: [string | null, string[], string[]] = yield* Effect.tryPromise({
|
|
66
|
+
try: () => neg.reconcile(response.msgHex),
|
|
67
|
+
catch: (e) =>
|
|
68
|
+
new SyncError({
|
|
69
|
+
message: `Negentropy reconcile failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
70
|
+
phase: "negotiate",
|
|
71
|
+
cause: e,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const [nextMsg, haveIds, needIds] = reconcileResult;
|
|
76
|
+
for (const id of haveIds) allHaveIds.push(id);
|
|
77
|
+
for (const id of needIds) allNeedIds.push(id);
|
|
78
|
+
currentMsg = nextMsg;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { haveIds: allHaveIds, needIds: allNeedIds };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Effect, Ref } from "effect";
|
|
2
|
+
import type { IDBStorageHandle } from "../storage/idb.ts";
|
|
3
|
+
import type { RelayHandle } from "./relay.ts";
|
|
4
|
+
import type { NostrEvent } from "nostr-tools/pure";
|
|
5
|
+
import type { RelayError, StorageError } from "../errors.ts";
|
|
6
|
+
|
|
7
|
+
export interface PublishQueueHandle {
|
|
8
|
+
readonly enqueue: (eventId: string) => Effect.Effect<void>;
|
|
9
|
+
readonly flush: (relayUrls: readonly string[]) => Effect.Effect<void, RelayError | StorageError>;
|
|
10
|
+
readonly size: () => Effect.Effect<number>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createPublishQueue(
|
|
14
|
+
storage: IDBStorageHandle,
|
|
15
|
+
relay: RelayHandle,
|
|
16
|
+
): Effect.Effect<PublishQueueHandle> {
|
|
17
|
+
return Effect.gen(function* () {
|
|
18
|
+
const pendingRef = yield* Ref.make<Set<string>>(new Set());
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
enqueue: (eventId) =>
|
|
22
|
+
Ref.update(pendingRef, (set) => {
|
|
23
|
+
const next = new Set(set);
|
|
24
|
+
next.add(eventId);
|
|
25
|
+
return next;
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
flush: (relayUrls) =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const pending = yield* Ref.get(pendingRef);
|
|
31
|
+
if (pending.size === 0) return;
|
|
32
|
+
|
|
33
|
+
const succeeded = new Set<string>();
|
|
34
|
+
|
|
35
|
+
for (const eventId of pending) {
|
|
36
|
+
const gw = yield* storage.getGiftWrap(eventId);
|
|
37
|
+
if (!gw) {
|
|
38
|
+
succeeded.add(eventId);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const result = yield* Effect.result(
|
|
42
|
+
relay.publish(gw.event as unknown as NostrEvent, relayUrls),
|
|
43
|
+
);
|
|
44
|
+
if (result._tag === "Success") {
|
|
45
|
+
succeeded.add(eventId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
yield* Ref.update(pendingRef, (set) => {
|
|
50
|
+
const next = new Set(set);
|
|
51
|
+
for (const id of succeeded) {
|
|
52
|
+
next.delete(id);
|
|
53
|
+
}
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
size: () => Ref.get(pendingRef).pipe(Effect.map((s) => s.size)),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|