|
33 | 33 | * reads them. The divergence itself is the defect, so the divergence is what is |
34 | 34 | * pinned: same declaration, same write, both faces, one assertion. |
35 | 35 | * |
36 | | - * # The three pins, and what each one alone would miss |
| 36 | + * # The four pins, and what each one alone would miss |
37 | 37 | * |
38 | 38 | * - **The DDL pin** asserts the UNIQUE index the remote face emits for a |
39 | 39 | * `unique: true` column, by name and by key. Without it a future rewrite of |
|
49 | 49 | * `toThrow()` here is blind in both directions: it stays green on the raw |
50 | 50 | * `SqliteError` this card exists to remove. |
51 | 51 | * |
| 52 | + * - **The parity pin** [#8568] asserts the two faces' refusals against EACH |
| 53 | + * OTHER — message, `code` and `status` — on one condition raised twice. |
| 54 | + * Every pin above it, and #8445's local-face suite, is single-face and pins |
| 55 | + * its own wording as a LITERAL: a reword of one package with its own literal |
| 56 | + * updated alongside it drifts the pair while every suite stays green. That |
| 57 | + * is the state this file was written to make impossible, reached from the |
| 58 | + * one direction it had left open. |
| 59 | + * |
52 | 60 | * The refusal pin carries a **positive control** beside it: with the unique |
53 | 61 | * index present, the same `conflictKeys` upsert MERGES. Without that half, a |
54 | 62 | * transport that refused every `conflictKeys` upsert unconditionally would pass |
55 | 63 | * the refusal pin — and would have broken the capability instead of fixing it. |
| 64 | + * The parity pin carries the same control for the same reason, doubled: two |
| 65 | + * faces that refused everything would agree perfectly. |
56 | 66 | * |
57 | 67 | * # Reverse verification, direction predicted BEFORE it was run |
58 | 68 | * |
|
63 | 73 | * The refusal pin's negative half stays GREEN across the revert: its table |
64 | 74 | * never had a unique index to lose, which is what makes it the pin for the |
65 | 75 | * already-created-table case rather than a second copy of the DDL pin. |
| 76 | + * |
| 77 | + * The parity pin [#8568] has TWO legs to predict, because two-way redness is |
| 78 | + * the entire property being bought: rewording ONLY `driver-sql`'s helper must |
| 79 | + * turn it red, and rewording ONLY `driver-turso`'s must turn it red as well. A |
| 80 | + * pin that reddens on one leg only is the one-way pin this card replaced. Both |
| 81 | + * legs were measured (`1 failed | 9 passed` each, the failure being the message |
| 82 | + * comparison in both). The REMOTE leg is the one worth reading: under it every |
| 83 | + * pre-existing pin in this file stayed GREEN, the refusal pin included — its |
| 84 | + * assertions are token regexes (`/crm_contact_plain/`, `/email/`, `/unique/i`) |
| 85 | + * that a reword preserving those words walks straight past, which is exactly |
| 86 | + * how the drift could have landed unnoticed. |
| 87 | + * |
| 88 | + * ⚠️ One measured trap for whoever runs that verification again: the LOCAL face |
| 89 | + * arrives here through the BUILT `@objectstack/driver-sql` (this package |
| 90 | + * resolves the workspace dependency to its `dist`, and there is no vitest alias |
| 91 | + * to `src`). A reworded `sql-driver.ts` therefore changes nothing until that |
| 92 | + * package is rebuilt — leg 1 ran GREEN against a stale `dist` before the |
| 93 | + * rebuild, which reads exactly like a pin that does not work. |
66 | 94 | */ |
67 | 95 |
|
68 | 96 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
@@ -277,4 +305,92 @@ describe('[#8413] `unique` is enforced on BOTH TursoDriver faces', () => { |
277 | 305 | expect(rows[0].title).toBe('second'); |
278 | 306 | }); |
279 | 307 | }); |
| 308 | + |
| 309 | + // ───────────────────────────────────────────────────────────────────── |
| 310 | + // Pin 4 — the two refusals held against EACH OTHER |
| 311 | + // ───────────────────────────────────────────────────────────────────── |
| 312 | + |
| 313 | + describe('[#8568] the unbacked-conflict-target refusal is ONE answer across both faces', () => { |
| 314 | + /** |
| 315 | + * Pin 3 above asserts the refusal on the REMOTE face; #8445's |
| 316 | + * `sql-driver-upsert-conflict-target-envelope.test.ts` asserts it on the |
| 317 | + * LOCAL one. Both are single-face by construction, and each pins its own |
| 318 | + * wording as a LITERAL — so a reword of one package, with its own literal |
| 319 | + * updated in the same commit, drifts the pair while every suite stays |
| 320 | + * green. `TursoDriver` picks its face from `url`, so that drift makes the |
| 321 | + * answer to one condition a property of the connection string (#5240: one |
| 322 | + * condition, one wording; the defect class of #6203 / #5769). |
| 323 | + * |
| 324 | + * ⚠️ The assertion below is therefore two RUNTIME answers compared to EACH |
| 325 | + * OTHER — never each against a literal. The literal form is what this pin |
| 326 | + * exists to replace: it passes forever while the faces diverge. A parity |
| 327 | + * test measures agreement, so the change that must turn it red is the |
| 328 | + * SINGLE-face one, and reverse verification has to be run on each face |
| 329 | + * separately to prove it (both legs measured — see the header's reverse |
| 330 | + * verification section, including the stale-`dist` trap on the local leg). |
| 331 | + */ |
| 332 | + it('answers the same message, `code` and `status` on the local and the remote face', async () => { |
| 333 | + await local.initObjects([{ ...CONTACT_NO_UNIQUE, fields: { ...CONTACT_NO_UNIQUE.fields } }]); |
| 334 | + await remote.initObjects([{ ...CONTACT_NO_UNIQUE, fields: { ...CONTACT_NO_UNIQUE.fields } }]); |
| 335 | + |
| 336 | + // One condition, raised twice: the same object with no `unique` |
| 337 | + // declaration, the same `conflictKeys` upsert, once per face. |
| 338 | + const localErr = await captureError(() => |
| 339 | + local.upsert(CONTACT_NO_UNIQUE.name, { email: 'a@b.com', title: 'x' }, ['email'], { bypassTenantAudit: true }), |
| 340 | + ); |
| 341 | + const remoteErr = await captureError(() => |
| 342 | + remote.upsert(CONTACT_NO_UNIQUE.name, { email: 'a@b.com', title: 'x' }, ['email']), |
| 343 | + ); |
| 344 | + |
| 345 | + // Neither face may ACCEPT it — without this guard a face that stopped |
| 346 | + // refusing would reach the comparison below holding `null`, and the |
| 347 | + // message equality would report a `TypeError` rather than the divergence. |
| 348 | + expect({ local: localErr !== null, remote: remoteErr !== null }).toEqual({ local: true, remote: true }); |
| 349 | + |
| 350 | + // The comparison this pin is for. `toBe` between two runtime values: |
| 351 | + // rewording either compiler alone turns it red, whichever one it is. |
| 352 | + expect(localErr!.message).toBe(remoteErr!.message); |
| 353 | + expect(localErr!.code).toBe(remoteErr!.code); |
| 354 | + expect(localErr!.status).toBe(remoteErr!.status); |
| 355 | + |
| 356 | + // …and the agreement is ANCHORED, because agreement alone is satisfied by |
| 357 | + // both faces regressing together: two raw SqliteErrors agree on |
| 358 | + // `code: 'SQLITE_ERROR'` and on `status: undefined` just as well. What is |
| 359 | + // worth pinning is agreement ON THE ADR-0112 ENVELOPE. This is a value |
| 360 | + // assertion, not a wording literal — an identical reword of BOTH faces |
| 361 | + // keeps #5240 satisfied and is meant to stay green here. |
| 362 | + expect(localErr!.code).toBe(StandardErrorCode.enum.VALIDATION_ERROR); |
| 363 | + expect(localErr!.status).toBe(400); |
| 364 | + |
| 365 | + // Both faces also keep the SQLite ground truth as `cause` rather than |
| 366 | + // replacing it — the same shape on both, so an operator debugging either |
| 367 | + // deployment reads the same two layers. |
| 368 | + for (const err of [localErr!, remoteErr!]) { |
| 369 | + expect(String((err as unknown as { cause?: Error }).cause?.message)).toMatch( |
| 370 | + /ON CONFLICT clause does not match/i, |
| 371 | + ); |
| 372 | + } |
| 373 | + }); |
| 374 | + |
| 375 | + /** |
| 376 | + * The positive control for the parity pin, mirroring Pin 3's: a pair of |
| 377 | + * faces that refused EVERY `conflictKeys` upsert would agree perfectly and |
| 378 | + * satisfy the assertion above. Agreement is only worth pinning while the |
| 379 | + * capability still works on both — so the backed target must MERGE on both. |
| 380 | + */ |
| 381 | + it('and both faces still MERGE when a declared unique index does back the target', async () => { |
| 382 | + await local.initObjects([{ ...CONTACT, fields: { ...CONTACT.fields } }]); |
| 383 | + await remote.initObjects([{ ...CONTACT, fields: { ...CONTACT.fields } }]); |
| 384 | + |
| 385 | + for (const title of ['first', 'second']) { |
| 386 | + await local.upsert(CONTACT.name, { email: 'a@b.com', title }, ['email'], { bypassTenantAudit: true }); |
| 387 | + await remote.upsert(CONTACT.name, { email: 'a@b.com', title }, ['email']); |
| 388 | + } |
| 389 | + |
| 390 | + expect(await local.count(CONTACT.name, {})).toBe(1); |
| 391 | + expect(await remote.count(CONTACT.name, {})).toBe(1); |
| 392 | + expect((await local.find(CONTACT.name, {}))[0].title).toBe('second'); |
| 393 | + expect((await remote.find(CONTACT.name, {}))[0].title).toBe('second'); |
| 394 | + }); |
| 395 | + }); |
280 | 396 | }); |
0 commit comments