Skip to content

Commit ec0a6e7

Browse files
feat(objectql,cli): backfillSummaryNulls accepts recomputeUndefinedOnEmpty — a just-declared min/max/avg roll-up can be filled on request (#15064) (#15708)
* feat(objectql,cli): backfillSummaryNulls accepts recomputeUndefinedOnEmpty — a just-declared min/max/avg roll-up can be filled on request Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(objectql,cli): #15064 — a clean count control for the ablation, and the CLI wiring double drains stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(cli): #15064 wiring pin — type the captured options and the stdout double Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(objectql): #15064 count control asserts only the count column — a clean ablation control Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(devx): ratchet the error-status unpinned baseline — FIELD_NOT_FOUND now has a producer declaring 404 (#15064) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(objectql,cli): the recomputeUndefinedOnEmpty refusal answers INVALID_FIELD / 400 — the code every axis naming a field already takes (#15064) Contract-review patch round on PR #15708 (verdict 5550099353), items 1, 3, 4. - `resolveRecomputeScope` stamps `code = 'INVALID_FIELD'`, `status = 400` and `field = unresolved[0]` beside `fields`, the shape of the engine's sibling producers (`assertProjectionHasNoDottedPaths`, `undeclaredWriteFieldErrors`). `FIELD_NOT_FOUND` / 404 had no producer in this repo; handing a never-emitted 404 its first producer for a condition the catalog already assigns to a 400 is the two-codes-one-condition drift ADR-0112 exists to prevent — and two of the refused shapes name a field that exists (a real non-summary field; a roll-up on an object `objects` left out), so this is an option value that could not be applied, not an addressed resource that was not found. - Pins retargeted: `summary-backfill.test.ts` "REFUSES a name it cannot resolve" (six code/status assertions, now also `field`/`fields`) and `summary-nulls.test.ts` "a refused scope entry … reaches the --json error envelope" (`code`). - `content/docs/api/error-catalog.mdx`: the `INVALID_FIELD` Cause gains the `recomputeUndefinedOnEmpty` / `--recompute-undefined-on-empty` axis; the `FIELD_NOT_FOUND` entry is untouched. - `SummaryBackfillReport.nullRows` TSDoc counts the named min/max/avg holes. - Option TSDoc, resolver docblock and the changeset paragraph updated wherever FIELD_NOT_FOUND / 404 was named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(devx): restore the error-status unpinned baseline to main — FIELD_NOT_FOUND has no producer again (#15064) Patch-round item 2 (verdict 5550099353): with the refusal answering INVALID_FIELD / 400, FIELD_NOT_FOUND loses the producer that commit 7c96f73 ratcheted the baseline for, so `scripts/error-status-unpinned-baseline.json` goes back to origin/main's blob 0596eb3 and the PR no longer touches the file. A no-op against main; not the maintainer-only baseline expansion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(objectql,cli): the recomputeUndefinedOnEmpty refusal prose describes its producer — one message for every unresolved entry, and INVALID_FIELD's siblings named as the projection and write axes (#15064) Contract re-review verdict 5550562004 (FAIL, patch round, pinned to 1914df8), two prose items, no behaviour change: 1. content/docs/api/error-catalog.mdx, INVALID_FIELD Cause: the appended sentence said the three refusal shapes each "gets its own message", which in this entry's idiom means distinct messages. resolveRecomputeScope emits ONE Error for every unresolved entry, so a typo, a real non-summary field and a roll-up on an object left out receive identical text. The sentence now says they are refused alike, one message naming every unresolved entry and how many objects the run walked. FIELD_NOT_FOUND's entry is untouched. 2. "the code every other axis that names a field answers" was over-general: the sort axis names a field and answers INVALID_SORT (engine.ts, assertOrderByIsMaterializable; assertSortFieldsExist at the ingress). Narrowed to "the projection and write axes that name a field" in the option TSDoc, the changeset paragraph (it compiles into release notes) and the REFUSES test title, each now also stating that sorting keeps INVALID_SORT. No pin reds on prose alone: check:error-status-conformance reads headings, HTTP Status lines and quick-reference rows; error-catalog-docs.test.ts reads headings and the member count. typecheck and the two vitest files stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc5b3dd commit ec0a6e7

7 files changed

Lines changed: 722 additions & 37 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064)
7+
8+
A roll-up value has three producers — the insert-time seed, the child-write
9+
recompute, and the one-off backfill — and **declaring a summary field on an
10+
object that already has rows reaches none of them**. For `count`/`sum` the
11+
backfill repairs that as a side effect (every `NULL` is a hole to it). For
12+
`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the
13+
function alone, so "never computed" and "no child rows" were indistinguishable,
14+
the column stayed `NULL` on every pre-existing parent, and the report said
15+
`filled: 0` — a false all-clear that a timed flow built on the column then
16+
turned into "matches nothing" (the customer case behind cloud#1908).
17+
18+
**What changes** — maintainer ruling on #15064, option A: the caller who holds
19+
the fact gets a way to say it; the predicate and the default run do not move.
20+
21+
- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]``object.field`
22+
roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is
23+
walked like a `count`: every `NULL` parent is recomputed through the same
24+
`aggregateSummaryValue` the engine writes. A parent whose aggregate is the
25+
empty-set reading (`null` — no child rows) already holds the engine's own
26+
value, so it is neither counted as a hole nor written; the scoped run is
27+
therefore idempotent in the same "re-run until it reports zero" sense.
28+
Naming a `count`/`sum` is accepted and changes nothing, so a publish path can
29+
pass every column it just declared without knowing the empty-set list.
30+
- A name that resolves to no roll-up owned by an object the run walks — a typo,
31+
a plain field, or an object `objects` left out — is **refused before any row
32+
is read**, dry run or apply, with an ADR-0112 envelope (`code:
33+
'INVALID_FIELD'`, `status: 400` — the code the projection and write axes
34+
that name a field already answer, while sorting keeps `INVALID_SORT`;
35+
`field` names the first unresolved entry, `fields` all of them). A silent
36+
no-op there would be the same false all-clear this option exists to end.
37+
- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement
38+
of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an
39+
unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'`
40+
to every roll-up function, since a named `max` now appears in `fields`.
41+
- `os migrate summary-nulls --recompute-undefined-on-empty object.field`
42+
(repeatable) passes the scope through; the confirmation prompt names the
43+
columns; `formatSummaryBackfillReport` lists them under "Recomputed on
44+
request" and explains a `NULL` that remains.
45+
46+
**What does not change:** without the option the walk, the writes, every
47+
counter and the human-readable report are byte-for-byte what they were (pinned
48+
against output captured on `main` before this change); `min`/`max`/`avg` stay
49+
out of scope and keep being reported under `skippedUndefinedOnEmpty`; the
50+
predicate `summaryNullIsBackfillable` is untouched, so `os migrate
51+
summary-nulls` keeps its meaning on every deployment. The only visible delta on
52+
an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`.
53+
54+
`minor` for both packages: an optional parameter on a published exported
55+
function, a new report key, and a new CLI flag are each a purely additive
56+
widening of a published surface, which takes at least `minor` (bump-level rule,
57+
2026-09-04); the `fix`-shaped motivation does not lower it.

content/docs/api/error-catalog.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ reads those as field filters, so one naming no field could only match zero
7575
records and is rejected rather than answered with an empty page — plus every
7676
other read axis that names a field: `select`, `expand` (a real field that holds
7777
no reference gets its own message), `searchFields` (a real field outside the
78-
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
78+
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
79+
Off the request path the same code answers `backfillSummaryNulls`'s
80+
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
81+
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up
82+
owned by an object the run walks — a typo, a real non-summary field, or a
83+
roll-up on an object `--object` left out are refused alike, one message naming
84+
every unresolved entry and how many objects the run walked.
7985
**Fix:** Check the object schema for valid field names. Use `os meta get object <name>` to inspect the object's fields. If the name was meant as a
8086
*parameter* rather than a field, use the real one — page size is `top` / `$top`
8187
/ `limit`, not `pageSize` / `perPage`; the response's `error` names the

content/docs/deployment/cli.mdx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,8 @@ os migrate summary-nulls # Dry run: full report, writes nothi
912912
os migrate summary-nulls --apply # Recompute and write (prompts)
913913
os migrate summary-nulls --apply --yes --json # CI / scripts
914914
os migrate summary-nulls --object project # Restrict to one object (repeatable)
915+
os migrate summary-nulls --apply --recompute-undefined-on-empty customer.last_follow_up_at
916+
# Also fill a min/max/avg column you know was never computed
915917
```
916918

