|
23 | 23 | * that only ever held keys somebody set. |
24 | 24 | */ |
25 | 25 |
|
26 | | -import { describe, it, expect, afterEach } from 'vitest'; |
| 26 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
27 | 27 | import { ObjectKernel } from '@objectstack/core'; |
28 | 28 | import { ObjectQLPlugin } from '@objectstack/objectql'; |
29 | 29 | import { SqlDriver } from '@objectstack/driver-sql'; |
@@ -527,3 +527,164 @@ describe('record-change trigger — end-to-end (#1491)', () => { |
527 | 527 | expect(audit[0]?.seen_tag).toBe('keep'); |
528 | 528 | }, 15000); |
529 | 529 | }); |
| 530 | + |
| 531 | +/** |
| 532 | + * #8482 — the hydration schema gate's "measured on a real engine" claim, |
| 533 | + * actually measured on a real engine. |
| 534 | + * |
| 535 | + * `objectHasFormulaField` (record-change-trigger.ts) used to gate on a |
| 536 | + * `getObjectConfig` method the concrete ObjectQL engine never implemented, so |
| 537 | + * on every real deployment the gate always took its `true` fallback and |
| 538 | + * `hydrateComputedFields` re-read via `findOne` on EVERY afterInsert / |
| 539 | + * afterUpdate dispatch — even for objects declaring no `formula` field, where |
| 540 | + * the re-read (per the code's own doc comment) adds nothing. The trigger's |
| 541 | + * OWN unit tests (`record-change-trigger.test.ts`, "computed-field hydration |
| 542 | + * guards") only ever proved the gate against a HAND-ATTACHED mock |
| 543 | + * (`Object.assign(engine, { getObjectConfig })`) — a true statement about the |
| 544 | + * trigger's own logic that said nothing about the real engine, which is |
| 545 | + * exactly how a gate that never engaged in production kept a green suite. |
| 546 | + * |
| 547 | + * This block re-proves the (now `getObject`-based) gate against the REAL |
| 548 | + * ObjectQL engine, wired the exact way production is (this file's own |
| 549 | + * kernel-boot harness: ObjectQLPlugin + AutomationServicePlugin + |
| 550 | + * RecordChangeTriggerPlugin + `@objectstack/driver-sql` on better-sqlite3 |
| 551 | + * `:memory:`), by spying on the engine's PUBLIC `findOne` — the same method |
| 552 | + * `RecordChangeDataEngine.findOne` structurally types, and the ONLY thing the |
| 553 | + * hydration re-read calls. The engine's own by-id-update prior-row fetch |
| 554 | + * (`engine.ts` `update()`) reads through `driver.findOne` directly, never the |
| 555 | + * public engine method, so this spy counts exactly the trigger's hydration |
| 556 | + * re-reads and nothing else — confirmed by the "no formula field" case below |
| 557 | + * asserting a hard zero, not just "fewer than before". |
| 558 | + * |
| 559 | + * Each `it` targets a DIFFERENT write than the flow's own effect (an audit |
| 560 | + * object, not the triggering object) so the flow's own write-back can never |
| 561 | + * re-fire itself — the self-trigger re-entrancy guard the `record-after-write` |
| 562 | + * tests above exercise is deliberately not in play here, so the only variable |
| 563 | + * under test is the schema gate. |
| 564 | + */ |
| 565 | +describe('hydration schema gate — real ObjectQL engine findOne count (#8482)', () => { |
| 566 | + const plainObjectDef = (name: string) => ({ |
| 567 | + name, |
| 568 | + label: name, |
| 569 | + fields: { |
| 570 | + status: { name: 'status', label: 'Status', type: 'text' as const }, |
| 571 | + }, |
| 572 | + }); |
| 573 | + |
| 574 | + const formulaObjectDef = (name: string) => ({ |
| 575 | + name, |
| 576 | + label: name, |
| 577 | + fields: { |
| 578 | + status: { name: 'status', label: 'Status', type: 'text' as const }, |
| 579 | + full_name: { |
| 580 | + name: 'full_name', |
| 581 | + label: 'Full Name', |
| 582 | + type: 'formula' as const, |
| 583 | + expression: { dialect: 'cel', source: "'computed'" }, |
| 584 | + }, |
| 585 | + }, |
| 586 | + }); |
| 587 | + |
| 588 | + const auditObjectDef = (name: string) => ({ |
| 589 | + name, |
| 590 | + label: name, |
| 591 | + fields: { |
| 592 | + note: { name: 'note', label: 'Note', type: 'text' as const }, |
| 593 | + }, |
| 594 | + }); |
| 595 | + |
| 596 | + /** A `record-after-update` flow whose own write targets a DIFFERENT object |
| 597 | + * (an audit log) — proof the flow actually fired lives in the audit |
| 598 | + * table, not in the triggering object, so nothing here can self-fire. */ |
| 599 | + function afterUpdateAuditFlow(name: string, object: string, auditObject: string) { |
| 600 | + return { |
| 601 | + name, |
| 602 | + label: name, |
| 603 | + type: 'record_change', |
| 604 | + nodes: [ |
| 605 | + { id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-update' } }, |
| 606 | + { id: 'log', type: 'create_record', label: 'Log', config: { objectName: auditObject, fields: { note: 'seen' } } }, |
| 607 | + { id: 'end', type: 'end', label: 'End' }, |
| 608 | + ], |
| 609 | + edges: [ |
| 610 | + { id: 'e1', source: 'start', target: 'log' }, |
| 611 | + { id: 'e2', source: 'log', target: 'end' }, |
| 612 | + ], |
| 613 | + }; |
| 614 | + } |
| 615 | + |
| 616 | + it('does NOT re-read via findOne on update for an object with no formula field', async () => { |
| 617 | + // `{ logger: { level: 'silent' } }`, NOT this file's other `{ logLevel: |
| 618 | + // 'silent' }` — see the doc comment on the #4953 test above for why (a |
| 619 | + // pre-existing TS2353 in the frozen TEST_DEBT ledger this new test must |
| 620 | + // not add to). |
| 621 | + const kernel = new ObjectKernel({ logger: { level: 'silent' } }); |
| 622 | + await kernel.use(new ObjectQLPlugin()); |
| 623 | + await kernel.use(new AutomationServicePlugin()); |
| 624 | + await kernel.use(new RecordChangeTriggerPlugin()); |
| 625 | + await kernel.bootstrap(); |
| 626 | + |
| 627 | + const objectql = kernel.getService<TestObjectQLEngine>('objectql'); |
| 628 | + const data = kernel.getService<IDataEngine>('data'); |
| 629 | + const automation = kernel.getService<AutomationEngine>('automation'); |
| 630 | + |
| 631 | + await attachSqlite(objectql); |
| 632 | + objectql.registry.registerObject(plainObjectDef('gate_plain'), 'test', 'test'); |
| 633 | + objectql.registry.registerObject(auditObjectDef('gate_plain_audit'), 'test', 'test'); |
| 634 | + await objectql.syncSchemas(); |
| 635 | + automation.registerFlow( |
| 636 | + 'gate_plain_audit_flow', |
| 637 | + afterUpdateAuditFlow('gate_plain_audit_flow', 'gate_plain', 'gate_plain_audit') as any, |
| 638 | + ); |
| 639 | + |
| 640 | + const created = await data.insert('gate_plain', { status: 'new' }, { context: { userId: 'u_trigger' } }); |
| 641 | + const id = Array.isArray(created) ? created[0]?.id : (created as any)?.id ?? created; |
| 642 | + await sleep(200); |
| 643 | + |
| 644 | + const findOneSpy = vi.spyOn(objectql, 'findOne'); |
| 645 | + await data.update('gate_plain', { id, status: 'done' }, { context: { userId: 'u_trigger' } }); |
| 646 | + await sleep(200); |
| 647 | + |
| 648 | + // Proof the flow actually dispatched (the gate skipped the RE-READ, not |
| 649 | + // the trigger itself). |
| 650 | + const audit: any[] = await data.find('gate_plain_audit', {}); |
| 651 | + expect(audit).toHaveLength(1); |
| 652 | + |
| 653 | + expect(findOneSpy).not.toHaveBeenCalled(); |
| 654 | + }, 15000); |
| 655 | + |
| 656 | + it('DOES re-read via findOne on update for an object that declares a formula field', async () => { |
| 657 | + const kernel = new ObjectKernel({ logger: { level: 'silent' } }); |
| 658 | + await kernel.use(new ObjectQLPlugin()); |
| 659 | + await kernel.use(new AutomationServicePlugin()); |
| 660 | + await kernel.use(new RecordChangeTriggerPlugin()); |
| 661 | + await kernel.bootstrap(); |
| 662 | + |
| 663 | + const objectql = kernel.getService<TestObjectQLEngine>('objectql'); |
| 664 | + const data = kernel.getService<IDataEngine>('data'); |
| 665 | + const automation = kernel.getService<AutomationEngine>('automation'); |
| 666 | + |
| 667 | + await attachSqlite(objectql); |
| 668 | + objectql.registry.registerObject(formulaObjectDef('gate_calc'), 'test', 'test'); |
| 669 | + objectql.registry.registerObject(auditObjectDef('gate_calc_audit'), 'test', 'test'); |
| 670 | + await objectql.syncSchemas(); |
| 671 | + automation.registerFlow( |
| 672 | + 'gate_calc_audit_flow', |
| 673 | + afterUpdateAuditFlow('gate_calc_audit_flow', 'gate_calc', 'gate_calc_audit') as any, |
| 674 | + ); |
| 675 | + |
| 676 | + const created = await data.insert('gate_calc', { status: 'new' }, { context: { userId: 'u_trigger' } }); |
| 677 | + const id = Array.isArray(created) ? created[0]?.id : (created as any)?.id ?? created; |
| 678 | + await sleep(200); |
| 679 | + |
| 680 | + const findOneSpy = vi.spyOn(objectql, 'findOne'); |
| 681 | + await data.update('gate_calc', { id, status: 'done' }, { context: { userId: 'u_trigger' } }); |
| 682 | + await sleep(200); |
| 683 | + |
| 684 | + const audit: any[] = await data.find('gate_calc_audit', {}); |
| 685 | + expect(audit).toHaveLength(1); |
| 686 | + |
| 687 | + expect(findOneSpy).toHaveBeenCalledTimes(1); |
| 688 | + expect(findOneSpy).toHaveBeenCalledWith('gate_calc', expect.objectContaining({ where: { id } })); |
| 689 | + }, 15000); |
| 690 | +}); |
0 commit comments