@pepys/testing 0.1.1 → 0.3.0
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/README.md +26 -0
- package/package.json +4 -4
- package/src/fact-index/collect-scope-facts.ts +5 -12
- package/src/fact-index/types.ts +2 -2
- package/src/fact-mode/apply-fact-operations.ts +8 -6
- package/src/links/read-link-rows.ts +0 -1
- package/src/links/remove-link.ts +12 -2
- package/src/links/write-link.ts +12 -4
- package/src/rows/build-key-filter.ts +0 -1
- package/src/rows/build-row-key.ts +2 -2
- package/src/rows/find-key-problem.ts +36 -0
- package/src/rows/find-row-problem.ts +5 -19
- package/src/rows/is-column-value.ts +23 -0
- package/src/rows/to-row-value.ts +0 -1
- package/src/rows/to-sql-value.ts +0 -1
- package/src/scope-replace/build-row-id.ts +0 -1
- package/src/scope-replace/pick-scope-changes.ts +5 -5
- package/src/scope-replace/read-scope-state.ts +2 -2
- package/src/scope-replace/replace-scope.ts +2 -2
- package/src/scope-replace/scope-replace-cases.json +81 -0
- package/src/scope-replace/types.ts +3 -2
- package/src/scope-replace/write-scope-changes.ts +2 -2
- package/src/store-database/types.ts +2 -2
- package/src/test-store/create-test-store.ts +19 -1
- package/src/test-store/run-consumers.ts +6 -5
- package/src/utils/compare-code-points.ts +19 -0
- package/src/utils/types.ts +3 -3
package/README.md
CHANGED
|
@@ -25,6 +25,32 @@ bun add -d @pepys/testing
|
|
|
25
25
|
|
|
26
26
|
Set the compiler options from the [kit's README](../kit/README.md#configuration).
|
|
27
27
|
|
|
28
|
+
Then test a query on rows written for the test:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { expect, test } from 'bun:test';
|
|
32
|
+
import type { EntityID } from '@pepys/kit';
|
|
33
|
+
import { createTestStore } from '@pepys/testing';
|
|
34
|
+
import { toTagged } from '@zgeoff/std';
|
|
35
|
+
import { shelfBookConsumer, shelfPlugin, shelfTitleQuery } from './shelf-plugin.ts';
|
|
36
|
+
|
|
37
|
+
test('it answers a query from rows written for the test', async () => {
|
|
38
|
+
await using store = await createTestStore({ plugins: [shelfPlugin] });
|
|
39
|
+
|
|
40
|
+
await store.writeRows(shelfBookConsumer, 'shelf_book', [
|
|
41
|
+
{ id: toTagged<EntityID>('shelf:book:atlas'), title: 'The Coral Atlas' },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(await store.query(shelfTitleQuery, { id: 'shelf:book:atlas' })).toStrictEqual({
|
|
45
|
+
title: 'The Coral Atlas',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bun test
|
|
52
|
+
```
|
|
53
|
+
|
|
28
54
|
## Features
|
|
29
55
|
|
|
30
56
|
- `createTestStore`: apply facts or write rows, then run a query, read rows and read links.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Test a pepys plug-in on Bun: an in-memory store that runs its consumers and queries",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pepys/ingest": "0.
|
|
23
|
-
"@pepys/kit": "0.
|
|
24
|
-
"@pepys/kysely-turso": "0.
|
|
22
|
+
"@pepys/ingest": "0.3.0",
|
|
23
|
+
"@pepys/kit": "0.3.0",
|
|
24
|
+
"@pepys/kysely-turso": "0.3.0",
|
|
25
25
|
"@tursodatabase/database": "0.8.3",
|
|
26
26
|
"kysely": "0.29.8",
|
|
27
27
|
"tiny-invariant": "1.3.3",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { FactEnvelope } from '@pepys/kit';
|
|
1
|
+
import type { EntityID, FactEnvelope } from '@pepys/kit';
|
|
2
|
+
import { compareCodePoints } from '../utils/compare-code-points.ts';
|
|
2
3
|
import type { FactIndex } from './types.ts';
|
|
3
4
|
|
|
4
5
|
// The read of one scope, as the host serves it: every fact of the entity on `streams`, one per fact
|
|
5
6
|
// ID, sorted by time, then stream, then sequence number.
|
|
6
7
|
export function collectScopeFacts(
|
|
7
8
|
index: FactIndex,
|
|
8
|
-
scope:
|
|
9
|
+
scope: EntityID,
|
|
9
10
|
streams: readonly string[],
|
|
10
11
|
): FactEnvelope<unknown>[] {
|
|
11
12
|
return [...index.firstCopies.values()]
|
|
@@ -13,8 +14,8 @@ export function collectScopeFacts(
|
|
|
13
14
|
.map((record) => ({ record, sortKey: toSortKey(record.envelope.at) }))
|
|
14
15
|
.toSorted(
|
|
15
16
|
(left, right) =>
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
compareCodePoints(left.sortKey, right.sortKey) ||
|
|
18
|
+
compareCodePoints(left.record.stream, right.record.stream) ||
|
|
18
19
|
left.record.seqNum - right.record.seqNum,
|
|
19
20
|
)
|
|
20
21
|
.map((entry) => entry.record.envelope);
|
|
@@ -32,11 +33,3 @@ function toSortKey(at: string): string {
|
|
|
32
33
|
|
|
33
34
|
return `${parts.groups['seconds']}.${fraction}${parts.groups['zone']}`;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
function compareText(left: string, right: string): number {
|
|
37
|
-
if (left === right) {
|
|
38
|
-
return 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return left < right ? -1 : 1;
|
|
42
|
-
}
|
package/src/fact-index/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FactEnvelope } from '@pepys/kit';
|
|
1
|
+
import type { FactEnvelope, FactID } from '@pepys/kit';
|
|
2
2
|
|
|
3
3
|
// One record of the test store's log: a fact in its stream, at its sequence number.
|
|
4
4
|
export interface LoggedFact {
|
|
@@ -11,5 +11,5 @@ export interface LoggedFact {
|
|
|
11
11
|
// fact ID, and a module reads synchronously, so the test store keeps the index out of the database.
|
|
12
12
|
export interface FactIndex {
|
|
13
13
|
log: readonly LoggedFact[];
|
|
14
|
-
firstCopies: ReadonlyMap<
|
|
14
|
+
firstCopies: ReadonlyMap<FactID, LoggedFact>;
|
|
15
15
|
}
|
|
@@ -6,6 +6,7 @@ import { removeLink } from '../links/remove-link.ts';
|
|
|
6
6
|
import { writeLink } from '../links/write-link.ts';
|
|
7
7
|
import type { Operation } from '../operations/types.ts';
|
|
8
8
|
import { buildRowKey } from '../rows/build-row-key.ts';
|
|
9
|
+
import { findKeyProblem } from '../rows/find-key-problem.ts';
|
|
9
10
|
import { findRowProblem } from '../rows/find-row-problem.ts';
|
|
10
11
|
import { removeRow } from '../rows/remove-row.ts';
|
|
11
12
|
import { writeRow } from '../rows/write-row.ts';
|
|
@@ -28,12 +29,13 @@ export async function applyFactOperations(
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
if (operation.verb === 'upsert') {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
operation.
|
|
36
|
-
|
|
32
|
+
if (operation.verb === 'upsert' || operation.verb === 'delete') {
|
|
33
|
+
const table = getTable(consumer, operation.table);
|
|
34
|
+
|
|
35
|
+
const reason =
|
|
36
|
+
operation.verb === 'upsert'
|
|
37
|
+
? findRowProblem(table, operation.row, operation.inexact)
|
|
38
|
+
: findKeyProblem(table, operation.key, operation.inexact);
|
|
37
39
|
|
|
38
40
|
if (reason !== undefined) {
|
|
39
41
|
throw new ApplyError({
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { LinkRow } from '@pepys/kit';
|
|
2
2
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
3
3
|
|
|
4
|
-
// Every link, or every link of one consumer, in the order of its key.
|
|
5
4
|
export function readLinkRows(db: StoreDatabase, origin?: string): Promise<LinkRow[]> {
|
|
6
5
|
const links = db.selectFrom('link').selectAll();
|
|
7
6
|
|
package/src/links/remove-link.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
+
import { isEntityID } from '@pepys/kit';
|
|
1
2
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
2
3
|
import type { WireLink } from '../utils/types.ts';
|
|
3
4
|
|
|
5
|
+
// No check runs on an unlink, and a stored link always joins two entity IDs, so an end that is no
|
|
6
|
+
// entity ID matches no link.
|
|
4
7
|
export async function removeLink(
|
|
5
8
|
db: StoreDatabase,
|
|
6
9
|
origin: string,
|
|
7
10
|
link: Pick<WireLink, 'type' | 'from' | 'to'>,
|
|
8
11
|
): Promise<void> {
|
|
12
|
+
const from = link.from;
|
|
13
|
+
const to = link.to;
|
|
14
|
+
|
|
15
|
+
if (!isEntityID(from) || !isEntityID(to)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
await db
|
|
10
20
|
.deleteFrom('link')
|
|
11
21
|
.where('origin', '=', origin)
|
|
12
22
|
.where('type', '=', link.type)
|
|
13
|
-
.where('from_id', '=',
|
|
14
|
-
.where('to_id', '=',
|
|
23
|
+
.where('from_id', '=', from)
|
|
24
|
+
.where('to_id', '=', to)
|
|
15
25
|
.execute();
|
|
16
26
|
}
|
package/src/links/write-link.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
import { isEntityID } from '@pepys/kit';
|
|
2
|
+
import type { EntityID } from '@pepys/kit';
|
|
3
|
+
import invariant from 'tiny-invariant';
|
|
1
4
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
2
5
|
import type { WireLink } from '../utils/types.ts';
|
|
3
6
|
|
|
4
7
|
// Writes the link of `origin`, the consumer's name, with the scope that owns it, or null in fact
|
|
5
|
-
// mode.
|
|
8
|
+
// mode. The link has passed the link check.
|
|
6
9
|
export async function writeLink(
|
|
7
10
|
db: StoreDatabase,
|
|
8
11
|
origin: string,
|
|
9
|
-
scope:
|
|
12
|
+
scope: EntityID | null,
|
|
10
13
|
link: WireLink,
|
|
11
14
|
): Promise<void> {
|
|
15
|
+
const from = link.from;
|
|
16
|
+
const to = link.to;
|
|
17
|
+
|
|
18
|
+
invariant(isEntityID(from) && isEntityID(to), 'a checked link joins two entity IDs');
|
|
19
|
+
|
|
12
20
|
await db
|
|
13
21
|
.replaceInto('link')
|
|
14
22
|
.values({
|
|
15
23
|
type: link.type,
|
|
16
|
-
from_id:
|
|
17
|
-
to_id:
|
|
24
|
+
from_id: from,
|
|
25
|
+
to_id: to,
|
|
18
26
|
scope,
|
|
19
27
|
origin,
|
|
20
28
|
confidence: link.confidence,
|
|
@@ -3,7 +3,6 @@ import { sql } from 'kysely';
|
|
|
3
3
|
import type { RawBuilder } from 'kysely';
|
|
4
4
|
import { toSQLValue } from './to-sql-value.ts';
|
|
5
5
|
|
|
6
|
-
// The `where` clause of one row, from the JSON text of its key's values.
|
|
7
6
|
export function buildKeyFilter(table: TableDeclaration, key: string): RawBuilder<unknown> {
|
|
8
7
|
const parsed: unknown = JSON.parse(key);
|
|
9
8
|
const values: readonly unknown[] = Array.isArray(parsed) ? parsed : [];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { TableDeclaration } from '@pepys/kit';
|
|
2
2
|
import type { Row } from '../utils/types.ts';
|
|
3
3
|
|
|
4
|
-
// The key of a row as the host writes it in `scope_row` and the change log
|
|
5
|
-
//
|
|
4
|
+
// The key of a row as the host writes it in `scope_row` and the change log, for a key of one column
|
|
5
|
+
// and of several. The host sorts an object's keys, so a key that holds an object can differ.
|
|
6
6
|
export function buildRowKey(table: TableDeclaration, row: Row): string {
|
|
7
7
|
return JSON.stringify(table.primaryKey.map((column) => row[column] ?? null));
|
|
8
8
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import invariant from 'tiny-invariant';
|
|
3
|
+
import type { Row } from '../utils/types.ts';
|
|
4
|
+
import { isColumnValue } from './is-column-value.ts';
|
|
5
|
+
|
|
6
|
+
// The host's check of a delete's key, in the host's order: no column outside the primary key, each
|
|
7
|
+
// key value present fit for its column, then each key column present.
|
|
8
|
+
export function findKeyProblem(
|
|
9
|
+
table: TableDeclaration,
|
|
10
|
+
key: Row,
|
|
11
|
+
inexact: ReadonlySet<string>,
|
|
12
|
+
): string | undefined {
|
|
13
|
+
const outside = Object.keys(key).find((name) => !table.primaryKey.includes(name));
|
|
14
|
+
|
|
15
|
+
if (outside !== undefined) {
|
|
16
|
+
return `${outside} is not a key column`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const present = table.primaryKey.filter((name) => Object.hasOwn(key, name));
|
|
20
|
+
|
|
21
|
+
const unfit = present.find((name) => {
|
|
22
|
+
const column = table.columns[name];
|
|
23
|
+
|
|
24
|
+
invariant(column, 'the deployment check refuses a key column that the table lacks');
|
|
25
|
+
|
|
26
|
+
return !isColumnValue(column, key[name], inexact.has(name));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (unfit !== undefined) {
|
|
30
|
+
return `the column ${unfit} holds ${JSON.stringify(key[unfit])}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const absent = table.primaryKey.find((name) => !Object.hasOwn(key, name));
|
|
34
|
+
|
|
35
|
+
return absent === undefined ? undefined : `the key column ${absent} is absent`;
|
|
36
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
-
import { match } from 'ts-pattern';
|
|
3
2
|
import type { Row } from '../utils/types.ts';
|
|
3
|
+
import { isColumnValue } from './is-column-value.ts';
|
|
4
4
|
|
|
5
|
-
// The host's check of a row: every column present, no other key, each value
|
|
6
|
-
//
|
|
7
|
-
// wrote with a fraction or an exponent, such as `1.0`: an integer column refuses those.
|
|
5
|
+
// The host's check of a row: every column present, no other key, and each value fit for its
|
|
6
|
+
// column. `inexact` names the columns whose number the module wrote with a fraction or an exponent.
|
|
8
7
|
export function findRowProblem(
|
|
9
8
|
table: TableDeclaration,
|
|
10
9
|
row: Row,
|
|
@@ -21,21 +20,8 @@ export function findRowProblem(
|
|
|
21
20
|
return `the column ${name} is absent`;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const fits =
|
|
27
|
-
value === null
|
|
28
|
-
? column.nullable === true
|
|
29
|
-
: match(column.kind)
|
|
30
|
-
.with('text', 'entityID', 'timestamp', () => typeof value === 'string')
|
|
31
|
-
.with('boolean', () => typeof value === 'boolean')
|
|
32
|
-
.with('json', () => true)
|
|
33
|
-
.with('real', () => typeof value === 'number')
|
|
34
|
-
.with('integer', () => Number.isSafeInteger(value) && !inexact.has(name))
|
|
35
|
-
.exhaustive();
|
|
36
|
-
|
|
37
|
-
if (!fits) {
|
|
38
|
-
return `the column ${name} holds ${JSON.stringify(value)}`;
|
|
23
|
+
if (!isColumnValue(column, row[name], inexact.has(name))) {
|
|
24
|
+
return `the column ${name} holds ${JSON.stringify(row[name])}`;
|
|
39
25
|
}
|
|
40
26
|
}
|
|
41
27
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ColumnDeclaration } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
|
|
4
|
+
// The host's check of one value against its column: of the column's kind, and null only where the
|
|
5
|
+
// column is nullable. `isInexact` marks a number the module wrote with a fraction or an exponent,
|
|
6
|
+
// such as `1.0`, which an integer column refuses.
|
|
7
|
+
export function isColumnValue(
|
|
8
|
+
column: ColumnDeclaration,
|
|
9
|
+
value: unknown,
|
|
10
|
+
isInexact: boolean,
|
|
11
|
+
): boolean {
|
|
12
|
+
if (value === null) {
|
|
13
|
+
return column.nullable === true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return match(column.kind)
|
|
17
|
+
.with('text', 'entityID', 'timestamp', () => typeof value === 'string')
|
|
18
|
+
.with('boolean', () => typeof value === 'boolean')
|
|
19
|
+
.with('json', () => true)
|
|
20
|
+
.with('real', () => typeof value === 'number')
|
|
21
|
+
.with('integer', () => Number.isSafeInteger(value) && !isInexact)
|
|
22
|
+
.exhaustive();
|
|
23
|
+
}
|
package/src/rows/to-row-value.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ColumnDeclaration } from '@pepys/kit';
|
|
2
2
|
import { match } from 'ts-pattern';
|
|
3
3
|
|
|
4
|
-
// A stored value as the module wrote it: 0 or 1 back to a boolean, a JSON column's text parsed.
|
|
5
4
|
export function toRowValue(column: ColumnDeclaration, stored: unknown): unknown {
|
|
6
5
|
if (stored === null || stored === undefined) {
|
|
7
6
|
return null;
|
package/src/rows/to-sql-value.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ColumnDeclaration } from '@pepys/kit';
|
|
2
2
|
import { match } from 'ts-pattern';
|
|
3
3
|
|
|
4
|
-
// A value of a checked row as SQLite stores it: a boolean as 0 or 1, a JSON column as its text.
|
|
5
4
|
export function toSQLValue(column: ColumnDeclaration, value: unknown): string | number | null {
|
|
6
5
|
if (value === null || value === undefined) {
|
|
7
6
|
return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Result } from '@pepys/kit';
|
|
1
|
+
import type { EntityID, Result } from '@pepys/kit';
|
|
2
|
+
import { compareCodePoints } from '../utils/compare-code-points.ts';
|
|
2
3
|
import type { ApplyFailure, ChangeEntry, WireLink } from '../utils/types.ts';
|
|
3
4
|
import { buildLinkKey } from './build-link-key.ts';
|
|
4
5
|
import { buildRowID } from './build-row-id.ts';
|
|
@@ -9,7 +10,7 @@ import type { KeyedRow, ScopeChanges, ScopeResult, ScopeState } from './types.ts
|
|
|
9
10
|
// entries come as rows written, links written, rows deleted, links deleted, each in key order.
|
|
10
11
|
export function pickScopeChanges(
|
|
11
12
|
consumer: string,
|
|
12
|
-
scope:
|
|
13
|
+
scope: EntityID,
|
|
13
14
|
result: ScopeResult,
|
|
14
15
|
state: ScopeState,
|
|
15
16
|
): Result<ScopeChanges, ApplyFailure> {
|
|
@@ -74,13 +75,12 @@ export function pickScopeChanges(
|
|
|
74
75
|
return { ok: true, value: { writtenRows, writtenLinks, deletedRows, deletedLinks, entries } };
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
// Plain `<` on the parts, as Rust's ordered maps and SQLite compare text: never a locale order.
|
|
78
78
|
function compareKeys(left: readonly string[], right: readonly string[]): number {
|
|
79
79
|
for (const [index, part] of left.entries()) {
|
|
80
80
|
const other = right[index] ?? '';
|
|
81
81
|
|
|
82
82
|
if (part !== other) {
|
|
83
|
-
return part
|
|
83
|
+
return compareCodePoints(part, other);
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -89,7 +89,7 @@ function compareKeys(left: readonly string[], right: readonly string[]): number
|
|
|
89
89
|
|
|
90
90
|
function findConflict(
|
|
91
91
|
consumer: string,
|
|
92
|
-
scope:
|
|
92
|
+
scope: EntityID,
|
|
93
93
|
result: ScopeResult,
|
|
94
94
|
state: ScopeState,
|
|
95
95
|
): ApplyFailure | undefined {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
1
|
+
import type { ConsumerDeclaration, EntityID } from '@pepys/kit';
|
|
2
2
|
import { readLinkRows } from '../links/read-link-rows.ts';
|
|
3
3
|
import { readTableRows } from '../rows/read-table-rows.ts';
|
|
4
4
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
@@ -10,7 +10,7 @@ import type { ScopeResult, ScopeState, StoredLink } from './types.ts';
|
|
|
10
10
|
export async function readScopeState(
|
|
11
11
|
db: StoreDatabase,
|
|
12
12
|
consumer: ConsumerDeclaration,
|
|
13
|
-
scope:
|
|
13
|
+
scope: EntityID,
|
|
14
14
|
result: ScopeResult,
|
|
15
15
|
): Promise<ScopeState> {
|
|
16
16
|
const owned = await db
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
1
|
+
import type { ConsumerDeclaration, EntityID } from '@pepys/kit';
|
|
2
2
|
import { parseOperations } from '../operations/parse-operations.ts';
|
|
3
3
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
4
4
|
import { ApplyError } from '../utils/apply-error.ts';
|
|
@@ -13,7 +13,7 @@ import { writeScopeChanges } from './write-scope-changes.ts';
|
|
|
13
13
|
export async function replaceScope(
|
|
14
14
|
db: StoreDatabase,
|
|
15
15
|
consumer: ConsumerDeclaration,
|
|
16
|
-
scope:
|
|
16
|
+
scope: EntityID,
|
|
17
17
|
output: string,
|
|
18
18
|
): Promise<readonly ChangeEntry[]> {
|
|
19
19
|
const operations = parseOperations(consumer, output);
|
|
@@ -1740,6 +1740,87 @@
|
|
|
1740
1740
|
"ledger_tag": []
|
|
1741
1741
|
},
|
|
1742
1742
|
"links": []
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
"title": "the change entries come in code point order, so a key above U+FFFF sorts after a key at U+E000",
|
|
1746
|
+
"batches": [
|
|
1747
|
+
{
|
|
1748
|
+
"scopes": [
|
|
1749
|
+
{
|
|
1750
|
+
"scope": "ledger:account:a",
|
|
1751
|
+
"output": "[{\"upsert\":{\"ledger_entry\":{\"id\":\"ledger:entry:\\ud800\\udc00\",\"account_id\":\"ledger:account:a\",\"amount\":5,\"rate\":1.25,\"memo\":\"Rope\",\"details\":{\"till\":\"north\"},\"posted_at\":\"2026-11-01T09:00:00.000Z\"}}},{\"upsert\":{\"ledger_entry\":{\"id\":\"ledger:entry:\\ue000\",\"account_id\":\"ledger:account:a\",\"amount\":5,\"rate\":1.25,\"memo\":\"Rope\",\"details\":{\"till\":\"north\"},\"posted_at\":\"2026-11-01T09:00:00.000Z\"}}}]"
|
|
1752
|
+
}
|
|
1753
|
+
],
|
|
1754
|
+
"changes": [
|
|
1755
|
+
{
|
|
1756
|
+
"type": "ledger_entry",
|
|
1757
|
+
"key": "[\"ledger:entry:\"]",
|
|
1758
|
+
"value": {
|
|
1759
|
+
"id": "ledger:entry:",
|
|
1760
|
+
"account_id": "ledger:account:a",
|
|
1761
|
+
"amount": 5,
|
|
1762
|
+
"rate": 1.25,
|
|
1763
|
+
"memo": "Rope",
|
|
1764
|
+
"details": {
|
|
1765
|
+
"till": "north"
|
|
1766
|
+
},
|
|
1767
|
+
"posted_at": "2026-11-01T09:00:00.000Z"
|
|
1768
|
+
},
|
|
1769
|
+
"headers": {
|
|
1770
|
+
"operation": "insert"
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
"type": "ledger_entry",
|
|
1775
|
+
"key": "[\"ledger:entry:𐀀\"]",
|
|
1776
|
+
"value": {
|
|
1777
|
+
"id": "ledger:entry:𐀀",
|
|
1778
|
+
"account_id": "ledger:account:a",
|
|
1779
|
+
"amount": 5,
|
|
1780
|
+
"rate": 1.25,
|
|
1781
|
+
"memo": "Rope",
|
|
1782
|
+
"details": {
|
|
1783
|
+
"till": "north"
|
|
1784
|
+
},
|
|
1785
|
+
"posted_at": "2026-11-01T09:00:00.000Z"
|
|
1786
|
+
},
|
|
1787
|
+
"headers": {
|
|
1788
|
+
"operation": "insert"
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
]
|
|
1792
|
+
}
|
|
1793
|
+
],
|
|
1794
|
+
"rows": {
|
|
1795
|
+
"ledger_account": [],
|
|
1796
|
+
"ledger_entry": [
|
|
1797
|
+
{
|
|
1798
|
+
"id": "ledger:entry:",
|
|
1799
|
+
"account_id": "ledger:account:a",
|
|
1800
|
+
"amount": 5,
|
|
1801
|
+
"rate": 1.25,
|
|
1802
|
+
"memo": "Rope",
|
|
1803
|
+
"details": {
|
|
1804
|
+
"till": "north"
|
|
1805
|
+
},
|
|
1806
|
+
"posted_at": "2026-11-01T09:00:00.000Z"
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
"id": "ledger:entry:𐀀",
|
|
1810
|
+
"account_id": "ledger:account:a",
|
|
1811
|
+
"amount": 5,
|
|
1812
|
+
"rate": 1.25,
|
|
1813
|
+
"memo": "Rope",
|
|
1814
|
+
"details": {
|
|
1815
|
+
"till": "north"
|
|
1816
|
+
},
|
|
1817
|
+
"posted_at": "2026-11-01T09:00:00.000Z"
|
|
1818
|
+
}
|
|
1819
|
+
],
|
|
1820
|
+
"ledger_rate": [],
|
|
1821
|
+
"ledger_tag": []
|
|
1822
|
+
},
|
|
1823
|
+
"links": []
|
|
1743
1824
|
}
|
|
1744
1825
|
]
|
|
1745
1826
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EntityID } from '@pepys/kit';
|
|
1
2
|
import type { ChangeEntry, Row, WireLink } from '../utils/types.ts';
|
|
2
3
|
|
|
3
4
|
export interface KeyedRow {
|
|
@@ -13,7 +14,7 @@ export interface ScopeResult {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface StoredLink {
|
|
16
|
-
scope:
|
|
17
|
+
scope: EntityID | null;
|
|
17
18
|
link: WireLink;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -21,7 +22,7 @@ export interface StoredLink {
|
|
|
21
22
|
// `buildRowID`, links by the JSON text of their key.
|
|
22
23
|
export interface ScopeState {
|
|
23
24
|
// the scope of each row that a scope owns
|
|
24
|
-
rowOwners: ReadonlyMap<string,
|
|
25
|
+
rowOwners: ReadonlyMap<string, EntityID>;
|
|
25
26
|
|
|
26
27
|
// the stored row of each key that the result names or that the scope owns
|
|
27
28
|
storedRows: ReadonlyMap<string, Row>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
1
|
+
import type { ConsumerDeclaration, EntityID } from '@pepys/kit';
|
|
2
2
|
import { removeLink } from '../links/remove-link.ts';
|
|
3
3
|
import { writeLink } from '../links/write-link.ts';
|
|
4
4
|
import { removeRow } from '../rows/remove-row.ts';
|
|
@@ -9,7 +9,7 @@ import type { ScopeChanges } from './types.ts';
|
|
|
9
9
|
export async function writeScopeChanges(
|
|
10
10
|
db: StoreDatabase,
|
|
11
11
|
consumer: ConsumerDeclaration,
|
|
12
|
-
scope:
|
|
12
|
+
scope: EntityID,
|
|
13
13
|
changes: ScopeChanges,
|
|
14
14
|
): Promise<void> {
|
|
15
15
|
for (const row of changes.writtenRows) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LinkRow, SharedViews } from '@pepys/kit';
|
|
1
|
+
import type { EntityID, LinkRow, SharedViews } from '@pepys/kit';
|
|
2
2
|
import type { Kysely } from 'kysely';
|
|
3
3
|
|
|
4
4
|
// The tables that the test store keeps beside the consumers' own. The host keeps `link` and
|
|
@@ -6,7 +6,7 @@ import type { Kysely } from 'kysely';
|
|
|
6
6
|
// consumer's name.
|
|
7
7
|
export interface StoreTables extends SharedViews {
|
|
8
8
|
link: LinkRow;
|
|
9
|
-
scope_row: { consumer: string; table_name: string; key: string; scope:
|
|
9
|
+
scope_row: { consumer: string; table_name: string; key: string; scope: EntityID };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export type StoreDatabase = Kysely<StoreTables>;
|
|
@@ -50,7 +50,7 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
|
|
|
50
50
|
return table;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
const
|
|
53
|
+
const applyInTurn = async (records: Parameters<StoreHooks['applyEnvelopes']>[0]) => {
|
|
54
54
|
const next = buildFactIndex(index, records);
|
|
55
55
|
|
|
56
56
|
applied = await db
|
|
@@ -62,6 +62,24 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
|
|
|
62
62
|
index = next.index;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
// Each apply builds on the index and applied IDs that the one before it left, so two applies
|
|
66
|
+
// that run at once take turns. A refused apply rejects for its caller only.
|
|
67
|
+
let lastApply: Promise<void> = Promise.resolve();
|
|
68
|
+
|
|
69
|
+
const applyEnvelopes: StoreHooks['applyEnvelopes'] = async (records) => {
|
|
70
|
+
const before = lastApply;
|
|
71
|
+
const turn = Promise.withResolvers<void>();
|
|
72
|
+
|
|
73
|
+
lastApply = turn.promise;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await before;
|
|
77
|
+
await applyInTurn(records);
|
|
78
|
+
} finally {
|
|
79
|
+
turn.resolve();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
65
83
|
const testStore: TestStore = {
|
|
66
84
|
writeRows: async (_consumer, table, rows) => {
|
|
67
85
|
for (const row of rows) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseEntityID } from '@pepys/kit';
|
|
2
|
-
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
2
|
+
import type { ConsumerDeclaration, FactID } from '@pepys/kit';
|
|
3
3
|
import invariant from 'tiny-invariant';
|
|
4
4
|
import { buildReadFunctions } from '../consumer-entries/build-read-functions.ts';
|
|
5
5
|
import { runEntry } from '../consumer-entries/run-entry.ts';
|
|
@@ -11,9 +11,10 @@ import { parseOperations } from '../operations/parse-operations.ts';
|
|
|
11
11
|
import { replaceScope } from '../scope-replace/replace-scope.ts';
|
|
12
12
|
import type { StoreDatabase } from '../store-database/types.ts';
|
|
13
13
|
import { ApplyError } from '../utils/apply-error.ts';
|
|
14
|
+
import { compareCodePoints } from '../utils/compare-code-points.ts';
|
|
14
15
|
|
|
15
16
|
// The fact IDs each fact-mode consumer has applied, by consumer name.
|
|
16
|
-
export type AppliedFactIDs = ReadonlyMap<string, ReadonlySet<
|
|
17
|
+
export type AppliedFactIDs = ReadonlyMap<string, ReadonlySet<FactID>>;
|
|
17
18
|
|
|
18
19
|
interface ConsumerRun {
|
|
19
20
|
index: FactIndex;
|
|
@@ -56,8 +57,8 @@ async function runFactConsumer(
|
|
|
56
57
|
db: StoreDatabase,
|
|
57
58
|
consumer: ConsumerDeclaration,
|
|
58
59
|
facts: readonly LoggedFact[],
|
|
59
|
-
applied: ReadonlySet<
|
|
60
|
-
): Promise<ReadonlySet<
|
|
60
|
+
applied: ReadonlySet<FactID>,
|
|
61
|
+
): Promise<ReadonlySet<FactID>> {
|
|
61
62
|
const seen = new Set(applied);
|
|
62
63
|
|
|
63
64
|
for (const record of facts) {
|
|
@@ -104,7 +105,7 @@ async function runScopeConsumer(
|
|
|
104
105
|
return parsed.ok && `${parsed.value.source}:${parsed.value.kind}` === scopeKind;
|
|
105
106
|
}),
|
|
106
107
|
),
|
|
107
|
-
].toSorted(
|
|
108
|
+
].toSorted(compareCodePoints);
|
|
108
109
|
|
|
109
110
|
for (const scope of scopes) {
|
|
110
111
|
const scopeFacts = JSON.stringify(collectScopeFacts(index, scope, consumer.input.streams));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The host's Rust strings and SQLite's binary collation order text by code point, but `<` on
|
|
2
|
+
// JavaScript strings compares UTF-16 code units, which puts text above U+FFFF before U+E000.
|
|
3
|
+
export function compareCodePoints(left: string, right: string): number {
|
|
4
|
+
const length = Math.min(left.length, right.length);
|
|
5
|
+
let index = 0;
|
|
6
|
+
|
|
7
|
+
while (index < length) {
|
|
8
|
+
const leftPoint = left.codePointAt(index) ?? 0;
|
|
9
|
+
const rightPoint = right.codePointAt(index) ?? 0;
|
|
10
|
+
|
|
11
|
+
if (leftPoint !== rightPoint) {
|
|
12
|
+
return leftPoint - rightPoint;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
index += leftPoint > 0xff_ff ? 2 : 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return left.length - right.length;
|
|
19
|
+
}
|
package/src/utils/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LinkProblem } from '@pepys/kit';
|
|
1
|
+
import type { EntityID, LinkProblem } from '@pepys/kit';
|
|
2
2
|
|
|
3
3
|
// Why the test store refused an apply. The kinds from `bad-operation` on are the host's refusals
|
|
4
4
|
// of a module's output, which the scope replace case file names.
|
|
@@ -18,10 +18,10 @@ export type ApplyFailure =
|
|
|
18
18
|
| {
|
|
19
19
|
kind: 'scope-conflict';
|
|
20
20
|
consumer: string;
|
|
21
|
-
scope:
|
|
21
|
+
scope: EntityID;
|
|
22
22
|
table: string;
|
|
23
23
|
key: string;
|
|
24
|
-
owner:
|
|
24
|
+
owner: EntityID | null;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// A row as the module writes it and as the test store reads it back: booleans as booleans and a
|