917919
**Each affected row is recomputed, not set to 0.** A pre-upgrade parent that
@@ -920,9 +922,25 @@ them — writing 0 there would replace a missing value with a wrong one, and the
920922
next child write would change it back. The report separates the two: `N NULL
921923
row(s), M with real child data`.
922924

923-
`min` / `max` / `avg` are **never touched**. They are undefined on an empty set,
924-
so a `null` there is the correct reading of "no child rows"; the report lists
925-
them as deliberately skipped.
925+
`min` / `max` / `avg` are **never touched by default**. They are undefined on an
926+
empty set, so a `null` there is the correct reading of "no child rows"; the
927+
report lists them as deliberately skipped.
928+
929+
The one case that reading gets wrong is a summary field **declared after its
930+
parent rows already existed**: nothing has ever computed it — the insert-time
931+
seed is create-time, the recompute runs only on a child write — so every
932+
pre-existing parent reads `NULL` whether or not it has children, and a flow
933+
built on the column matches nothing. The migration cannot tell that `NULL`
934+
from a legitimate one; the operator (or the publish path) who just declared
935+
the column can. Name it with `--recompute-undefined-on-empty object.field`
936+
(repeatable) and it is walked like a `count`: every `NULL` parent is recomputed
937+
through the same aggregate the engine writes, a parent with no child rows keeps
938+
`NULL` (that is the aggregate's own value, and it is neither counted nor
939+
written), and the report lists the column under "recomputed on request". A
940+
name that is not a roll-up this run walks — a typo, a plain field, or an object
941+
`--object` left out — is refused before any row is read. Naming a `count` /
942+
`sum` is accepted and changes nothing, so a caller can pass every column it
943+
just declared.
926944

