Skip to content

Commit 69a12a0

Browse files
fix(plugin-audit): the Audit write FAILED line names the refused table and the row it lost (#21383)
Fixes #21262 Clause-②: no ## What changed `reportAuditWriteFailure` in `packages/plugins/plugin-audit/src/audit-writers.ts` prints the `error` line for a refused audit-trail insert. The record writer stores the `sys_audit_log` row that records who did it, then, when activities are enabled and the write has one, its `sys_activity` timeline row. - **Which table.** `persistAuditTrailRow` now moves a progress marker forward before the activity insert, so after a throw the caller knows which insert was refused. The line opens `Audit write FAILED on TABLE` and names that table. The writer decides the table. The table is no longer picked from a list. - **Which row is lost.** A refused `sys_activity` insert says the ledger row landed and only the activity row is lost. Its header reads "the activity timeline is now INCOMPLETE". A refused `sys_audit_log` insert says the ledger row is lost. When the object writes an activity row, it says that row is lost too, because it is written only after the ledger row. - **Which remedy.** The missing-table question is asked about the refused table first, then about the other table the writer writes. A SQLSTATE such as `42P01` with no phrase naming a relation answers "missing" for any table name. Asked in list order, it would have named `sys_audit_log` for a refused `sys_activity` insert. For a missing table, the line no longer asserts the datasource split. It names both causes the evidence cannot tell apart, in the order to check them: (1) schema sync never created the table, so look for `Schema sync FAILED for object 'TABLE'` in the boot log; (2) otherwise the ADR-0057 §3.6 telemetry-datasource split, with `OS_TELEMETRY_DB=0`. Any other cause keeps the driver-fault remedy unchanged. - **The once key.** The key gains the refused table: (audited object, refused table, error code). The sentence now states that key in place of "this CAUSE is reported ONCE". The `debug` repeat and the `error` meta carry `table`. `auditFailureCauseKey` takes an optional third argument for the table. The two single-table sinks (`auth-event-audit.ts`, `read-audit.ts`) do not pass it, and their keys are byte-identical to before. Neither sibling file is touched. ## Measured on `main`, then on this branch Fixture: a MySQL-shaped refusal (`ER_NO_SUCH_TABLE`, errno 1146, `Table 'objectstack.TABLE' doesn't exist`) thrown by that table's own `create`. Four audited objects, three writes each. The fixture was a temporary test file, run and then deleted, never committed. On `main` at `f39760864`, refused insert into `sys_activity`: 4 `error` lines, 8 `debug` lines, and 12 `sys_audit_log` rows landed. The first line: ```text Audit write FAILED (ER_NO_SUCH_TABLE: Table 'objectstack.sys_activity' doesn't exist) — the compliance trail is now INCOMPLETE. The audited write itself SUCCEEDED and is on disk, so the API returned success and nothing downstream looks broken; only the `sys_audit_log` row that records who did it never landed, and nothing retries it. Every subsequent audited write failing THIS WAY is losing its row the same way (this CAUSE is reported ONCE — raise the log level to `debug` to see the rest; a DIFFERENT cause gets its own `error` line). Fix: confirm `sys_audit_log` is reachable from the connection this write ran on. Its ADR-0057 §3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered (`os dev` provisions one by default as a SIBLING SQLite file), so a "no such table" here usually means the write executed against a DIFFERENT datasource than the one the table was created in — see framework#5226. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on the primary datasource. ``` On `main`, refused insert into `sys_audit_log`: the same text with only the quoted driver message changed. 4 `error` lines, 8 `debug` lines, and no rows landed in either table. On this branch, refused insert into `sys_activity`: still 4 `error` lines and 8 `debug` lines, and 12 `sys_audit_log` rows landed. The first line: ```text Audit write FAILED on `sys_activity` (ER_NO_SUCH_TABLE: Table 'objectstack.sys_activity' doesn't exist) — the activity timeline is now INCOMPLETE. The audited write itself SUCCEEDED and is on disk, and so did its `sys_audit_log` row that records who did it, so the API returned success and the compliance ledger is whole; only the `sys_activity` row — the entry the record's activity timeline and the recent-activity feed show — never landed, and nothing retries it. Every later audited write of 'crm_lead' that `sys_activity` refuses with this same code loses its `sys_activity` row the same way. This line is printed ONCE per audited object, refused table and error code: the same fault on another audited object prints its own line, and so does a different code. Raise the log level to `debug` to see every repeat. Fix: `sys_activity` does not exist on the connection this write reached, and this line cannot tell which of two causes that is, so check them in this order. (1) Schema sync never created it: the boot log then carries `Schema sync FAILED for object 'sys_activity'` with the driver's refusal of its DDL; fix that error and restart, and the table is created (a deployment that runs `OS_SKIP_SCHEMA_SYNC` creates it out-of-band instead). (2) Otherwise it was created on a DIFFERENT datasource than the one this write reached: its ADR-0057 §3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered (`os dev` provisions one by default as a SIBLING SQLite file) — see framework#5226. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on the primary datasource. ``` On this branch, refused insert into `sys_audit_log`: the line opens `Audit write FAILED on` `sys_audit_log`, keeps "the compliance trail is now INCOMPLETE", and says that the `sys_audit_log` row never landed, "and neither did its `sys_activity` timeline row, which is written only after it". The remedy names `sys_audit_log` in both causes. ## The once key: kept per audited object, with the table added - **Per audited object, kept.** It is #15166's granularity, and that card's pin `separates causes per OBJECT as well as per code` holds two objects failing the same way to two lines. The direction says #15166's pins stay green. A per-table key would turn that pin red. - **Refused table, added.** The line now names the table and the lost row, and it describes every repeat folded under its key. Without the table in the key, the other table refusing with the same code on the same object would fold into a line that names the wrong table and the wrong lost row. The bound is still fixed at boot: the object registry, times two tables, times the driver's code vocabulary. - **Count.** One missing table therefore prints one line per audited object that writes through it. That is 4 lines in the measured fixture, the same as on `main`. The line now states this count where it used to say "reported ONCE". ## Why the missing-table remedy names two causes The writer holds the error and nothing else. The boot reports a refused DDL through objectql's schema-sync report (`Schema sync FAILED for object 'X'`), but nothing records it where this writer can read it. The engine's datasource binding resolver is private. Telling the two causes apart would need a new seam in `@objectstack/objectql`, which is outside this card's file surface. So the line names both. The boot-log check comes first because it settles the question whenever that line is present. ## Pins These are in `audit-writers.test.ts`, block `the line names the refused table and the lost row (#21262)`, with 9 cases. The engine refuses one table's insert from that table's own `create`. 1. Refused `sys_activity`: the line names `sys_activity`, says the ledger row landed, and never says the `sys_audit_log` row was lost. It also checks the rows that actually landed. 2. Refused `sys_audit_log`: the line names it and says the activity row due after it was lost too. 3. An object with `enable.activities: false`: no activity row is claimed as lost. 4. Missing table: both causes appear, each naming the refused table, with the boot-log check first. 5. Any other refusal (a NOT NULL constraint): the driver-fault remedy appears, with neither missing-table cause. 6. Code-only `42P01` on a refused `sys_activity` insert: `sys_activity` is named. This guards against picking the table by list order. 7. Four objects with three writes each give 4 `error` lines and 8 `debug` lines, and the line states its key. 8. The same object and the same code, with the two tables refusing in turn, give two lines, one per table. 9. No value stored in the audited row appears in any log line or its meta. The line is operator text only. The `#5226` and `#15166` blocks are byte-unchanged. The test diff is +232 / -0, and all of their pins pass. ## Ablations (one-time proof, not kept) Each one was run through `scripts/ablation-replace.mjs` in wrap mode, under the verify lock, on the committed fix. The test imports `./audit-writers.js`, which resolves straight to `src/audit-writers.ts`. No package `exports` or `dist/` is in the path, so no rebuild leg and no dist preflight was owed. | Mutation | On-disk evidence | Red | |---|---|---| | Refused table hard-coded: `table: progress.writing,` replaced by `table: 'sys_audit_log' as AuditTrailTable,` | anchor 1 to 0, blob `437819f06fb9` to `0e5e65f028f2` | 4 of 87: pins 1, 5, 6 and 8 (pin 1 is the `sys_activity` line) | | Key reverted: `auditFailureCauseKey(object, err, table)` replaced by `auditFailureCauseKey(object, err)` | anchor 1 to 0, blob `437819f06fb9` to `b89d418cd559` | 1 of 87: pin 8 (the count per table) | | List order: the refused table is no longer asked first (`sys_audit_log` is asked first) | anchor 1 to 0, blob `437819f06fb9` to `119abca80eea` | 1 of 87: pin 6 | Every restore was proven the same way: the blob after restore equals the blob at HEAD (`437819f06fb9`), and `git diff HEAD` is empty. ## Verification, at `743fc4e5e` - `pnpm --filter @objectstack/plugin-audit test`: 36 files, 581 tests passed. - `pnpm --filter @objectstack/plugin-audit typecheck`: `tsc --noEmit` for src and for `tsconfig.scripts.json`, plus `check:test-typecheck` over `tsconfig.test.json`, which compiles the test files. All green. - `packages/types` `driver-error-classification.callers.test.ts` reads every `isMissingTableError` call under `packages/`, so it reads this file: 7 of 7 passed. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 65 commands, and all 65 exited 0. `check:dual-build-cjs-loads` and `check:i18n` first answered `PREREQUISITE NOT MET` (exit 3, nothing measured). After the turbo builds they name (all cached), both re-ran with exit 0. Reconciling with `--ran` gives 65 derived, 65 run, 0 NOT-MEASURED. That zero is derived from the recorded exit codes. - Also run, outside the derivation, all exit 0: `check:durability-log-level` (39 durability catch seams, all loud), `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:startup-registry-verdict`. - `check:doc-authoring`: the runtime string keeps its one `framework#5226` id, so the sibling-package prose-id ledger holds, with no growth and no burn-down. - ESLint, narrowed to the 2 changed TypeScript files with `--no-inline-config --format json`: 2 files linted, 0 errors, 0 warnings. Both files resolve a non-empty config under `--print-config`, so they are in eslint's own population. The config never enables type-aware linting (`parserOptions.project` and `projectService` are null for both), so this diff cannot move a verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. - Left to CI: the path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood, Build Core) and the workspace type-check lanes. ## Acceptance notes (not filed) - **The same remedy in two sibling sinks.** `read-audit.ts` (`Read-audit write FAILED`) and `auth-event-audit.ts` (`Auth-event audit write FAILED`) write only `sys_audit_log`, so the table they name is right. For a missing table, their remedy still says the datasource split is the usual cause, and a refused DDL gets the same misdirection there. Nobody has measured a missing `sys_audit_log` table through either seam, so this is a note, not a card. `content/docs/kernel/runtime-services/audit-service.mdx` ("The most common cause of a failing insert is a datasource split") describes the auth-event seam and carries the same claim. This PR makes none of those sentences false, so they stay as they are. Carrier: none. - **The split may no longer be reachable by its original route.** `#5226` was a ledger insert riding a primary-datasource transaction. The engine's `enforceTransactionOrigin` now runs system-ledger writes inside a transaction outside it, on their own connection. That route to "no such table" looks closed, but this is inference from reading the code. The line keeps the split as cause (2) because the writer cannot rule it out. - **The tracker id in the runtime string.** The remedy still ends cause (2) with `see framework#5226`. Removing it would shrink `scripts/doc-authoring-prose-id.baseline.json` by one, and that file is outside this card's file surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f13c94 commit 69a12a0

3 files changed

Lines changed: 407 additions & 30 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
The `Audit write FAILED` line names the table whose insert was refused and the row that is lost, gives a missing table the two causes the evidence cannot tell apart, and says it is printed once per audited object, refused table and error code
6+
7+
Clause-②: no
8+
9+
The record writer stores the `sys_audit_log` row that records who did it, then, when activities are enabled and the write has one, its `sys_activity` timeline row. When either insert was refused, the line always said the `sys_audit_log` row never landed. When the refused insert was `sys_activity`, every ledger row had in fact landed.
10+
11+
- The line now opens `Audit write FAILED on TABLE` and names the table the writer had in flight when it threw. A refused `sys_activity` insert says the ledger row landed and only the activity row is lost. A refused `sys_audit_log` insert says the ledger row is lost, and so is the activity row due after it when the object writes one.
12+
- A missing table no longer gets only the telemetry-datasource split as its remedy. The table may never have been created because schema sync's DDL for it was refused at boot. The line cannot tell the two causes apart, so it names both, in order: look for `Schema sync FAILED for object 'TABLE'` in the boot log first, then the split and `OS_TELEMETRY_DB=0`. Any other cause keeps the driver-fault remedy.
13+
- Whether the table is missing is asked about the refused table first. An error code that means "missing" without a phrase naming a relation is now attributed to that table, not to `sys_audit_log` by list order.
14+
- The line is printed once per audited object, refused table and error code, and it now says so in place of "reported ONCE". The refused table joins the key, so the other table refusing with the same code on the same object gets its own line. The same missing table still prints one line per audited object that writes through it. Repeats stay at `debug`, which now also carries the `table`.
15+
16+
Log text and log metadata only: no status, error code, route, row or control flow changes. A log filter that matches the old text (`Audit write FAILED (`, `reported ONCE`) needs the new spelling.

‎packages/plugins/plugin-audit/src/audit-writers.test.ts‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,238 @@ describe('audit writers — reported once per CAUSE, not once per process (#1516
14061406
});
14071407
});
14081408

