Skip to content

Commit 758c77d

Browse files
fix(metadata-protocol): a seed replay no longer rewrites every seeded row that carries a password or secret field on every boot (#24696)
Fixes #24624 Clause-②: no (a replay comparison: no accept set, served body or masking verdict moves; the loader takes no new read and never sees a stored credential) ## What this changes Ruling 6278934212 on #24624 records letter B of analysis 6275762852 (the maintainer, verbatim: 「同意」): **a masked field never blocks the replay's skip.** - `packages/metadata-protocol/src/seed-loader.ts`: the replay read (`engine.find`, `isSystem: true`) serves a set `secret` field, and a set `password` field on an object not managed by better-auth, as `SECRET_MASK` (ADR-0100). `isNoOpReplay` compared the seed's plaintext with that mask, read the row as changed, and rewrote it on every boot. It now skips a field when all three hold: the object declares the field masked on read, the existing row read back `SECRET_MASK`, and the seed sets a value. - `isNoOpReplay` stays a pure predicate, because `decideWriteAction` asks it up to three times per record (the refusal check, the early signal and the decision). The skip arms carry the uncompared fields, and the load records them only where a skip is counted. - One `info` line per load, when a replay skipped at least one row without comparing a credential. It names every such field, the rule, and both remedies (change another field of the row, or rebuild the database). Text from the real-engine reading below: > [SeedLoader] 1 seeded row(s) were left unchanged without comparing their credential field(s) showcase_field_zoo.f_password: a secret field, and a password field on an object not managed by better-auth, is masked on read, so a replay cannot see whether the seed changed a value that is already set and never rewrites a row for that alone. A seed change to only such a field is not applied on replay: change another field of the same row too (the rewrite then writes the seed's value), or rebuild the database. - What this PR does not do: take a new unmasked read, change `ObjectQL.maskSecretFields` or `collectMaskedReadFields`, add an export, edit an ADR, or add a dependency. ## How the loader knows a field is masked (dispatch mechanism assumption 2) - **(b) `collectMaskedReadFields` from `@objectstack/objectql`: impossible.** `packages/objectql/package.json` already lists `@objectstack/metadata-protocol` under `dependencies` (`workspace:*`), so the import would create a dependency cycle. - **(a) the mask alone (`existing[key] === SECRET_MASK`)** fixes the showcase. But it would also skip any non-credential field that happens to store the eight-bullet string, so a seed change to that field would be dropped silently. Ablation leg 2 below measures this: the pin `a field the declaration does not mask keeps comparing, even when it holds the mask's text` goes red. - **Used: both, each read from its single source.** The field check asks `isMaskedOnReadFieldType(type, managedBy)` from `@objectstack/spec/data`. That is the declaration `collectMaskedReadFields` itself asks, and `@objectstack/spec` is already a dependency of this package. The value check reads `SECRET_MASK` from the same module. The loader contains no copy of the "secret, or password on a non-better-auth object" rule. ## One reading of the ruling for the seat to confirm: presence still counts The ruling's execution text says `isNoOpReplay` "skips the fields `collectMaskedReadFields` collects". This PR skips such a field **only while its value is set**. The mask preserves presence: an unset credential reads back `null`, never the mask (`SECRET_MASK`'s docblock, ADR-0100). So a `null` read is a stored fact the replay can compare: - a seed that sets a credential on a row that has none is written. Under the literal reading it would never be written unless another field changed too; - a seed that sets a credential to `null` over a set one is written, which clears it. Neither case churns, because the next replay reads the same presence and skips. Ablation leg 3 swaps in the literal reading (`return comparands.masked.has(key);`), and it turns only the presence pin red. If the seat reads the ruling literally, that one-line change is the entire difference. ## Notice level: `info`, and what that costs - Why `info`: this skip is the declared, ruled outcome, and it fires on every healthy replay of a seed that carries a credential (the stock showcase, every boot). The precedent is `filterByEnv`'s skip line in the same file, which logs at `info` "rather than crying wolf on every production boot". The official examples also boot with zero warnings (`examples/app-showcase/test/no-startup-warnings.test.ts`). Nothing the system claims to hold is lost, because the row keeps its credential, so this is not AGENTS.md's durability class. - The cost, measured: the CLI's default kernel level is `warn` (`packages/cli/src/utils/log-level.ts`). The boot-quiet window also drops in-window `info` whatever `--log-level` says (`BOOT_DIAGNOSTIC_FLOOR = 'warn'`, `packages/cli/src/utils/boot-log-capture.ts`). So on an `os dev` or `os serve` boot the line is not on screen. It reaches a host logging at `info` outside that window: hot reload, package apply, draft publish, marketplace install and per-organization replay. If the seat wants the line visible at boot, it is a one-word change to `warn`, at the price of a warning on every showcase boot. ## Tests (all at `a947341b91`, after merging `origin/main` `92ce3af404`) - New pins in `packages/metadata-protocol/src/seed-loader-replay.test.ts`, block `seed replay — a credential the read path masks never blocks the skip`. The engine double wraps the existing faithful engine. It stores a `password` verbatim and a `secret` as an opaque ref, stamps `updated_at` on every write, and masks on read through `isMaskedOnReadFieldType` (returning copies). Both write paths are covered: batched `decideWriteAction`, and sequential `writeRecord` through a self-referencing object. - an unchanged replay issues no update, and the store, `updated_at` included, is deep-equal (both paths × `upsert`/`update`); - a credential-only change (`password` and `secret`) is not rewritten, and the load emits exactly one notice, at `info`, naming every uncompared field; - a change to another field rewrites the row: the stored `password` becomes the seed's new value, and the `secret` update carries the new plaintext; - setting an unset credential, and clearing a set one, are both written; - a field the declaration does not mask keeps comparing even when it holds the mask's text; - no notice when no skipped row left a credential uncompared. - Red first. Before the fix (`bfc1539fe0` source), 10 of the new pins failed: every row carrying a credential was rewritten (3 of 4 rows updated on an unchanged replay). - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: `Test Files 249 passed | 3 skipped (252)`, `Tests 20793 passed | 19 skipped (20812)`, verify-lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` includes `seed-loader-replay.test.ts`. - Ablation, one-shot, through `scripts/ablation-replace.mjs` (anchor hits 1, mutation proven on disk by count and blob, restored to the HEAD blob `3963a4b4` with `git diff HEAD` empty after each leg). The test imports `./seed-loader.js` from `src`, so no `dist/` sits on the path. Expected directions were declared first, and all three held: 1. delete the mask branch in `isNoOpReplay`: 10 failed | 15 passed (the replay, credential-only, other-field and presence pins); 2. value-only (`return stored === SECRET_MASK && seedValue != null;`, i.e. option (a) alone): 1 failed | 24 passed (the literal-bullets pin only); 3. declaration-only (`return comparands.masked.has(key);`, the literal reading): 2 failed | 23 passed (the presence pin, both paths). ## Real-engine reading (scratch harness, not committed) Setup: real `ObjectQL` with `SqlDriver` (better-sqlite3) on one SQLite file. Every `examples/app-showcase` object definition, plus `sys_business_unit`, and every showcase seed dataset (19 datasets, 132 rows). One process per "boot"; the loader runs from this branch's source. | run | inserted | updated | skipped | `Specimen — Full` stored `f_password` | its `updated_at` | notices | |:--|--:|--:|--:|:--|:--|--:| | boot 1 (fresh file) | 132 | 0 | 0 | `hunter2` (plaintext at rest; `engine.find` reads the mask) | `…23:17:00.189Z` | 0 | | boot 2 (same seed) | 0 | **0** | 132 | `hunter2` | unchanged | 1 | | password-only change (`hunter3`) | 0 | 0 | 132 | `hunter2` | unchanged | 1 | | `hunter3` + `f_phone` changed | 0 | 1 | 131 | `hunter3` | moved | 0 | | same seed, mask branch ablated (the pre-fix comparison) | 0 | 1 (`Specimen — Full`) | 131 | — | moved | 0 | | same seed, restored | 0 | 0 | 132 | — | unchanged | 1 | This confirms dispatch mechanism assumption 1 on the real engine: `f_password` is stored as plaintext, and the seed's own system-context read returns `SECRET_MASK`. ## Gates - **Derived list, run locally at `a947341b91`:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 families, with the change set taken from git by the tool. All 64 exited 0, each exit captured before any pipe. - **Reconciliation:** `--ran` reports `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero because every line carries its exit code. On the pre-merge head `20abbaf71d`, `check:dual-build-cjs-loads` printed `PREREQUISITE NOT MET` (not every package had a `dist/`). At `a947341b91` it measured green: 111 require entry points across 69 packages. - **Not run locally:** - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: no export was renamed or removed. - `test:repo`: the diff touches no `repo`-project test. CI's `Test Core` runs it. - **CI-owned families:** read from this PR's check-runs, not re-run here. ## Acceptance notes - Changeset: `.changeset/24624-replay-masked-field.md`, `@objectstack/metadata-protocol` `patch`, carrying the `Clause-②: no` line. - Not measured: what `history` records on a churned replay. That belonged to the first claim's measurement list, and this PR removes the churn. - The showcase seeds no `f_secret`: its seed comment says the seed path has no CryptoProvider, so a `secret` value is refused fail-closed. The `secret` half is therefore pinned through the double only, not read on the real engine. - Card #24562's acceptance measure was all 132 showcase rows unchanged on a second boot. The harness above reads boot 2 as 0 updated and 132 skipped. --- _Generated by [Claude Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ca8180 commit 758c77d

3 files changed

Lines changed: 441 additions & 22 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a seed replay no longer rewrites every seeded row that carries a password or secret field on every boot
6+
7+
Clause-②: no (a replay comparison: no accept set, served body or masking verdict moves; the loader takes no new read and never sees a stored credential)
8+
9+
- **What changed.** A replay reads the existing rows through `engine.find`, which serves a set `secret` field, and a set `password` field on an object not managed by better-auth, as `SECRET_MASK` in every context (ADR-0100). The replay compared the seed's plaintext with that mask, read the row as changed and rewrote it on every boot: `updated_at` moved, lifecycle validation ran again and history could record an edit nobody made. A field the object declares masked on read (asked of `isMaskedOnReadFieldType`, the same declaration the engine's own mask reads) now never blocks the skip while the existing row reads back the mask.
10+
- **What a seed author sees.** A seed change to ONLY such a field is not applied on replay. Change another field of the same row as well (the rewrite then writes the whole seeded record, the new credential included), or rebuild the database. Setting a credential on a row that has none, or clearing one, is still written: an unset credential reads back `null`, not the mask. A load that skipped a row without comparing a credential says so in one `info` line naming the fields and both remedies.

