ignotum 0.0.6 → 0.0.7

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