Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/value-roundtrip-conformance-case-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": patch
---

test(spec,drivers): add the `VALUE_ROUNDTRIP` conformance case-set — "what you wrote is what you read back", enforced per driver per dialect (#12393)

The driver-conformance census was green at 9 of 9 dialect-scored cells after
#12136 promoted `MATRIXED`, and **none of its nine case-sets was about value
storage**. All nine ask *which rows come back*; none asks *what is in them*. So
that green was not weak evidence about a round-trip defect — it was no evidence
at all, and it would have stayed green forever with the defect in place. That is
why this family kept arriving one card at a time: #12380 (SQLite's `Field.json`
codec was not injective), #11535 (a multi-value field read back as the string
`'["x","y"]'`), #11782 (MySQL answering `1`/`0` for a declared boolean), #10995
(PG json values bound without `JSON.stringify`).

`VALUE_ROUNDTRIP_CASES` closes it as a class rather than as a tenth instance. It
is 41 cases over five declared value classes — `json`, `multiple: true`,
`string`, `number`, `boolean` — and every value in it is one some driver was
**measured** to change, or a control that stayed faithful in the same
measurement. Assertions pin **type as well as value**: the before-state of every
card above was a wrong type carrying a right-looking value, which survives
`toEqual`-style coercion and every truthiness check. `VALUE_ROUNDTRIP_COLLISION_PAIRS`
adds the injectivity half a per-value check cannot see — a string and the native
value whose encoding it resembles must stay distinguishable.

Enrolled through the census's existing machinery rather than as a bespoke suite,
which is the whole argument for this route: `CLASSIFIED` obliges the new fixture
to be named in `CASE_SETS`, `CONSUMED` obliges every driver to run it, and
`MATRIXED` obliges `driver-sql`'s cell to be answered on **every dialect it
speaks** rather than on SQLite alone — the coverage shape that let #12380 survive
in the first place. The census now reads **50 covered cells across 5 drivers ×
10 case-sets, 10 of 10 dialect-scored cells matrix-routed, 0 DEBT, 0 exempt**.

**No shipped behaviour and no public surface changes.** This is `@objectstack/spec`'s
`data` export gaining one conformance fixture, six new test files, and one
`CASE_SETS` row in the census script. No Zod schema, no runtime, no driver
source, no API. Graded `patch` for that reason: the package's published surface
grows by a test fixture that only conformance suites consume, and nothing an
existing consumer resolves changes shape.