‎packages/metadata-protocol/src/seed-loader-replay.test.ts‎

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SeedLoaderService } from './seed-loader.js';
55
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
66
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
77
import { temporalComparandKind, temporalStorageForm } from '@objectstack/core';
8+
import { SECRET_MASK, isMaskedOnReadFieldType } from '@objectstack/spec/data';
89

910
/**
1011
* Replay regression: seeds with lookup natural keys must survive a dev-server
@@ -491,3 +492,245 @@ describe('seed replay — a temporal value spelled short is the value the driver
491492
expect(replay.result.summary.totalSkipped).toBe(3);
492493
});
493494
});
495+
496+
/**
497+
* [#24624] A `password` or `secret` value replayed over the row the engine
498+
* stored for it.
499+
*
500+
* The engine never reads a credential back: `ObjectQL.maskSecretFields` serves
501+
* every `secret` field, and every `password` field on an object that is not
502+
* `managedBy: 'better-auth'`, as `SECRET_MASK` on `find` / `findOne` — in every
503+
* context, the seed's system read included (ADR-0100) — and an unset one as
504+
* `null`. So the replay holds the seed's plaintext on one side and the mask on
505+
* the other, and no comparison of the two can say whether the value changed.
506+
* Ruled (letter B): such a field never blocks the skip, and a load that skipped
507+
* a row without comparing one says so in one line.
508+
*
509+
* The engine below stores what a real one stores — a `password` verbatim, a
510+
* `secret` as an opaque ref — stamps `updated_at` on every write, and masks on
511+
* read by the spec's declaration (`isMaskedOnReadFieldType`, the predicate the
512+
* engine's own collector asks), returning copies so the mask never reaches the
513+
* store.
514+
*/
515+
describe('seed replay — a credential the read path masks never blocks the skip', () => {
516+
const VAULT_FIELDS = {
517+
name: { type: 'text' },
518+
note: { type: 'text' },
519+
pin: { type: 'password' },
520+
api_key: { type: 'secret' },
521+
};
522+
const VAULT = { name: 'cred_replay_vault', fields: VAULT_FIELDS };
523+
const TREE_VAULT = {
524+
name: 'cred_replay_tree_vault',
525+
fields: { ...VAULT_FIELDS, parent: { type: 'lookup', reference: 'cred_replay_tree_vault' } },
526+
};
527+
type VaultObject = { name: string; managedBy?: string; fields: Record<string, { type: string }> };
528+
const PATHS: Array<{ path: string; object: VaultObject; sequential: boolean }> = [
529+
{ path: 'batched decideWriteAction', object: VAULT, sequential: false },
530+
{ path: 'sequential writeRecord', object: TREE_VAULT, sequential: true },
531+
];
532+
533+
const SEED_ROWS: Array<Record<string, unknown>> = [
534+
{ name: 'with-password', note: 'n1', pin: 'hunter2' },
535+
{ name: 'with-secret', note: 'n2', api_key: 'sk-live-1' },
536+
{ name: 'with-both', note: 'n3', pin: 'swordfish', api_key: 'sk-live-2' },
537+
{ name: 'no-credential', note: 'n4' },
538+
];
539+
540+
function createMaskingEngine(object: VaultObject) {
541+
const { engine, store } = createFaithfulEngine();
542+
let clock = 0;
543+
let handle = 0;
544+
const stored = (row: Record<string, unknown>) => {
545+
const out: Record<string, unknown> = { ...row, updated_at: `stamp-${++clock}` };
546+
for (const [key, value] of Object.entries(row)) {
547+
if (object.fields[key]?.type === 'secret' && value != null) out[key] = `secret:h${++handle}`;
548+
}
549+
return out;
550+
};
551+
const masked = (row: Record<string, unknown>) => {
552+
const out = { ...row };
553+
for (const [key, def] of Object.entries(object.fields)) {
554+
if (key in out && isMaskedOnReadFieldType(def.type, object.managedBy)) {
555+
out[key] = out[key] == null ? null : SECRET_MASK;
556+
}
557+
}
558+
return out;
559+
};
560+
const insert = (engine.insert as any).getMockImplementation();
561+
(engine.insert as any).mockImplementation(async (o: string, data: any, opts?: any) =>
562+
insert(o, Array.isArray(data) ? data.map(stored) : stored(data), opts));
563+
const update = (engine.update as any).getMockImplementation();
564+
(engine.update as any).mockImplementation(async (o: string, data: any, opts?: any) => update(o, stored(data), opts));
565+
const find = (engine.find as any).getMockImplementation();
566+
(engine.find as any).mockImplementation(async (o: string, query?: any) =>
567+
((await find(o, query)) as Array<Record<string, unknown>>).map(masked));
568+
return { engine, store };
569+
}
570+
571+
function metadataOf(object: VaultObject): IMetadataService {
572+
return {
573+
getObject: vi.fn(async (name: string) => (name === object.name ? object : undefined)),
574+
listObjects: vi.fn(async () => [object]),
575+
register: vi.fn(async () => {}),
576+
get: vi.fn(async (_t: string, name: string) => (name === object.name ? object : undefined)),
577+
list: vi.fn(async () => []),
578+
unregister: vi.fn(async () => {}),
579+
exists: vi.fn(async () => false),
580+
listNames: vi.fn(async () => []),
581+
} as unknown as IMetadataService;
582+
}
583+
584+
async function load(
585+
engine: IDataEngine,
586+
object: VaultObject,
587+
mode: string,
588+
records: Array<Record<string, unknown>>,
589+
) {
590+
const logger = createLogger();
591+
const writeRecord = vi.spyOn(SeedLoaderService.prototype as any, 'writeRecord');
592+
try {
593+
const result = await new SeedLoaderService(engine, metadataOf(object), logger).load({
594+
seeds: [{ object: object.name, externalId: 'name', mode, env: ['prod', 'dev', 'test'], records }] as any,
595+
config: CONFIG,
596+
});
597+
return { result, logger, writeRecordCalls: writeRecord.mock.calls.length };
598+
} finally {
599+
writeRecord.mockRestore();
600+
}
601+
}
602+
603+
const rowOf = (store: Record<string, any[]>, object: VaultObject, name: string) =>
604+
store[object.name].find((r) => r.name === name)!;
605+
const updatedNames = (engine: IDataEngine, store: Record<string, any[]>, object: VaultObject) =>
606+
(engine.update as any).mock.calls
607+
.map(([, data]: [string, any]) => store[object.name].find((r) => r.id === data.id)?.name)
608+
.sort();
609+
/** The load's notice lines: the ones naming the uncompared credential rule. */
610+
const credentialNotices = (logger: ReturnType<typeof createLogger>) =>
611+
[...logger.info.mock.calls, ...logger.warn.mock.calls]
612+
.map(([message]) => String(message))
613+
.filter((message) => message.includes('masked on read'));
614+
615+
it.each(PATHS.flatMap((p) => ['upsert', 'update'].map((mode) => ({ ...p, mode }))))(
616+
'$path, mode $mode: replaying the unchanged seed issues no update and moves no updated_at',
617+
async ({ object, sequential, mode }) => {
618+
const { engine, store } = createMaskingEngine(object);
619+
const first = await load(engine, object, 'upsert', SEED_ROWS);
620+
expect(first.result.summary.totalInserted).toBe(4);
621+
// The premise: the store holds the credential, the read hands back the mask.
622+
expect(rowOf(store, object, 'with-password').pin).toBe('hunter2');
623+
expect(String(rowOf(store, object, 'with-secret').api_key)).toMatch(/^secret:h\d+$/);
624+
expect((await engine.find(object.name, {}))[0].pin).toBe(SECRET_MASK);
625+
(engine.update as any).mockClear();
626+
const before = structuredClone(store);
627+
628+
const replay = await load(engine, object, mode, SEED_ROWS);
629+
630+
expect(replay.writeRecordCalls > 0).toBe(sequential);
631+
expect(engine.update).not.toHaveBeenCalled();
632+
expect(replay.result.summary.totalUpdated).toBe(0);
633+
expect(replay.result.summary.totalSkipped).toBe(4);
634+
expect(replay.result.summary.totalErrored).toBe(0);
635+
expect(store).toEqual(before);
636+
},
637+
);
638+
639+
it.each(PATHS)('$path: a credential-only seed change is not rewritten, and the load says so in one line', async ({ object, sequential }) => {
640+
const { engine, store } = createMaskingEngine(object);
641+
await load(engine, object, 'upsert', SEED_ROWS);
642+
(engine.update as any).mockClear();
643+
const before = structuredClone(store);
644+
const credentialOnly = SEED_ROWS.map((r) =>
645+
r.name === 'with-password' ? { ...r, pin: 'hunter3' }
646+
: r.name === 'with-secret' ? { ...r, api_key: 'sk-live-9' }
647+
: r);
648+
649+
const replay = await load(engine, object, 'upsert', credentialOnly);
650+
651+
expect(replay.writeRecordCalls > 0).toBe(sequential);
652+
expect(engine.update).not.toHaveBeenCalled();
653+
expect(replay.result.summary.totalSkipped).toBe(4);
654+
expect(store).toEqual(before);
655+
// ONE line for the whole load, naming every field it left uncompared,
656+
// the rule, and the two ways a credential-only change takes effect.
657+
const notices = credentialNotices(replay.logger);
658+
expect(notices).toHaveLength(1);
659+
expect(replay.logger.info.mock.calls.map(([m]) => String(m))).toContain(notices[0]);
660+
expect(notices[0]).toContain(`${object.name}.pin`);
661+
expect(notices[0]).toContain(`${object.name}.api_key`);
662+
expect(notices[0]).toMatch(/another field/);
663+
expect(notices[0]).toMatch(/rebuild/);
664+
});
665+
666+
it.each(PATHS)('$path: a change to another field rewrites the row, and the credential lands as the seed declares it', async ({ object, sequential }) => {
667+
const { engine, store } = createMaskingEngine(object);
668+
await load(engine, object, 'upsert', SEED_ROWS);
669+
(engine.update as any).mockClear();
670+
const secretRefBefore = rowOf(store, object, 'with-secret').api_key;
671+
const changed = SEED_ROWS.map((r) =>
672+
r.name === 'with-password' ? { ...r, note: 'n1-edited', pin: 'hunter3' }
673+
: r.name === 'with-secret' ? { ...r, note: 'n2-edited', api_key: 'sk-live-9' }
674+
: r);
675+
676+
const replay = await load(engine, object, 'upsert', changed);
677+
678+
expect(replay.writeRecordCalls > 0).toBe(sequential);
679+
expect(updatedNames(engine, store, object)).toEqual(['with-password', 'with-secret']);
680+
expect(replay.result.summary.totalUpdated).toBe(2);
681+
expect(rowOf(store, object, 'with-password').pin).toBe('hunter3');
682+
expect(rowOf(store, object, 'with-password').note).toBe('n1-edited');
683+
// The update carried the seed's plaintext, so the engine wrote a new ref.
684+
expect((engine.update as any).mock.calls.find(([, d]: [string, any]) => d.note === 'n2-edited')[1].api_key).toBe('sk-live-9');
685+
expect(rowOf(store, object, 'with-secret').api_key).not.toBe(secretRefBefore);
686+
});
687+
688+
it.each(PATHS)('$path: the read still tells set from unset, so setting or clearing a credential is written', async ({ object, sequential }) => {
689+
const { engine, store } = createMaskingEngine(object);
690+
await load(engine, object, 'upsert', SEED_ROWS);
691+
(engine.update as any).mockClear();
692+
const presenceChanged = SEED_ROWS.map((r) =>
693+
r.name === 'no-credential' ? { ...r, pin: 'first-pin' }
694+
: r.name === 'with-password' ? { ...r, pin: null }
695+
: r);
696+
697+
const replay = await load(engine, object, 'upsert', presenceChanged);
698+
699+
expect(replay.writeRecordCalls > 0).toBe(sequential);
700+
expect(updatedNames(engine, store, object)).toEqual(['no-credential', 'with-password']);
701+
expect(rowOf(store, object, 'no-credential').pin).toBe('first-pin');
702+
expect(rowOf(store, object, 'with-password').pin).toBeNull();
703+
});
704+
705+
it('a field the declaration does not mask keeps comparing, even when it holds the mask\'s text', async () => {
706+
const object = VAULT;
707+
const { engine, store } = createMaskingEngine(object);
708+
await load(engine, object, 'upsert', [{ name: 'literal-bullets', note: SECRET_MASK }]);
709+
(engine.update as any).mockClear();
710+
711+
const replay = await load(engine, object, 'upsert', [{ name: 'literal-bullets', note: 'plain note' }]);
712+
713+
expect(updatedNames(engine, store, object)).toEqual(['literal-bullets']);
714+
expect(rowOf(store, object, 'literal-bullets').note).toBe('plain note');
715+
expect(credentialNotices(replay.logger)).toEqual([]);
716+
});
717+
718+
it('a load that skips no row with an uncompared credential says nothing about the rule', async () => {
719+
const object = VAULT;
720+
const { engine } = createMaskingEngine(object);
721+
await load(engine, object, 'upsert', SEED_ROWS);
722+
723+
const changedOnly = await load(
724+
engine,
725+
object,
726+
'upsert',
727+
SEED_ROWS.filter((r) => 'pin' in r || 'api_key' in r).map((r) => ({ ...r, note: `${r.note}-edited` })),
728+
);
729+
const noCredential = await load(engine, object, 'upsert', SEED_ROWS.filter((r) => r.name === 'no-credential'));
730+
731+
expect(changedOnly.result.summary.totalUpdated).toBe(3);
732+
expect(credentialNotices(changedOnly.logger)).toEqual([]);
733+
expect(noCredential.result.summary.totalSkipped).toBe(1);
734+
expect(credentialNotices(noCredential.logger)).toEqual([]);
735+
});
736+
});

0 commit comments

Comments
 (0)