ignotum 0.0.6 → 0.0.8

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 (54) hide show
  1. package/README.md +85 -101
  2. package/dist/cli/bin.mjs +1487 -168
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DtX8qPrq.js → api-BTeMI5tx.js} +27 -3
  5. package/dist/runtime/api-BTeMI5tx.js.map +1 -0
  6. package/dist/runtime/{api-D2bsAz4T.d.ts → api-D9lV-5av.d.ts} +7 -10
  7. package/dist/runtime/client.d.ts +27 -3
  8. package/dist/runtime/client.js +468 -68
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{descriptor-t6BOEGw9-BTHR-ZMv.js → descriptor-C5VA9qRl-C338iC6l.js} +63 -5
  11. package/dist/runtime/descriptor-C5VA9qRl-C338iC6l.js.map +1 -0
  12. package/dist/runtime/file-BXf63ulU.js +166 -0
  13. package/dist/runtime/file-BXf63ulU.js.map +1 -0
  14. package/dist/runtime/{result-BgcHn25t.d.ts → id-Btwac71X-DGj0DQuu.d.ts} +56 -4
  15. package/dist/runtime/{index-Dl_VQGmA.d.ts → index-CF04_Dps.d.ts} +96 -86
  16. package/dist/runtime/internal/api.d.ts +2 -2
  17. package/dist/runtime/internal/api.js +1 -1
  18. package/dist/runtime/internal/host.d.ts +25 -8
  19. package/dist/runtime/internal/host.js +209 -15
  20. package/dist/runtime/internal/host.js.map +1 -1
  21. package/dist/runtime/internal/server.d.ts +1 -1
  22. package/dist/runtime/internal/server.js +1 -1
  23. package/dist/runtime/internal/types.d.ts +1 -1
  24. package/dist/runtime/internal/types.js +1 -1
  25. package/dist/runtime/pagination-BNFhAjns.d.ts +1 -0
  26. package/dist/runtime/pagination-DrOowBve-Bnipd34u.d.ts +84 -0
  27. package/dist/runtime/{schema-B-jMZEbs.js → schema-DJfwfq87.js} +108 -30
  28. package/dist/runtime/schema-DJfwfq87.js.map +1 -0
  29. package/dist/runtime/server.d.ts +3 -3
  30. package/dist/runtime/server.js +2 -2
  31. package/dist/runtime/server.js.map +1 -1
  32. package/dist/runtime/sync-mIXn9eKv.d.ts +8 -0
  33. package/package.json +2 -2
  34. package/src/cli/agent-files.ts +56 -13
  35. package/src/cli/app-configuration.ts +4 -1
  36. package/src/cli/build/server.ts +83 -6
  37. package/src/cli/new-app.ts +15 -0
  38. package/src/client/files.ts +168 -0
  39. package/src/client/hooks.ts +166 -17
  40. package/src/client/index.ts +9 -1
  41. package/src/client/sync.ts +137 -21
  42. package/src/dev-runtime/database.ts +621 -78
  43. package/src/dev-runtime/files.ts +338 -0
  44. package/src/dev-runtime/functions.ts +14 -6
  45. package/src/dev-runtime/migrations.ts +44 -0
  46. package/src/dev-runtime/sync.ts +123 -5
  47. package/src/internal/api.ts +16 -1
  48. package/src/server/index.ts +8 -1
  49. package/dist/runtime/api-DtX8qPrq.js.map +0 -1
  50. package/dist/runtime/descriptor-t6BOEGw9-BTHR-ZMv.js.map +0 -1
  51. package/dist/runtime/id-Btwac71X-B9OeBzvq.d.ts +0 -9
  52. package/dist/runtime/id-D570vudg.js +0 -26
  53. package/dist/runtime/id-D570vudg.js.map +0 -1
  54. package/dist/runtime/schema-B-jMZEbs.js.map +0 -1
@@ -11,8 +11,27 @@ import {
11
11
  dependencyKey,
12
12
  documentDependency,
13
13
  tableDependency,
14
- type DependencyKey,
14
+ indexPointInvalidation,
15
+ indexRangeDependency,
16
+ type ReadDependency,
17
+ type WriteInvalidation,
15
18
  } from "@ignotum/contracts/runtime/hosted";
