Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/summary-backfill-recompute-undefined-on-empty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
"@objectstack/objectql": minor
"@objectstack/cli": minor
---

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)

A roll-up value has three producers — the insert-time seed, the child-write
recompute, and the one-off backfill — and **declaring a summary field on an
object that already has rows reaches none of them**. For `count`/`sum` the
backfill repairs that as a side effect (every `NULL` is a hole to it). For
`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the
function alone, so "never computed" and "no child rows" were indistinguishable,
the column stayed `NULL` on every pre-existing parent, and the report said
`filled: 0` — a false all-clear that a timed flow built on the column then
turned into "matches nothing" (the customer case behind cloud#1908).

**What changes** — maintainer ruling on #15064, option A: the caller who holds
the fact gets a way to say it; the predicate and the default run do not move.

- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]` — `object.field`
roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is
walked like a `count`: every `NULL` parent is recomputed through the same
`aggregateSummaryValue` the engine writes. A parent whose aggregate is the
empty-set reading (`null` — no child rows) already holds the engine's own
value, so it is neither counted as a hole nor written; the scoped run is
therefore idempotent in the same "re-run until it reports zero" sense.
Naming a `count`/`sum` is accepted and changes nothing, so a publish path can
pass every column it just declared without knowing the empty-set list.
- A name that resolves to no roll-up owned by an object the run walks — a typo,
a plain field, or an object `objects` left out — is **refused before any row
is read**, dry run or apply, with an ADR-0112 envelope (`code:
'INVALID_FIELD'`, `status: 400` — the code the projection and write axes
that name a field already answer, while sorting keeps `INVALID_SORT`;
`field` names the first unresolved entry, `fields` all of them). A silent
no-op there would be the same false all-clear this option exists to end.
- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement
of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an
unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'`
to every roll-up function, since a named `max` now appears in `fields`.
- `os migrate summary-nulls --recompute-undefined-on-empty object.field`
(repeatable) passes the scope through; the confirmation prompt names the
columns; `formatSummaryBackfillReport` lists them under "Recomputed on
request" and explains a `NULL` that remains.

**What does not change:** without the option the walk, the writes, every
counter and the human-readable report are byte-for-byte what they were (pinned
against output captured on `main` before this change); `min`/`max`/`avg` stay
out of scope and keep being reported under `skippedUndefinedOnEmpty`; the
predicate `summaryNullIsBackfillable` is untouched, so `os migrate
summary-nulls` keeps its meaning on every deployment. The only visible delta on
an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`.

`minor` for both packages: an optional parameter on a published exported
function, a new report key, and a new CLI flag are each a purely additive
widening of a published surface, which takes at least `minor` (bump-level rule,
2026-09-04); the `fix`-shaped motivation does not lower it.
8 changes: 7 additions & 1 deletion content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ reads those as field filters, so one naming no field could only match zero
records and is rejected rather than answered with an empty page — plus every
other read axis that names a field: `select`, `expand` (a real field that holds
no reference gets its own message), `searchFields` (a real field outside the
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
Off the request path the same code answers `backfillSummaryNulls`'s
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up
owned by an object the run walks — a typo, a real non-summary field, or a
roll-up on an object `--object` left out are refused alike, one message naming
every unresolved entry and how many objects the run walked.
**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
*parameter* rather than a field, use the real one — page size is `top` / `$top`
/ `limit`, not `pageSize` / `perPage`; the response's `error` names the
Expand Down
24 changes: 21 additions & 3 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,8 @@ os migrate summary-nulls # Dry run: full report, writes nothi
os migrate summary-nulls --apply # Recompute and write (prompts)
os migrate summary-nulls --apply --yes --json # CI / scripts
os migrate summary-nulls --object project # Restrict to one object (repeatable)
os migrate summary-nulls --apply --recompute-undefined-on-empty customer.last_follow_up_at
# Also fill a min/max/avg column you know was never computed
```

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

`min` / `max` / `avg` are **never touched**. They are undefined on an empty set,
so a `null` there is the correct reading of "no child rows"; the report lists
them as deliberately skipped.
`min` / `max` / `avg` are **never touched by default**. They are undefined on an
empty set, so a `null` there is the correct reading of "no child rows"; the
report lists them as deliberately skipped.

The one case that reading gets wrong is a summary field **declared after its
parent rows already existed**: nothing has ever computed it — the insert-time
seed is create-time, the recompute runs only on a child write — so every
pre-existing parent reads `NULL` whether or not it has children, and a flow
built on the column matches nothing. The migration cannot tell that `NULL`
from a legitimate one; the operator (or the publish path) who just declared
the column can. Name it with `--recompute-undefined-on-empty object.field`
(repeatable) and it is walked like a `count`: every `NULL` parent is recomputed
through the same aggregate the engine writes, a parent with no child rows keeps
`NULL` (that is the aggregate's own value, and it is neither counted nor
written), and the report lists the column under "recomputed on request". A
name that is not a roll-up this run walks — a typo, a plain field, or an object
`--object` left out — is refused before any row is read. Naming a `count` /
`sum` is accepted and changes nothing, so a caller can pass every column it
just declared.

