@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.
Files changed (43) hide show
  1. package/README.md +35 -2
  2. package/package.json +31 -4
  3. package/src/consumer-entries/build-read-functions.ts +26 -0
  4. package/src/consumer-entries/run-entry.ts +46 -0
  5. package/src/fact-index/build-fact-index.ts +39 -0
  6. package/src/fact-index/collect-scope-facts.ts +42 -0
  7. package/src/fact-index/types.ts +15 -0
  8. package/src/fact-mode/apply-fact-operations.ts +71 -0
  9. package/src/fact-mode/read-row-functions.ts +34 -0
  10. package/src/index.ts +7 -0
  11. package/src/links/read-link-rows.ts +14 -0
  12. package/src/links/remove-link.ts +16 -0
  13. package/src/links/write-link.ts +24 -0
  14. package/src/memory-store-reader/create-memory-store-reader.ts +61 -0
  15. package/src/operations/parse-operations.ts +168 -0
  16. package/src/operations/types.ts +9 -0
  17. package/src/orders/apply-in-both-orders.ts +59 -0
  18. package/src/rows/build-key-filter.ts +19 -0
  19. package/src/rows/build-row-key.ts +8 -0
  20. package/src/rows/find-row-problem.ts +43 -0
  21. package/src/rows/read-table-rows.ts +35 -0
  22. package/src/rows/remove-row.ts +13 -0
  23. package/src/rows/to-row-value.ts +15 -0
  24. package/src/rows/to-sql-value.ts +18 -0
  25. package/src/rows/write-row.ts +21 -0
  26. package/src/scope-replace/build-link-key.ts +6 -0
  27. package/src/scope-replace/build-row-id.ts +4 -0
  28. package/src/scope-replace/collect-scope-result.ts +87 -0
  29. package/src/scope-replace/pick-scope-changes.ts +166 -0
  30. package/src/scope-replace/read-scope-state.ts +61 -0
  31. package/src/scope-replace/replace-scope.ts +42 -0
  32. package/src/scope-replace/scope-replace-cases.json +1360 -0
  33. package/src/scope-replace/types.ts +39 -0
  34. package/src/scope-replace/write-scope-changes.ts +50 -0
  35. package/src/store-database/create-store-database.ts +31 -0
  36. package/src/store-database/format-store-schema.ts +57 -0
  37. package/src/store-database/types.ts +12 -0
  38. package/src/test-store/create-test-store.ts +94 -0
  39. package/src/test-store/parse-applied-facts.ts +64 -0
  40. package/src/test-store/run-consumers.ts +117 -0
  41. package/src/test-store/types.ts +68 -0
  42. package/src/utils/apply-error.ts +13 -0
  43. package/src/utils/types.ts +47 -0
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@pepys/testing</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@pepys/testing"><img src="https://img.shields.io/npm/v/@pepys/testing" alt="npm version"></a>
6
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/README.md">Documentation</a>
11
+ </p>
12
+ </div>
13
+
14
+ Test a pepys plug-in on Bun, with no host and no network.
15
+
16
+ The test store runs a plug-in's consumers over one in-memory database, with every table, the link
17
+ table and the shared views, and answers the plug-in's queries. It repeats the host's link check and
18
+ scope replace in TypeScript. It does not prove the scriptc compile or the Rust host.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ bun add -d @pepys/testing
24
+ ```
25
+
26
+ Set the compiler options from the [kit's README](../kit/README.md#configuration).
27
+
28
+ ## Features
29
+
30
+ - `createTestStore`: apply facts or write rows, then run a query, read rows and read links.
31
+ - `applyInBothOrders`: apply facts in two orders and compare the stores.
32
+ - The memory adapter of the store reader, for a test of a core query.
33
+
34
+ ## Documentation
35
+
36
+ The [pepys docs](../../docs/README.md) hold the design of the test store.
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@pepys/testing",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Test a pepys plug-in on Bun: an in-memory store that runs its consumers and queries",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zgeoff/pepys.git",
9
+ "directory": "packages/testing"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/test-utils"
15
+ ],
16
+ "type": "module",
17
+ "exports": "./src/index.ts",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@pepys/kit": "0.0.1",
23
+ "@pepys/kysely-turso": "0.0.1",
24
+ "@tursodatabase/database": "0.8.3",
25
+ "kysely": "0.29.8",
26
+ "tiny-invariant": "1.3.3",
27
+ "ts-pattern": "5.9.2",
28
+ "zod": "4.6.7"
29
+ },
30
+ "devDependencies": {
31
+ "@zgeoff/std": "0.4.3"
32
+ }
33
+ }
@@ -0,0 +1,26 @@
1
+ import type { HostFunctions } from './run-entry.ts';
2
+
3
+ // The two host functions of one read, `<name>Length` and `<name>Into`, over JSON texts by argument.
4
+ // A length of -1 means no value. A read the texts do not hold throws, as the host's trap does.
5
+ export function buildReadFunctions(
6
+ name: string,
7
+ texts: ReadonlyMap<string, string>,
8
+ missing: 'none' | 'trap',
9
+ ): HostFunctions {
10
+ const encodeText = (argument: string): Uint8Array | undefined => {
11
+ const text = texts.get(argument);
12
+
13
+ if (text === undefined && missing === 'trap') {
14
+ throw new Error(`the module read ${argument}, which the host does not serve it`);
15
+ }
16
+
17
+ return text === undefined ? undefined : new TextEncoder().encode(text);
18
+ };
19
+
20
+ return {
21
+ [`${name}Length`]: (argument: string): number => encodeText(argument)?.length ?? -1,
22
+ [`${name}Into`]: (argument: string, buffer: Uint8Array): void => {
23
+ buffer.set(encodeText(argument) ?? new Uint8Array());
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,46 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { ApplyError } from '../utils/apply-error.ts';
3
+
4
+ // Host functions as globals, the way the glue's `declare function` lines reach them on Bun.
5
+ export type HostFunctions = Readonly<Record<string, (...parameters: never[]) => unknown>>;
6
+
7
+ // Calls one export of the consumer's TypeScript entry with the host functions in place, and
8
+ // returns the output text. A throw, a missing export or output that is no text is an ApplyError.
9
+ export async function runEntry(
10
+ consumer: ConsumerDeclaration,
11
+ exportName: 'applyFact' | 'applyScope',
12
+ argument: string,
13
+ host: HostFunctions,
14
+ ): Promise<string> {
15
+ const failure = (reason: string) =>
16
+ new ApplyError({ kind: 'entry', consumer: consumer.name, reason });
17
+
18
+ const entry: unknown = await import(consumer.entry.href);
19
+
20
+ const call: unknown =
21
+ typeof entry === 'object' && entry !== null && exportName in entry
22
+ ? Reflect.get(entry, exportName)
23
+ : undefined;
24
+
25
+ if (typeof call !== 'function') {
26
+ throw failure(`the entry file exports no function ${exportName}`);
27
+ }
28
+
29
+ Object.assign(globalThis, host);
30
+
31
+ try {
32
+ const output: unknown = Reflect.apply(call, undefined, [argument]);
33
+
34
+ if (typeof output !== 'string') {
35
+ throw failure(`${exportName} returned no text`);
36
+ }
37
+
38
+ return output;
39
+ } catch (error) {
40
+ throw error instanceof ApplyError ? error : failure(`${exportName} threw: ${String(error)}`);
41
+ } finally {
42
+ for (const name of Object.keys(host)) {
43
+ Reflect.deleteProperty(globalThis, name);
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,39 @@
1
+ import type { FactEnvelope } from '@pepys/kit';
2
+ import type { FactIndex, LoggedFact } from './types.ts';
3
+
4
+ interface IndexedBatch {
5
+ index: FactIndex;
6
+
7
+ // the batch's records, copies included, in log order
8
+ appended: readonly LoggedFact[];
9
+ }
10
+
11
+ // The index after a batch is appended to the log. Each stream numbers its records from 0.
12
+ export function buildFactIndex(
13
+ previous: FactIndex,
14
+ batch: readonly { stream: string; envelope: FactEnvelope<unknown> }[],
15
+ ): IndexedBatch {
16
+ const counts = new Map<string, number>();
17
+
18
+ for (const record of previous.log) {
19
+ counts.set(record.stream, (counts.get(record.stream) ?? 0) + 1);
20
+ }
21
+
22
+ const appended = batch.map((fact) => {
23
+ const seqNum = counts.get(fact.stream) ?? 0;
24
+
25
+ counts.set(fact.stream, seqNum + 1);
26
+
27
+ return { stream: fact.stream, seqNum, envelope: fact.envelope };
28
+ });
29
+
30
+ const firstCopies = new Map(previous.firstCopies);
31
+
32
+ for (const record of appended) {
33
+ if (!firstCopies.has(record.envelope.id)) {
34
+ firstCopies.set(record.envelope.id, record);
35
+ }
36
+ }
37
+
38
+ return { index: { log: [...previous.log, ...appended], firstCopies }, appended };
39
+ }
@@ -0,0 +1,42 @@
1
+ import type { FactEnvelope } from '@pepys/kit';
2
+ import type { FactIndex } from './types.ts';
3
+
4
+ // The read of one scope, as the host serves it: every fact of the entity on `streams`, one per fact
5
+ // ID, sorted by time, then stream, then sequence number.
6
+ export function collectScopeFacts(
7
+ index: FactIndex,
8
+ scope: string,
9
+ streams: readonly string[],
10
+ ): FactEnvelope<unknown>[] {
11
+ return [...index.firstCopies.values()]
12
+ .filter((record) => record.envelope.entity === scope && streams.includes(record.stream))
13
+ .map((record) => ({ record, sortKey: toSortKey(record.envelope.at) }))
14
+ .toSorted(
15
+ (left, right) =>
16
+ compareText(left.sortKey, right.sortKey) ||
17
+ compareText(left.record.stream, right.record.stream) ||
18
+ left.record.seqNum - right.record.seqNum,
19
+ )
20
+ .map((entry) => entry.record.envelope);
21
+ }
22
+
23
+ // The host's sort key: the fraction of the seconds set to 9 digits, so text order is time order.
24
+ function toSortKey(at: string): string {
25
+ const parts = /^(?<seconds>[^.]*T\d\d:\d\d:\d\d)(?:\.(?<fraction>\d+))?(?<zone>.*)$/u.exec(at);
26
+
27
+ if (parts?.groups === undefined) {
28
+ return at;
29
+ }
30
+
31
+ const fraction = (parts.groups['fraction'] ?? '').padEnd(9, '0').slice(0, 9);
32
+
33
+ return `${parts.groups['seconds']}.${fraction}${parts.groups['zone']}`;
34
+ }
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,15 @@
1
+ import type { FactEnvelope } from '@pepys/kit';
2
+
3
+ // One record of the test store's log: a fact in its stream, at its sequence number.
4
+ export interface LoggedFact {
5
+ stream: string;
6
+ seqNum: number;
7
+ envelope: FactEnvelope<unknown>;
8
+ }
9
+
10
+ // The log in memory, with the first copy of each fact ID: the host's fact index holds one row per
11
+ // fact ID, and a module reads synchronously, so the test store keeps the index out of the database.
12
+ export interface FactIndex {
13
+ log: readonly LoggedFact[];
14
+ firstCopies: ReadonlyMap<string, LoggedFact>;
15
+ }
@@ -0,0 +1,71 @@
1
+ import { findLinkProblem } from '@pepys/kit';
2
+ import type { ConsumerDeclaration, TableDeclaration } from '@pepys/kit';
3
+ import invariant from 'tiny-invariant';
4
+ import { match } from 'ts-pattern';
5
+ import { removeLink } from '../links/remove-link.ts';
6
+ import { writeLink } from '../links/write-link.ts';
7
+ import type { Operation } from '../operations/types.ts';
8
+ import { buildRowKey } from '../rows/build-row-key.ts';
9
+ import { findRowProblem } from '../rows/find-row-problem.ts';
10
+ import { removeRow } from '../rows/remove-row.ts';
11
+ import { writeRow } from '../rows/write-row.ts';
12
+ import type { StoreDatabase } from '../store-database/types.ts';
13
+ import { ApplyError } from '../utils/apply-error.ts';
14
+
15
+ // The operations of one fact, in fact mode: every check first, then the writes in order. A link
16
+ // has no scope.
17
+ export async function applyFactOperations(
18
+ db: StoreDatabase,
19
+ consumer: ConsumerDeclaration,
20
+ operations: readonly Operation[],
21
+ ): Promise<void> {
22
+ for (const operation of operations) {
23
+ if (operation.verb === 'link') {
24
+ const problem = findLinkProblem(consumer, operation.link);
25
+
26
+ if (problem !== undefined) {
27
+ throw new ApplyError({ kind: 'bad-link', consumer: consumer.name, problem });
28
+ }
29
+ }
30
+
31
+ if (operation.verb === 'upsert') {
32
+ const reason = findRowProblem(
33
+ getTable(consumer, operation.table),
34
+ operation.row,
35
+ operation.inexact,
36
+ );
37
+
38
+ if (reason !== undefined) {
39
+ throw new ApplyError({
40
+ kind: 'bad-row',
41
+ consumer: consumer.name,
42
+ table: operation.table,
43
+ reason,
44
+ });
45
+ }
46
+ }
47
+ }
48
+
49
+ for (const operation of operations) {
50
+ await match(operation)
51
+ .with({ verb: 'upsert' }, (upsert) =>
52
+ writeRow(db, upsert.table, getTable(consumer, upsert.table), upsert.row),
53
+ )
54
+ .with({ verb: 'delete' }, (removal) => {
55
+ const table = getTable(consumer, removal.table);
56
+
57
+ return removeRow(db, removal.table, table, buildRowKey(table, removal.key));
58
+ })
59
+ .with({ verb: 'link' }, (linking) => writeLink(db, consumer.name, null, linking.link))
60
+ .with({ verb: 'unlink' }, (unlinking) => removeLink(db, consumer.name, unlinking.link))
61
+ .exhaustive();
62
+ }
63
+ }
64
+
65
+ function getTable(consumer: ConsumerDeclaration, name: string): TableDeclaration {
66
+ const table = consumer.tables[name];
67
+
68
+ invariant(table, 'the parse of the operations refuses a table the consumer does not own');
69
+
70
+ return table;
71
+ }
@@ -0,0 +1,34 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { toPascalCase } from '@pepys/kit/generators';
3
+ import { buildReadFunctions } from '../consumer-entries/build-read-functions.ts';
4
+ import type { HostFunctions } from '../consumer-entries/run-entry.ts';
5
+ import { readTableRows } from '../rows/read-table-rows.ts';
6
+ import type { StoreDatabase } from '../store-database/types.ts';
7
+
8
+ // The row reads of a fact-mode consumer, over the rows its `reads` tables hold now. A module reads
9
+ // synchronously, so the rows are read before each call. The argument of a read is the key: the
10
+ // value of a key of one column as text, or the JSON text of the values of a key of several.
11
+ export async function readRowFunctions(
12
+ db: StoreDatabase,
13
+ consumer: ConsumerDeclaration,
14
+ ): Promise<HostFunctions> {
15
+ const functions: HostFunctions[] = [];
16
+
17
+ for (const name of consumer.reads) {
18
+ const table = consumer.tables[name];
19
+ const rows = table === undefined ? [] : await readTableRows(db, name, table);
20
+
21
+ const texts = new Map(
22
+ rows.map((row) => {
23
+ const values = (table?.primaryKey ?? []).map((column) => row[column]);
24
+ const key = values.length === 1 ? String(values[0]) : JSON.stringify(values);
25
+
26
+ return [key, JSON.stringify(row)];
27
+ }),
28
+ );
29
+
30
+ functions.push(buildReadFunctions(`read${toPascalCase(name)}`, texts, 'none'));
31
+ }
32
+
33
+ return Object.fromEntries(functions.flatMap((pair) => Object.entries(pair)));
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { applyInBothOrders } from './orders/apply-in-both-orders.ts';
2
+ export type { OrderReport, StoreSnapshot } from './orders/apply-in-both-orders.ts';
3
+ export type { MemoryStoreReader } from './memory-store-reader/create-memory-store-reader.ts';
4
+ export { createTestStore } from './test-store/create-test-store.ts';
5
+ export type { AppliedFact, ApplyOptions, TestStore } from './test-store/types.ts';
6
+ export { ApplyError } from './utils/apply-error.ts';
7
+ export type { ApplyFailure, ChangeEntry, Row } from './utils/types.ts';
@@ -0,0 +1,14 @@
1
+ import type { LinkRow } from '@pepys/kit';
2
+ import type { StoreDatabase } from '../store-database/types.ts';
3
+
4
+ // Every link, or every link of one consumer, in the order of its key.
5
+ export function readLinkRows(db: StoreDatabase, origin?: string): Promise<LinkRow[]> {
6
+ const links = db.selectFrom('link').selectAll();
7
+
8
+ return (origin === undefined ? links : links.where('origin', '=', origin))
9
+ .orderBy('type')
10
+ .orderBy('from_id')
11
+ .orderBy('to_id')
12
+ .orderBy('origin')
13
+ .execute();
14
+ }
@@ -0,0 +1,16 @@
1
+ import type { StoreDatabase } from '../store-database/types.ts';
2
+ import type { WireLink } from '../utils/types.ts';
3
+
4
+ export async function removeLink(
5
+ db: StoreDatabase,
6
+ origin: string,
7
+ link: Pick<WireLink, 'type' | 'from' | 'to'>,
8
+ ): Promise<void> {
9
+ await db
10
+ .deleteFrom('link')
11
+ .where('origin', '=', origin)
12
+ .where('type', '=', link.type)
13
+ .where('from_id', '=', link.from)
14
+ .where('to_id', '=', link.to)
15
+ .execute();
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { StoreDatabase } from '../store-database/types.ts';
2
+ import type { WireLink } from '../utils/types.ts';
3
+
4
+ // Writes the link of `origin`, the consumer's name, with the scope that owns it, or null in fact
5
+ // mode.
6
+ export async function writeLink(
7
+ db: StoreDatabase,
8
+ origin: string,
9
+ scope: string | null,
10
+ link: WireLink,
11
+ ): Promise<void> {
12
+ await db
13
+ .replaceInto('link')
14
+ .values({
15
+ type: link.type,
16
+ from_id: link.from,
17
+ to_id: link.to,
18
+ scope,
19
+ origin,
20
+ confidence: link.confidence,
21
+ evidence: link.evidence,
22
+ })
23
+ .execute();
24
+ }
@@ -0,0 +1,61 @@
1
+ import type { CheckedDeployment, ConsumersDB, StoreReader } from '@pepys/kit';
2
+ import { createTursoDialect } from '@pepys/kysely-turso';
3
+ import type { Database } from '@tursodatabase/database';
4
+ import { Kysely } from 'kysely';
5
+
6
+ // The memory adapter of the store reader port. `markDown` gives a test the path of a consumer with
7
+ // no current version.
8
+ export interface MemoryStoreReader extends StoreReader {
9
+ markDown: (consumer: string) => void;
10
+ }
11
+
12
+ // One database holds every consumer, so the two shared views show the rows of every consumer, the
13
+ // ones a call does not attach included. Each call runs in a transaction that rolls back, so a
14
+ // handler keeps nothing it writes. The test store owns the database and closes it.
15
+ export function createMemoryStoreReader(
16
+ database: Database,
17
+ deployment: CheckedDeployment,
18
+ ): MemoryStoreReader {
19
+ const down = new Set<string>();
20
+ const deployed = new Set(deployment.consumers.map((consumer) => consumer.name));
21
+
22
+ const isCurrent = (name: string) => deployed.has(name) && !down.has(name);
23
+
24
+ return {
25
+ markDown: (consumer) => {
26
+ down.add(consumer);
27
+ },
28
+ withConsumers: async (consumers, work, options) => {
29
+ const missing = consumers.map((consumer) => consumer.name).filter((name) => !isCurrent(name));
30
+
31
+ if (missing.length > 0) {
32
+ return { ok: false, failure: { reason: 'no-current-version', consumers: missing } };
33
+ }
34
+
35
+ const optional = (options?.whenCurrent ?? []).map((consumer) => consumer.name);
36
+
37
+ const attached = [
38
+ ...consumers.map((consumer) => consumer.name),
39
+ ...optional.filter((name) => isCurrent(name)),
40
+ ];
41
+
42
+ const db = new Kysely<ConsumersDB<typeof consumers>>({
43
+ dialect: createTursoDialect({ database: () => Promise.resolve(database) }),
44
+ });
45
+
46
+ const transaction = await db.startTransaction().execute();
47
+
48
+ try {
49
+ const value = await work(transaction, {
50
+ attached: Object.fromEntries(attached.map((name) => [name, { version: 'memory' }])),
51
+ skipped: optional.filter((name) => !isCurrent(name)),
52
+ });
53
+
54
+ return { ok: true, value };
55
+ } finally {
56
+ await transaction.rollback().execute();
57
+ }
58
+ },
59
+ close: () => Promise.resolve(),
60
+ };
61
+ }
@@ -0,0 +1,168 @@
1
+ import type { ConsumerDeclaration, Result } from '@pepys/kit';
2
+ import type { ApplyFailure, Row, WireLink } from '../utils/types.ts';
3
+ import type { Operation } from './types.ts';
4
+
5
+ // The wire of a module's output: one JSON list, each item an object with exactly one verb.
6
+ export function parseOperations(
7
+ consumer: ConsumerDeclaration,
8
+ output: string,
9
+ ): Result<Operation[], ApplyFailure> {
10
+ const bad = (reason: string): Result<Operation[], ApplyFailure> => ({
11
+ ok: false,
12
+ failure: { kind: 'bad-operation', consumer: consumer.name, reason },
13
+ });
14
+
15
+ const parsed = parseOutput(output);
16
+
17
+ if (!parsed.ok) {
18
+ return bad('the output is not JSON');
19
+ }
20
+
21
+ if (!Array.isArray(parsed.value)) {
22
+ return bad('the output is not a list');
23
+ }
24
+
25
+ const operations: Operation[] = [];
26
+
27
+ for (const item of parsed.value) {
28
+ const operation = parseOperation(consumer, item);
29
+
30
+ if (!operation.ok) {
31
+ return operation;
32
+ }
33
+
34
+ operations.push(operation.value);
35
+ }
36
+
37
+ return { ok: true, value: operations };
38
+ }
39
+
40
+ function parseOutput(output: string): Result<unknown, undefined> {
41
+ try {
42
+ const value: unknown = JSON.parse(output, reviveNumber);
43
+
44
+ return { ok: true, value };
45
+ } catch {
46
+ return { ok: false, failure: undefined };
47
+ }
48
+ }
49
+
50
+ interface ReviverContext {
51
+ source?: string;
52
+ }
53
+
54
+ // JSON.parse loses the text of a number, so `1.0` reads as `1`. The reviver's source text keeps
55
+ // it: each object records the keys whose number had a fraction or an exponent.
56
+ const inexactKeys = new WeakMap<object, Set<string>>();
57
+
58
+ function reviveNumber(
59
+ this: unknown,
60
+ key: string,
61
+ value: unknown,
62
+ context?: ReviverContext,
63
+ ): unknown {
64
+ if (typeof value === 'number' && isObject(this) && /[.eE]/u.test(context?.source ?? '')) {
65
+ inexactKeys.set(this, new Set([...(inexactKeys.get(this) ?? []), key]));
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ function isObject(value: unknown): value is Row {
72
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ function parseOperation(
76
+ consumer: ConsumerDeclaration,
77
+ item: unknown,
78
+ ): Result<Operation, ApplyFailure> {
79
+ const bad = (reason: string): Result<Operation, ApplyFailure> => ({
80
+ ok: false,
81
+ failure: { kind: 'bad-operation', consumer: consumer.name, reason },
82
+ });
83
+
84
+ if (!isObject(item)) {
85
+ return bad('an operation is not an object');
86
+ }
87
+
88
+ const entries = Object.entries(item);
89
+ const [verb, body] = entries[0] ?? [];
90
+
91
+ if (entries.length !== 1) {
92
+ return bad('an operation does not hold exactly one verb');
93
+ }
94
+
95
+ if (verb === 'link') {
96
+ const link = parseLink(body);
97
+
98
+ return link === undefined
99
+ ? bad('a link is not a type, two ends, a confidence and an evidence')
100
+ : { ok: true, value: { verb: 'link', link } };
101
+ }
102
+
103
+ if (verb === 'unlink') {
104
+ const link = parseLinkKey(body);
105
+
106
+ return link === undefined
107
+ ? bad('an unlink is not a type and two ends')
108
+ : { ok: true, value: { verb: 'unlink', link } };
109
+ }
110
+
111
+ if (verb !== 'upsert' && verb !== 'delete') {
112
+ return bad(`the verb ${String(verb)} is unknown`);
113
+ }
114
+
115
+ const tables = isObject(body) ? Object.entries(body) : [];
116
+ const [table, row] = tables[0] ?? [];
117
+
118
+ if (tables.length !== 1 || table === undefined || !isObject(row)) {
119
+ return bad(`an ${verb} does not hold exactly one table and one row`);
120
+ }
121
+
122
+ if (!Object.hasOwn(consumer.tables, table)) {
123
+ return { ok: false, failure: { kind: 'not-owned', consumer: consumer.name, table } };
124
+ }
125
+
126
+ const columns = inexactKeys.get(row) ?? new Set<string>();
127
+
128
+ return {
129
+ ok: true,
130
+ value:
131
+ verb === 'upsert'
132
+ ? { verb, table, row, inexact: columns }
133
+ : { verb, table, key: row, inexact: columns },
134
+ };
135
+ }
136
+
137
+ function parseLink(body: unknown): WireLink | undefined {
138
+ const key = parseLinkKey(body);
139
+
140
+ if (key === undefined || !isObject(body)) {
141
+ return undefined;
142
+ }
143
+
144
+ const confidence = body['confidence'];
145
+ const evidence = body['evidence'];
146
+
147
+ if (typeof confidence !== 'number' || typeof evidence !== 'string') {
148
+ return undefined;
149
+ }
150
+
151
+ return { ...key, confidence, evidence };
152
+ }
153
+
154
+ function parseLinkKey(body: unknown): Pick<WireLink, 'type' | 'from' | 'to'> | undefined {
155
+ if (!isObject(body)) {
156
+ return undefined;
157
+ }
158
+
159
+ const type = body['type'];
160
+ const from = body['from'];
161
+ const to = body['to'];
162
+
163
+ if (typeof type !== 'string' || typeof from !== 'string' || typeof to !== 'string') {
164
+ return undefined;
165
+ }
166
+
167
+ return { type, from, to };
168
+ }
@@ -0,0 +1,9 @@
1
+ import type { Row, WireLink } from '../utils/types.ts';
2
+
3
+ // One operation of a module's output. `inexact` names the columns whose number the module wrote
4
+ // with a fraction or an exponent.
5
+ export type Operation =
6
+ | { verb: 'upsert'; table: string; row: Row; inexact: ReadonlySet<string> }
7
+ | { verb: 'delete'; table: string; key: Row; inexact: ReadonlySet<string> }
8
+ | { verb: 'link'; link: WireLink }
9
+ | { verb: 'unlink'; link: Pick<WireLink, 'type' | 'from' | 'to'> };