tablinum 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +571 -0
  4. package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +498 -0
  5. package/.context/notes.md +0 -0
  6. package/.context/plans/add-changesets-to-douala-v4.md +48 -0
  7. package/.context/plans/dexie-js-style-query-language-for-localstr.md +115 -0
  8. package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +336 -0
  9. package/.context/plans/implementation-plan-localstr-v0-2.md +263 -0
  10. package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +71 -0
  11. package/.context/plans/revise-localstr-prd-v0-2.md +132 -0
  12. package/.context/plans/svelte-5-runes-bindings-for-localstr.md +233 -0
  13. package/.context/todos.md +0 -0
  14. package/.github/workflows/release.yml +36 -0
  15. package/.oxlintrc.json +8 -0
  16. package/README.md +1 -0
  17. package/bun.lock +705 -0
  18. package/examples/svelte/bun.lock +261 -0
  19. package/examples/svelte/package.json +21 -0
  20. package/examples/svelte/src/app.html +11 -0
  21. package/examples/svelte/src/lib/db.ts +44 -0
  22. package/examples/svelte/src/routes/+page.svelte +322 -0
  23. package/examples/svelte/svelte.config.js +16 -0
  24. package/examples/svelte/tsconfig.json +6 -0
  25. package/examples/svelte/vite.config.ts +6 -0
  26. package/examples/vanilla/app.ts +219 -0
  27. package/examples/vanilla/index.html +144 -0
  28. package/examples/vanilla/serve.ts +42 -0
  29. package/package.json +46 -0
  30. package/prds/localstr-v0.2.md +221 -0
  31. package/prek.toml +10 -0
  32. package/scripts/validate.ts +392 -0
  33. package/src/crud/collection-handle.ts +189 -0
  34. package/src/crud/query-builder.ts +414 -0
  35. package/src/crud/watch.ts +78 -0
  36. package/src/db/create-localstr.ts +217 -0
  37. package/src/db/database-handle.ts +16 -0
  38. package/src/db/identity.ts +49 -0
  39. package/src/errors.ts +37 -0
  40. package/src/index.ts +32 -0
  41. package/src/main.ts +10 -0
  42. package/src/schema/collection.ts +53 -0
  43. package/src/schema/field.ts +25 -0
  44. package/src/schema/types.ts +19 -0
  45. package/src/schema/validate.ts +111 -0
  46. package/src/storage/events-store.ts +24 -0
  47. package/src/storage/giftwraps-store.ts +23 -0
  48. package/src/storage/idb.ts +244 -0
  49. package/src/storage/lww.ts +17 -0
  50. package/src/storage/records-store.ts +76 -0
  51. package/src/svelte/collection.svelte.ts +87 -0
  52. package/src/svelte/database.svelte.ts +83 -0
  53. package/src/svelte/index.svelte.ts +52 -0
  54. package/src/svelte/live-query.svelte.ts +29 -0
  55. package/src/svelte/query.svelte.ts +101 -0
  56. package/src/sync/gift-wrap.ts +33 -0
  57. package/src/sync/negentropy.ts +83 -0
  58. package/src/sync/publish-queue.ts +61 -0
  59. package/src/sync/relay.ts +239 -0
  60. package/src/sync/sync-service.ts +183 -0
  61. package/src/sync/sync-status.ts +17 -0
  62. package/src/utils/uuid.ts +22 -0
  63. package/src/vendor/negentropy.js +616 -0
  64. package/tests/db/create-localstr.test.ts +174 -0
  65. package/tests/db/identity.test.ts +33 -0
  66. package/tests/main.test.ts +9 -0
  67. package/tests/schema/collection.test.ts +27 -0
  68. package/tests/schema/field.test.ts +41 -0
  69. package/tests/schema/validate.test.ts +85 -0
  70. package/tests/setup.ts +1 -0
  71. package/tests/storage/idb.test.ts +144 -0
  72. package/tests/storage/lww.test.ts +33 -0
  73. package/tests/sync/gift-wrap.test.ts +56 -0
  74. package/tsconfig.json +18 -0
  75. package/vitest.config.ts +8 -0
