@supawatch/verify 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omar Dulaimi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ import { type Snapshot } from "@supawatch/core";
2
+ export declare const FIXTURE_SQL = "\ncreate type parcel_state as enum ('queued', 'shipped', 'lost');\ncreate type dimensions as (width_mm int4, height_mm int4);\ncreate domain tracking_code as text;\ncreate domain weight_grams as int4;\n\ncreate table parcels (\n size dimensions,\n tracking tracking_code not null default 'T-1',\n weight weight_grams not null default 250,\n id serial primary key,\n small int2 not null default 1,\n big int8 not null default 9007199254740993,\n ratio float4 not null default 0.5,\n wide float8 not null default 2.25,\n price numeric(10,2) not null default 19.99,\n ref uuid not null default gen_random_uuid(),\n label text not null default 'x',\n short_code varchar(12) not null default 'abc',\n padded bpchar(4) not null default 'ab',\n active bool not null default true,\n seen_at timestamptz not null default now(),\n local_at timestamp not null default now(),\n blob_j json not null default '{\"a\":1}',\n blob_jb jsonb not null default '{\"b\":2}',\n state parcel_state not null default 'queued',\n shipped_on date not null default '2026-02-03',\n cutoff time not null default '13:45:10',\n cutoff_tz timetz not null default '13:45:10+02',\n transit interval not null default '1 day 02:00:00',\n stamp bytea not null default '\\xdeadbeef',\n source_ip inet not null default '192.168.0.1',\n subnet cidr not null default '10.0.0.0/8',\n device macaddr not null default '08:00:2b:01:02:03',\n tags text[] not null default array['x','y'],\n counts int4[] not null default array[1,2,3],\n amounts numeric[] not null default array['1.50']::numeric[],\n states parcel_state[] not null default array['queued']::parcel_state[],\n grid int4[][] not null default array[array[1,2],array[3,4]],\n note text\n);\n\ninsert into parcels (note, size) values\n ('first', row(100, 40)::dimensions),\n (null, null);\n\ncreate view lost_parcels as\n select id, tracking, state from parcels where state = 'lost';\n";
3
+ export declare function assertFixtureCompleteness(snapshot: Snapshot): void;
@@ -0,0 +1,65 @@
1
+ import { MAPPED_PG_TYPES } from "@supawatch/core";
2
+ // The harness fixture exercises EVERY row of core's runtime map, plus an
3
+ // enum and nullable variants. The completeness check below is what makes
4
+ // "every mapping row is verified" a build property instead of a hope.
5
+ export const FIXTURE_SQL = `
6
+ create type parcel_state as enum ('queued', 'shipped', 'lost');
7
+ create type dimensions as (width_mm int4, height_mm int4);
8
+ create domain tracking_code as text;
9
+ create domain weight_grams as int4;
10
+
11
+ create table parcels (
12
+ size dimensions,
13
+ tracking tracking_code not null default 'T-1',
14
+ weight weight_grams not null default 250,
15
+ id serial primary key,
16
+ small int2 not null default 1,
17
+ big int8 not null default 9007199254740993,
18
+ ratio float4 not null default 0.5,
19
+ wide float8 not null default 2.25,
20
+ price numeric(10,2) not null default 19.99,
21
+ ref uuid not null default gen_random_uuid(),
22
+ label text not null default 'x',
23
+ short_code varchar(12) not null default 'abc',
24
+ padded bpchar(4) not null default 'ab',
25
+ active bool not null default true,
26
+ seen_at timestamptz not null default now(),
27
+ local_at timestamp not null default now(),
28
+ blob_j json not null default '{"a":1}',
29
+ blob_jb jsonb not null default '{"b":2}',
30
+ state parcel_state not null default 'queued',
31
+ shipped_on date not null default '2026-02-03',
32
+ cutoff time not null default '13:45:10',
33
+ cutoff_tz timetz not null default '13:45:10+02',
34
+ transit interval not null default '1 day 02:00:00',
35
+ stamp bytea not null default '\\xdeadbeef',
36
+ source_ip inet not null default '192.168.0.1',
37
+ subnet cidr not null default '10.0.0.0/8',
38
+ device macaddr not null default '08:00:2b:01:02:03',
39
+ tags text[] not null default array['x','y'],
40
+ counts int4[] not null default array[1,2,3],
41
+ amounts numeric[] not null default array['1.50']::numeric[],
42
+ states parcel_state[] not null default array['queued']::parcel_state[],
43
+ grid int4[][] not null default array[array[1,2],array[3,4]],
44
+ note text
45
+ );
46
+
47
+ insert into parcels (note, size) values
48
+ ('first', row(100, 40)::dimensions),
49
+ (null, null);
50
+
51
+ create view lost_parcels as
52
+ select id, tracking, state from parcels where state = 'lost';
53
+ `;
54
+ export function assertFixtureCompleteness(snapshot) {
55
+ const seen = new Set();
56
+ for (const table of snapshot.tables) {
57
+ for (const col of table.columns)
58
+ seen.add(col.pgTypeName);
59
+ }
60
+ const missing = MAPPED_PG_TYPES.filter((t) => !seen.has(t));
61
+ if (missing.length > 0) {
62
+ throw new Error(`runtime-map rows with no fixture coverage: ${missing.join(", ")}; ` +
63
+ "add columns to the harness fixture before adding mapping rows");
64
+ }
65
+ }
@@ -0,0 +1,46 @@
1
+ import { type Target, type TargetOptions } from "@supawatch/core";
2
+ export interface HarnessTarget {
3
+ target: Target;
4
+ options: TargetOptions;
5
+ }
6
+ export interface AllowedDivergence {
7
+ id: string;
8
+ target: string;
9
+ caseName: string;
10
+ reason: string;
11
+ }
12
+ export interface ParityCase {
13
+ table: string;
14
+ caseName: string;
15
+ verdicts: Record<string, boolean>;
16
+ agreed: boolean;
17
+ allowedId?: string;
18
+ }
19
+ export interface GroundTruthRow {
20
+ target: string;
21
+ table: string;
22
+ rows: number;
23
+ passed: number;
24
+ failures: string[];
25
+ }
26
+ export interface NegativeResult {
27
+ target: string;
28
+ caseName: string;
29
+ fired: boolean;
30
+ }
31
+ export interface HarnessResult {
32
+ groundTruth: GroundTruthRow[];
33
+ negatives: NegativeResult[];
34
+ parity: ParityCase[];
35
+ unfiredAllowed: string[];
36
+ problems: string[];
37
+ }
38
+ export declare const DRIVER_DELTAS: readonly [{
39
+ readonly id: "int8-bigint-vs-string";
40
+ readonly detail: "PGlite parses int8 to a JS BigInt; postgres.js returns its decimal string. Normalized: BigInt -> String(value).";
41
+ }];
42
+ export declare function runHarness(opts: {
43
+ targets: HarnessTarget[];
44
+ workDir: string;
45
+ allowed?: AllowedDivergence[];
46
+ }): Promise<HarnessResult>;
@@ -0,0 +1,212 @@
1
+ import path from "node:path";
2
+ import { PGlite } from "@electric-sql/pglite";
3
+ import { assemble, atomicSink, introspect, } from "@supawatch/core";
4
+ import { FIXTURE_SQL, assertFixtureCompleteness } from "./fixture.js";
5
+ // Where PGlite's client and postgres.js disagree about a type's JS value,
6
+ // the harness normalizes to the postgres.js profile, because that is what
7
+ // consumers run. Each delta is named here; a new one must be added
8
+ // consciously, not absorbed silently.
9
+ export const DRIVER_DELTAS = [
10
+ {
11
+ id: "int8-bigint-vs-string",
12
+ detail: "PGlite parses int8 to a JS BigInt; postgres.js returns its decimal string. Normalized: BigInt -> String(value).",
13
+ },
14
+ ];
15
+ function normalize(value) {
16
+ if (typeof value === "bigint")
17
+ return value.toString();
18
+ return value;
19
+ }
20
+ function querierFromPglite(db) {
21
+ return async (text, params) => {
22
+ const result = await db.query(text, params);
23
+ return result.rows.map((row) => {
24
+ const out = {};
25
+ for (const [k, v] of Object.entries(row))
26
+ out[k] = normalize(v);
27
+ return out;
28
+ });
29
+ };
30
+ }
31
+ // Build per-column wrong values from the runtime kind. Each one is a value
32
+ // the driver can never produce for that column, so every target must
33
+ // reject it. json and unknown accept anything and produce no negatives;
34
+ // so does an array of unknown, which is the honest multidim mapping.
35
+ function negativeValueFor(table, colName) {
36
+ const col = table.columns.find((c) => c.name === colName);
37
+ return wrongValueFor(col.runtime);
38
+ }
39
+ function wrongValueFor(runtime) {
40
+ switch (runtime.kind) {
41
+ case "number":
42
+ return "42";
43
+ case "string":
44
+ return 42;
45
+ case "boolean":
46
+ return "true";
47
+ case "date":
48
+ return "2026-01-01T00:00:00Z";
49
+ case "bytes":
50
+ return "deadbeef";
51
+ case "enum":
52
+ return "not_a_label";
53
+ case "array": {
54
+ // Two failure modes: not an array at all, or a wrong element.
55
+ // The scalar case here is the not-an-array one; the element-level
56
+ // negative is added separately below.
57
+ return "not-an-array";
58
+ }
59
+ case "json":
60
+ case "unknown":
61
+ return undefined;
62
+ }
63
+ }
64
+ function wrongElementFor(runtime) {
65
+ if (runtime.kind !== "array")
66
+ return undefined;
67
+ const el = wrongValueFor(runtime.element);
68
+ if (el === undefined)
69
+ return undefined;
70
+ return [el];
71
+ }
72
+ export async function runHarness(opts) {
73
+ const allowed = opts.allowed ?? [];
74
+ const db = new PGlite();
75
+ const problems = [];
76
+ try {
77
+ await db.exec(FIXTURE_SQL);
78
+ const query = querierFromPglite(db);
79
+ const snapshot = await introspect(query);
80
+ assertFixtureCompleteness(snapshot);
81
+ // Emit every target's schemas once, into workDir/<target>/.
82
+ const loaded = new Map();
83
+ for (const { target, options } of opts.targets) {
84
+ if (!target.verifier) {
85
+ throw new Error(`harness target ${target.name} has no verifier; type-only targets do not belong in the harness`);
86
+ }
87
+ const dir = path.join(opts.workDir, target.name);
88
+ const verifier = target.verifier();
89
+ const schemaByTable = new Map();
90
+ for (const table of snapshot.tables) {
91
+ const rendered = target.renderTable(table, snapshot, options);
92
+ const file = path.join(dir, `${table.name}${target.fileExtension}`);
93
+ await atomicSink.write(file, assemble(rendered));
94
+ schemaByTable.set(table.name, await verifier.load(file, rendered.exportName));
95
+ }
96
+ loaded.set(target.name, { schemaByTable, verifier });
97
+ }
98
+ // Case pool: real rows (positive) plus synthetic negatives.
99
+ const groundTruth = [];
100
+ const negatives = [];
101
+ const parity = [];
102
+ for (const table of snapshot.tables) {
103
+ const rows = await query(`select * from "${table.name}" limit 10`);
104
+ // Ground truth: every real row must be accepted by every target.
105
+ for (const [name, { schemaByTable, verifier }] of loaded) {
106
+ const schema = schemaByTable.get(table.name);
107
+ let passed = 0;
108
+ const failures = [];
109
+ for (const row of rows) {
110
+ const verdict = verifier.check(schema, row);
111
+ if (verdict.ok)
112
+ passed++;
113
+ else
114
+ failures.push(`${table.name}: ${verdict.reason ?? "no reason"}`);
115
+ }
116
+ groundTruth.push({
117
+ target: name,
118
+ table: table.name,
119
+ rows: rows.length,
120
+ passed,
121
+ failures,
122
+ });
123
+ if (passed !== rows.length) {
124
+ problems.push(`ground truth: ${name} rejected a real ${table.name} row (${failures[0]})`);
125
+ }
126
+ }
127
+ if (rows.length === 0)
128
+ continue;
129
+ const base = rows[0];
130
+ // Negatives and parity, one case per column plus one extra-key case.
131
+ const cases = [];
132
+ for (const col of table.columns) {
133
+ const wrong = negativeValueFor(table, col.name);
134
+ if (wrong === undefined)
135
+ continue;
136
+ cases.push({
137
+ caseName: `${table.name}.${col.name}:wrong-${col.runtime.kind}`,
138
+ value: { ...base, [col.name]: wrong },
139
+ expectReject: true,
140
+ });
141
+ const wrongElement = wrongElementFor(col.runtime);
142
+ if (wrongElement !== undefined) {
143
+ cases.push({
144
+ caseName: `${table.name}.${col.name}:wrong-element`,
145
+ value: { ...base, [col.name]: wrongElement },
146
+ expectReject: true,
147
+ });
148
+ }
149
+ if (!col.nullable && col.runtime.kind !== "unknown" && col.runtime.kind !== "json") {
150
+ cases.push({
151
+ caseName: `${table.name}.${col.name}:null-in-not-null`,
152
+ value: { ...base, [col.name]: null },
153
+ expectReject: true,
154
+ });
155
+ }
156
+ }
157
+ cases.push({
158
+ caseName: `${table.name}:extra-key`,
159
+ value: { ...base, __supawatch_extra: 1 },
160
+ expectReject: true,
161
+ });
162
+ for (const c of cases) {
163
+ const verdicts = {};
164
+ for (const [name, { schemaByTable, verifier }] of loaded) {
165
+ const schema = schemaByTable.get(table.name);
166
+ const verdict = verifier.check(schema, c.value);
167
+ verdicts[name] = verdict.ok;
168
+ if (c.expectReject) {
169
+ const fired = !verdict.ok;
170
+ negatives.push({ target: name, caseName: c.caseName, fired });
171
+ if (!fired) {
172
+ const entry = allowed.find((a) => a.target === name && a.caseName === c.caseName);
173
+ if (!entry) {
174
+ problems.push(`negative did not fire: ${name} accepted ${c.caseName}`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ const values = Object.values(verdicts);
180
+ const agreed = values.every((x) => x === values[0]);
181
+ let allowedId;
182
+ if (!agreed) {
183
+ const entry = allowed.find((a) => a.caseName === c.caseName);
184
+ allowedId = entry?.id;
185
+ if (!entry) {
186
+ problems.push(`parity: targets disagree on ${c.caseName} (${JSON.stringify(verdicts)}) with no ALLOWED entry`);
187
+ }
188
+ }
189
+ parity.push({ table: table.name, caseName: c.caseName, verdicts, agreed, allowedId });
190
+ }
191
+ }
192
+ // Ledger hygiene: every ALLOWED entry must have fired.
193
+ const firedIds = new Set(parity.filter((p) => p.allowedId).map((p) => p.allowedId));
194
+ for (const n of negatives) {
195
+ if (!n.fired) {
196
+ const entry = allowed.find((a) => a.target === n.target && a.caseName === n.caseName);
197
+ if (entry)
198
+ firedIds.add(entry.id);
199
+ }
200
+ }
201
+ const unfiredAllowed = allowed
202
+ .filter((a) => !firedIds.has(a.id))
203
+ .map((a) => a.id);
204
+ for (const id of unfiredAllowed) {
205
+ problems.push(`ALLOWED entry ${id} never fired; the divergence it excuses is gone, remove it`);
206
+ }
207
+ return { groundTruth, negatives, parity, unfiredAllowed, problems };
208
+ }
209
+ finally {
210
+ await db.close();
211
+ }
212
+ }
@@ -0,0 +1,2 @@
1
+ export { runHarness, DRIVER_DELTAS, type AllowedDivergence, type GroundTruthRow, type HarnessResult, type HarnessTarget, type NegativeResult, type ParityCase, } from "./harness.js";
2
+ export { FIXTURE_SQL, assertFixtureCompleteness } from "./fixture.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { runHarness, DRIVER_DELTAS, } from "./harness.js";
2
+ export { FIXTURE_SQL, assertFixtureCompleteness } from "./fixture.js";
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@supawatch/verify",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "author": "Omar Dulaimi",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/omar-dulaimi/supawatch.git",
9
+ "directory": "packages/verify"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "dependencies": {
24
+ "@electric-sql/pglite": "^0.3.0",
25
+ "@supawatch/core": "0.1.1"
26
+ },
27
+ "devDependencies": {
28
+ "@sinclair/typebox": "^0.34.52",
29
+ "@types/node": "^22.20.1",
30
+ "arktype": "^2.2.3",
31
+ "typescript": "^5.9.0",
32
+ "valibot": "^1.1.0",
33
+ "zod": "^4.0.0",
34
+ "@supawatch/target-typebox": "0.1.1",
35
+ "@supawatch/target-valibot": "0.1.1",
36
+ "@supawatch/target-zod": "0.1.1",
37
+ "@supawatch/target-arktype": "0.1.1"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json"
41
+ }
42
+ }