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.
- package/dist/cli/bin.mjs +663 -38
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/runtime/{api-D2bsAz4T.d.ts → api-Dp4J-xt-.d.ts} +2 -3
- package/dist/runtime/client.d.ts +20 -3
- package/dist/runtime/client.js +197 -20
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/{descriptor-t6BOEGw9-BTHR-ZMv.js → descriptor-t6BOEGw9-C1rwYIlx.js} +29 -2
- package/dist/runtime/descriptor-t6BOEGw9-C1rwYIlx.js.map +1 -0
- package/dist/runtime/{result-BgcHn25t.d.ts → id-Btwac71X-DhnKYsjY.d.ts} +8 -2
- package/dist/runtime/{index-Dl_VQGmA.d.ts → index-B5KSOjGN.d.ts} +60 -69
- package/dist/runtime/internal/api.d.ts +1 -1
- package/dist/runtime/internal/host.d.ts +20 -4
- package/dist/runtime/internal/host.js +190 -7
- package/dist/runtime/internal/host.js.map +1 -1
- package/dist/runtime/internal/server.d.ts +1 -1
- package/dist/runtime/internal/server.js +1 -1
- package/dist/runtime/internal/types.d.ts +1 -1
- package/dist/runtime/internal/types.js +1 -1
- package/dist/runtime/pagination-B1BzNkh8-BUSTbeSg.d.ts +80 -0
- package/dist/runtime/pagination-BKPko9Hm.d.ts +1 -0
- package/dist/runtime/{schema-B-jMZEbs.js → schema-D9RmboaS.js} +93 -29
- package/dist/runtime/schema-D9RmboaS.js.map +1 -0
- package/dist/runtime/server.d.ts +2 -2
- package/dist/runtime/server.js +2 -2
- package/package.json +4 -4
- package/src/cli/agent-files.ts +4 -0
- package/src/client/hooks.ts +164 -2
- package/src/client/index.ts +2 -1
- package/src/dev-runtime/database.ts +568 -37
- package/src/dev-runtime/migrations.ts +30 -0
- package/dist/runtime/descriptor-t6BOEGw9-BTHR-ZMv.js.map +0 -1
- package/dist/runtime/id-Btwac71X-B9OeBzvq.d.ts +0 -9
- 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
|
-
|
|
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 {
|
|
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<
|
|
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<
|
|
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
|
|
132
|
-
readonly
|
|
133
|
-
readonly
|
|
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 =
|
|
137
|
-
|
|
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
|
|
528
|
+
const queryDocuments = Effect.fn("LocalDatabase.queryDocuments")(function* (
|
|
342
529
|
schema: RuntimeSchemaDefinition,
|
|
343
530
|
tableName: string,
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|