@pepys/testing 0.0.0-stage → 0.0.1
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 +35 -2
- package/package.json +31 -4
- package/src/consumer-entries/build-read-functions.ts +26 -0
- package/src/consumer-entries/run-entry.ts +46 -0
- package/src/fact-index/build-fact-index.ts +39 -0
- package/src/fact-index/collect-scope-facts.ts +42 -0
- package/src/fact-index/types.ts +15 -0
- package/src/fact-mode/apply-fact-operations.ts +71 -0
- package/src/fact-mode/read-row-functions.ts +34 -0
- package/src/index.ts +7 -0
- package/src/links/read-link-rows.ts +14 -0
- package/src/links/remove-link.ts +16 -0
- package/src/links/write-link.ts +24 -0
- package/src/memory-store-reader/create-memory-store-reader.ts +61 -0
- package/src/operations/parse-operations.ts +168 -0
- package/src/operations/types.ts +9 -0
- package/src/orders/apply-in-both-orders.ts +59 -0
- package/src/rows/build-key-filter.ts +19 -0
- package/src/rows/build-row-key.ts +8 -0
- package/src/rows/find-row-problem.ts +43 -0
- package/src/rows/read-table-rows.ts +35 -0
- package/src/rows/remove-row.ts +13 -0
- package/src/rows/to-row-value.ts +15 -0
- package/src/rows/to-sql-value.ts +18 -0
- package/src/rows/write-row.ts +21 -0
- package/src/scope-replace/build-link-key.ts +6 -0
- package/src/scope-replace/build-row-id.ts +4 -0
- package/src/scope-replace/collect-scope-result.ts +87 -0
- package/src/scope-replace/pick-scope-changes.ts +166 -0
- package/src/scope-replace/read-scope-state.ts +61 -0
- package/src/scope-replace/replace-scope.ts +42 -0
- package/src/scope-replace/scope-replace-cases.json +1360 -0
- package/src/scope-replace/types.ts +39 -0
- package/src/scope-replace/write-scope-changes.ts +50 -0
- package/src/store-database/create-store-database.ts +31 -0
- package/src/store-database/format-store-schema.ts +57 -0
- package/src/store-database/types.ts +12 -0
- package/src/test-store/create-test-store.ts +94 -0
- package/src/test-store/parse-applied-facts.ts +64 -0
- package/src/test-store/run-consumers.ts +117 -0
- package/src/test-store/types.ts +68 -0
- package/src/utils/apply-error.ts +13 -0
- package/src/utils/types.ts +47 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LinkRow, Plugin, SourceDefinition } from '@pepys/kit';
|
|
2
|
+
import { createTestStore } from '../test-store/create-test-store.ts';
|
|
3
|
+
import type { AppliedFact } from '../test-store/types.ts';
|
|
4
|
+
import type { Row } from '../utils/types.ts';
|
|
5
|
+
|
|
6
|
+
export interface StoreSnapshot {
|
|
7
|
+
rows: Readonly<Record<string, readonly Row[]>>;
|
|
8
|
+
links: readonly LinkRow[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OrderReport {
|
|
12
|
+
given: StoreSnapshot;
|
|
13
|
+
reversed: StoreSnapshot;
|
|
14
|
+
|
|
15
|
+
// the two snapshots are equal
|
|
16
|
+
isSame: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OrderCheck<Source extends SourceDefinition> {
|
|
20
|
+
plugins: readonly Plugin[];
|
|
21
|
+
source: Source;
|
|
22
|
+
facts: readonly AppliedFact<Source>[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One arrival order cannot show a missing tie-break. The reverse order puts every pair of facts the
|
|
26
|
+
// other way round, and the second pass applies them all again as one batch.
|
|
27
|
+
export async function applyInBothOrders<Source extends SourceDefinition>(
|
|
28
|
+
check: OrderCheck<Source>,
|
|
29
|
+
): Promise<OrderReport> {
|
|
30
|
+
const given = await readSnapshot(check, check.facts);
|
|
31
|
+
const reversed = await readSnapshot(check, check.facts.toReversed());
|
|
32
|
+
|
|
33
|
+
return { given, reversed, isSame: Bun.deepEquals(given, reversed, true) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readSnapshot<Source extends SourceDefinition>(
|
|
37
|
+
check: OrderCheck<Source>,
|
|
38
|
+
facts: readonly AppliedFact<Source>[],
|
|
39
|
+
): Promise<StoreSnapshot> {
|
|
40
|
+
await using store = await createTestStore({ plugins: check.plugins });
|
|
41
|
+
|
|
42
|
+
for (const fact of facts) {
|
|
43
|
+
await store.apply(check.source, [fact]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await store.apply(check.source, facts);
|
|
47
|
+
|
|
48
|
+
const tables = check.plugins
|
|
49
|
+
.flatMap((plugin) => plugin.consumers)
|
|
50
|
+
.flatMap((consumer) => Object.keys(consumer.tables));
|
|
51
|
+
|
|
52
|
+
const rows: Record<string, readonly Row[]> = {};
|
|
53
|
+
|
|
54
|
+
for (const table of tables) {
|
|
55
|
+
rows[table] = await store.readRows(table);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { rows, links: await store.readLinks() };
|
|
59
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import type { RawBuilder } from 'kysely';
|
|
4
|
+
import { toSQLValue } from './to-sql-value.ts';
|
|
5
|
+
|
|
6
|
+
// The `where` clause of one row, from the JSON text of its key's values.
|
|
7
|
+
export function buildKeyFilter(table: TableDeclaration, key: string): RawBuilder<unknown> {
|
|
8
|
+
const parsed: unknown = JSON.parse(key);
|
|
9
|
+
const values: readonly unknown[] = Array.isArray(parsed) ? parsed : [];
|
|
10
|
+
|
|
11
|
+
return sql.join(
|
|
12
|
+
table.primaryKey.map((column, index) => {
|
|
13
|
+
const declaration = table.columns[column] ?? { kind: 'text' };
|
|
14
|
+
|
|
15
|
+
return sql`${sql.ref(column)} = ${toSQLValue(declaration, values[index])}`;
|
|
16
|
+
}),
|
|
17
|
+
sql` and `,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import type { Row } from '../utils/types.ts';
|
|
3
|
+
|
|
4
|
+
// The key of a row as the host writes it in `scope_row` and the change log: the JSON text of the
|
|
5
|
+
// primary key's values, in key order, for a key of one column and of several.
|
|
6
|
+
export function buildRowKey(table: TableDeclaration, row: Row): string {
|
|
7
|
+
return JSON.stringify(table.primaryKey.map((column) => row[column] ?? null));
|
|
8
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import type { Row } from '../utils/types.ts';
|
|
4
|
+
|
|
5
|
+
// The host's check of a row: every column present, no other key, each value of its column's kind,
|
|
6
|
+
// and null only where the column is nullable. `inexact` names the columns whose number the module
|
|
7
|
+
// wrote with a fraction or an exponent, such as `1.0`: an integer column refuses those.
|
|
8
|
+
export function findRowProblem(
|
|
9
|
+
table: TableDeclaration,
|
|
10
|
+
row: Row,
|
|
11
|
+
inexact: ReadonlySet<string>,
|
|
12
|
+
): string | undefined {
|
|
13
|
+
const unknown = Object.keys(row).find((name) => !Object.hasOwn(table.columns, name));
|
|
14
|
+
|
|
15
|
+
if (unknown !== undefined) {
|
|
16
|
+
return `${unknown} is not a column`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const [name, column] of Object.entries(table.columns)) {
|
|
20
|
+
if (!Object.hasOwn(row, name)) {
|
|
21
|
+
return `the column ${name} is absent`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const value = row[name];
|
|
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)}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import type { QueryExecutorProvider } from 'kysely';
|
|
4
|
+
import type { Row } from '../utils/types.ts';
|
|
5
|
+
import { buildKeyFilter } from './build-key-filter.ts';
|
|
6
|
+
import { toRowValue } from './to-row-value.ts';
|
|
7
|
+
|
|
8
|
+
// Every row of a table in primary key order, or with `key` the one row of that key, as the module
|
|
9
|
+
// wrote it.
|
|
10
|
+
export async function readTableRows(
|
|
11
|
+
db: QueryExecutorProvider,
|
|
12
|
+
name: string,
|
|
13
|
+
table: TableDeclaration,
|
|
14
|
+
key?: string,
|
|
15
|
+
): Promise<Row[]> {
|
|
16
|
+
const order = sql.join(table.primaryKey.map((column) => sql.ref(column)));
|
|
17
|
+
const filter = key === undefined ? sql`1` : buildKeyFilter(table, key);
|
|
18
|
+
|
|
19
|
+
const stored =
|
|
20
|
+
await sql`select * from ${sql.table(name)} where ${filter} order by ${order}`.execute(db);
|
|
21
|
+
|
|
22
|
+
return stored.rows.map((row) =>
|
|
23
|
+
Object.fromEntries(
|
|
24
|
+
Object.entries(table.columns).map(([column, declaration]) => {
|
|
25
|
+
const value = isRecord(row) ? row[column] : undefined;
|
|
26
|
+
|
|
27
|
+
return [column, toRowValue(declaration, value)];
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === 'object' && value !== null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import type { QueryExecutorProvider } from 'kysely';
|
|
4
|
+
import { buildKeyFilter } from './build-key-filter.ts';
|
|
5
|
+
|
|
6
|
+
export async function removeRow(
|
|
7
|
+
db: QueryExecutorProvider,
|
|
8
|
+
name: string,
|
|
9
|
+
table: TableDeclaration,
|
|
10
|
+
key: string,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
await sql`delete from ${sql.table(name)} where ${buildKeyFilter(table, key)}`.execute(db);
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ColumnDeclaration } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
|
|
4
|
+
// A stored value as the module wrote it: 0 or 1 back to a boolean, a JSON column's text parsed.
|
|
5
|
+
export function toRowValue(column: ColumnDeclaration, stored: unknown): unknown {
|
|
6
|
+
if (stored === null || stored === undefined) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return match(column.kind)
|
|
11
|
+
.with('boolean', () => stored === 1)
|
|
12
|
+
.with('json', (): unknown => (typeof stored === 'string' ? JSON.parse(stored) : stored))
|
|
13
|
+
.with('integer', 'real', 'text', 'entityID', 'timestamp', () => stored)
|
|
14
|
+
.exhaustive();
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ColumnDeclaration } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
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
|
+
export function toSQLValue(column: ColumnDeclaration, value: unknown): string | number | null {
|
|
6
|
+
if (value === null || value === undefined) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return match(column.kind)
|
|
11
|
+
.with('boolean', () => (value === true ? 1 : 0))
|
|
12
|
+
.with('json', () => JSON.stringify(value))
|
|
13
|
+
.with('integer', 'real', () => Number(value))
|
|
14
|
+
.with('text', 'entityID', 'timestamp', () =>
|
|
15
|
+
typeof value === 'string' ? value : JSON.stringify(value),
|
|
16
|
+
)
|
|
17
|
+
.exhaustive();
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TableDeclaration } from '@pepys/kit';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import type { QueryExecutorProvider } from 'kysely';
|
|
4
|
+
import type { Row } from '../utils/types.ts';
|
|
5
|
+
import { toSQLValue } from './to-sql-value.ts';
|
|
6
|
+
|
|
7
|
+
export async function writeRow(
|
|
8
|
+
db: QueryExecutorProvider,
|
|
9
|
+
name: string,
|
|
10
|
+
table: TableDeclaration,
|
|
11
|
+
row: Row,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const columns = Object.entries(table.columns);
|
|
14
|
+
const names = sql.join(columns.map(([column]) => sql.ref(column)));
|
|
15
|
+
|
|
16
|
+
const values = sql.join(
|
|
17
|
+
columns.map(([column, declaration]) => sql`${toSQLValue(declaration, row[column])}`),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
await sql`insert or replace into ${sql.table(name)} (${names}) values (${values})`.execute(db);
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { WireLink } from '../utils/types.ts';
|
|
2
|
+
|
|
3
|
+
// The key of a link in the change log: the JSON text of its type and its two ends.
|
|
4
|
+
export function buildLinkKey(link: Pick<WireLink, 'type' | 'from' | 'to'>): string {
|
|
5
|
+
return JSON.stringify([link.type, link.from, link.to]);
|
|
6
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { findLinkProblem } from '@pepys/kit';
|
|
2
|
+
import type { ConsumerDeclaration, Result } from '@pepys/kit';
|
|
3
|
+
import type { Operation } from '../operations/types.ts';
|
|
4
|
+
import { buildRowKey } from '../rows/build-row-key.ts';
|
|
5
|
+
import { findRowProblem } from '../rows/find-row-problem.ts';
|
|
6
|
+
import type { ApplyFailure, WireLink } from '../utils/types.ts';
|
|
7
|
+
import { buildLinkKey } from './build-link-key.ts';
|
|
8
|
+
import { buildRowID } from './build-row-id.ts';
|
|
9
|
+
import type { KeyedRow, ScopeResult } from './types.ts';
|
|
10
|
+
|
|
11
|
+
// The host's checks of one scope's operations, in its order: no delete or unlink, every link, then
|
|
12
|
+
// each row and its key, then each link key.
|
|
13
|
+
export function collectScopeResult(
|
|
14
|
+
consumer: ConsumerDeclaration,
|
|
15
|
+
operations: readonly Operation[],
|
|
16
|
+
): Result<ScopeResult, ApplyFailure> {
|
|
17
|
+
const name = consumer.name;
|
|
18
|
+
const upserts: (Operation & { verb: 'upsert' })[] = [];
|
|
19
|
+
const links: WireLink[] = [];
|
|
20
|
+
|
|
21
|
+
for (const operation of operations) {
|
|
22
|
+
if (operation.verb === 'delete' || operation.verb === 'unlink') {
|
|
23
|
+
const reason = `a scope consumer returned ${operation.verb === 'delete' ? 'a delete' : 'an unlink'}`;
|
|
24
|
+
|
|
25
|
+
return { ok: false, failure: { kind: 'bad-operation', consumer: name, reason } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (operation.verb === 'link') {
|
|
29
|
+
links.push(operation.link);
|
|
30
|
+
} else {
|
|
31
|
+
upserts.push(operation);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const link of links) {
|
|
36
|
+
const problem = findLinkProblem(consumer, link);
|
|
37
|
+
|
|
38
|
+
if (problem !== undefined) {
|
|
39
|
+
return { ok: false, failure: { kind: 'bad-link', consumer: name, problem } };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = new Map<string, KeyedRow>();
|
|
44
|
+
|
|
45
|
+
for (const upsert of upserts) {
|
|
46
|
+
const table = consumer.tables[upsert.table];
|
|
47
|
+
|
|
48
|
+
if (table === undefined) {
|
|
49
|
+
return { ok: false, failure: { kind: 'not-owned', consumer: name, table: upsert.table } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const reason = findRowProblem(table, upsert.row, upsert.inexact);
|
|
53
|
+
|
|
54
|
+
if (reason !== undefined) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
failure: { kind: 'bad-row', consumer: name, table: upsert.table, reason },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const key = buildRowKey(table, upsert.row);
|
|
62
|
+
const id = buildRowID(upsert.table, key);
|
|
63
|
+
|
|
64
|
+
if (rows.has(id)) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
failure: { kind: 'duplicate-row', consumer: name, table: upsert.table, key },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
rows.set(id, { table: upsert.table, key, row: upsert.row });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const keyed = new Map<string, WireLink>();
|
|
75
|
+
|
|
76
|
+
for (const link of links) {
|
|
77
|
+
const key = buildLinkKey(link);
|
|
78
|
+
|
|
79
|
+
if (keyed.has(key)) {
|
|
80
|
+
return { ok: false, failure: { kind: 'duplicate-link', consumer: name, key } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
keyed.set(key, link);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: true, value: { rows: [...rows.values()], links: [...keyed.values()] } };
|
|
87
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Result } from '@pepys/kit';
|
|
2
|
+
import type { ApplyFailure, ChangeEntry, WireLink } from '../utils/types.ts';
|
|
3
|
+
import { buildLinkKey } from './build-link-key.ts';
|
|
4
|
+
import { buildRowID } from './build-row-id.ts';
|
|
5
|
+
import type { KeyedRow, ScopeChanges, ScopeResult, ScopeState } from './types.ts';
|
|
6
|
+
|
|
7
|
+
// The replace of one scope as a diff: write each returned row and link that is new or differs,
|
|
8
|
+
// delete each one the scope owned and did not return. Nothing may belong to another scope. The
|
|
9
|
+
// entries come as rows written, links written, rows deleted, links deleted, each in key order.
|
|
10
|
+
export function pickScopeChanges(
|
|
11
|
+
consumer: string,
|
|
12
|
+
scope: string,
|
|
13
|
+
result: ScopeResult,
|
|
14
|
+
state: ScopeState,
|
|
15
|
+
): Result<ScopeChanges, ApplyFailure> {
|
|
16
|
+
const rows = result.rows.toSorted((left, right) =>
|
|
17
|
+
compareKeys([left.table, left.key], [right.table, right.key]),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const links = result.links.toSorted((left, right) =>
|
|
21
|
+
compareKeys([left.type, left.from, left.to], [right.type, right.from, right.to]),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const conflict = findConflict(consumer, scope, { rows, links }, state);
|
|
25
|
+
|
|
26
|
+
if (conflict !== undefined) {
|
|
27
|
+
return { ok: false, failure: conflict };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const writtenRows = rows.filter((row) => {
|
|
31
|
+
const stored = state.storedRows.get(buildRowID(row.table, row.key));
|
|
32
|
+
|
|
33
|
+
return stored === undefined || !Bun.deepEquals(stored, row.row);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const writtenLinks = links.filter((link) => {
|
|
37
|
+
const stored = state.storedLinks.get(buildLinkKey(link));
|
|
38
|
+
|
|
39
|
+
return stored === undefined || !isSameLink(stored.link, link);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const returned = new Set(rows.map((row) => buildRowID(row.table, row.key)));
|
|
43
|
+
|
|
44
|
+
const deletedRows = [...state.rowOwners]
|
|
45
|
+
.filter(([id, owner]) => owner === scope && !returned.has(id))
|
|
46
|
+
.map(([id]) => toTableKey(id))
|
|
47
|
+
.toSorted((left, right) => compareKeys([left.table, left.key], [right.table, right.key]));
|
|
48
|
+
|
|
49
|
+
const returnedLinks = new Set(links.map((link) => buildLinkKey(link)));
|
|
50
|
+
|
|
51
|
+
const deletedLinks = [...state.storedLinks]
|
|
52
|
+
.filter(([key, stored]) => stored.scope === scope && !returnedLinks.has(key))
|
|
53
|
+
.map(([, stored]) => stored.link)
|
|
54
|
+
.toSorted((left, right) =>
|
|
55
|
+
compareKeys([left.type, left.from, left.to], [right.type, right.from, right.to]),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const entries: ChangeEntry[] = [
|
|
59
|
+
...writtenRows.map((row) => buildRowEntry(row, state)),
|
|
60
|
+
...writtenLinks.map((link) => buildLinkEntry(link, state)),
|
|
61
|
+
...deletedRows.map((row) => ({
|
|
62
|
+
type: row.table,
|
|
63
|
+
key: row.key,
|
|
64
|
+
old_value: state.storedRows.get(buildRowID(row.table, row.key)) ?? null,
|
|
65
|
+
headers: { operation: 'delete' as const },
|
|
66
|
+
})),
|
|
67
|
+
...deletedLinks.map((link) => ({
|
|
68
|
+
type: 'link',
|
|
69
|
+
key: buildLinkKey(link),
|
|
70
|
+
headers: { operation: 'delete' as const },
|
|
71
|
+
})),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
return { ok: true, value: { writtenRows, writtenLinks, deletedRows, deletedLinks, entries } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Plain `<` on the parts, as Rust's ordered maps and SQLite compare text: never a locale order.
|
|
78
|
+
function compareKeys(left: readonly string[], right: readonly string[]): number {
|
|
79
|
+
for (const [index, part] of left.entries()) {
|
|
80
|
+
const other = right[index] ?? '';
|
|
81
|
+
|
|
82
|
+
if (part !== other) {
|
|
83
|
+
return part < other ? -1 : 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return left.length - right.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findConflict(
|
|
91
|
+
consumer: string,
|
|
92
|
+
scope: string,
|
|
93
|
+
result: ScopeResult,
|
|
94
|
+
state: ScopeState,
|
|
95
|
+
): ApplyFailure | undefined {
|
|
96
|
+
for (const row of result.rows) {
|
|
97
|
+
const owner = state.rowOwners.get(buildRowID(row.table, row.key));
|
|
98
|
+
|
|
99
|
+
if (owner !== undefined && owner !== scope) {
|
|
100
|
+
return { kind: 'scope-conflict', consumer, scope, table: row.table, key: row.key, owner };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const link of result.links) {
|
|
105
|
+
const key = buildLinkKey(link);
|
|
106
|
+
const stored = state.storedLinks.get(key);
|
|
107
|
+
|
|
108
|
+
if (stored !== undefined && stored.scope !== scope) {
|
|
109
|
+
return { kind: 'scope-conflict', consumer, scope, table: 'link', key, owner: stored.scope };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A confidence is a float on both sides, so `1` and `1.0` are one value.
|
|
117
|
+
function isSameLink(stored: WireLink, link: WireLink): boolean {
|
|
118
|
+
return stored.confidence === link.confidence && stored.evidence === link.evidence;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toTableKey(id: string): Omit<KeyedRow, 'row'> {
|
|
122
|
+
const parsed: unknown = JSON.parse(id);
|
|
123
|
+
const [table, key] = Array.isArray(parsed) ? parsed.map(String) : [];
|
|
124
|
+
|
|
125
|
+
return { table: table ?? '', key: key ?? '' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildRowEntry(row: KeyedRow, state: ScopeState): ChangeEntry {
|
|
129
|
+
const old = state.storedRows.get(buildRowID(row.table, row.key));
|
|
130
|
+
|
|
131
|
+
return old === undefined
|
|
132
|
+
? { type: row.table, key: row.key, value: row.row, headers: { operation: 'insert' } }
|
|
133
|
+
: {
|
|
134
|
+
type: row.table,
|
|
135
|
+
key: row.key,
|
|
136
|
+
value: row.row,
|
|
137
|
+
old_value: old,
|
|
138
|
+
headers: { operation: 'update' },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildLinkEntry(link: WireLink, state: ScopeState): ChangeEntry {
|
|
143
|
+
const key = buildLinkKey(link);
|
|
144
|
+
const old = state.storedLinks.get(key);
|
|
145
|
+
const value = toLinkValue(link);
|
|
146
|
+
|
|
147
|
+
return old === undefined
|
|
148
|
+
? { type: 'link', key, value, headers: { operation: 'insert' } }
|
|
149
|
+
: {
|
|
150
|
+
type: 'link',
|
|
151
|
+
key,
|
|
152
|
+
value,
|
|
153
|
+
old_value: toLinkValue(old.link),
|
|
154
|
+
headers: { operation: 'update' },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toLinkValue(link: WireLink): WireLink {
|
|
159
|
+
return {
|
|
160
|
+
type: link.type,
|
|
161
|
+
from: link.from,
|
|
162
|
+
to: link.to,
|
|
163
|
+
confidence: link.confidence,
|
|
164
|
+
evidence: link.evidence,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
2
|
+
import { readLinkRows } from '../links/read-link-rows.ts';
|
|
3
|
+
import { readTableRows } from '../rows/read-table-rows.ts';
|
|
4
|
+
import type { StoreDatabase } from '../store-database/types.ts';
|
|
5
|
+
import type { Row } from '../utils/types.ts';
|
|
6
|
+
import { buildLinkKey } from './build-link-key.ts';
|
|
7
|
+
import { buildRowID } from './build-row-id.ts';
|
|
8
|
+
import type { ScopeResult, ScopeState, StoredLink } from './types.ts';
|
|
9
|
+
|
|
10
|
+
export async function readScopeState(
|
|
11
|
+
db: StoreDatabase,
|
|
12
|
+
consumer: ConsumerDeclaration,
|
|
13
|
+
scope: string,
|
|
14
|
+
result: ScopeResult,
|
|
15
|
+
): Promise<ScopeState> {
|
|
16
|
+
const owned = await db
|
|
17
|
+
.selectFrom('scope_row')
|
|
18
|
+
.select(['table_name', 'key', 'scope'])
|
|
19
|
+
.where('consumer', '=', consumer.name)
|
|
20
|
+
.execute();
|
|
21
|
+
|
|
22
|
+
const rowOwners = new Map(owned.map((row) => [buildRowID(row.table_name, row.key), row.scope]));
|
|
23
|
+
|
|
24
|
+
const wanted = [
|
|
25
|
+
...result.rows.map((row) => ({ table: row.table, key: row.key })),
|
|
26
|
+
...owned
|
|
27
|
+
.filter((row) => row.scope === scope)
|
|
28
|
+
.map((row) => ({ table: row.table_name, key: row.key })),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const storedRows = new Map<string, Row>();
|
|
32
|
+
|
|
33
|
+
for (const row of wanted) {
|
|
34
|
+
const declaration = consumer.tables[row.table];
|
|
35
|
+
|
|
36
|
+
const [stored] =
|
|
37
|
+
declaration === undefined ? [] : await readTableRows(db, row.table, declaration, row.key);
|
|
38
|
+
|
|
39
|
+
if (stored !== undefined) {
|
|
40
|
+
storedRows.set(buildRowID(row.table, row.key), stored);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const links = await readLinkRows(db, consumer.name);
|
|
45
|
+
|
|
46
|
+
const storedLinks = new Map<string, StoredLink>(
|
|
47
|
+
links.map((row) => {
|
|
48
|
+
const link = {
|
|
49
|
+
type: row.type,
|
|
50
|
+
from: row.from_id,
|
|
51
|
+
to: row.to_id,
|
|
52
|
+
confidence: row.confidence,
|
|
53
|
+
evidence: row.evidence,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return [buildLinkKey(link), { scope: row.scope, link }];
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return { rowOwners, storedRows, storedLinks };
|
|
61
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
2
|
+
import { parseOperations } from '../operations/parse-operations.ts';
|
|
3
|
+
import type { StoreDatabase } from '../store-database/types.ts';
|
|
4
|
+
import { ApplyError } from '../utils/apply-error.ts';
|
|
5
|
+
import type { ChangeEntry } from '../utils/types.ts';
|
|
6
|
+
import { collectScopeResult } from './collect-scope-result.ts';
|
|
7
|
+
import { pickScopeChanges } from './pick-scope-changes.ts';
|
|
8
|
+
import { readScopeState } from './read-scope-state.ts';
|
|
9
|
+
import { writeScopeChanges } from './write-scope-changes.ts';
|
|
10
|
+
|
|
11
|
+
// The host's scope replace of one scope, from the module's output text, inside the caller's
|
|
12
|
+
// transaction. It throws an ApplyError on a refusal, and the caller rolls the batch back.
|
|
13
|
+
export async function replaceScope(
|
|
14
|
+
db: StoreDatabase,
|
|
15
|
+
consumer: ConsumerDeclaration,
|
|
16
|
+
scope: string,
|
|
17
|
+
output: string,
|
|
18
|
+
): Promise<readonly ChangeEntry[]> {
|
|
19
|
+
const operations = parseOperations(consumer, output);
|
|
20
|
+
|
|
21
|
+
if (!operations.ok) {
|
|
22
|
+
throw new ApplyError(operations.failure);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = collectScopeResult(consumer, operations.value);
|
|
26
|
+
|
|
27
|
+
if (!result.ok) {
|
|
28
|
+
throw new ApplyError(result.failure);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const state = await readScopeState(db, consumer, scope, result.value);
|
|
32
|
+
|
|
33
|
+
const changes = pickScopeChanges(consumer.name, scope, result.value, state);
|
|
34
|
+
|
|
35
|
+
if (!changes.ok) {
|
|
36
|
+
throw new ApplyError(changes.failure);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await writeScopeChanges(db, consumer, scope, changes.value);
|
|
40
|
+
|
|
41
|
+
return changes.value.entries;
|
|
42
|
+
}
|