The one non-test change is a **test-double fidelity fix** the new case-set
surfaced: `driver-turso`'s `makeLibsqlSqliteStub` did not model `@libsql/client`'s
client-side boolean → `1`/`0` conversion, so a declared `boolean` written through
the REMOTE transport could not be bound at all. Verified against the dependency's
own source rather than the transport's comment; the transport is correct and
unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12393] `driver-memory` held to `VALUE_ROUNDTRIP_CASES` — the shared
* `@objectstack/spec/data` table: what you wrote is what you read back.
*
* This driver runs in process, so every case here is a REAL execution — no
* server-free half in the shape `driver-mongodb` needs (#5517), and no
* emitted-shape assertion standing in for a value.
*
* ## Why an in-process store is not exempt from this table
*
* "It keeps the object you handed it, so of course it round-trips" is the
* assumption the census exists to disprove, and this driver has broken it
* before on the neighbouring axis: `computeAggregate` had no `count_distinct`
* arm and answered `null` silently (#6814). A store that clones, normalises,
* indexes or re-serialises a written value on the way in or out has exactly the
* seam every other driver has — and if it ever grows one, this is the file that
* says so. Its being green today is the measurement, not a reason to skip it.
*
* The reverse-verification leg below is what keeps it from being a test that
* cannot fail.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import {
VALUE_ROUNDTRIP_CASES,
VALUE_ROUNDTRIP_COLLISION_PAIRS,
VALUE_ROUNDTRIP_FIELDS,
VALUE_ROUNDTRIP_ROWS,
valueRoundTripDivergence,
} from '@objectstack/spec/data';
import type { DriverQuery } from '@objectstack/spec/contracts';
import { InMemoryDriver } from './memory-driver.js';

const TABLE = 'conformance_value_roundtrip';

describe('[#12393] driver-memory — value storage round-trip conformance', () => {
let driver: InMemoryDriver;

beforeAll(async () => {
driver = new InMemoryDriver();
// `syncSchema` rather than `initObjects`: this driver is SCHEMALESS and has
// no `initObjects` at all. Declaring the object anyway is the point — it is
// what makes the declaration reachable if this driver ever grows a
// declared-type write path, and it is the same declaration every sibling
// suite hands its own driver.
await driver.syncSchema(TABLE, { fields: { ...VALUE_ROUNDTRIP_FIELDS } });
for (const row of VALUE_ROUNDTRIP_ROWS) {
await driver.create(TABLE, { ...row });
}
});

// The fixture read back rather than trusted: a seed that dropped or folded a
// row would turn every assertion below into a test of the wrong table.
it('the fixture is one row per case', async () => {
const rows = (await driver.find(TABLE, {})) as Array<{ label: string }>;
expect(rows.map((r) => r.label).sort()).toEqual(VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort());
});

for (const c of VALUE_ROUNDTRIP_CASES) {
it(`round-trips ${c.name} (${c.note})`, async () => {
const rows = (await driver.find(TABLE, {
where: { label: c.name },
} as DriverQuery)) as any[];
expect(rows).toHaveLength(1);
const read = rows[0][c.column];
// The type pin comes first: a wrong type carrying a right-looking value
// is the before-state of every card this table was written from, and it
// survives every value-only comparison.
expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote);
expect(read, `value for ${c.name}`).toStrictEqual(c.wrote);
});
}

it('every case in the table round-trips — the whole set at once', async () => {
const rows = (await driver.find(TABLE, {})) as any[];
const byLabel = new Map(rows.map((r) => [r.label, r]));
const divergences = VALUE_ROUNDTRIP_CASES.map((c) =>
valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]),
).filter((d): d is string => d !== null);
expect(
divergences,
`${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`,
).toEqual([]);
});

it('a string and the native value it looks like stay distinguishable', async () => {
const rows = (await driver.find(TABLE, {})) as any[];
const byLabel = new Map(rows.map((r) => [r.label, r]));
const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!;
for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) {
const s = byLabel.get(strName)?.[caseOf(strName).column];
const n = byLabel.get(nativeName)?.[caseOf(nativeName).column];
expect(typeof s, `${strName} must read back as a string`).toBe('string');
expect(
JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n,
`${strName} and ${nativeName} read identically`,
).toBe(false);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#12393] `driver-mongodb` held to `VALUE_ROUNDTRIP_CASES` — the shared
* `@objectstack/spec/data` table, answered **without a server**.
*
* ## Why the assertions run in-process rather than against mongod
*
* The same reason as `mongodb-filter-text-conformance.test.ts` (#6682) and
* `mongodb-comparand-type-conformance.test.ts` (#7872): this package's
* real-mongod suites are opt-in (#5517), so a standard that needed a server
* would not run in CI. What this suite substitutes is not a weaker question —
* it is the same question asked at the seam where this driver's stored values
* are actually decided.
*
* ## Where that seam is, read from source rather than assumed
*
* A `create()` on this driver does exactly two things to a value before it is
* stored, and this suite drives both:
*
* 1. **`toStorageForms`** — the write-side transform. Read at
* `mongodb-driver.ts`: it iterates the object's declared **temporal** fields
* and returns `data` unchanged when there are none. The fixture declares no
* temporal field at all, which the first test asserts mechanically rather
* than leaving as a claim — so for this table the transform is provably an
* identity and there is nothing of it to model.
* 2. **BSON encoding**, which is where every remaining value decision is made.
* `find()` returns the driver documents straight off the cursor (no
* read-side coercion pass exists in this driver), so the round trip a caller
* sees IS `BSON.deserialize(BSON.serialize(doc))`.
*
* ⇒ The server-free judgement here is not "we modelled mongod". It is the
* driver's own two value steps, executed, with the storage engine — which
* stores what BSON hands it — left out. That is the accepted substitute this
* package's sibling conformance suites already use, stated here rather than
* implied.
*
* ⚠️ What it therefore does NOT cover, said plainly: anything mongod itself
* would do to a value after decoding. The real-mongod half of this cell is
* absent, as it is for this driver's aggregation and filter-text cells.
*/

import { describe, it, expect } from 'vitest';
import { BSON } from 'mongodb';
import {
VALUE_ROUNDTRIP_CASES,
VALUE_ROUNDTRIP_COLLISION_PAIRS,
VALUE_ROUNDTRIP_FIELDS,
VALUE_ROUNDTRIP_ROWS,
valueRoundTripDivergence,
} from '@objectstack/spec/data';

/** The temporal types `toStorageForms` reaches — the set that must be empty here. */
const TEMPORAL_TYPES = new Set(['date', 'datetime', 'time']);

const caseOf = (name: string) => VALUE_ROUNDTRIP_CASES.find((c) => c.name === name)!;

/** One row through the driver's storage seam: BSON out, BSON back. */
function throughBson(row: Record<string, unknown>): Record<string, unknown> {
return BSON.deserialize(BSON.serialize(row)) as Record<string, unknown>;
}

describe('[#12393] driver-mongodb — value storage round-trip conformance (server-free)', () => {
/**
* The premise the whole file rests on, asserted rather than asserted-in-prose:
* if the fixture ever grows a temporal column, `toStorageForms` stops being an
* identity and this suite would be modelling one of the driver's two value
* steps instead of both. Then it must drive that transform too — and this
* test is what says so, at the moment it becomes true.
*/
it('the fixture declares no temporal field, so the write-side transform is an identity', () => {
const temporal = Object.entries(VALUE_ROUNDTRIP_FIELDS)
.filter(([, def]) => TEMPORAL_TYPES.has((def as { type: string }).type))
.map(([name]) => name);
expect(temporal).toEqual([]);
});

// The fixture read back rather than trusted: a table that lost a row would
// turn every assertion below into a test of the wrong corpus.
it('the fixture is one row per case', () => {
expect(VALUE_ROUNDTRIP_ROWS.map((r) => r.label).sort()).toEqual(
VALUE_ROUNDTRIP_CASES.map((c) => c.name).sort(),
);
});

for (const c of VALUE_ROUNDTRIP_CASES) {
it(`round-trips ${c.name} (${c.note})`, () => {
const read = throughBson({ label: c.name, [c.column]: c.wrote })[c.column];
// The type pin comes first: a wrong type carrying a right-looking value
// is the before-state of every card this table was written from.
expect(typeof read, `typeof for ${c.name}`).toBe(typeof c.wrote);
expect(read, `value for ${c.name}`).toStrictEqual(c.wrote);
});
}

it('every case in the table round-trips — the whole set at once', () => {
const byLabel = new Map(
VALUE_ROUNDTRIP_ROWS.map((r) => [r.label as string, throughBson({ ...r })]),
);
const divergences = VALUE_ROUNDTRIP_CASES.map((c) =>
valueRoundTripDivergence(c, byLabel.get(c.name)?.[c.column]),
).filter((d): d is string => d !== null);
expect(
divergences,
`${VALUE_ROUNDTRIP_CASES.length - divergences.length}/${VALUE_ROUNDTRIP_CASES.length} faithful`,
).toEqual([]);
});

it('a string and the native value it looks like stay distinguishable', () => {
const byLabel = new Map(
VALUE_ROUNDTRIP_ROWS.map((r) => [r.label as string, throughBson({ ...r })]),
);
for (const [strName, nativeName] of VALUE_ROUNDTRIP_COLLISION_PAIRS) {
const s = byLabel.get(strName)?.[caseOf(strName).column];
const n = byLabel.get(nativeName)?.[caseOf(nativeName).column];
expect(typeof s, `${strName} must read back as a string`).toBe('string');
expect(
JSON.stringify(s) === JSON.stringify(n) && typeof s === typeof n,
`${strName} and ${nativeName} read identically`,
).toBe(false);
}
});
});
Loading
Loading