19
+ import {
20
+ encodeIndexKey,
21
+ encodeIndexRange,
22
+ EncodedIndexKey,
23
+ IndexScalarValue,
24
+ indexIdentity,
25
+ successorIndexPrefix,
26
+ type IndexScalar,
27
+ } from "@ignotum/contracts/runtime/index";
28
+ import {
29
+ decodePaginationCursor,
30
+ encodePaginationCursor,
31
+ paginationQueryIdentity,
32
+ type PaginationOptions,
33
+ type PaginationPage,
34
+ } from "@ignotum/contracts/runtime/pagination";
16
35
  import {
17
36
  documentNotFound,
18
37
  resultFromEffect,
@@ -23,7 +42,12 @@ import type {
23
42
  RuntimeSchemaDefinition,
24
43
  RuntimeTableDefinition,
25
44
  } from "@ignotum/contracts/runtime/schema";
26
- import { SchemaDefinitionTypeId } from "@ignotum/contracts/schema";
45
+ import {
46
+ getValueDescriptor,
47
+ SchemaDefinitionTypeId,
48
+ type ValueDescriptor,
49
+ } from "@ignotum/contracts/schema";
50
+ import type { FileValue } from "@ignotum/contracts/schema/file";
27
51
 
28
52
  import { DevelopmentDatabase } from "./migrations.js";
29
53
 
@@ -36,6 +60,7 @@ type RuntimeDocumentValue =
36
60
  | number
37
61
  | string
38
62
  | Date
63
+ | FileValue
39
64
  | ReadonlyArray<RuntimeDocumentValue>
40
65
  | RuntimeObject;
41
66
  type RuntimeValue = RuntimeObject;
@@ -47,19 +72,46 @@ export interface RuntimeDocument {
47
72
  readonly updatedAt: Date;
48
73
  }
49
74
 
75
+ interface LocalIndexRange {
76
+ readonly eq: (field: string, value: RuntimeDocumentValue) => LocalIndexRange;
77
+ readonly gt: (field: string, value: RuntimeDocumentValue) => LocalIndexRange;
78
+ readonly gte: (field: string, value: RuntimeDocumentValue) => LocalIndexRange;
79
+ readonly lt: (field: string, value: RuntimeDocumentValue) => LocalIndexRange;
80
+ readonly lte: (field: string, value: RuntimeDocumentValue) => LocalIndexRange;
81
+ }
82
+
83
+ interface LocalDatabaseQuery {
84
+ readonly index: (
85
+ name: string,
86
+ range?: (builder: LocalIndexRange) => LocalIndexRange,
87
+ ) => LocalDatabaseQuery;
88
+ readonly order: (direction: "asc" | "desc") => LocalDatabaseQuery;
89
+ readonly collect: () => Result<ReadonlyArray<RuntimeDocument>, never>;
90
+ readonly take: (count: number) => Result<ReadonlyArray<RuntimeDocument>, never>;
91
+ readonly first: () => Result<RuntimeDocument | undefined, never>;
92
+ readonly unique: () => Result<RuntimeDocument | undefined, never>;
93
+ readonly paginate: (options: PaginationOptions) => Result<PaginationPage<RuntimeDocument>, never>;
94
+ }
95
+
50
96
  export interface LocalDatabaseReader {
51
97
  readonly find: (tableName: string, id: string) => Result<RuntimeDocument | undefined, never>;
52
98
  readonly get: (tableName: string, id: string) => Result<RuntimeDocument, DocumentNotFoundValue>;
53
- readonly query: (tableName: string) => {
54
- readonly collect: () => Result<ReadonlyArray<RuntimeDocument>, never>;
55
- };
99
+ readonly query: (tableName: string) => LocalDatabaseQuery;
56
100
  }
57
101
 
58
102
  export interface LocalDatabaseWriter extends LocalDatabaseReader {
59
- readonly delete: (tableName: string, id: string) => Result<void, never>;
103
+ readonly delete: (tableName: string, id: string) => Result<void, DocumentNotFoundValue>;
60
104
  readonly insert: (tableName: string, value: RuntimeValue) => Result<GeneratedIdType, never>;
61
- readonly patch: (tableName: string, id: string, value: RuntimeValue) => Result<void, never>;
62
- readonly replace: (tableName: string, id: string, value: RuntimeValue) => Result<void, never>;
105
+ readonly patch: (
106
+ tableName: string,
107
+ id: string,
108
+ value: RuntimeValue,
109
+ ) => Result<void, DocumentNotFoundValue>;
110
+ readonly replace: (
111
+ tableName: string,
112
+ id: string,
113
+ value: RuntimeValue,
114
+ ) => Result<void, DocumentNotFoundValue>;
63
115
  }
64
116
 
65
117
  export interface LocalQueryContext {
@@ -82,7 +134,7 @@ interface LocalDatabaseService {
82
134
  ) => Effect.Effect<
83
135
  {
84
136
  readonly value: Success;
85
- readonly dependencies: ReadonlyArray<DependencyKey>;
137
+ readonly dependencies: ReadonlyArray<ReadDependency>;
86
138
  readonly observedRevision: AppStateRevision;
87
139
  },
88
140
  Failure | SqlError.SqlError
@@ -97,7 +149,7 @@ interface LocalDatabaseService {
97
149
  ) => Effect.Effect<
98
150
  {
99
151
  readonly value: Success;
100
- readonly invalidations: ReadonlyArray<DependencyKey>;
152
+ readonly invalidations: ReadonlyArray<WriteInvalidation>;
101
153
  readonly committedRevision: AppStateRevision;
102
154
  },
103
155
  Failure | SqlError.SqlError
@@ -111,6 +163,15 @@ const StoredDocument = Schema.Struct({
111
163
  fields: Schema.String,
112
164
  });
113
165
  type StoredDocument = typeof StoredDocument.Type;
166
+ const IndexedStoredDocument = Schema.Struct({ ...StoredDocument.fields, key: Schema.String });
167
+ type IndexedStoredDocument = typeof IndexedStoredDocument.Type;
168
+ const StoredIndex = Schema.Struct({
169
+ id: Schema.Natural,
170
+ identity: Schema.String,
171
+ name: Schema.String,
172
+ fields: Schema.String,
173
+ });
174
+ type StoredIndex = typeof StoredIndex.Type;
114
175
 
115
176
  const StoredTable = Schema.Struct({ id: TableId, name: Schema.String });
116
177
  type StoredTable = typeof StoredTable.Type;
@@ -128,13 +189,20 @@ const TableInsert = Schema.Struct({ id: TableId, name: Schema.String });
128
189
  const RevisionRow = Schema.Struct({ revision: AppStateRevision });
129
190
  type DatabaseOperation = "collect" | "get" | "insert" | "patch" | "replace";
130
191
 
131
- interface DependencyRecorder {
132
- readonly record: (...dependencies: ReadonlyArray<DependencyKey>) => void;
133
- readonly values: () => ReadonlyArray<DependencyKey>;
192
+ interface ResolvedIndex extends StoredIndex {
193
+ readonly definition: { readonly name: string; readonly fields: readonly string[] };
194
+ readonly descriptors: ReadonlyArray<ValueDescriptor>;
195
+ }
196
+
197
+ interface DependencyRecorder<Dependency extends ReadDependency | WriteInvalidation> {
198
+ readonly record: (...dependencies: ReadonlyArray<Dependency>) => void;
199
+ readonly values: () => ReadonlyArray<Dependency>;
134
200
  }
135
201
 
136
- const makeDependencyRecorder = (): DependencyRecorder => {
137
- const dependencies = new Map<string, DependencyKey>();
202
+ const makeDependencyRecorder = <
203
+ Dependency extends ReadDependency | WriteInvalidation,
204
+ >(): DependencyRecorder<Dependency> => {
205
+ const dependencies = new Map<string, Dependency>();
138
206
  return {
139
207
  record: (...added) => {
140
208
  for (const dependency of added) dependencies.set(dependencyKey(dependency), dependency);
@@ -240,6 +308,25 @@ const toRuntimeDocument = Effect.fn("LocalDatabase.toRuntimeDocument")(function*
240
308
  const nextUpdatedAt = (now: DateTime.DateTime, previous: number): number =>
241
309
  Math.max(DateTime.toEpochMillis(now), previous + 1);
242
310
 
311
+ const decodeIndexScalar = Schema.decodeUnknownSync(IndexScalarValue);
312
+
313
+ const indexDescriptors = (
314
+ table: RuntimeTableDefinition,
315
+ fields: ReadonlyArray<string>,
316
+ ): ReadonlyArray<ValueDescriptor> =>
317
+ fields.map((field) => {
318
+ const schema = table.fields[field];
319
+ const descriptor = schema === undefined ? undefined : getValueDescriptor(schema);
320
+ if (descriptor === undefined) throw new Error(`Index field '${field}' has no descriptor.`);
321
+ return descriptor;
322
+ });
323
+
324
+ const storedIndexKey = (index: ResolvedIndex, row: StoredDocument): EncodedIndexKey => {
325
+ const fields = Schema.decodeSync(Schema.fromJsonString(Schema.JsonObject))(row.fields);
326
+ const values = index.definition.fields.map((field) => decodeIndexScalar(fields[field]));
327
+ return encodeIndexKey(index.descriptors, values, row.createdAt, row.id);
328
+ };
329
+
243
330
  export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseService>()(
244
331
  "ignotum/dev-runtime/database/LocalDatabase",
245
332
  ) {
@@ -291,6 +378,31 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
291
378
  ORDER BY createdAt ASC, id ASC
292
379
  `,
293
380
  });
381
+ const listIndexes = SqlSchema.findAll({
382
+ Request: TableLookup,
383
+ Result: StoredIndex,
384
+ execute: ({ tableId }) => sql`
385
+ SELECT id, identity, name, fields
386
+ FROM indexes
387
+ WHERE tableId = ${tableId}
388
+ ORDER BY id ASC
389
+ `,
390
+ });
391
+ const insertIndex = SqlSchema.findOne({
392
+ Request: Schema.Struct({
393
+ tableId: TableId,
394
+ identity: Schema.String,
395
+ name: Schema.String,
396
+ fields: Schema.String,
397
+ }),
398
+ Result: StoredIndex,
399
+ execute: ({ tableId, identity, name, fields }) => sql`
400
+ INSERT INTO indexes (tableId, identity, name, fields)
401
+ VALUES (${tableId}, ${identity}, ${name}, ${fields})
402
+ ON CONFLICT (identity) DO UPDATE SET identity = excluded.identity
403
+ RETURNING id, identity, name, fields
404
+ `,
405
+ });
294
406
  const currentRevision = SqlSchema.findOne({
295
407
  Request: Schema.Void,
296
408
  Result: RevisionRow,
@@ -324,11 +436,96 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
324
436
  return { ...stored, definition };
325
437
  });
326
438
 
439
+ const resolvedIndexes = Effect.fn("LocalDatabase.resolvedIndexes")(function* (
440
+ table: ResolvedTable,
441
+ ) {
442
+ const stored = yield* listIndexes({ tableId: table.id });
443
+ return Object.values(table.definition.indexes).map((definition) => {
444
+ const identity = indexIdentity(table.id, definition.name, definition.fields);
445
+ const row = stored.find((candidate) => candidate.identity === identity);
446
+ if (row === undefined) throw new Error(`Index '${definition.name}' is not prepared.`);
447
+ return {
448
+ ...row,
449
+ definition,
450
+ descriptors: indexDescriptors(table.definition, definition.fields),
451
+ } satisfies ResolvedIndex;
452
+ });
453
+ });
454
+
455
+ const prepareIndexes = Effect.fn("LocalDatabase.prepareIndexes")(function* (
456
+ schema: RuntimeSchemaDefinition,
457
+ ) {
458
+ for (const [tableName, definition] of Object.entries(schema[SchemaDefinitionTypeId])) {
459
+ const table = yield* resolveTable(schema, tableName);
460
+ const stored = yield* listIndexes({ tableId: table.id });
461
+ const expected = new Set<string>();
462
+ for (const index of Object.values(definition.indexes)) {
463
+ const identity = indexIdentity(table.id, index.name, index.fields);
464
+ expected.add(identity);
465
+ if (stored.some((candidate) => candidate.identity === identity)) continue;
466
+ const fields = JSON.stringify(index.fields);
467
+ const created = yield* insertIndex({
468
+ tableId: table.id,
469
+ identity,
470
+ name: index.name,
471
+ fields,
472
+ }).pipe(Effect.catchTags({ NoSuchElementError: Effect.die }));
473
+ const resolved: ResolvedIndex = {
474
+ ...created,
475
+ definition: index,
476
+ descriptors: indexDescriptors(definition, index.fields),
477
+ };
478
+ const documents = yield* collectStored({ tableId: table.id });
479
+ yield* Effect.forEach(
480
+ documents,
481
+ (document) =>
482
+ sql`
483
+ INSERT INTO indexEntries (indexId, tableId, documentId, key)
484
+ VALUES (${resolved.id}, ${table.id}, ${document.id}, ${storedIndexKey(resolved, document)})
485
+ `,
486
+ );
487
+ }
488
+ yield* Effect.forEach(
489
+ stored.filter((index) => !expected.has(index.identity)),
490
+ (index) => sql`DELETE FROM indexes WHERE id = ${index.id}`,
491
+ );
492
+ }
493
+ });
494
+
495
+ const indexKeys = Effect.fn("LocalDatabase.indexKeys")(function* (
496
+ table: ResolvedTable,
497
+ row: StoredDocument,
498
+ ) {
499
+ const indexes = yield* resolvedIndexes(table);
500
+ return indexes.map((index) => ({
501
+ index,
502
+ key: storedIndexKey(index, row),
503
+ identity: indexIdentity(table.id, index.name, index.definition.fields),
504
+ }));
505
+ });
506
+
507
+ const writeIndexEntries = Effect.fn("LocalDatabase.writeIndexEntries")(function* (
508
+ table: ResolvedTable,
509
+ row: StoredDocument,
510
+ ) {
511
+ const keys = yield* indexKeys(table, row);
512
+ yield* Effect.forEach(
513
+ keys,
514
+ ({ index, key }) =>
515
+ sql`
516
+ INSERT INTO indexEntries (indexId, tableId, documentId, key)
517
+ VALUES (${index.id}, ${table.id}, ${row.id}, ${key})
518
+ ON CONFLICT (indexId, documentId) DO UPDATE SET key = excluded.key
519
+ `,
520
+ );
521
+ return keys;
522
+ });
523
+
327
524
  const find = Effect.fn("LocalDatabase.find")(function* (
328
525
  schema: RuntimeSchemaDefinition,
329
526
  tableName: string,
330
527
  id: string,
331
- dependencies?: DependencyRecorder,
528
+ dependencies?: DependencyRecorder<ReadDependency>,
332
529
  ) {
333
530
  const table = yield* resolveTable(schema, tableName);
334
531
  const documentId = GeneratedId.make(id);
@@ -338,24 +535,178 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
338
535
  return yield* toRuntimeDocument(table.definition, tableName, row.value, "get");
339
536
  });
340
537
 
341
- const collect = Effect.fn("LocalDatabase.collect")(function* (
538
+ const queryDocuments = Effect.fn("LocalDatabase.queryDocuments")(function* (
342
539
  schema: RuntimeSchemaDefinition,
343
540
  tableName: string,
344
- dependencies?: DependencyRecorder,
541
+ selectedIndex: string | undefined,
542
+ bounds: { readonly lower?: EncodedIndexKey; readonly upper?: EncodedIndexKey },
543
+ order: "Asc" | "Desc",
544
+ limit: number | undefined,
545
+ pagination: PaginationOptions | undefined,
546
+ dependencies?: DependencyRecorder<ReadDependency>,
345
547
  ) {
346
548
  const table = yield* resolveTable(schema, tableName);
347
- dependencies?.record(tableDependency(table.id));
348
- const rows = yield* collectStored({ tableId: table.id });
349
- return yield* Effect.forEach(rows, (row) =>
549
+ if (selectedIndex === undefined) {
550
+ dependencies?.record(tableDependency(table.id));
551
+ const queryIdentity = paginationQueryIdentity(
552
+ table.id,
553
+ null,
554
+ undefined,
555
+ undefined,
556
+ order,
557
+ );
558
+ const decodedCursor =
559
+ pagination?.cursor === null || pagination === undefined
560
+ ? undefined
561
+ : decodePaginationCursor(pagination.cursor);
562
+ if (decodedCursor !== undefined && decodedCursor.query !== queryIdentity) {
563
+ throw new Error("A pagination cursor belongs to another query.");
564
+ }
565
+ const cursor = decodedCursor?.position;
566
+ if (cursor !== undefined && cursor.type !== "Table") {
567
+ throw new Error("A pagination cursor has the wrong position type.");
568
+ }
569
+ const clauses = ["tableId = ?"];
570
+ const parameters: Array<number | string> = [table.id];
571
+ if (cursor !== undefined) {
572
+ const operator = order === "Asc" ? ">" : "<";
573
+ clauses.push(`(createdAt ${operator} ? OR (createdAt = ? AND id ${operator} ?))`);
574
+ parameters.push(cursor.createdAt, cursor.createdAt, cursor.id);
575
+ }
576
+ const storageLimit = pagination === undefined ? limit : pagination.pageSize + 1;
577
+ if (storageLimit !== undefined) parameters.push(storageLimit);
578
+ const rows = yield* sql.unsafe<StoredDocument>(
579
+ `SELECT id, createdAt, updatedAt, fields
580
+ FROM documents
581
+ WHERE ${clauses.join(" AND ")}
582
+ ORDER BY createdAt ${order === "Asc" ? "ASC" : "DESC"}, id ${order === "Asc" ? "ASC" : "DESC"}${storageLimit === undefined ? "" : " LIMIT ?"}`,
583
+ parameters,
584
+ );
585
+ const decoded = yield* Schema.decodeUnknownEffect(Schema.Array(StoredDocument))(rows);
586
+ const hasMore = pagination !== undefined && decoded.length > pagination.pageSize;
587
+ const pageRows =
588
+ pagination === undefined ? decoded : decoded.slice(0, pagination.pageSize);
589
+ const documents = yield* Effect.forEach(pageRows, (row) =>
590
+ toRuntimeDocument(table.definition, tableName, row, "collect"),
591
+ );
592
+ const last = pageRows.at(-1);
593
+ const nextCursor =
594
+ !hasMore || last === undefined
595
+ ? null
596
+ : encodePaginationCursor(queryIdentity, {
597
+ type: "Table",
598
+ createdAt: last.createdAt,
599
+ id: last.id,
600
+ });
601
+ return { documents, nextCursor };
602
+ }
603
+
604
+ const indexes = yield* resolvedIndexes(table);
605
+ const index = indexes.find((candidate) => candidate.name === selectedIndex);
606
+ if (index === undefined) throw new Error(`Unknown index '${selectedIndex}'.`);
607
+ const identity = indexIdentity(table.id, index.name, index.definition.fields);
608
+ const queryIdentity = paginationQueryIdentity(
609
+ table.id,
610
+ identity,
611
+ bounds.lower,
612
+ bounds.upper,
613
+ order,
614
+ );
615
+ const decodedCursor =
616
+ pagination?.cursor === null || pagination === undefined
617
+ ? undefined
618
+ : decodePaginationCursor(pagination.cursor);
619
+ if (decodedCursor !== undefined && decodedCursor.query !== queryIdentity) {
620
+ throw new Error("A pagination cursor belongs to another query.");
621
+ }
622
+ const cursor = decodedCursor?.position;
623
+ if (cursor !== undefined && cursor.type !== "Index") {
624
+ throw new Error("A pagination cursor has the wrong position type.");
625
+ }
626
+ const cursorLower =
627
+ cursor !== undefined && order === "Asc" ? successorIndexPrefix(cursor.key) : undefined;
628
+ const cursorUpper = cursor !== undefined && order === "Desc" ? cursor.key : undefined;
629
+ const effectiveBounds = {
630
+ lower:
631
+ bounds.lower === undefined
632
+ ? cursorLower
633
+ : cursorLower === undefined || bounds.lower > cursorLower
634
+ ? bounds.lower
635
+ : cursorLower,
636
+ upper:
637
+ bounds.upper === undefined
638
+ ? cursorUpper
639
+ : cursorUpper === undefined || bounds.upper < cursorUpper
640
+ ? bounds.upper
641
+ : cursorUpper,
642
+ };
643
+ const clauses = ["entries.indexId = ?"];
644
+ const parameters: Array<number | string> = [index.id];
645
+ if (effectiveBounds.lower !== undefined) {
646
+ clauses.push("entries.key >= ?");
647
+ parameters.push(effectiveBounds.lower);
648
+ }
649
+ if (effectiveBounds.upper !== undefined) {
650
+ clauses.push("entries.key < ?");
651
+ parameters.push(effectiveBounds.upper);
652
+ }
653
+ const storageLimit = pagination === undefined ? limit : pagination.pageSize + 1;
654
+ if (storageLimit !== undefined) parameters.push(storageLimit);
655
+ const rows = yield* sql.unsafe<IndexedStoredDocument>(
656
+ `SELECT documents.id, documents.createdAt, documents.updatedAt, documents.fields,
657
+ entries.key
658
+ FROM indexEntries AS entries
659
+ JOIN documents
660
+ ON documents.tableId = entries.tableId
661
+ AND documents.id = entries.documentId
662
+ WHERE ${clauses.join(" AND ")}
663
+ ORDER BY entries.key ${order === "Asc" ? "ASC" : "DESC"}${storageLimit === undefined ? "" : " LIMIT ?"}`,
664
+ parameters,
665
+ );
666
+ const decoded = yield* Schema.decodeUnknownEffect(Schema.Array(IndexedStoredDocument))(
667
+ rows,
668
+ );
669
+ const hasMore = pagination !== undefined && decoded.length > pagination.pageSize;
670
+ const pageRows = pagination === undefined ? decoded : decoded.slice(0, pagination.pageSize);
671
+ let dependencyBounds = effectiveBounds;
672
+ const shouldCrop =
673
+ pagination === undefined
674
+ ? limit !== undefined && limit > 0 && decoded.length === limit
675
+ : hasMore;
676
+ if (shouldCrop) {
677
+ const last = pageRows.at(-1);
678
+ if (last !== undefined) {
679
+ dependencyBounds =
680
+ order === "Asc"
681
+ ? {
682
+ ...effectiveBounds,
683
+ upper: successorIndexPrefix(EncodedIndexKey.make(last.key)),
684
+ }
685
+ : { ...effectiveBounds, lower: EncodedIndexKey.make(last.key) };
686
+ }
687
+ }
688
+ dependencies?.record(
689
+ indexRangeDependency(table.id, identity, dependencyBounds.lower, dependencyBounds.upper),
690
+ );
691
+ const documents = yield* Effect.forEach(pageRows, (row) =>
350
692
  toRuntimeDocument(table.definition, tableName, row, "collect"),
351
693
  );
694
+ const last = pageRows.at(-1);
695
+ const nextCursor =
696
+ !hasMore || last === undefined
697
+ ? null
698
+ : encodePaginationCursor(queryIdentity, {
699
+ type: "Index",
700
+ key: EncodedIndexKey.make(last.key),
701
+ });
702
+ return { documents, nextCursor };
352
703
  });
353
704
 
354
705
  const insert = Effect.fn("LocalDatabase.insert")(function* (
355
706
  schema: RuntimeSchemaDefinition,
356
707
  tableName: string,
357
708
  value: RuntimeValue,
358
- invalidations: DependencyRecorder,
709
+ invalidations: DependencyRecorder<WriteInvalidation>,
359
710
  ) {
360
711
  const table = yield* resolveTable(schema, tableName);
361
712
  const fields = yield* encodeFields(table.definition, tableName, value, "insert", null);
@@ -365,7 +716,13 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
365
716
  INSERT INTO documents (id, tableId, createdAt, updatedAt, fields)
366
717
  VALUES (${id}, ${table.id}, ${now}, ${now}, ${fields})
367
718
  `;
368
- invalidations.record(tableDependency(table.id), documentDependency(table.id, id));
719
+ const row = { id, createdAt: now, updatedAt: now, fields } satisfies StoredDocument;
720
+ const keys = yield* writeIndexEntries(table, row);
721
+ invalidations.record(
722
+ tableDependency(table.id),
723
+ documentDependency(table.id, id),
724
+ ...keys.map(({ identity, key }) => indexPointInvalidation(table.id, identity, key)),
725
+ );
369
726
  return id;
370
727
  });
371
728
 
@@ -373,12 +730,21 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
373
730
  schema: RuntimeSchemaDefinition,
374
731
  tableName: string,
375
732
  id: string,
376
- invalidations: DependencyRecorder,
733
+ invalidations: DependencyRecorder<WriteInvalidation>,
377
734
  ) {
378
- const table = yield* resolveTable(schema, tableName);
735
+ const table = yield* resolveTable(schema, tableName).pipe(Effect.orDie);
379
736
  const documentId = GeneratedId.make(id);
380
- yield* sql`DELETE FROM documents WHERE id = ${id} AND tableId = ${table.id}`;
381
- invalidations.record(tableDependency(table.id), documentDependency(table.id, documentId));
737
+ const stored = yield* findStored({ id, tableId: table.id }).pipe(Effect.orDie);
738
+ if (Option.isNone(stored)) return yield* Effect.fail(documentNotFound(tableName, id));
739
+ const keys = yield* indexKeys(table, stored.value).pipe(Effect.orDie);
740
+ yield* sql`DELETE FROM documents WHERE id = ${id} AND tableId = ${table.id}`.pipe(
741
+ Effect.orDie,
742
+ );
743
+ invalidations.record(
744
+ tableDependency(table.id),
745
+ documentDependency(table.id, documentId),
746
+ ...keys.map(({ identity, key }) => indexPointInvalidation(table.id, identity, key)),
747
+ );
382
748
  });
383
749
 
384
750
  const patch = Effect.fn("LocalDatabase.patch")(function* (
@@ -386,28 +752,44 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
386
752
  tableName: string,
387
753
  id: string,
388
754
  value: RuntimeValue,
389
- invalidations: DependencyRecorder,
755
+ invalidations: DependencyRecorder<WriteInvalidation>,
390
756
  ) {
391
- const table = yield* resolveTable(schema, tableName);
757
+ const table = yield* resolveTable(schema, tableName).pipe(Effect.orDie);
392
758
  const documentId = GeneratedId.make(id);
393
- const stored = yield* findStored({ id, tableId: table.id });
394
- if (Option.isSome(stored)) {
395
- const current = yield* decodeFields(table.definition, tableName, stored.value, "patch");
396
- const fields = yield* encodeFields(
397
- table.definition,
398
- tableName,
399
- { ...current, ...value },
400
- "patch",
401
- id,
402
- );
403
- const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
404
- yield* sql`
405
- UPDATE documents
406
- SET fields = ${fields}, updatedAt = ${updatedAt}
407
- WHERE id = ${id} AND tableId = ${table.id}
408
- `;
409
- }
410
- invalidations.record(tableDependency(table.id), documentDependency(table.id, documentId));
759
+ const stored = yield* findStored({ id, tableId: table.id }).pipe(Effect.orDie);
760
+ if (Option.isNone(stored)) return yield* Effect.fail(documentNotFound(tableName, id));
761
+ const oldKeys = yield* indexKeys(table, stored.value).pipe(Effect.orDie);
762
+ const current = yield* decodeFields(
763
+ table.definition,
764
+ tableName,
765
+ stored.value,
766
+ "patch",
767
+ ).pipe(Effect.orDie);
768
+ const fields = yield* encodeFields(
769
+ table.definition,
770
+ tableName,
771
+ { ...current, ...value },
772
+ "patch",
773
+ id,
774
+ ).pipe(Effect.orDie);
775
+ const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
776
+ yield* sql`
777
+ UPDATE documents
778
+ SET fields = ${fields}, updatedAt = ${updatedAt}
779
+ WHERE id = ${id} AND tableId = ${table.id}
780
+ `.pipe(Effect.orDie);
781
+ const newKeys = yield* writeIndexEntries(table, {
782
+ ...stored.value,
783
+ fields,
784
+ updatedAt,
785
+ }).pipe(Effect.orDie);
786
+ invalidations.record(
787
+ tableDependency(table.id),
788
+ documentDependency(table.id, documentId),
789
+ ...[...oldKeys, ...newKeys].map(({ identity, key }) =>
790
+ indexPointInvalidation(table.id, identity, key),
791
+ ),
792
+ );
411
793
  });
412
794
 
413
795
  const replace = Effect.fn("LocalDatabase.replace")(function* (
@@ -415,28 +797,194 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
415
797
  tableName: string,
416
798
  id: string,
417
799
  value: RuntimeValue,
418
- invalidations: DependencyRecorder,
800
+ invalidations: DependencyRecorder<WriteInvalidation>,
419
801
  ) {
420
- const table = yield* resolveTable(schema, tableName);
802
+ const table = yield* resolveTable(schema, tableName).pipe(Effect.orDie);
421
803
  const documentId = GeneratedId.make(id);
422
- const stored = yield* findStored({ id, tableId: table.id });
423
- if (Option.isSome(stored)) {
424
- const fields = yield* encodeFields(table.definition, tableName, value, "replace", id);
425
- const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
426
- yield* sql`
427
- UPDATE documents
428
- SET fields = ${fields}, updatedAt = ${updatedAt}
429
- WHERE id = ${id} AND tableId = ${table.id}
430
- `;
431
- }
432
- invalidations.record(tableDependency(table.id), documentDependency(table.id, documentId));
804
+ const stored = yield* findStored({ id, tableId: table.id }).pipe(Effect.orDie);
805
+ if (Option.isNone(stored)) return yield* Effect.fail(documentNotFound(tableName, id));
806
+ const oldKeys = yield* indexKeys(table, stored.value).pipe(Effect.orDie);
807
+ const fields = yield* encodeFields(table.definition, tableName, value, "replace", id).pipe(
808
+ Effect.orDie,
809
+ );
810
+ const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
811
+ yield* sql`
812
+ UPDATE documents
813
+ SET fields = ${fields}, updatedAt = ${updatedAt}
814
+ WHERE id = ${id} AND tableId = ${table.id}
815
+ `.pipe(Effect.orDie);
816
+ const newKeys = yield* writeIndexEntries(table, {
817
+ ...stored.value,
818
+ fields,
819
+ updatedAt,
820
+ }).pipe(Effect.orDie);
821
+ invalidations.record(
822
+ tableDependency(table.id),
823
+ documentDependency(table.id, documentId),
824
+ ...[...oldKeys, ...newKeys].map(({ identity, key }) =>
825
+ indexPointInvalidation(table.id, identity, key),
826
+ ),
827
+ );
433
828
  });
434
829
 
435
830
  const makeReader = (
436
831
  schema: RuntimeSchemaDefinition,
437
- dependencies?: DependencyRecorder,
438
- ): LocalDatabaseReader =>
439
- Object.freeze({
832
+ dependencies?: DependencyRecorder<ReadDependency>,
833
+ ): LocalDatabaseReader => {
834
+ const databaseQuery = (
835
+ tableName: string,
836
+ selectedIndex?: string,
837
+ selectedBounds: {
838
+ readonly lower?: EncodedIndexKey;
839
+ readonly upper?: EncodedIndexKey;
840
+ } = {},
841
+ order: "Asc" | "Desc" = "Asc",
842
+ ): LocalDatabaseQuery => {
843
+ const table = schema[SchemaDefinitionTypeId][tableName];
844
+ if (table === undefined) throw new Error(`Unknown database table '${tableName}'.`);
845
+ const run = (limit?: number) =>
846
+ queryDocuments(
847
+ schema,
848
+ tableName,
849
+ selectedIndex,
850
+ selectedBounds,
851
+ order,
852
+ limit,
853
+ undefined,
854
+ dependencies,
855
+ ).pipe(
856
+ Effect.map(({ documents }) => documents),
857
+ Effect.orDie,
858
+ );
859
+ const runPage = (pagination: PaginationOptions) =>
860
+ queryDocuments(
861
+ schema,
862
+ tableName,
863
+ selectedIndex,
864
+ selectedBounds,
865
+ order,
866
+ undefined,
867
+ pagination,
868
+ dependencies,
869
+ ).pipe(Effect.orDie);
870
+ const validateLimit = (count: number) => {
871
+ if (!Number.isInteger(count) || count < 0 || count > 1_000) {
872
+ throw new Error("Query limits must be integers between 0 and 1000.");
873
+ }
874
+ return count;
875
+ };
876
+ const result: LocalDatabaseQuery = {
877
+ index: (name, buildRange) => {
878
+ if (selectedIndex !== undefined)
879
+ throw new Error("A query can select only one index.");
880
+ const definition = table.indexes[name];
881
+ if (definition === undefined) {
882
+ throw new Error(`Unknown index '${name}' on table '${tableName}'.`);
883
+ }
884
+ if (buildRange === undefined) return databaseQuery(tableName, name, {}, order);
885
+ const fields = [...definition.fields, "createdAt", "id"];
886
+ const descriptors = [
887
+ ...indexDescriptors(table, definition.fields),
888
+ { type: "date" } as const,
889
+ { type: "id", table: tableName } as const,
890
+ ];
891
+ const equal: IndexScalar[] = [];
892
+ let lower: { value: IndexScalar; inclusive: boolean } | undefined;
893
+ let upper: { value: IndexScalar; inclusive: boolean } | undefined;
894
+ let position = 0;
895
+ const encodeValue = (field: string, value: RuntimeDocumentValue): IndexScalar => {
896
+ const codec =
897
+ field === "createdAt"
898
+ ? Schema.DateFromMillis
899
+ : field === "id"
900
+ ? GeneratedId
901
+ : table.fields[field];
902
+ if (codec === undefined) throw new Error(`Unknown index field '${field}'.`);
903
+ // SAFETY: defineSchema only accepts service-free application field codecs.
904
+ return decodeIndexScalar(
905
+ Schema.encodeUnknownSync(codec as Schema.Codec<unknown, unknown, never>)(value),
906
+ );
907
+ };
908
+ const expectField = (field: string) => {
909
+ const expected = fields[position];
910
+ if (field !== expected) {
911
+ throw new Error(`Expected index field '${expected}', received '${field}'.`);
912
+ }
913
+ };
914
+ const builder: LocalIndexRange = {
915
+ eq: (field, value) => {
916
+ if (lower !== undefined || upper !== undefined) {
917
+ throw new Error("Equality cannot follow an index bound.");
918
+ }
919
+ expectField(field);
920
+ equal.push(encodeValue(field, value));
921
+ position += 1;
922
+ return builder;
923
+ },
924
+ gt: (field, value) => {
925
+ expectField(field);
926
+ if (lower !== undefined) throw new Error("An index range has one lower bound.");
927
+ lower = { value: encodeValue(field, value), inclusive: false };
928
+ return builder;
929
+ },
930
+ gte: (field, value) => {
931
+ expectField(field);
932
+ if (lower !== undefined) throw new Error("An index range has one lower bound.");
933
+ lower = { value: encodeValue(field, value), inclusive: true };
934
+ return builder;
935
+ },
936
+ lt: (field, value) => {
937
+ expectField(field);
938
+ if (upper !== undefined) throw new Error("An index range has one upper bound.");
939
+ upper = { value: encodeValue(field, value), inclusive: false };
940
+ return builder;
941
+ },
942
+ lte: (field, value) => {
943
+ expectField(field);
944
+ if (upper !== undefined) throw new Error("An index range has one upper bound.");
945
+ upper = { value: encodeValue(field, value), inclusive: true };
946
+ return builder;
947
+ },
948
+ };
949
+ buildRange(builder);
950
+ return databaseQuery(
951
+ tableName,
952
+ name,
953
+ encodeIndexRange(descriptors, equal, lower, upper),
954
+ order,
955
+ );
956
+ },
957
+ order: (direction) =>
958
+ databaseQuery(
959
+ tableName,
960
+ selectedIndex,
961
+ selectedBounds,
962
+ direction === "asc" ? "Asc" : "Desc",
963
+ ),
964
+ collect: () => resultFromEffect(run()),
965
+ take: (count) => resultFromEffect(run(validateLimit(count))),
966
+ first: () => resultFromEffect(run(1).pipe(Effect.map((documents) => documents[0]))),
967
+ unique: () =>
968
+ resultFromEffect(
969
+ run(2).pipe(
970
+ Effect.flatMap((documents) =>
971
+ documents.length > 1
972
+ ? Effect.die(new Error("A unique query matched more than one document."))
973
+ : Effect.succeed(documents[0]),
974
+ ),
975
+ ),
976
+ ),
977
+ paginate: (options) =>
978
+ resultFromEffect(
979
+ runPage(options).pipe(
980
+ Effect.map(({ documents, nextCursor }) => ({ items: documents, nextCursor })),
981
+ ),
982
+ ),
983
+ };
984
+ return Object.freeze(result);
985
+ };
986
+
987
+ return Object.freeze({
440
988
  find: (tableName: string, id: string) =>
441
989
  resultFromEffect(find(schema, tableName, id, dependencies).pipe(Effect.orDie)),
442
990
  get: (tableName: string, id: string) =>
@@ -450,32 +998,25 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
450
998
  ),
451
999
  ),
452
1000
  ),
453
- query: (tableName: string) =>
454
- Object.freeze({
455
- collect: () =>
456
- resultFromEffect(collect(schema, tableName, dependencies).pipe(Effect.orDie)),
457
- }),
1001
+ query: (tableName: string) => databaseQuery(tableName),
458
1002
  });
1003
+ };
459
1004
 
460
1005
  const makeWriter = (
461
1006
  schema: RuntimeSchemaDefinition,
462
- invalidations: DependencyRecorder,
1007
+ invalidations: DependencyRecorder<WriteInvalidation>,
463
1008
  ): LocalDatabaseWriter => {
464
1009
  const reader = makeReader(schema);
465
1010
  return Object.freeze({
466
1011
  ...reader,
467
1012
  delete: (tableName: string, id: string) =>
468
- resultFromEffect(
469
- deleteDocument(schema, tableName, id, invalidations).pipe(Effect.orDie),
470
- ),
1013
+ resultFromEffect(deleteDocument(schema, tableName, id, invalidations)),
471
1014
  insert: (tableName: string, value: RuntimeValue) =>
472
1015
  resultFromEffect(insert(schema, tableName, value, invalidations).pipe(Effect.orDie)),
473
1016
  patch: (tableName: string, id: string, value: RuntimeValue) =>
474
- resultFromEffect(patch(schema, tableName, id, value, invalidations).pipe(Effect.orDie)),
1017
+ resultFromEffect(patch(schema, tableName, id, value, invalidations)),
475
1018
  replace: (tableName: string, id: string, value: RuntimeValue) =>
476
- resultFromEffect(
477
- replace(schema, tableName, id, value, invalidations).pipe(Effect.orDie),
478
- ),
1019
+ resultFromEffect(replace(schema, tableName, id, value, invalidations)),
479
1020
  });
480
1021
  };
481
1022
 
@@ -485,7 +1026,8 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
485
1026
  ) =>
486
1027
  sql.withTransaction(
487
1028
  Effect.gen(function* () {
488
- const dependencies = makeDependencyRecorder();
1029
+ yield* prepareIndexes(schema).pipe(Effect.orDie);
1030
+ const dependencies = makeDependencyRecorder<ReadDependency>();
489
1031
  const context = Object.freeze({
490
1032
  db: makeReader(schema, dependencies),
491
1033
  });
@@ -502,7 +1044,8 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
502
1044
  ) =>
503
1045
  sql.withTransaction(
504
1046
  Effect.gen(function* () {
505
- const invalidations = makeDependencyRecorder();
1047
+ yield* prepareIndexes(schema).pipe(Effect.orDie);
1048
+ const invalidations = makeDependencyRecorder<WriteInvalidation>();
506
1049
  const context = Object.freeze({
507
1050
  db: makeWriter(schema, invalidations),
508
1051
  });