|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
4 | | -import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; |
| 4 | +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs'; |
5 | 5 | import { createRequire } from 'node:module'; |
6 | 6 | import { tmpdir } from 'node:os'; |
7 | 7 | import { dirname, join, resolve } from 'node:path'; |
@@ -494,3 +494,295 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur |
494 | 494 | } |
495 | 495 | }, 60_000); |
496 | 496 | }); |
| 497 | + |
| 498 | +/** |
| 499 | + * #13332 — the same guarantee, end to end, against a real SQL driver. |
| 500 | + * |
| 501 | + * The unit half |
| 502 | + * (`schema-migration-plugins.declaration-boot-write-guard.test.ts`) pins the |
| 503 | + * mechanism on a recording driver. This half proves the property the operator |
| 504 | + * actually depends on: `os migrate plan`'s boot, with a host plugin that |
| 505 | + * registers a writing hook from `init()`, leaves the DATABASE unchanged — on a |
| 506 | + * database whose tables already exist, which is the condition under which the |
| 507 | + * measured inserts SUCCEED instead of failing. |
| 508 | + * |
| 509 | + * The positive control comes first and is load-bearing. The identical plugin, |
| 510 | + * on a boot that composes no host stack (so no declaration composition and no |
| 511 | + * write guard), lands its rows. Without that leg the assertion below would be |
| 512 | + * green over a fixture that could not have written. |
| 513 | + */ |
| 514 | +describe('a plan writes nothing even when the host writes from init() (#13332)', () => { |
| 515 | + let dir: string; |
| 516 | + let dbFile: string; |
| 517 | + let hookLog: string; |
| 518 | + const savedEnv: Record<string, string | undefined> = {}; |
| 519 | + |
| 520 | + const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const; |
| 521 | + |
| 522 | + /** |
| 523 | + * cloud's measured shape, as a plugin this file can hand to either boot: the |
| 524 | + * writing hooks are registered from `init()`, so `composeForDeclarations`'s |
| 525 | + * `start()` suppression never sees them, and they fire on each of the three |
| 526 | + * phases `kernel.ts` triggers unconditionally after the suppressed pass. |
| 527 | + * |
| 528 | + * The driver is found by scanning `driver.*` — the same surface |
| 529 | + * `ObjectQLPlugin`'s discovery loop reads — rather than by naming one, so the |
| 530 | + * fixture does not depend on what the standalone stack calls its default. |
| 531 | + */ |
| 532 | + const initWritingPlugin = (tag: string): any => ({ |
| 533 | + name: `com.example.writes-from-init.${tag}`, |
| 534 | + version: '1.0.0', |
| 535 | + init: async (ctx: any) => { |
| 536 | + for (const phase of PHASES) { |
| 537 | + ctx.hook(phase, async () => { |
| 538 | + appendFileSync(hookLog, `${tag}|log-only|${phase}\n`); |
| 539 | + }); |
| 540 | + ctx.hook(phase, async () => { |
| 541 | + const services: Map<string, any> = ctx.getServices(); |
| 542 | + const entry = [...services.entries()].find(([n]) => n.startsWith('driver.')); |
| 543 | + if (!entry) return; |
| 544 | + await entry[1].create('sys_metadata', { |
| 545 | + id: `os13332-${tag}-${phase}`, |
| 546 | + name: `os13332-${tag}-${phase}`, |
| 547 | + type: 'os13332_probe', |
| 548 | + }); |
| 549 | + appendFileSync(hookLog, `${tag}|write|${phase}\n`); |
| 550 | + }); |
| 551 | + } |
| 552 | + }, |
| 553 | + }); |
| 554 | + |
| 555 | + const probeRows = async (driver: any): Promise<number> => { |
| 556 | + const rows: any = await driver.knex('sys_metadata') |
| 557 | + .where({ type: 'os13332_probe' }) |
| 558 | + .count({ c: '*' }); |
| 559 | + return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1); |
| 560 | + }; |
| 561 | + |
| 562 | + beforeAll(async () => { |
| 563 | + dir = mkdtempSync(join(tmpdir(), 'os-13332-')); |
| 564 | + dbFile = join(dir, 'control.db'); |
| 565 | + hookLog = join(dir, 'hooks.log'); |
| 566 | + writeFileSync(hookLog, ''); |
| 567 | + |
| 568 | + savedEnv.NODE_ENV = process.env.NODE_ENV; |
| 569 | + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; |
| 570 | + process.env.NODE_ENV = 'production'; |
| 571 | + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); |
| 572 | + |
| 573 | + // Materialize `sys_metadata` FIRST, with no host config on disk yet. The |
| 574 | + // measured defect is precisely that on a database whose tables EXIST the |
| 575 | + // inserts succeed rather than fail, so neither case below may run against |
| 576 | + // an empty schema — and the fixture must not depend on the fix to build |
| 577 | + // itself: with the guard ablated, a writing hook against a table that does |
| 578 | + // not exist yet THROWS, and boot hooks dispatch propagating, so the whole |
| 579 | + // bootstrap dies. Setting the schema up before the writer exists keeps an |
| 580 | + // ablation landing on the assertions below instead of on this hook. |
| 581 | + const boot = await bootSchemaStack({ |
| 582 | + jsonOutput: false, |
| 583 | + databaseUrl: `file:${dbFile}`, |
| 584 | + deferSchemaDdl: true, |
| 585 | + composeHostStack: false, |
| 586 | + projectRoot: dir, |
| 587 | + }); |
| 588 | + try { |
| 589 | + await boot.flushSchemaDdl(); |
| 590 | + } finally { |
| 591 | + await boot.shutdown(); |
| 592 | + } |
| 593 | + |
| 594 | + // A host config carrying the SAME plugin shape, so the composed path is |
| 595 | + // exercised as an operator would hit it — the plugin comes out of |
| 596 | + // `objectstack.config.ts`, through `composeForDeclarations`. |
| 597 | + writeFileSync( |
| 598 | + join(dir, 'objectstack.config.ts'), |
| 599 | + [ |
| 600 | + "import { appendFileSync } from 'node:fs';", |
| 601 | + '', |
| 602 | + `const LOG = ${JSON.stringify(hookLog)};`, |
| 603 | + "const PHASES = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'];", |
| 604 | + '', |
| 605 | + 'export default {', |
| 606 | + ' plugins: [{', |
| 607 | + " name: 'com.example.host-writes-from-init',", |
| 608 | + " version: '1.0.0',", |
| 609 | + ' init: async (ctx: any) => {', |
| 610 | + ' for (const phase of PHASES) {', |
| 611 | + " ctx.hook(phase, async () => { appendFileSync(LOG, `host|log-only|${phase}\\n`); });", |
| 612 | + ' ctx.hook(phase, async () => {', |
| 613 | + ' const entry = [...ctx.getServices().entries()]', |
| 614 | + " .find(([n]: [string, unknown]) => n.startsWith('driver.'));", |
| 615 | + ' if (!entry) return;', |
| 616 | + " await entry[1].create('sys_metadata', {", |
| 617 | + ' id: `os13332-host-${phase}`,', |
| 618 | + ' name: `os13332-host-${phase}`,', |
| 619 | + " type: 'os13332_probe',", |
| 620 | + ' });', |
| 621 | + " appendFileSync(LOG, `host|write|${phase}\\n`);", |
| 622 | + ' });', |
| 623 | + ' }', |
| 624 | + ' },', |
| 625 | + ' }],', |
| 626 | + '};', |
| 627 | + '', |
| 628 | + ].join('\n'), |
| 629 | + ); |
| 630 | + |
| 631 | + writeFileSync(hookLog, ''); |
| 632 | + }, 60_000); |
| 633 | + |
| 634 | + afterAll(() => { |
| 635 | + if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV; |
| 636 | + else process.env.NODE_ENV = savedEnv.NODE_ENV; |
| 637 | + if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH; |
| 638 | + else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; |
| 639 | + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } |
| 640 | + }); |
| 641 | + |
| 642 | + it('POSITIVE CONTROL: the same plugin lands three rows on a boot with no declaration composition', async () => { |
| 643 | + const stack = await bootSchemaStack({ |
| 644 | + jsonOutput: false, |
| 645 | + databaseUrl: `file:${dbFile}`, |
| 646 | + deferSchemaDdl: true, |
| 647 | + // No host composition ⇒ no declaration wrapper and no write guard. This |
| 648 | + // is the leg that proves the fixture can write at all. |
| 649 | + composeHostStack: false, |
| 650 | + extraPlugins: [initWritingPlugin('control')], |
| 651 | + projectRoot: dir, |
| 652 | + }); |
| 653 | + try { |
| 654 | + expect(await probeRows(stack.driver)).toBe(3); |
| 655 | + const log = readFileSync(hookLog, 'utf8'); |
| 656 | + for (const phase of PHASES) expect(log).toContain(`control|write|${phase}`); |
| 657 | + } finally { |
| 658 | + await stack.shutdown(); |
| 659 | + } |
| 660 | + }, 60_000); |
| 661 | + |
| 662 | + it('THE FIX: the declaration boot lands none of them — from the host config or from anywhere else', async () => { |
| 663 | + const before = await (async () => { |
| 664 | + const s = await bootSchemaStack({ |
| 665 | + jsonOutput: false, |
| 666 | + databaseUrl: `file:${dbFile}`, |
| 667 | + deferSchemaDdl: true, |
| 668 | + composeHostStack: false, |
| 669 | + projectRoot: dir, |
| 670 | + }); |
| 671 | + try { return await probeRows(s.driver); } finally { await s.shutdown(); } |
| 672 | + })(); |
| 673 | + // The control's three rows are still there — this case measures a DELTA, |
| 674 | + // not an empty table, so a fixture that silently stopped writing cannot |
| 675 | + // pass it. |
| 676 | + expect(before).toBe(3); |
| 677 | + |
| 678 | + writeFileSync(hookLog, ''); |
| 679 | + const stack = await bootSchemaStack({ |
| 680 | + jsonOutput: false, |
| 681 | + databaseUrl: `file:${dbFile}`, |
| 682 | + deferSchemaDdl: true, |
| 683 | + composeHostStack: true, |
| 684 | + // Both routes at once: the host config's own plugin (composed through |
| 685 | + // `composeForDeclarations`) and one handed straight to the kernel. The |
| 686 | + // guard sits at the driver, so neither reaches the database. |
| 687 | + extraPlugins: [initWritingPlugin('extra')], |
| 688 | + projectRoot: dir, |
| 689 | + }); |
| 690 | + try { |
| 691 | + expect(await probeRows(stack.driver)).toBe(before); |
| 692 | + |
| 693 | + const log = readFileSync(hookLog, 'utf8'); |
| 694 | + |
| 695 | + // The property (b) was chosen for: the hooks RAN — the log-only ones |
| 696 | + // included — on the path an operator reads before a production apply. |
| 697 | + for (const phase of PHASES) { |
| 698 | + expect(log).toContain(`host|log-only|${phase}`); |
| 699 | + expect(log).toContain(`extra|log-only|${phase}`); |
| 700 | + // …and the writing hooks got all the way to their `create()` call, |
| 701 | + // which returned instead of throwing: the line after it was reached. |
| 702 | + expect(log).toContain(`host|write|${phase}`); |
| 703 | + expect(log).toContain(`extra|write|${phase}`); |
| 704 | + } |
| 705 | + |
| 706 | + // The refusals are REPORTED, not swallowed — this is the line the plan |
| 707 | + // prints and `--json` carries. No raw execute() went through on this |
| 708 | + // boot, so the outcome claim HELD and is printed with the report. |
| 709 | + const notes = stack.composition.notes.join(' '); |
| 710 | + expect(notes).toContain('Refused 6 write(s) during the declaration boot — a plan writes nothing'); |
| 711 | + expect(notes).toContain('create() on sys_metadata'); |
| 712 | + } finally { |
| 713 | + await stack.shutdown(); |
| 714 | + } |
| 715 | + }, 60_000); |
| 716 | + |
| 717 | + it('R1 (#14053): a raw execute() is FORWARDED — the row lands — and the run reports it instead of claiming it wrote nothing', async () => { |
| 718 | + // The at-tier review's own control shape, pinned: in one guarded boot, a |
| 719 | + // hook issues a contract write (refused — the in-run control) and a raw |
| 720 | + // `execute("INSERT …")`. `execute()` is a REQUIRED member of `IDataDriver` |
| 721 | + // (`packages/spec/src/contracts/data-driver.ts`, "Raw Execution (Escape |
| 722 | + // Hatch)"), and the guard cannot classify a raw command as read-vs-write, |
| 723 | + // so the row LANDS — that is the documented behaviour, not the defect. |
| 724 | + // The defect was the SILENT half: before this case's fix, the same run |
| 725 | + // printed "a plan writes nothing" and a refusal list that looked |
| 726 | + // complete. Now the notes name the forwarded call and drop the claim. |
| 727 | + const rawWritingPlugin: any = { |
| 728 | + name: 'com.example.raw-execute-from-init', |
| 729 | + version: '1.0.0', |
| 730 | + init: async (ctx: any) => { |
| 731 | + ctx.hook('kernel:ready', async () => { |
| 732 | + const entry = [...ctx.getServices().entries()] |
| 733 | + .find(([n]: [string, unknown]) => n.startsWith('driver.')); |
| 734 | + if (!entry) return; |
| 735 | + const driver = entry[1]; |
| 736 | + // In-run control: the guarded surface refuses this one. |
| 737 | + await driver.create('sys_metadata', { |
| 738 | + id: 'os14053-create-probe', |
| 739 | + name: 'os14053-create-probe', |
| 740 | + type: 'os14053_create_probe', |
| 741 | + }); |
| 742 | + // The escape hatch: forwarded, so this one LANDS. |
| 743 | + await driver.execute( |
| 744 | + "INSERT INTO sys_metadata (id, name, type) VALUES " |
| 745 | + + "('os14053-exec-probe', 'os14053-exec-probe', 'os14053_exec_probe')", |
| 746 | + ); |
| 747 | + }); |
| 748 | + }, |
| 749 | + }; |
| 750 | + |
| 751 | + const stack = await bootSchemaStack({ |
| 752 | + jsonOutput: false, |
| 753 | + databaseUrl: `file:${dbFile}`, |
| 754 | + deferSchemaDdl: true, |
| 755 | + composeHostStack: true, |
| 756 | + extraPlugins: [rawWritingPlugin], |
| 757 | + projectRoot: dir, |
| 758 | + }); |
| 759 | + try { |
| 760 | + const countByType = async (type: string) => { |
| 761 | + const rows: any = await (stack.driver as any).knex('sys_metadata') |
| 762 | + .where({ type }).count({ c: '*' }); |
| 763 | + return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1); |
| 764 | + }; |
| 765 | + // The control half: the contract write was refused. |
| 766 | + expect(await countByType('os14053_create_probe')).toBe(0); |
| 767 | + // The escape hatch half: the raw INSERT landed — forwarded on purpose. |
| 768 | + expect(await countByType('os14053_exec_probe')).toBe(1); |
| 769 | + |
| 770 | + // …and the run SAYS so. The refusal line drops the flat claim (the |
| 771 | + // colon directly after "boot" is the dropped phrase), the forwarded |
| 772 | + // call is named with its count, and no note in the run claims the |
| 773 | + // plan wrote nothing. 4 refusals: the host config's plugin on three |
| 774 | + // phases, plus this fixture's in-run control. |
| 775 | + const notes = stack.composition.notes.join(' '); |
| 776 | + expect(notes).toContain('Refused 4 write(s) during the declaration boot:'); |
| 777 | + expect(notes).toContain('Raw execute() was called 1 time(s) during the declaration boot'); |
| 778 | + expect(notes).not.toContain('a plan writes nothing'); |
| 779 | + |
| 780 | + // The guard's structural surface carries it too, for `--json` consumers. |
| 781 | + expect(stack.composition.writeGuard?.rawExecutions).toEqual([ |
| 782 | + expect.objectContaining({ count: 1 }), |
| 783 | + ]); |
| 784 | + } finally { |
| 785 | + await stack.shutdown(); |
| 786 | + } |
| 787 | + }, 60_000); |
| 788 | +}); |
0 commit comments