@pepys/testing 0.1.0 → 0.2.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 +3 -0
- package/package.json +4 -3
- package/src/fact-index/collect-scope-facts.ts +3 -10
- package/src/fact-log/apply-fact-log.ts +58 -0
- package/src/fact-mode/apply-fact-operations.ts +8 -6
- package/src/index.ts +1 -0
- package/src/links/read-link-rows.ts +0 -1
- 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 +2 -2
- package/src/scope-replace/scope-replace-cases.json +81 -0
- package/src/test-store/create-test-store.ts +38 -10
- package/src/test-store/run-consumers.ts +2 -1
- package/src/test-store/store-hooks.ts +16 -0
- package/src/utils/compare-code-points.ts +19 -0
package/README.md
CHANGED
|
@@ -28,6 +28,9 @@ Set the compiler options from the [kit's README](../kit/README.md#configuration)
|
|
|
28
28
|
## Features
|
|
29
29
|
|
|
30
30
|
- `createTestStore`: apply facts or write rows, then run a query, read rows and read links.
|
|
31
|
+
- `applyFactLog`: apply the facts that `createIngest` of `@pepys/ingest` stored in a memory fact log
|
|
32
|
+
to a test store, in place of the host. A second call applies only the records the log stored after
|
|
33
|
+
the first.
|
|
31
34
|
- `applyInBothOrders`: apply facts in two orders and compare the stores.
|
|
32
35
|
- The memory adapter of the store reader, for a test of a core query.
|
|
33
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,8 +19,9 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pepys/
|
|
23
|
-
"@pepys/
|
|
22
|
+
"@pepys/ingest": "0.2.0",
|
|
23
|
+
"@pepys/kit": "0.2.0",
|
|
24
|
+
"@pepys/kysely-turso": "0.2.0",
|
|
24
25
|
"@tursodatabase/database": "0.8.3",
|
|
25
26
|
"kysely": "0.29.8",
|
|
26
27
|
"tiny-invariant": "1.3.3",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { 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
|
|
@@ -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
|
-
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { MemoryFactLog } from '@pepys/ingest';
|
|
2
|
+
import { toStreamName, toStreamOffset } from '@pepys/kit';
|
|
3
|
+
import type { CheckedDeployment, FactEnvelope, StreamName } from '@pepys/kit';
|
|
4
|
+
import invariant from 'tiny-invariant';
|
|
5
|
+
import { storeHooks } from '../test-store/store-hooks.ts';
|
|
6
|
+
import type { TestStore } from '../test-store/types.ts';
|
|
7
|
+
|
|
8
|
+
// The host's part for a test: the records of every stream a consumer reads go to the store as one
|
|
9
|
+
// batch, whole streams in the order the consumers name them. A refusal throws an ApplyError and
|
|
10
|
+
// moves no offset, so the next call reads the same records again.
|
|
11
|
+
export async function applyFactLog(
|
|
12
|
+
store: TestStore,
|
|
13
|
+
log: MemoryFactLog,
|
|
14
|
+
deployment: CheckedDeployment,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const hooks = storeHooks.get(store);
|
|
17
|
+
|
|
18
|
+
if (hooks === undefined) {
|
|
19
|
+
throw new Error('the store is not the object that createTestStore returned');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const streams = new Set(deployment.consumers.flatMap((consumer) => consumer.input.streams));
|
|
23
|
+
|
|
24
|
+
const records: { stream: StreamName; envelope: FactEnvelope<unknown> }[] = [];
|
|
25
|
+
|
|
26
|
+
const tails = new Map<StreamName, number>();
|
|
27
|
+
|
|
28
|
+
for (const declared of streams) {
|
|
29
|
+
const stream = toStreamName(declared);
|
|
30
|
+
const from = hooks.nextOffsets.get(stream) ?? 0;
|
|
31
|
+
|
|
32
|
+
const tail = await log.readTail(stream);
|
|
33
|
+
|
|
34
|
+
if (!tail.ok) {
|
|
35
|
+
throw new Error(`the fact log has no stream ${declared}, which a consumer reads`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (tail.value === from) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const read = await log.read(stream, toStreamOffset(from), tail.value - from);
|
|
43
|
+
|
|
44
|
+
invariant(read.ok, 'a stream with a tail can be read');
|
|
45
|
+
|
|
46
|
+
for (const stored of read.value) {
|
|
47
|
+
records.push({ stream, envelope: stored.fact });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
tails.set(stream, tail.value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await hooks.applyEnvelopes(records);
|
|
54
|
+
|
|
55
|
+
for (const [stream, tail] of tails) {
|
|
56
|
+
hooks.nextOffsets.set(stream, tail);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -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({
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { applyFactLog } from './fact-log/apply-fact-log.ts';
|
|
1
2
|
export { applyInBothOrders } from './orders/apply-in-both-orders.ts';
|
|
2
3
|
export type { OrderReport, StoreSnapshot } from './orders/apply-in-both-orders.ts';
|
|
3
4
|
export type { MemoryStoreReader } from './memory-store-reader/create-memory-store-reader.ts';
|
|
@@ -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
|
|
|
@@ -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
1
|
import type { 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';
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -11,6 +11,8 @@ import { ApplyError } from '../utils/apply-error.ts';
|
|
|
11
11
|
import { parseAppliedFacts } from './parse-applied-facts.ts';
|
|
12
12
|
import { runConsumers } from './run-consumers.ts';
|
|
13
13
|
import type { AppliedFactIDs } from './run-consumers.ts';
|
|
14
|
+
import { storeHooks } from './store-hooks.ts';
|
|
15
|
+
import type { StoreHooks } from './store-hooks.ts';
|
|
14
16
|
import type { TestStore } from './types.ts';
|
|
15
17
|
|
|
16
18
|
interface TestStoreOptions {
|
|
@@ -48,7 +50,37 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
|
|
|
48
50
|
return table;
|
|
49
51
|
};
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
const applyInTurn = async (records: Parameters<StoreHooks['applyEnvelopes']>[0]) => {
|
|
54
|
+
const next = buildFactIndex(index, records);
|
|
55
|
+
|
|
56
|
+
applied = await db
|
|
57
|
+
.transaction()
|
|
58
|
+
.execute((transaction) =>
|
|
59
|
+
runConsumers(transaction, deployment.consumers, { ...next, applied }),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
index = next.index;
|
|
63
|
+
};
|
|
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
|
+
|
|
83
|
+
const testStore: TestStore = {
|
|
52
84
|
writeRows: async (_consumer, table, rows) => {
|
|
53
85
|
for (const row of rows) {
|
|
54
86
|
await writeRow(db, table, getTable(table), row);
|
|
@@ -61,15 +93,7 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
|
|
|
61
93
|
throw new ApplyError(envelopes.failure);
|
|
62
94
|
}
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
applied = await db
|
|
67
|
-
.transaction()
|
|
68
|
-
.execute((transaction) =>
|
|
69
|
-
runConsumers(transaction, deployment.consumers, { ...next, applied }),
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
index = next.index;
|
|
96
|
+
await applyEnvelopes(envelopes.value);
|
|
73
97
|
},
|
|
74
98
|
query: async (query, input) => {
|
|
75
99
|
const parsed = query.input.parse(input);
|
|
@@ -91,4 +115,8 @@ export async function createTestStore(options: TestStoreOptions): Promise<TestSt
|
|
|
91
115
|
reader,
|
|
92
116
|
[Symbol.asyncDispose]: () => db.destroy(),
|
|
93
117
|
};
|
|
118
|
+
|
|
119
|
+
storeHooks.set(testStore, { applyEnvelopes, nextOffsets: new Map() });
|
|
120
|
+
|
|
121
|
+
return testStore;
|
|
94
122
|
}
|
|
@@ -11,6 +11,7 @@ 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
17
|
export type AppliedFactIDs = ReadonlyMap<string, ReadonlySet<string>>;
|
|
@@ -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,16 @@
|
|
|
1
|
+
import type { FactEnvelope, StreamName } from '@pepys/kit';
|
|
2
|
+
import type { TestStore } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export interface StoreHooks {
|
|
5
|
+
// Runs the consumers on the records as one batch in one transaction. A refusal throws an
|
|
6
|
+
// ApplyError, and the batch then writes nothing.
|
|
7
|
+
applyEnvelopes: (
|
|
8
|
+
records: readonly { stream: string; envelope: FactEnvelope<unknown> }[],
|
|
9
|
+
) => Promise<void>;
|
|
10
|
+
|
|
11
|
+
// the offset of the next record to apply from a fact log, by stream
|
|
12
|
+
nextOffsets: Map<StreamName, number>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// What a test store gives the package's own functions and keeps out of its public type.
|
|
16
|
+
export const storeHooks = new WeakMap<TestStore, StoreHooks>();
|
|
@@ -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
|
+
}
|