927945
Idempotent — every write turns a `NULL` into a number, so a second run finds
928946
nothing and writes nothing. Re-running until the report says zero *is* the
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate summary-nulls` command shape, and the #15064 scope it surfaces.
5+
*
6+
* The backfill itself is proven in `@objectstack/objectql`'s
7+
* `summary-backfill.test.ts`. What is pinned here is what a unit test of the
8+
* backfill cannot see: that the command is dry-run-by-default (#2186), and
9+
* that `--recompute-undefined-on-empty object.field` reaches
10+
* `backfillSummaryNulls` as `recomputeUndefinedOnEmpty` — every entry, in
11+
* order — while a run without the flag hands the option through as `undefined`
12+
* (the unscoped run the ruling keeps byte-for-byte). The seams that would boot
13+
* a database or walk a real engine are replaced; the command's own parse and
14+
* control flow run for real.
15+
*/
16+
17+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
18+
import { dirname, resolve } from 'node:path';
19+
import { fileURLToPath } from 'node:url';
20+
import MigrateSummaryNulls from './summary-nulls.js';
21+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
22+
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
23+
import { isExitSignal } from '../../utils/format.js';
24+
import { backfillSummaryNulls } from '@objectstack/objectql';
25+
26+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
27+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
28+
OCCUPANCY_HINT: 'occupancy hint',
29+
probeMigrationTarget: vi.fn(),
30+
}));
31+
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
32+
vi.mock('@objectstack/objectql', () => ({
33+
backfillSummaryNulls: vi.fn(),
34+
formatSummaryBackfillReport: vi.fn(() => []),
35+
}));
36+
37+
const HERE = dirname(fileURLToPath(import.meta.url));
38+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
39+
/** oclif builds its whole command table on the first `run()` in a process. */
40+
const RUN_TIMEOUT = 60_000;
41+
42+
/** The engine surface the command checks before it runs: the roll-up index
43+
* verb, and at least one loaded app object (a `sys_`-only stack is refused). */
44+
const engine = {
45+
getOwnedSummaryDescriptors: () => [],
46+
getConfigs: () => ({ customer: {}, sys_user: {} }),
47+
};
48+
49+
const EMPTY_REPORT = {
50+
scannedObjects: [], scannedRecords: 0, fields: [], nullRows: 0, filled: 0,
51+
skippedUndefinedOnEmpty: [], recomputedUndefinedOnEmpty: [], applied: false,
52+
truncated: false, unreadableObjects: [], failures: [],
53+
};
54+
55+
let stdout: ReturnType<typeof vi.spyOn>;
56+
let log: ReturnType<typeof vi.spyOn>;
57+
beforeEach(() => {
58+
vi.mocked(probeMigrationTarget).mockResolvedValue({ status: 'free' } as any);
59+
vi.mocked(bootSchemaStack).mockResolvedValue({
60+
kernel: { getService: () => engine },
61+
dbLabel: 'file:test.db',
62+
shutdown: vi.fn(async () => {}),
63+
} as any);
64+
vi.mocked(backfillSummaryNulls).mockReset();
65+
vi.mocked(backfillSummaryNulls).mockResolvedValue(EMPTY_REPORT as any);
66+
// `emitJson` awaits the write's DRAIN callback (a `--json` payload must be
67+
// fully written before the process can exit), so the double has to invoke
68+
// it — a bare `() => true` hangs the command forever.
69+
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
70+
const done = typeof enc === 'function' ? enc : cb;
71+
if (typeof done === 'function') done();
72+
return true;
73+
}) as typeof process.stdout.write);
74+
log = vi.spyOn(console, 'log').mockImplementation(() => {});
75+
});
76+
afterEach(() => {
77+
stdout.mockRestore();
78+
log.mockRestore();
79+
});
80+
81+
const optionsHandedToBackfill = () => {
82+
expect(vi.mocked(backfillSummaryNulls)).toHaveBeenCalledTimes(1);
83+
const options = vi.mocked(backfillSummaryNulls).mock.calls[0][2];
84+
expect(options).toBeDefined();
85+
return options!;
86+
};
87+
88+
describe('os migrate summary-nulls', () => {
89+
it('is a dry run by default — --apply is opt-in (#2186)', () => {
90+
expect(MigrateSummaryNulls.flags.apply.default).toBe(false);
91+
});
92+
93+
it('requires explicit confirmation to write — --yes is opt-in', () => {
94+
expect(MigrateSummaryNulls.flags.yes.default).toBe(false);
95+
});
96+
97+
it('declares --recompute-undefined-on-empty as a repeatable object.field list, and shows it in --help', () => {
98+
const flag = MigrateSummaryNulls.flags['recompute-undefined-on-empty'];
99+
expect(flag.multiple).toBe(true);
100+
expect(flag.description).toContain('object.field');
101+
expect(flag.description).toMatch(/min\/max\/avg/);
102+
expect(flag.description).toContain('never computed');
103+
expect(MigrateSummaryNulls.examples).toEqual(
104+
expect.arrayContaining([expect.stringContaining('--recompute-undefined-on-empty customer.last_follow_up_at')]),
105+
);
106+
});
107+
108+
it('hands every --recompute-undefined-on-empty entry to backfillSummaryNulls as recomputeUndefinedOnEmpty, in order (#15064)', async () => {
109+
await MigrateSummaryNulls.run([
110+
'--json',
111+
'--object', 'customer',
112+
'--recompute-undefined-on-empty', 'customer.last_follow_up_at',
113+
'--recompute-undefined-on-empty', 'customer.first_follow_up_at',
114+
], { root: CLI_ROOT });
115+
116+
expect(optionsHandedToBackfill()).toEqual({
117+
apply: false,
118+
objects: ['customer'],
119+
recomputeUndefinedOnEmpty: ['customer.last_follow_up_at', 'customer.first_follow_up_at'],
120+
maxRecordsPerObject: undefined,
121+
});
122+
}, RUN_TIMEOUT);
123+
124+
it('without the flag the option is absent — the unscoped run the ruling keeps as it was', async () => {
125+
await MigrateSummaryNulls.run(['--json'], { root: CLI_ROOT });
126+
127+
const options = optionsHandedToBackfill();
128+
expect(options.recomputeUndefinedOnEmpty).toBeUndefined();
129+
expect(options).toEqual({ apply: false, objects: undefined, recomputeUndefinedOnEmpty: undefined, maxRecordsPerObject: undefined });
130+
}, RUN_TIMEOUT);
131+
132+
it('a refused scope entry (INVALID_FIELD) reaches the --json error envelope with its code, and the command exits 1', async () => {
133+
const refusal = Object.assign(new Error('[summary-backfill] recomputeUndefinedOnEmpty names 1 roll-up(s) this run cannot find: customer.nope.'), {
134+
code: 'INVALID_FIELD', status: 400, field: 'customer.nope', fields: ['customer.nope'],
135+
});
136+
vi.mocked(backfillSummaryNulls).mockRejectedValue(refusal);
137+
138+
const err = await MigrateSummaryNulls.run(
139+
['--json', '--recompute-undefined-on-empty', 'customer.nope'],
140+
{ root: CLI_ROOT },
141+
).catch((e: unknown) => e);
142+
143+
expect(isExitSignal(err)).toBe(true);
144+
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
145+
const emitted = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('');
146+
const payload = JSON.parse(emitted);
147+
expect(payload).toMatchObject({ code: 'INVALID_FIELD' });
148+
expect(payload.error).toContain('customer.nope');
149+
}, RUN_TIMEOUT);
150+
});

packages/cli/src/commands/migrate/summary-nulls.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,15 @@ async function confirm(question: string): Promise<boolean> {
5757
* nothing left to do.
5858
*
5959
* `min`/`max`/`avg` are never touched — undefined on an empty set, so a `null`
60-
* there is the correct reading of "no child rows", not a defect.
60+
* there is the correct reading of "no child rows", not a defect — UNLESS the
61+
* operator names one with `--recompute-undefined-on-empty object.field`
62+
* (#15064). A summary field declared after its parent rows already existed has
63+
* never been computed by anything (the insert-time seed is create-time, the
64+
* recompute runs on a child write, and this run skips the function), and the
65+
* one who just declared it is the one who knows that; named, the column is
66+
* walked like a `count` and every `NULL` parent is recomputed through the same
67+
* aggregate the engine writes. A parent with no child rows keeps `NULL` there —
68+
* the aggregate's own value. Unnamed, nothing about this command changes.
6169
*
6270
* ## No deployment flag, deliberately
6371
*
@@ -77,6 +85,7 @@ export default class MigrateSummaryNulls extends Command {
7785
'$ os migrate summary-nulls --apply',
7886
'$ os migrate summary-nulls --apply --yes --json',
7987
'$ os migrate summary-nulls --object project',
88+
'$ os migrate summary-nulls --apply --object customer --recompute-undefined-on-empty customer.last_follow_up_at',
8089
];
8190

8291
static override flags = {
@@ -97,6 +106,14 @@ export default class MigrateSummaryNulls extends Command {
97106
description: 'Restrict to this object (repeatable; default: every object owning a count/sum roll-up)',
98107
multiple: true,
99108
}),
109+
'recompute-undefined-on-empty': Flags.string({
110+
description:
111+
'Also recompute this min/max/avg roll-up, spelled object.field (repeatable) — for a column you KNOW was never ' +
112+
'computed, e.g. one declared after its parent rows already existed. Every NULL parent is recomputed through ' +
113+
'the same aggregate the engine writes; a parent with no child rows keeps NULL. A name that is not a roll-up ' +
114+
'this run walks is refused before any row is read. Without this flag min/max/avg are never touched.',
115+
multiple: true,
116+
}),
100117
'max-records': Flags.integer({
101118
description: 'Safety bound on parent rows read per object — exceeding it truncates the walk',
102119
}),
@@ -152,8 +169,13 @@ export default class MigrateSummaryNulls extends Command {
152169
this.exit(1);
153170
return;
154171
}
172+
const named = flags['recompute-undefined-on-empty'] ?? [];
155173
const ok = await confirm(
156-
chalk.bold('\nRecompute and write every NULL count/sum roll-up value on this database? [y/N] '),
174+
chalk.bold(
175+
'\nRecompute and write every NULL count/sum roll-up value' +
176+
(named.length > 0 ? ` — and every NULL in ${named.join(', ')} —` : '') +
177+
' on this database? [y/N] ',
178+
),
157179
);
158180
if (!ok) {
159181
printInfo('Aborted — no changes made.');
@@ -207,6 +229,7 @@ export default class MigrateSummaryNulls extends Command {
207229
const report = await backfillSummaryNulls(engine, logger, {
208230
apply,
209231
objects: flags.object,
232+
recomputeUndefinedOnEmpty: flags['recompute-undefined-on-empty'],
210233
maxRecordsPerObject: flags['max-records'],
211234
});
212235

0 commit comments

Comments
 (0)