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,392 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ interface ValidationError {
4
+ tool: string;
5
+ file?: string;
6
+ line?: number;
7
+ column?: number;
8
+ message: string;
9
+ code?: string;
10
+ severity: "error" | "warning";
11
+ }
12
+
13
+ interface ValidationResult {
14
+ success: boolean;
15
+ errors: ValidationError[];
16
+ summary: string;
17
+ duration_ms: number;
18
+ stopped_early: boolean;
19
+ next_action?: string;
20
+ }
21
+
22
+ interface CommandResult {
23
+ stdout: string;
24
+ stderr: string;
25
+ success: boolean;
26
+ }
27
+
28
+ interface OxlintResult {
29
+ diagnostics?: Array<{
30
+ message: string;
31
+ code?: string;
32
+ severity?: string;
33
+ filename?: string;
34
+ labels?: Array<{
35
+ span?: {
36
+ line?: number;
37
+ column?: number;
38
+ };
39
+ }>;
40
+ }>;
41
+ }
42
+
43
+ interface VitestResult {
44
+ success?: boolean;
45
+ testResults?: Array<{
46
+ name: string;
47
+ assertionResults?: Array<{
48
+ ancestorTitles: string[];
49
+ title: string;
50
+ status: string;
51
+ failureMessages?: string[];
52
+ }>;
53
+ }>;
54
+ }
55
+
56
+ interface Check {
57
+ name: string;
58
+ fn: () => Promise<ValidationError[]>;
59
+ }
60
+
61
+ const MAX_ERRORS = 3;
62
+ const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-9;?]*[A-Za-z]`, "g");
63
+
64
+ let collectedErrors: ValidationError[] = [];
65
+ let failedTools: string[] = [];
66
+ let aborted = false;
67
+ const runningProcesses: ChildProcess[] = [];
68
+
69
+ function abort() {
70
+ if (aborted) return;
71
+ aborted = true;
72
+
73
+ for (const proc of runningProcesses) {
74
+ proc.kill("SIGTERM");
75
+ }
76
+ }
77
+
78
+ function addErrors(toolName: string, errors: ValidationError[]): boolean {
79
+ if (aborted || errors.length === 0) return aborted;
80
+
81
+ if (!failedTools.includes(toolName)) {
82
+ failedTools.push(toolName);
83
+ }
84
+
85
+ for (const error of errors) {
86
+ if (collectedErrors.length >= MAX_ERRORS) {
87
+ abort();
88
+ return true;
89
+ }
90
+
91
+ collectedErrors.push(error);
92
+ }
93
+
94
+ if (collectedErrors.length >= MAX_ERRORS) {
95
+ abort();
96
+ return true;
97
+ }
98
+
99
+ return false;
100
+ }
101
+
102
+ function removeRunningProcess(proc: ChildProcess) {
103
+ const index = runningProcesses.indexOf(proc);
104
+ if (index !== -1) {
105
+ runningProcesses.splice(index, 1);
106
+ }
107
+ }
108
+
109
+ function run(command: string, args: string[]): Promise<CommandResult> {
110
+ return new Promise((resolve) => {
111
+ if (aborted) {
112
+ resolve({ stdout: "", stderr: "", success: false });
113
+ return;
114
+ }
115
+
116
+ const proc = spawn(command, args, {
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ env: process.env,
119
+ });
120
+
121
+ runningProcesses.push(proc);
122
+
123
+ let stdout = "";
124
+ let stderr = "";
125
+ let settled = false;
126
+
127
+ const finish = (result: CommandResult) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ removeRunningProcess(proc);
131
+ resolve(result);
132
+ };
133
+
134
+ proc.stdout?.setEncoding("utf8");
135
+ proc.stdout?.on("data", (chunk: string) => {
136
+ stdout += chunk;
137
+ });
138
+
139
+ proc.stderr?.setEncoding("utf8");
140
+ proc.stderr?.on("data", (chunk: string) => {
141
+ stderr += chunk;
142
+ });
143
+
144
+ proc.on("error", (error) => {
145
+ const message = error.message.trim();
146
+ finish({
147
+ stdout,
148
+ stderr: [stderr.trim(), message].filter(Boolean).join("\n"),
149
+ success: false,
150
+ });
151
+ });
152
+
153
+ proc.on("close", (code, signal) => {
154
+ if (aborted) {
155
+ finish({ stdout: "", stderr: "", success: false });
156
+ return;
157
+ }
158
+
159
+ finish({
160
+ stdout,
161
+ stderr,
162
+ success: code === 0 && signal === null,
163
+ });
164
+ });
165
+ });
166
+ }
167
+
168
+ function normalizeOutputLines(text: string): string[] {
169
+ return text
170
+ .split("\n")
171
+ .map((line) => line.replace(ANSI_ESCAPE_PATTERN, "").trim())
172
+ .filter((line) => line.length > 0);
173
+ }
174
+
175
+ function summarizeFailureOutput(text: string): string | undefined {
176
+ const lines = normalizeOutputLines(text);
177
+
178
+ if (lines.length === 0) return undefined;
179
+
180
+ const prioritized = lines.find((line) =>
181
+ /(^error\b|failed|AssertionError|Format issues found)/i.test(line),
182
+ );
183
+
184
+ return prioritized ?? lines[0];
185
+ }
186
+
187
+ function truncateText(text: string, maxLength = 220): string {
188
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`;
189
+ }
190
+
191
+ function parseJson<T>(text: string): T | undefined {
192
+ try {
193
+ return JSON.parse(text.trim()) as T;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ function escapeRegExp(text: string): string {
200
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
201
+ }
202
+
203
+ function extractVitestLocation(
204
+ fileName: string,
205
+ failureMessage: string | undefined,
206
+ ): Pick<ValidationError, "line" | "column"> {
207
+ if (!failureMessage) return {};
208
+
209
+ const match = failureMessage.match(new RegExp(`${escapeRegExp(fileName)}:(\\d+):(\\d+)`));
210
+
211
+ if (!match) return {};
212
+
213
+ return {
214
+ line: Number.parseInt(match[1], 10),
215
+ column: Number.parseInt(match[2], 10),
216
+ };
217
+ }
218
+
219
+ async function runFormatCheck(): Promise<ValidationError[]> {
220
+ if (aborted) return [];
221
+
222
+ const errors: ValidationError[] = [];
223
+ const { stdout, stderr, success } = await run("node_modules/.bin/oxfmt", ["--check", "."]);
224
+
225
+ if (aborted) return [];
226
+ if (success) return errors;
227
+
228
+ const output = `${stdout}\n${stderr}`;
229
+ const lines = normalizeOutputLines(output);
230
+
231
+ for (const line of lines) {
232
+ const match = line.match(/^(.*\S)\s+\(\d+ms\)$/);
233
+ if (!match) continue;
234
+
235
+ const file = match[1];
236
+ errors.push({
237
+ tool: "oxfmt",
238
+ file,
239
+ severity: "error",
240
+ message: `File needs formatting. Run: node_modules/.bin/oxfmt --write ${file}`,
241
+ });
242
+ }
243
+
244
+ if (errors.length === 0) {
245
+ errors.push({
246
+ tool: "oxfmt",
247
+ severity: "error",
248
+ message: `Formatting check failed${summarizeFailureOutput(output) ? `: ${summarizeFailureOutput(output)}` : ""}`,
249
+ });
250
+ }
251
+
252
+ return errors;
253
+ }
254
+
255
+ async function runLint(): Promise<ValidationError[]> {
256
+ if (aborted) return [];
257
+
258
+ const errors: ValidationError[] = [];
259
+ const { stdout, stderr, success } = await run("node_modules/.bin/oxlint", ["--format", "json"]);
260
+
261
+ if (aborted) return [];
262
+
263
+ const result = parseJson<OxlintResult>(stdout);
264
+
265
+ if (result) {
266
+ for (const diagnostic of result.diagnostics ?? []) {
267
+ if (diagnostic.severity !== "error") continue;
268
+
269
+ const span = diagnostic.labels?.[0]?.span;
270
+ errors.push({
271
+ tool: "oxlint",
272
+ severity: "error",
273
+ message: diagnostic.message,
274
+ ...(diagnostic.filename ? { file: diagnostic.filename } : {}),
275
+ ...(span?.line !== undefined ? { line: span.line } : {}),
276
+ ...(span?.column !== undefined ? { column: span.column } : {}),
277
+ ...(diagnostic.code ? { code: diagnostic.code } : {}),
278
+ });
279
+ }
280
+ }
281
+
282
+ if (errors.length === 0 && !success) {
283
+ const summary = summarizeFailureOutput(`${stdout}\n${stderr}`);
284
+ errors.push({
285
+ tool: "oxlint",
286
+ severity: "error",
287
+ message: `Lint failed${summary ? `: ${summary}` : ""}`,
288
+ });
289
+ }
290
+
291
+ return errors;
292
+ }
293
+
294
+ async function runTests(): Promise<ValidationError[]> {
295
+ if (aborted) return [];
296
+
297
+ const errors: ValidationError[] = [];
298
+ const { stdout, stderr, success } = await run("node_modules/.bin/vitest", [
299
+ "run",
300
+ "--reporter=json",
301
+ ]);
302
+
303
+ if (aborted) return [];
304
+
305
+ const result = parseJson<VitestResult>(stdout);
306
+
307
+ if (result) {
308
+ for (const file of result.testResults ?? []) {
309
+ for (const test of file.assertionResults ?? []) {
310
+ if (test.status !== "failed") continue;
311
+
312
+ const failureMessage = test.failureMessages?.[0];
313
+ const { line, column } = extractVitestLocation(file.name, failureMessage);
314
+ const summary = failureMessage
315
+ ?.split("\n")
316
+ .map((entry) => entry.trim())
317
+ .find(Boolean);
318
+
319
+ errors.push({
320
+ tool: "vitest",
321
+ file: file.name,
322
+ severity: "error",
323
+ message: truncateText(
324
+ `${[...test.ancestorTitles, test.title].join(" > ")}: ${summary ?? "failed"}`,
325
+ ),
326
+ ...(line !== undefined ? { line } : {}),
327
+ ...(column !== undefined ? { column } : {}),
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ if (errors.length === 0 && !success) {
334
+ const summary = summarizeFailureOutput(`${stdout}\n${stderr}`);
335
+ errors.push({
336
+ tool: "vitest",
337
+ severity: "error",
338
+ message: `Tests failed${summary ? `: ${summary}` : ""}`,
339
+ });
340
+ }
341
+
342
+ return errors;
343
+ }
344
+
345
+ async function validate(): Promise<ValidationResult> {
346
+ const start = Date.now();
347
+
348
+ collectedErrors = [];
349
+ failedTools = [];
350
+ aborted = false;
351
+
352
+ const checks: Check[] = [
353
+ { name: "oxfmt", fn: runFormatCheck },
354
+ { name: "oxlint", fn: runLint },
355
+ { name: "vitest", fn: runTests },
356
+ ];
357
+
358
+ console.error(`→ Running ${checks.length} checks in parallel...`);
359
+
360
+ await Promise.all(
361
+ checks.map(async (check) => {
362
+ const checkStart = Date.now();
363
+ const errors = await check.fn();
364
+ const duration = Date.now() - checkStart;
365
+ const wasAborted = addErrors(check.name, errors);
366
+
367
+ const status = aborted && errors.length === 0 ? "○" : errors.length === 0 ? "✓" : "✗";
368
+ const suffix = wasAborted && errors.length === 0 ? " (cancelled)" : "";
369
+ console.error(` ${status} ${check.name} (${duration}ms)${suffix}`);
370
+ }),
371
+ );
372
+
373
+ const duration_ms = Date.now() - start;
374
+
375
+ return {
376
+ success: collectedErrors.length === 0,
377
+ errors: collectedErrors,
378
+ summary:
379
+ collectedErrors.length === 0
380
+ ? `✓ All ${checks.length} checks passed in ${duration_ms}ms`
381
+ : `✗ ${collectedErrors.length} error(s) from ${failedTools.join(", ")}`,
382
+ duration_ms,
383
+ stopped_early: aborted,
384
+ ...(collectedErrors.length > 0
385
+ ? { next_action: "Fix these errors, then re-run validation." }
386
+ : {}),
387
+ };
388
+ }
389
+
390
+ const result = await validate();
391
+ console.log(JSON.stringify(result, null, 2));
392
+ process.exit(result.success ? 0 : 1);
@@ -0,0 +1,189 @@
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
+ }