Idempotent — every write turns a `NULL` into a number, so a second run finds
nothing and writes nothing. Re-running until the report says zero *is* the
Expand Down
150 changes: 150 additions & 0 deletions packages/cli/src/commands/migrate/summary-nulls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os migrate summary-nulls` command shape, and the #15064 scope it surfaces.
*
* The backfill itself is proven in `@objectstack/objectql`'s
* `summary-backfill.test.ts`. What is pinned here is what a unit test of the
* backfill cannot see: that the command is dry-run-by-default (#2186), and
* that `--recompute-undefined-on-empty object.field` reaches
* `backfillSummaryNulls` as `recomputeUndefinedOnEmpty` — every entry, in
* order — while a run without the flag hands the option through as `undefined`
* (the unscoped run the ruling keeps byte-for-byte). The seams that would boot
* a database or walk a real engine are replaced; the command's own parse and
* control flow run for real.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import MigrateSummaryNulls from './summary-nulls.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
import { isExitSignal } from '../../utils/format.js';
import { backfillSummaryNulls } from '@objectstack/objectql';

vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
OCCUPANCY_HINT: 'occupancy hint',
probeMigrationTarget: vi.fn(),
}));
vi.mock('../../utils/data-migration-plugins.js', () => ({ buildDataMigrationPlugins: vi.fn(async () => []) }));
vi.mock('@objectstack/objectql', () => ({
backfillSummaryNulls: vi.fn(),
formatSummaryBackfillReport: vi.fn(() => []),
}));

const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, '..', '..', '..');
/** oclif builds its whole command table on the first `run()` in a process. */
const RUN_TIMEOUT = 60_000;

/** The engine surface the command checks before it runs: the roll-up index
* verb, and at least one loaded app object (a `sys_`-only stack is refused). */
const engine = {
getOwnedSummaryDescriptors: () => [],
getConfigs: () => ({ customer: {}, sys_user: {} }),
};

const EMPTY_REPORT = {
scannedObjects: [], scannedRecords: 0, fields: [], nullRows: 0, filled: 0,
skippedUndefinedOnEmpty: [], recomputedUndefinedOnEmpty: [], applied: false,
truncated: false, unreadableObjects: [], failures: [],
};

let stdout: ReturnType<typeof vi.spyOn>;
let log: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.mocked(probeMigrationTarget).mockResolvedValue({ status: 'free' } as any);
vi.mocked(bootSchemaStack).mockResolvedValue({
kernel: { getService: () => engine },
dbLabel: 'file:test.db',
shutdown: vi.fn(async () => {}),
} as any);
vi.mocked(backfillSummaryNulls).mockReset();
vi.mocked(backfillSummaryNulls).mockResolvedValue(EMPTY_REPORT as any);
// `emitJson` awaits the write's DRAIN callback (a `--json` payload must be
// fully written before the process can exit), so the double has to invoke
// it — a bare `() => true` hangs the command forever.
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
const done = typeof enc === 'function' ? enc : cb;
if (typeof done === 'function') done();
return true;
}) as typeof process.stdout.write);
log = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
stdout.mockRestore();
log.mockRestore();
});

const optionsHandedToBackfill = () => {
expect(vi.mocked(backfillSummaryNulls)).toHaveBeenCalledTimes(1);
const options = vi.mocked(backfillSummaryNulls).mock.calls[0][2];
expect(options).toBeDefined();
return options!;
};

describe('os migrate summary-nulls', () => {
it('is a dry run by default — --apply is opt-in (#2186)', () => {
expect(MigrateSummaryNulls.flags.apply.default).toBe(false);
});

it('requires explicit confirmation to write — --yes is opt-in', () => {
expect(MigrateSummaryNulls.flags.yes.default).toBe(false);
});

it('declares --recompute-undefined-on-empty as a repeatable object.field list, and shows it in --help', () => {
const flag = MigrateSummaryNulls.flags['recompute-undefined-on-empty'];
expect(flag.multiple).toBe(true);
expect(flag.description).toContain('object.field');
expect(flag.description).toMatch(/min\/max\/avg/);
expect(flag.description).toContain('never computed');
expect(MigrateSummaryNulls.examples).toEqual(
expect.arrayContaining([expect.stringContaining('--recompute-undefined-on-empty customer.last_follow_up_at')]),
);
});

it('hands every --recompute-undefined-on-empty entry to backfillSummaryNulls as recomputeUndefinedOnEmpty, in order (#15064)', async () => {
await MigrateSummaryNulls.run([
'--json',
'--object', 'customer',
'--recompute-undefined-on-empty', 'customer.last_follow_up_at',
'--recompute-undefined-on-empty', 'customer.first_follow_up_at',
], { root: CLI_ROOT });

expect(optionsHandedToBackfill()).toEqual({
apply: false,
objects: ['customer'],
recomputeUndefinedOnEmpty: ['customer.last_follow_up_at', 'customer.first_follow_up_at'],
maxRecordsPerObject: undefined,
});
}, RUN_TIMEOUT);

it('without the flag the option is absent — the unscoped run the ruling keeps as it was', async () => {
await MigrateSummaryNulls.run(['--json'], { root: CLI_ROOT });

const options = optionsHandedToBackfill();
expect(options.recomputeUndefinedOnEmpty).toBeUndefined();
expect(options).toEqual({ apply: false, objects: undefined, recomputeUndefinedOnEmpty: undefined, maxRecordsPerObject: undefined });
}, RUN_TIMEOUT);

it('a refused scope entry (INVALID_FIELD) reaches the --json error envelope with its code, and the command exits 1', async () => {
const refusal = Object.assign(new Error('[summary-backfill] recomputeUndefinedOnEmpty names 1 roll-up(s) this run cannot find: customer.nope.'), {
code: 'INVALID_FIELD', status: 400, field: 'customer.nope', fields: ['customer.nope'],
});
vi.mocked(backfillSummaryNulls).mockRejectedValue(refusal);

const err = await MigrateSummaryNulls.run(
['--json', '--recompute-undefined-on-empty', 'customer.nope'],
{ root: CLI_ROOT },
).catch((e: unknown) => e);

expect(isExitSignal(err)).toBe(true);
expect((err as { oclif?: { exit?: number } }).oclif?.exit).toBe(1);
const emitted = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('');
const payload = JSON.parse(emitted);
expect(payload).toMatchObject({ code: 'INVALID_FIELD' });
expect(payload.error).toContain('customer.nope');
}, RUN_TIMEOUT);
});
27 changes: 25 additions & 2 deletions packages/cli/src/commands/migrate/summary-nulls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,15 @@ async function confirm(question: string): Promise<boolean> {
* nothing left to do.
*
* `min`/`max`/`avg` are never touched — undefined on an empty set, so a `null`
* there is the correct reading of "no child rows", not a defect.
* there is the correct reading of "no child rows", not a defect — UNLESS the
* operator names one with `--recompute-undefined-on-empty object.field`
* (#15064). A summary field declared after its parent rows already existed has
* never been computed by anything (the insert-time seed is create-time, the
* recompute runs on a child write, and this run skips the function), and the
* one who just declared it is the one who knows that; named, the column is
* walked like a `count` and every `NULL` parent is recomputed through the same
* aggregate the engine writes. A parent with no child rows keeps `NULL` there —
* the aggregate's own value. Unnamed, nothing about this command changes.
*
* ## No deployment flag, deliberately
*
Expand All @@ -77,6 +85,7 @@ export default class MigrateSummaryNulls extends Command {
'$ os migrate summary-nulls --apply',
'$ os migrate summary-nulls --apply --yes --json',
'$ os migrate summary-nulls --object project',
'$ os migrate summary-nulls --apply --object customer --recompute-undefined-on-empty customer.last_follow_up_at',
];

static override flags = {
Expand All @@ -97,6 +106,14 @@ export default class MigrateSummaryNulls extends Command {
description: 'Restrict to this object (repeatable; default: every object owning a count/sum roll-up)',
multiple: true,
}),
'recompute-undefined-on-empty': Flags.string({
description:
'Also recompute this min/max/avg roll-up, spelled object.field (repeatable) — for a column you KNOW was never ' +
'computed, e.g. one declared after its parent rows already existed. Every NULL parent is recomputed through ' +
'the same aggregate the engine writes; a parent with no child rows keeps NULL. A name that is not a roll-up ' +
'this run walks is refused before any row is read. Without this flag min/max/avg are never touched.',
multiple: true,
}),
'max-records': Flags.integer({
description: 'Safety bound on parent rows read per object — exceeding it truncates the walk',
}),
Expand Down Expand Up @@ -152,8 +169,13 @@ export default class MigrateSummaryNulls extends Command {
this.exit(1);
return;
}
const named = flags['recompute-undefined-on-empty'] ?? [];
const ok = await confirm(
chalk.bold('\nRecompute and write every NULL count/sum roll-up value on this database? [y/N] '),
chalk.bold(
'\nRecompute and write every NULL count/sum roll-up value' +
(named.length > 0 ? ` — and every NULL in ${named.join(', ')} —` : '') +
' on this database? [y/N] ',
),
);
if (!ok) {
printInfo('Aborted — no changes made.');
Expand Down Expand Up @@ -207,6 +229,7 @@ export default class MigrateSummaryNulls extends Command {
const report = await backfillSummaryNulls(engine, logger, {
apply,
objects: flags.object,
recomputeUndefinedOnEmpty: flags['recompute-undefined-on-empty'],
maxRecordsPerObject: flags['max-records'],
});

Expand Down
Loading
Loading