1409+
/**
1410+
* [#21262] The line names the table whose insert was REFUSED, and the row that
1411+
* is lost.
1412+
*
1413+
* Measured on MySQL: `sys_activity` was never created because its DDL was
1414+
* refused at boot, every `sys_audit_log` row LANDED, and the line said "only
1415+
* the `sys_audit_log` row that records who did it never landed", sent the
1416+
* operator to the telemetry-datasource split, and printed four times under the
1417+
* words "reported ONCE" — once per audited object.
1418+
*
1419+
* The fixture refuses ONE table's insert, from that table's own `create`, the
1420+
* way a real driver does — unlike the #15166 block above, whose engine refuses
1421+
* the ledger insert whatever the error names. Its pins stay as they are.
1422+
*/
1423+
describe('audit writers — the line names the refused table and the lost row (#21262)', () => {
1424+
interface LogLine { level: string; message: string; meta?: any }
1425+
1426+
/**
1427+
* Engine whose `refusing` table rejects every insert with `nextError(n)`; the
1428+
* other table accepts. Records which rows LANDED, so a line's claim about
1429+
* them can be checked against what happened.
1430+
*/
1431+
function makeRefusingEngine(
1432+
refusing: 'sys_audit_log' | 'sys_activity' | ((n: number) => 'sys_audit_log' | 'sys_activity'),
1433+
nextError: (n: number) => unknown,
1434+
objectDefs: Record<string, any> = {},
1435+
) {
1436+
const hooks = new Map<string, Array<(ctx: any) => any>>();
1437+
const logs: LogLine[] = [];
1438+
const landed: string[] = [];
1439+
let n = 0;
1440+
const sudoApi = {
1441+
object(name: string) {
1442+
return {
1443+
async create(_row: Record<string, any>) {
1444+
const refused = typeof refusing === 'function' ? refusing(n) : refusing;
1445+
if (name === refused) throw nextError(n++);
1446+
landed.push(name);
1447+
return { id: 'generated-id' };
1448+
},
1449+
};
1450+
},
1451+
};
1452+
const api = { sudo: () => sudoApi };
1453+
const engine = {
1454+
getSchema(name: string) {
1455+
const fields = (SINGLE_TENANT as Record<string, string[]>)[name];
1456+
const base = fields
1457+
? { name, fields: Object.fromEntries(fields.map((f) => [f, { type: 'text' }])) }
1458+
: { name, fields: { id: { type: 'text' }, name: { type: 'text' } } };
1459+
return { ...base, ...(objectDefs[name] || {}) };
1460+
},
1461+
registerHook(event: string, fn: (ctx: any) => any) {
1462+
const list = hooks.get(event) ?? [];
1463+
list.push(fn);
1464+
hooks.set(event, list);
1465+
},
1466+
unregisterHooksByPackage() { /* no-op */ },
1467+
logger: {
1468+
error(message: string, _err?: unknown, meta?: any) { logs.push({ level: 'error', message, meta }); },
1469+
warn(message: string, meta?: any) { logs.push({ level: 'warn', message, meta }); },
1470+
debug(message: string, meta?: any) { logs.push({ level: 'debug', message, meta }); },
1471+
info() { /* unused */ },
1472+
},
1473+
};
1474+
installAuditWriters(engine as any, 'test.audit');
1475+
const fire = async (object: string, id: string, name = 'Acme') => {
1476+
for (const fn of hooks.get('afterInsert') ?? []) {
1477+
await fn({
1478+
event: 'afterInsert',
1479+
api,
1480+
object,
1481+
input: { id },
1482+
result: { id, name },
1483+
session: { organizationId: 'org-1', userId: 'user-1' },
1484+
});
1485+
}
1486+
};
1487+
const at = (level: string) => logs.filter((l) => l.level === level);
1488+
const landedIn = (table: string) => landed.filter((t) => t === table).length;
1489+
return { fire, at, logs, landedIn };
1490+
}
1491+
1492+
/** The measured shape: mysql2's `ER_NO_SUCH_TABLE` (errno 1146) naming the table. */
1493+
const mysqlNoSuchTable = (table: string) => () => {
1494+
const e = new Error(`Table 'objectstack.${table}' doesn't exist`) as Error & { code?: string; errno?: number };
1495+
e.code = 'ER_NO_SUCH_TABLE';
1496+
e.errno = 1146;
1497+
return e;
1498+
};
1499+
const coded = (message: string, code: string) => () => {
1500+
const e = new Error(message) as Error & { code?: string };
1501+
e.code = code;
1502+
return e;
1503+
};
1504+
1505+
it('names `sys_activity` when its insert is refused, and says the ledger row LANDED', async () => {
1506+
const { fire, at, landedIn } = makeRefusingEngine('sys_activity', mysqlNoSuchTable('sys_activity'));
1507+
1508+
await fire('crm_lead', 'l-1');
1509+
1510+
// What happened: the ledger row is on disk, only the activity row is lost.
1511+
expect(landedIn('sys_audit_log')).toBe(1);
1512+
expect(landedIn('sys_activity')).toBe(0);
1513+
const [line] = at('error');
1514+
expect(line.meta).toMatchObject({ object: 'crm_lead', action: 'create', table: 'sys_activity' });
1515+
expect(line.message).toMatch(/^Audit write FAILED on `sys_activity` \(ER_NO_SUCH_TABLE: /);
1516+
// The lost row is the activity row; the ledger row is said to have landed.
1517+
expect(line.message).toMatch(/only the `sys_activity` row/);
1518+
expect(line.message).toMatch(/so did its `sys_audit_log` row/);
1519+
// ⛔ The measured falsehood, by the subject it names.
1520+
expect(line.message).not.toMatch(/`sys_audit_log` row that records who did it never landed/);
1521+
expect(line.message).not.toMatch(/compliance trail is now INCOMPLETE/);
1522+
});
1523+
1524+
it('names `sys_audit_log` when its insert is refused, and the activity row due after it as lost too', async () => {
1525+
const { fire, at, landedIn } = makeRefusingEngine('sys_audit_log', mysqlNoSuchTable('sys_audit_log'));
1526+
1527+
await fire('crm_lead', 'l-1');
1528+
1529+
// The activity row is written only after the ledger row, so it never ran.
1530+
expect(landedIn('sys_audit_log')).toBe(0);
1531+
expect(landedIn('sys_activity')).toBe(0);
1532+
const [line] = at('error');
1533+
expect(line.meta).toMatchObject({ object: 'crm_lead', action: 'create', table: 'sys_audit_log' });
1534+
expect(line.message).toMatch(/^Audit write FAILED on `sys_audit_log` \(ER_NO_SUCH_TABLE: /);
1535+
expect(line.message).toMatch(/compliance trail is now INCOMPLETE/);
1536+
expect(line.message).toMatch(/`sys_audit_log` row that records who did it never landed/);
1537+
expect(line.message).toMatch(/neither did its `sys_activity` timeline row/);
1538+
});
1539+
1540+
it('does not claim an activity row was lost for an object that writes none', async () => {
1541+
// `enable.activities: false` — no activity row was ever due.
1542+
const { fire, at } = makeRefusingEngine('sys_audit_log', mysqlNoSuchTable('sys_audit_log'), {
1543+
crm_lead: { enable: { activities: false } },
1544+
});
1545+
1546+
await fire('crm_lead', 'l-1');
1547+
1548+
const [line] = at('error');
1549+
expect(line.message).toMatch(/`sys_audit_log` row that records who did it never landed/);
1550+
expect(line.message).not.toMatch(/neither did its `sys_activity`/);
1551+
});
1552+
1553+
it('gives a missing table BOTH causes it cannot tell apart, naming that table in each', async () => {
1554+
// A table is as missing when schema sync's DDL for it was refused at boot
1555+
// as when it was created on another datasource, and nothing in hand tells
1556+
// the two apart — so the remedy names both, the boot's own line first.
1557+
const { fire, at } = makeRefusingEngine('sys_activity', mysqlNoSuchTable('sys_activity'));
1558+
1559+
await fire('crm_lead', 'l-1');
1560+
1561+
const msg = at('error')[0].message;
1562+
expect(msg).toMatch(/`sys_activity` does not exist on the connection this write reached/);
1563+
expect(msg).toMatch(/Schema sync FAILED for object 'sys_activity'/);
1564+
expect(msg).toMatch(/OS_TELEMETRY_DB=0/);
1565+
expect(msg.indexOf('Schema sync FAILED')).toBeLessThan(msg.indexOf('OS_TELEMETRY_DB=0'));
1566+
});
1567+
1568+
it('gives any other refusal the driver-fault remedy, with neither missing-table cause', async () => {
1569+
const { fire, at } = makeRefusingEngine(
1570+
'sys_activity',
1571+
coded('NOT NULL constraint failed: sys_activity.summary', 'SQLITE_CONSTRAINT_NOTNULL'),
1572+
);
1573+
1574+
await fire('crm_lead', 'l-1');
1575+
1576+
const msg = at('error')[0].message;
1577+
expect(msg).toMatch(/^Audit write FAILED on `sys_activity` \(SQLITE_CONSTRAINT_NOTNULL: /);
1578+
expect(msg).toMatch(/Fix: resolve the driver fault/);
1579+
expect(msg).not.toMatch(/Schema sync/);
1580+
expect(msg).not.toMatch(/telemetry/i);
1581+
});
1582+
1583+
it('reads the missing table from the REFUSED write, not from a list, when the code alone says "missing"', async () => {
1584+
// SQLSTATE 42P01 with no phrase naming a relation is a missing-table
1585+
// verdict for ANY table name. Asked in list order, the ledger table would
1586+
// answer first and be named for a refused `sys_activity` insert.
1587+
const { fire, at } = makeRefusingEngine('sys_activity', coded('statement refused', '42P01'));
1588+
1589+
await fire('crm_lead', 'l-1');
1590+
1591+
const msg = at('error')[0].message;
1592+
expect(msg).toMatch(/`sys_activity` does not exist on the connection this write reached/);
1593+
expect(msg).not.toMatch(/`sys_audit_log` does not exist/);
1594+
});
1595+
1596+
it('prints once per audited object, refused table and code — and the line says so', async () => {
1597+
// The measured boot: one missing table, four audited objects, four lines.
1598+
// That count is the declared key, not a defect — what was wrong was the
1599+
// sentence calling it "reported ONCE".
1600+
const objects = ['crm_lead', 'crm_account', 'crm_contact', 'crm_opportunity'];
1601+
const { fire, at } = makeRefusingEngine('sys_activity', mysqlNoSuchTable('sys_activity'));
1602+
1603+
for (const object of objects) for (let i = 0; i < 3; i += 1) await fire(object, `r-${i}`);
1604+
1605+
const errors = at('error');
1606+
expect(errors).toHaveLength(objects.length);
1607+
expect(errors.map((l) => l.meta.object)).toEqual(objects);
1608+
expect(at('debug')).toHaveLength(objects.length * 3 - objects.length);
1609+
expect(errors[0].message).toMatch(/ONCE per audited object, refused table and error code/);
1610+
});
1611+
1612+
it('keys on the refused TABLE too: the other table refusing with the same code is its own line', async () => {
1613+
// Same object, same code, the two tables refusing in turn. Without the
1614+
// table in the key the second refusal folds into a line naming the first
1615+
// table and the first table's lost row.
1616+
const { fire, at } = makeRefusingEngine(
1617+
(n) => (n === 0 ? 'sys_activity' : 'sys_audit_log'),
1618+
coded('constraint failed', 'SQLITE_CONSTRAINT'),
1619+
);
1620+
1621+
await fire('crm_lead', 'l-1');
1622+
await fire('crm_lead', 'l-2');
1623+
1624+
const errors = at('error');
1625+
expect(errors.map((l) => l.meta.table)).toEqual(['sys_activity', 'sys_audit_log']);
1626+
expect(at('debug')).toEqual([]);
1627+
});
1628+
1629+
it('carries no stored value — operator text only', async () => {
1630+
const STORED = 'stored-value-7f3c';
1631+
const { fire, logs } = makeRefusingEngine('sys_activity', mysqlNoSuchTable('sys_activity'));
1632+
1633+
await fire('crm_lead', 'l-1', STORED);
1634+
await fire('crm_lead', 'l-2', STORED);
1635+
1636+
expect(logs.length).toBeGreaterThan(0);
1637+
expect(JSON.stringify(logs)).not.toContain(STORED);
1638+
});
1639+
});
1640+
14091641
/**
14101642
* [commit 1408fe385] Which organization an audit row is stamped with — the RECORD'S own,
14111643
* honouring the maintainer's ruling on #8287.

0 commit comments

Comments
 (0)