@@ -0,0 +1,16 @@
1
+ import type { Effect, Stream } from "effect";
2
+ import type { CollectionDef, CollectionFields } from "../schema/collection.ts";
3
+ import type { CollectionHandle } from "../crud/collection-handle.ts";
4
+ import type { InferRecord, SchemaConfig } from "../schema/types.ts";
5
+ import type { ClosedError, StorageError, SyncError, RelayError, CryptoError } from "../errors.ts";
6
+
7
+ export type SyncStatus = "idle" | "syncing";
8
+
9
+ export interface DatabaseHandle<S extends SchemaConfig> {
10
+ readonly collection: <K extends string & keyof S>(name: K) => CollectionHandle<S[K]>;
11
+ readonly exportKey: () => string;
12
+ readonly close: () => Effect.Effect<void, StorageError>;
13
+ readonly rebuild: () => Effect.Effect<void, StorageError>;
14
+ readonly sync: () => Effect.Effect<void, SyncError | RelayError | CryptoError | StorageError>;
15
+ readonly getSyncStatus: () => Effect.Effect<SyncStatus>;
16
+ }
@@ -0,0 +1,49 @@
1
+ import { Effect } from "effect";
2
+ import { getPublicKey } from "nostr-tools/pure";
3
+ import { CryptoError } from "../errors.ts";
4
+
5
+ export interface Identity {
6
+ readonly privateKey: Uint8Array;
7
+ readonly publicKey: string;
8
+ readonly exportKey: () => string;
9
+ }
10
+
11
+ function bytesToHex(bytes: Uint8Array): string {
12
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
13
+ }
14
+
15
+ export function createIdentity(suppliedKey?: Uint8Array): Effect.Effect<Identity, CryptoError> {
16
+ return Effect.gen(function* () {
17
+ let privateKey: Uint8Array;
18
+
19
+ if (suppliedKey) {
20
+ if (suppliedKey.length !== 32) {
21
+ return yield* new CryptoError({
22
+ message: `Private key must be 32 bytes, got ${suppliedKey.length}`,
23
+ });
24
+ }
25
+ privateKey = suppliedKey;
26
+ } else {
27
+ privateKey = new Uint8Array(32);
28
+ crypto.getRandomValues(privateKey);
29
+ }
30
+
31
+ const privateKeyHex = bytesToHex(privateKey);
32
+
33
+ let publicKey: string;
34
+ try {
35
+ publicKey = getPublicKey(privateKey);
36
+ } catch (e) {
37
+ return yield* new CryptoError({
38
+ message: `Failed to derive public key: ${e instanceof Error ? e.message : String(e)}`,
39
+ cause: e,
40
+ });
41
+ }
42
+
43
+ return {
44
+ privateKey,
45
+ publicKey,
46
+ exportKey: () => privateKeyHex,
47
+ };
48
+ });
49
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { Data } from "effect";
2
+
3
+ export class ValidationError extends Data.TaggedError("ValidationError")<{
4
+ readonly message: string;
5
+ readonly field?: string | undefined;
6
+ }> {}
7
+
8
+ export class StorageError extends Data.TaggedError("StorageError")<{
9
+ readonly message: string;
10
+ readonly cause?: unknown;
11
+ }> {}
12
+
13
+ export class CryptoError extends Data.TaggedError("CryptoError")<{
14
+ readonly message: string;
15
+ readonly cause?: unknown;
16
+ }> {}
17
+
18
+ export class RelayError extends Data.TaggedError("RelayError")<{
19
+ readonly message: string;
20
+ readonly url?: string | undefined;
21
+ readonly cause?: unknown;
22
+ }> {}
23
+
24
+ export class SyncError extends Data.TaggedError("SyncError")<{
25
+ readonly message: string;
26
+ readonly phase?: string | undefined;
27
+ readonly cause?: unknown;
28
+ }> {}
29
+
30
+ export class NotFoundError extends Data.TaggedError("NotFoundError")<{
31
+ readonly collection: string;
32
+ readonly id: string;
33
+ }> {}
34
+
35
+ export class ClosedError extends Data.TaggedError("ClosedError")<{
36
+ readonly message: string;
37
+ }> {}
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ // Schema
2
+ export { field } from "./schema/field.ts";
3
+ export { collection } from "./schema/collection.ts";
4
+ export type { CollectionDef, CollectionFields } from "./schema/collection.ts";
5
+ export type { FieldDef, FieldKind } from "./schema/field.ts";
6
+ export type { InferRecord, SchemaConfig } from "./schema/types.ts";
7
+
8
+ // Database
9
+ export { createLocalstr } from "./db/create-localstr.ts";
10
+ export type { LocalstrConfig } from "./db/create-localstr.ts";
11
+ export type { DatabaseHandle, SyncStatus } from "./db/database-handle.ts";
12
+
13
+ // CRUD
14
+ export type { CollectionHandle } from "./crud/collection-handle.ts";
15
+ export type { CollectionOptions } from "./schema/collection.ts";
16
+ export type {
17
+ WhereClause,
18
+ QueryBuilder,
19
+ OrderByBuilder,
20
+ QueryExecutor,
21
+ } from "./crud/query-builder.ts";
22
+
23
+ // Errors
24
+ export {
25
+ ValidationError,
26
+ StorageError,
27
+ CryptoError,
28
+ RelayError,
29
+ SyncError,
30
+ NotFoundError,
31
+ ClosedError,
32
+ } from "./errors.ts";
package/src/main.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { Console, Effect } from "effect";
2
+
3
+ const program = Effect.gen(function* () {
4
+ yield* Console.log("douala-v4 is running");
5
+ return 42;
6
+ });
7
+
8
+ Effect.runPromise(program).then((result) => {
9
+ console.log(`Result: ${result}`);
10
+ });
@@ -0,0 +1,53 @@
1
+ import type { FieldDef } from "./field.ts";
2
+
3
+ export type CollectionFields = Record<string, FieldDef<unknown>>;
4
+
5
+ export interface CollectionDef<out F extends CollectionFields = CollectionFields> {
6
+ readonly _tag: "CollectionDef";
7
+ readonly name: string;
8
+ readonly fields: F;
9
+ readonly indices: ReadonlyArray<string>;
10
+ }
11
+
12
+ export interface CollectionOptions<F extends CollectionFields> {
13
+ readonly indices?: ReadonlyArray<string & keyof F>;
14
+ }
15
+
16
+ const RESERVED_NAMES = new Set(["id", "_deleted", "_createdAt", "_updatedAt"]);
17
+
18
+ export function collection<F extends CollectionFields>(
19
+ name: string,
20
+ fields: F,
21
+ options?: CollectionOptions<F>,
22
+ ): CollectionDef<F> {
23
+ if (!name || name.trim().length === 0) {
24
+ throw new Error("Collection name must not be empty");
25
+ }
26
+ const fieldNames = Object.keys(fields);
27
+ if (fieldNames.length === 0) {
28
+ throw new Error(`Collection "${name}" must have at least one field`);
29
+ }
30
+ for (const fieldName of fieldNames) {
31
+ if (RESERVED_NAMES.has(fieldName)) {
32
+ throw new Error(`Field name "${fieldName}" is reserved`);
33
+ }
34
+ }
35
+
36
+ const indices: string[] = [];
37
+ if (options?.indices) {
38
+ for (const idx of options.indices) {
39
+ const fieldDef = fields[idx];
40
+ if (!fieldDef) {
41
+ throw new Error(`Index field "${idx}" does not exist in collection "${name}"`);
42
+ }
43
+ if (fieldDef.kind === "json" || fieldDef.isArray) {
44
+ throw new Error(
45
+ `Field "${idx}" cannot be indexed (type: ${fieldDef.kind}${fieldDef.isArray ? "[]" : ""})`,
46
+ );
47
+ }
48
+ indices.push(idx);
49
+ }
50
+ }
51
+
52
+ return { _tag: "CollectionDef" as const, name, fields, indices };
53
+ }
@@ -0,0 +1,25 @@
1
+ export type FieldKind = "string" | "number" | "boolean" | "json";
2
+
3
+ export interface FieldDef<out T = unknown> {
4
+ readonly _tag: "FieldDef";
5
+ readonly kind: FieldKind;
6
+ readonly isOptional: boolean;
7
+ readonly isArray: boolean;
8
+ /** Phantom type carrier — never read at runtime. */
9
+ readonly _T?: T;
10
+ }
11
+
12
+ function make<T>(kind: FieldKind, isOptional: boolean, isArray: boolean): FieldDef<T> {
13
+ return { _tag: "FieldDef", kind, isOptional, isArray };
14
+ }
15
+
16
+ export const field = {
17
+ string: () => make<string>("string", false, false),
18
+ number: () => make<number>("number", false, false),
19
+ boolean: () => make<boolean>("boolean", false, false),
20
+ json: () => make<unknown>("json", false, false),
21
+ optional: <T>(inner: FieldDef<T>): FieldDef<T | undefined> =>
22
+ make(inner.kind, true, inner.isArray),
23
+ array: <T>(inner: FieldDef<T>): FieldDef<ReadonlyArray<T>> =>
24
+ make(inner.kind, inner.isOptional, true),
25
+ };
@@ -0,0 +1,19 @@
1
+ import type { CollectionDef, CollectionFields } from "./collection.ts";
2
+ import type { FieldDef } from "./field.ts";
3
+
4
+ /** Extract the TypeScript type from a FieldDef phantom. */
5
+ export type InferFieldType<F> = F extends FieldDef<infer T> ? T : never;
6
+
7
+ /** Infer the full record type from a CollectionDef, including the auto-injected `id`. */
8
+ export type InferRecord<C> =
9
+ C extends CollectionDef<infer F>
10
+ ? { readonly id: string } & {
11
+ readonly [K in keyof F]: InferFieldType<F[K]>;
12
+ }
13
+ : never;
14
+
15
+ /** A schema configuration mapping collection names to their definitions. */
16
+ export type SchemaConfig = Record<string, CollectionDef<CollectionFields>>;
17
+
18
+ /** Extract indexed field names from a CollectionDef. */
19
+ export type IndexedFields<C> = C extends CollectionDef<infer _F> ? C["indices"][number] : never;
@@ -0,0 +1,111 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { CollectionDef, CollectionFields } from "./collection.ts";
3
+ import type { FieldDef } from "./field.ts";
4
+ import { ValidationError } from "../errors.ts";
5
+
6
+ function fieldDefToSchema(fd: FieldDef): Schema.Schema<unknown> {
7
+ let base: Schema.Schema<unknown>;
8
+ switch (fd.kind) {
9
+ case "string":
10
+ base = Schema.String as Schema.Schema<unknown>;
11
+ break;
12
+ case "number":
13
+ base = Schema.Number as Schema.Schema<unknown>;
14
+ break;
15
+ case "boolean":
16
+ base = Schema.Boolean as Schema.Schema<unknown>;
17
+ break;
18
+ case "json":
19
+ base = Schema.Unknown;
20
+ break;
21
+ }
22
+
23
+ if (fd.isArray) {
24
+ base = Schema.Array(base) as Schema.Schema<unknown>;
25
+ }
26
+ if (fd.isOptional) {
27
+ base = Schema.UndefinedOr(base) as Schema.Schema<unknown>;
28
+ }
29
+
30
+ return base;
31
+ }
32
+
33
+ export type RecordValidator<F extends CollectionFields> = (
34
+ input: unknown,
35
+ ) => Effect.Effect<{ readonly id: string } & { readonly [K in keyof F]: unknown }, ValidationError>;
36
+
37
+ export function buildValidator<F extends CollectionFields>(
38
+ collectionName: string,
39
+ def: CollectionDef<F>,
40
+ ): RecordValidator<F> {
41
+ const schemaFields: Record<string, Schema.Schema<unknown>> = {
42
+ id: Schema.String as Schema.Schema<unknown>,
43
+ };
44
+ for (const [name, fieldDef] of Object.entries(def.fields)) {
45
+ schemaFields[name] = fieldDefToSchema(fieldDef);
46
+ }
47
+
48
+ const recordSchema = Schema.Struct(
49
+ schemaFields as {
50
+ [K: string]: Schema.Schema<unknown>;
51
+ },
52
+ );
53
+
54
+ const decode = Schema.decodeUnknownSync(recordSchema);
55
+
56
+ return (input: unknown) =>
57
+ Effect.gen(function* () {
58
+ try {
59
+ const result = decode(input);
60
+ return result as { readonly id: string } & {
61
+ readonly [K in keyof F]: unknown;
62
+ };
63
+ } catch (e) {
64
+ return yield* new ValidationError({
65
+ message: `Validation failed for collection "${collectionName}": ${e instanceof Error ? e.message : String(e)}`,
66
+ });
67
+ }
68
+ });
69
+ }
70
+
71
+ export type PartialValidator<F extends CollectionFields> = (
72
+ input: unknown,
73
+ ) => Effect.Effect<{ readonly [K in keyof F]?: unknown }, ValidationError>;
74
+
75
+ export function buildPartialValidator<F extends CollectionFields>(
76
+ collectionName: string,
77
+ def: CollectionDef<F>,
78
+ ): PartialValidator<F> {
79
+ return (input: unknown) =>
80
+ Effect.gen(function* () {
81
+ if (typeof input !== "object" || input === null) {
82
+ return yield* new ValidationError({
83
+ message: `Validation failed for collection "${collectionName}": expected an object`,
84
+ });
85
+ }
86
+ const record = input as Record<string, unknown>;
87
+ for (const [key, value] of Object.entries(record)) {
88
+ const fieldDef = def.fields[key];
89
+ if (!fieldDef) {
90
+ return yield* new ValidationError({
91
+ message: `Unknown field "${key}" in collection "${collectionName}"`,
92
+ field: key,
93
+ });
94
+ }
95
+ if (value === undefined && fieldDef.isOptional) {
96
+ continue;
97
+ }
98
+ const fieldSchema = fieldDefToSchema(fieldDef);
99
+ const decode = Schema.decodeUnknownSync(fieldSchema);
100
+ try {
101
+ decode(value);
102
+ } catch (e) {
103
+ return yield* new ValidationError({
104
+ message: `Validation failed for field "${key}" in collection "${collectionName}": ${e instanceof Error ? e.message : String(e)}`,
105
+ field: key,
106
+ });
107
+ }
108
+ }
109
+ return record as { readonly [K in keyof F]?: unknown };
110
+ });
111
+ }
@@ -0,0 +1,24 @@
1
+ import { Effect } from "effect";
2
+ import type { IDBStorageHandle, StoredEvent } from "./idb.ts";
3
+ import type { StorageError } from "../errors.ts";
4
+
5
+ export function putEvent(
6
+ storage: IDBStorageHandle,
7
+ event: StoredEvent,
8
+ ): Effect.Effect<void, StorageError> {
9
+ return storage.putEvent(event);
10
+ }
11
+
12
+ export function getEventsByRecord(
13
+ storage: IDBStorageHandle,
14
+ collection: string,
15
+ recordId: string,
16
+ ): Effect.Effect<ReadonlyArray<StoredEvent>, StorageError> {
17
+ return storage.getEventsByRecord(collection, recordId);
18
+ }
19
+
20
+ export function getAllEvents(
21
+ storage: IDBStorageHandle,
22
+ ): Effect.Effect<ReadonlyArray<StoredEvent>, StorageError> {
23
+ return storage.getAllEvents();
24
+ }
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import type { IDBStorageHandle, StoredGiftWrap } from "./idb.ts";
3
+ import type { StorageError } from "../errors.ts";
4
+
5
+ export function putGiftWrap(
6
+ storage: IDBStorageHandle,
7
+ gw: StoredGiftWrap,
8
+ ): Effect.Effect<void, StorageError> {
9
+ return storage.putGiftWrap(gw);
10
+ }
11
+
12
+ export function getGiftWrap(
13
+ storage: IDBStorageHandle,
14
+ id: string,
15
+ ): Effect.Effect<StoredGiftWrap | undefined, StorageError> {
16
+ return storage.getGiftWrap(id);
17
+ }
18
+
19
+ export function getAllGiftWraps(
20
+ storage: IDBStorageHandle,
21
+ ): Effect.Effect<ReadonlyArray<StoredGiftWrap>, StorageError> {
22
+ return storage.getAllGiftWraps();
23
+ }
@@ -0,0 +1,244 @@
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
+ }
@@ -0,0 +1,17 @@
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
+ }