Skip to content

Commit 0b63f5f

Browse files
fix(metadata-protocol): the 409 that makes a controlled_by_parent master required counts the rows with no master (#28710)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQKN4hcnpATFdcNUrQvaJk3)_ Fixes #28665 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:把一个从表里查出来的数字写进已有的 409 提示文案。把子对象设为「受父对象控制」、同时把主从关系设为必填时,服务端会数出有多少行还没有父记录,告诉作者这些行会被所有人(包括管理员)看不到,并提示先补上父记录。 - **为什么改**:以前只说「空值行可能校验失败」,严重低估了后果:强制保存并发布后,这些行仍在库里,但任何入口都读不到也改不了(ADR-0055 的 fail-closed 规则)。 - **风险与回滚**:低。409 照常出现,`?force=true` 照常放行,错误码和结构都不变;只有匹配那一种情形时多一次 count 查询,查询失败就退回原来的文案。回滚即撤回本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-destructive-null-master.ts`(判定条件与新文案);`packages/metadata-protocol/src/protocol.ts` 里 Phase 3a-destructive 门的那一处调用(count 查询的写法);`packages/objectql/src/protocol-destructive-null-master.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/protocol-destructive-null-master.test.ts`(需先 build `@objectstack/metadata-protocol`),预期 8/8 通过;main 上是 3 失败。 - **需要你做的**:无,席位落地。 ## What changes - `packages/metadata-protocol/src/protocol-destructive-null-master.ts` (new): `withNullMasterRowCounts(findings, next, objectName, count)`. It picks the `field_required_no_default` findings whose field is a `master_detail` of a body whose `sharingModel` is `controlled_by_parent`. That covers both a detail switched to `controlled_by_parent` in this save and one that already was. For each one it asks `count`, and when the answer is positive it rewrites that finding's `message`. Nothing else changes: findings that do not match, a zero count and a count that throws all come back as they were, in the same order. - `packages/metadata-protocol/src/protocol.ts`: one call site in `saveMetaItem`'s Phase 3a-destructive gate, wrapping `detectDestructiveObjectChanges(prev, request.item)`. The counter is `this.engine.count(request.name, { where: { [field]: { $null: true } }, context: { isSystem: true } })`. That is system authority, because the question is what the table holds, not what the caller can read. The detector itself is untouched. It stays sync and reads only the two definitions. The gate already runs only when `!request.force`, so a forced save issues no count. - `packages/objectql/src/protocol-destructive-null-master.test.ts` (new): pins through the real `saveMetaItem` gate, using the `protocol-destructive.test.ts` harness (tenant-authored objects in a `SchemaRegistry` and a mock engine whose `count` answers from a row list by the `$null` predicate it is handed). - `.changeset/28665-cbp-null-master-count.md`: `@objectstack/metadata-protocol` patch. `files[]` ships `dist`, and the new text is in `dist/index.js` after a build. The new message, with N = 2 (with N = 1 it says "1 existing row has no 'project' … it becomes … Set 'project' on that row first."): > Field 'project' is now required but has no default value, and 'qow_task' is controlled by its parent: 2 existing rows have no 'project', and once the object is controlled by its parent they become unreadable through every door, the admin's included (a detail with no master has no reader, ADR-0055). Set 'project' on each of them first. Untouched, as ruled: ADR-0055 access derivation, plugin-security, any repair door, `ui/**`. No issue code, structured member or spec key is added, so `Clause-②: no` holds. No line of #28564's doors or #25467's `parseCommitItems` is touched. The one `saveMetaItem` line is the gate's own call to the detector. ## Why Since #27458, Studio's "Make required and save" re-sends the draft with `?force=true` after a 409 whose finding said only "existing rows with null values may fail validation". Under `controlled_by_parent` a detail derives every reader from its master, and plugin-security fails closed on a row with none. So after publish those rows answer `total: 0` on the list and 404 on the by-id read and write, the admin included, while they stay stored. The card's live reproduction is below, base vs head. ## The Studio confirm: the `domain:ui` follow-up `PackageOwdOverviewPanel` (`ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx`, about L424–L431 and L498–L503) already lists every `issues[].message` from the 409 in its confirm. So the count reaches the author with **no console change**, and the field to relay is `issues[].message` of the `field_required_no_default` entry. The follow-up the seat files is only owed if the console wants more than that, for example a localized sentence, or a "stop" path that sends the author to fix the rows first. That needs a structured count, which would be a new published member (`Clause-②: yes`) and is not added here. ## Tests <details><summary>Red on base (dist built from 2678600), green at head</summary> `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-destructive-null-master.test.ts` after building the `@objectstack/objectql^...` closure. The subject is imported as `@objectstack/metadata-protocol`, which resolves to `dist/`, so every leg rebuilt that package first. Base (`protocol.ts` at `2678600d`, helper absent): ``` × names how many stored rows have no master and that they become unreadable through every door × counts too when the detail already was controlled_by_parent and its master becomes required now × with no masterless row the finding is today's, unchanged AssertionError: expected 'Field \'project\' is now required but…' to contain '2 existing rows have no \'project\'' AssertionError: expected 'Field \'project\' is now required but…' to contain '1 existing row has no \'project\'' AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times Tests 3 failed | 5 passed (8) ``` Head (with `protocol-destructive.test.ts` beside it): `Test Files 2 passed (2) · Tests 15 passed (15) · VERDICT command-exit 0`. Same again after merging `origin/main` `a06feb76` and rebuilding. The eight cases: - 2 masterless rows: 409 `DESTRUCTIVE_CHANGE`. The finding and `err.message` name "2 existing rows have no 'project'" and "unreadable through every door". There is exactly one `count('qow_task', { where: { project: { $null: true } }, context: { isSystem: true } })`. Run on the direct save and on `mode: 'draft'`. - A detail already `controlled_by_parent` whose master becomes required now: counted ("1 existing row"). - Zero masterless rows: the finding is today's text, byte for byte. One count. - A count that throws: today's text, and the 409 stands. - `force: true`: `success: true`, no count. - No count: a master made required on a `private` detail (today's text); another field made required on a `controlled_by_parent` detail; an ordinary save with no destructive change (`success: true`). </details> <details><summary>Ablation: the count removed, three pins red; restored, green</summary> `node scripts/ablation-replace.mjs --file packages/metadata-protocol/src/protocol-destructive-null-master.ts --anchor 'if (masters.length === 0) return findings;' --replacement 'if (masters.length >= 0) return findings;' -- bash ablate-leg.sh`. The leg rebuilds `@objectstack/metadata-protocol`, runs `ablation-dist-preflight.mjs` for the marker, then runs the suite. ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob b97adc2 -> 2a91551eaa9a ✓ dist/: marker present in 2 built files -- the ablation is live in the artifact the suite consumes. × names how many stored rows have no master and that they become unreadable through every door × counts too when the detail already was controlled_by_parent and its master becomes required now × with no masterless row the finding is today's, unchanged Tests 3 failed | 5 passed (8) ablation-replace: ok restored: blob == HEAD (b97adc2) and `git diff HEAD` is empty ``` Restore leg: rebuild, then `ablation-dist-preflight.mjs … --absent` gives "✓ dist/: marker absent from all 24 built files", and the suite gives `Tests 8 passed (8)`. </details> <details><summary>Live probe: the card's steps 1–3 on a stock showcase boot, base vs head</summary> `pnpm dev -- --fresh -p PORT`, then `probe.sh PORT app.qow`: sign in as `admin@objectos.ai`, `POST /api/v1/packages` (`app.qow`), draft and publish `qow_project` and `qow_task` (`project` a `master_detail`, not required), `POST /api/v1/data/qow_task` twice with no project and once with one, then `PUT /api/v1/meta/object/qow_task?mode=draft&package=app.qow` with `sharingModel: "controlled_by_parent"` and `fields.project.required: true`. Base (`@objectstack/metadata-protocol` rebuilt from `2678600d`'s `protocol.ts`): ``` {"error":"object/qow_task would drop or transform existing data: Field 'project' is now required but has no default value — existing rows with null values may fail validation. — re-submit with ?force=true to proceed.","code":"DESTRUCTIVE_CHANGE","issues":[{"code":"field_required_no_default","field":"project","message":"Field 'project' is now required but has no default value — existing rows with null values may fail validation."}]} HTTP 409 ``` Head: ``` {"error":"object/qow_task would drop or transform existing data: Field 'project' is now required but has no default value, and 'qow_task' is controlled by its parent: 2 existing rows have no 'project', and once the object is controlled by its parent they become unreadable through every door, the admin's included (a detail with no master has no reader, ADR-0055). Set 'project' on each of them first. — re-submit with ?force=true to proceed.","code":"DESTRUCTIVE_CHANGE","issues":[{"code":"field_required_no_default","field":"project","message":"Field 'project' is now required but has no default value, and 'qow_task' is controlled by its parent: 2 existing rows have no 'project', …"}]} HTTP 409 ``` On both: `?force=true` gives 200, `publish-drafts` gives `{"outcome":"published","published":1}`, and the admin's `GET /api/v1/data/qow_task` gives `{"total":1}` of the 3 stored rows. That is the consequence the head text now names, and it is unchanged, as ruled. </details> ## Acceptance notes - **Not covered: a detail switched to `controlled_by_parent` whose master was already required.** Such a save raises no finding today, so no 409 carries a count. Masterless rows can still exist there, for example rows stored before an earlier forced required toggle. Raising a new 409 for that case would be a new block, which ruling 2 excludes, so it is reported to the seat as an open question rather than built. The Studio panel never reaches this case with a 409: it sends the master required in the same save. - When a save has more than three findings, the 409's top-level text quotes the first three. `issues[]` always carries the counted finding. ## Verification Gates and package suites at final head `01fa82d0` (merge of `origin/main` `a06feb76`): see the os-dev-report on #28665 for each command and exit code. In short: `pnpm --filter @objectstack/metadata-protocol typecheck` 0, its suite 291 files / 21950 tests passed; `pnpm --filter @objectstack/objectql typecheck` 0, its `local` suite 513 files / 9906 tests passed; `eslint --no-inline-config packages/metadata-protocol packages/objectql` 0; the derived gate list (`dispatch-gates.mjs --commands`, 62 families), each exit 0, and `--ran` reconciles 62 derived, 62 run, 0 NOT-MEASURED. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5miRY4M71TfnNGvmsh --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5miRY4M71TfnNGvmsh)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c6b7b8 commit 0b63f5f

4 files changed

Lines changed: 320 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the 409 `DESTRUCTIVE_CHANGE` that makes a `controlled_by_parent` detail's master required now counts the stored rows with no master and says they become unreadable to everyone
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Saving "Controlled by parent" on a detail whose `master_detail` was not required answers 409 `DESTRUCTIVE_CHANGE` with a `field_required_no_default` finding. Its message said only that "existing rows with null values may fail validation". Under `controlled_by_parent` a row with no master has no reader at all (ADR-0055 §1, fail-closed): after publish the list answers `total: 0` and the by-id read and write answer 404, for the admin too, while the row stays stored.
11+
12+
- **What the finding says now.** When the saved body is `controlled_by_parent` and the finding is on its `master_detail` field, the save door counts the stored rows whose master is null. If there are any, the finding's `message` (and the 409's own text) says how many, that they become unreadable through every door once the object is controlled by its parent, the admin's included, and to set the master on them first.
13+
- **Unchanged.** The finding's `code` and `field`, the 409 itself and `?force=true` behave as before; no code or member is added. With no masterless row, or when the count cannot be answered, the message is byte-identical to before. Access derivation is not touched.
14+
- **Cost.** One system-authority `count` on that column per matching finding. No other save issues a query, and a forced save issues none.
15+
- **The Studio confirm.** `PackageOwdOverviewPanel` already lists each `issues[].message` in its confirm, so the count reaches the author there with no console change.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28665, ADR-0055 §1] The rows a `controlled_by_parent` detail stops
5+
* serving, counted into the destructive-change finding that makes its master
6+
* required.
7+
*
8+
* `detectDestructiveObjectChanges` (`protocol.ts`) judges a save from the two
9+
* definitions alone and reads no row. Its `field_required_no_default` finding
10+
* says "existing rows with null values may fail validation", which is true of
11+
* an ordinary field and understates one case by a long way: the master of a
12+
* detail that is (or becomes) `controlled_by_parent`. Such a detail derives
13+
* every reader from its master, and plugin-security fails closed on a row with
14+
* none (ADR-0055 §1), so once the object is controlled by its parent a row
15+
* whose master is null answers `total: 0` on the list and 404 on the by-id
16+
* read and write, for the admin too. The row is still stored; nothing reads it.
17+
*
18+
* So for exactly that finding this module asks the table how many rows hold no
19+
* master and, when there are any, says so in the finding's own `message`: the
20+
* count, the consequence, and the step that avoids it. ⛔ It adds no finding,
21+
* no code and no member: the 409 fires where it fired before, `force` lifts it
22+
* as before (the gate does not run under `force`, so neither does the count),
23+
* and with no such row the finding is byte-identical to before. The Studio
24+
* confirm (`PackageOwdOverviewPanel`) already renders each `issues[].message`,
25+
* so the count reaches the author with no console change.
26+
*
27+
* Cost: one `count` query per finding that matches, and none otherwise. An
28+
* ordinary save (no finding, or a required toggle on any other field, or on a
29+
* master whose object is not `controlled_by_parent`) issues no query. A count
30+
* that cannot be answered (no table yet, a driver fault) leaves the finding as
31+
* it was: this is a warning's precision, not a gate, and the 409 still stands.
32+
*/
33+
34+
/** One Phase 3a-destructive finding, the shape `detectDestructiveObjectChanges` returns. */
35+
export interface DestructiveFinding {
36+
code: string;
37+
field?: string;
38+
message: string;
39+
}
40+
41+
/**
42+
* Counts the stored rows of the object being saved whose `field` holds no
43+
* value. Called with system authority by the save door: the question is what
44+
* the table holds, not what the caller can read.
45+
*/
46+
export type NullMasterRowCounter = (field: string) => Promise<number>;
47+
48+
/**
49+
* The field names whose `field_required_no_default` finding is the master a
50+
* `controlled_by_parent` detail derives its access from, judged on the
51+
* definition the save writes (`next`). The previous sharing model does not
52+
* matter: a detail switched to `controlled_by_parent` in this save and one
53+
* that already was both stop serving a masterless row once this lands.
54+
*/
55+
export function controlledByParentMasterFindings(
56+
findings: readonly DestructiveFinding[],
57+
next: unknown,
58+
): DestructiveFinding[] {
59+
if (!next || typeof next !== 'object') return [];
60+
const body = next as { sharingModel?: unknown; fields?: unknown };
61+
if (body.sharingModel !== 'controlled_by_parent') return [];
62+
const fields = body.fields && typeof body.fields === 'object' ? body.fields as Record<string, { type?: unknown }> : {};
63+
return findings.filter((finding) => finding.code === 'field_required_no_default'
64+
&& typeof finding.field === 'string'
65+
&& fields[finding.field]?.type === 'master_detail');
66+
}
67+
68+
/**
69+
* The findings with each `controlled_by_parent` master's null-master row count
70+
* written into its message. Findings that do not match, a count of zero and a
71+
* count that fails all come back unchanged; the array keeps its order.
72+
*/
73+
export async function withNullMasterRowCounts(
74+
findings: DestructiveFinding[],
75+
next: unknown,
76+
objectName: string,
77+
countNullMasterRows: NullMasterRowCounter,
78+
): Promise<DestructiveFinding[]> {
79+
const masters = controlledByParentMasterFindings(findings, next);
80+
if (masters.length === 0) return findings;
81+
const counted = new Map<DestructiveFinding, number>();
82+
for (const finding of masters) {
83+
let rows: number;
84+
try {
85+
rows = await countNullMasterRows(finding.field as string);
86+
} catch {
87+
continue;
88+
}
89+
if (Number.isFinite(rows) && rows > 0) counted.set(finding, rows);
90+
}
91+
if (counted.size === 0) return findings;
92+
return findings.map((finding) => {
93+
const rows = counted.get(finding);
94+
return rows === undefined ? finding : { ...finding, message: nullMasterMessage(objectName, finding.field as string, rows) };
95+
});
96+
}
97+
98+
/** The finding's message once the count is known to be positive. */
99+
function nullMasterMessage(objectName: string, field: string, rows: number): string {
100+
const subject = rows === 1 ? `1 existing row has no '${field}'` : `${rows} existing rows have no '${field}'`;
101+
return `Field '${field}' is now required but has no default value, and '${objectName}' is controlled by its parent: `
102+
+ `${subject}, and once the object is controlled by its parent ${rows === 1 ? 'it becomes' : 'they become'} `
103+
+ 'unreadable through every door, the admin\'s included (a detail with no master has no reader, ADR-0055). '
104+
+ `Set '${field}' on ${rows === 1 ? 'that row' : 'each of them'} first.`;
105+
}

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,8 @@ import { rollBackUnmanagedImport, unmanagedImportCollisions } from './protocol-u
466466
import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js';
467467
// [#28355] The `denied` row the four single-item doors leave for a refusal that writes none of its own.
468468
import { withRefusalAudit } from './protocol-refusal-audit.js';
469+
// [#28665] The null-master rows a `controlled_by_parent` detail stops serving, counted into the finding that makes its master required.
470+
import { withNullMasterRowCounts } from './protocol-destructive-null-master.js';
469471

470472
/**
471473
* Canonical Zod schema per metadata type lives in
@@ -21481,7 +21483,14 @@ export class ObjectStackProtocolImplementation implements
2148121483
} as any);
2148221484
const prev = (existing as any)?.item;
2148321485
if (prev) {
21484-
const issues = detectDestructiveObjectChanges(prev, request.item);
21486+
// [#28665, ADR-0055 §1] The master of a `controlled_by_parent`
21487+
// detail made required: its finding names how many stored rows
21488+
// hold no master, since those stop serving everyone. One count
21489+
// per such finding, none on any other save; see the module.
21490+
const issues = await withNullMasterRowCounts(
21491+
detectDestructiveObjectChanges(prev, request.item), request.item, request.name,
21492+
(field) => this.engine.count(request.name, { where: { [field]: { $null: true } }, context: { isSystem: true } }),
21493+
);
2148521494
if (issues.length > 0) {
2148621495
// [commit 809e61221] Deliberately NOT trimmed to a headline, and
2148721496
// this is a MEASURED verdict rather than an oversight —
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4+
import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol';
5+
import { SchemaRegistry } from './registry.js';
6+
7+
/**
8+
* [#28665, ADR-0055 §1] Making a detail `controlled_by_parent` with its
9+
* `master_detail` turned required: the 409 `DESTRUCTIVE_CHANGE` finding counts
10+
* the stored rows that hold no master and says they become unreadable to
11+
* everyone, the admin included. Before, it said only that such rows "may fail
12+
* validation", and the Studio confirm relayed that.
13+
*
14+
* Pinned, through the real `saveMetaItem` gate:
15+
* - N masterless rows: the finding (and the 409's message) names N and the
16+
* consequence; the count is ONE system-authority `count` on that column;
17+
* - zero masterless rows, or a count that fails: today's finding, unchanged;
18+
* - `force: true` still saves, and asks nothing;
19+
* - every other save issues no count at all.
20+
*
21+
* Harness: the shape `protocol-destructive.test.ts` uses (a tenant-authored
22+
* object in a `SchemaRegistry`, a mock engine), with a `count` that answers
23+
* from a row list by the `$null` predicate it is handed.
24+
*/
25+
26+
const TODAY = "Field 'project' is now required but has no default value — existing rows with null values may fail validation.";
27+
28+
const masterField = (required: boolean) => ({
29+
name: 'project', type: 'master_detail', reference: 'qow_project', ...(required ? { required: true } : {}),
30+
});
31+
32+
/** The detail as published: `private`, its master not required. */
33+
const storedTask = (over: Record<string, unknown> = {}) => ({
34+
name: 'qow_task',
35+
label: 'Task',
36+
_provenance: 'org',
37+
sharingModel: 'private',
38+
fields: {
39+
name: { name: 'name', type: 'text' },
40+
project: masterField(false),
41+
},
42+
...over,
43+
});
44+
45+
/** The body Studio's "Make required and save" sends. */
46+
const controlledTask = (over: Record<string, unknown> = {}) => ({
47+
name: 'qow_task',
48+
label: 'Task',
49+
sharingModel: 'controlled_by_parent',
50+
fields: {
51+
name: { name: 'name', type: 'text' },
52+
project: masterField(true),
53+
},
54+
...over,
55+
});
56+
57+
describe('[#28665] the controlled_by_parent master finding counts the rows with no master', () => {
58+
let protocol: ObjectStackProtocolImplementation;
59+
let registry: SchemaRegistry;
60+
let mockEngine: any;
61+
let taskRows: Array<Record<string, unknown>>;
62+
63+
function boot(stored: Record<string, unknown> = storedTask()) {
64+
registry = new SchemaRegistry({ multiTenant: false });
65+
registry.registerObject({
66+
name: 'qow_project', label: 'Project', _provenance: 'org', sharingModel: 'private',
67+
fields: { name: { name: 'name', type: 'text' } },
68+
} as any, 'pkg');
69+
registry.registerObject(stored as any, 'pkg');
70+
mockEngine = {
71+
registry,
72+
find: vi.fn().mockResolvedValue([]),
73+
findOne: vi.fn().mockResolvedValue(null),
74+
insert: vi.fn().mockResolvedValue({ id: 'x' }),
75+
update: vi.fn().mockResolvedValue({ id: 'x' }),
76+
delete: vi.fn().mockResolvedValue({ deleted: 1 }),
77+
aggregate: vi.fn().mockResolvedValue([]),
78+
// Answers only the has-no-value question on one column, with system
79+
// authority; anything else is a shape this gate does not send.
80+
count: vi.fn(async (object: string, query: any) => {
81+
if (object !== 'qow_task') throw new Error(`count on unexpected object ${object}`);
82+
if (query?.context?.isSystem !== true) throw new Error('count without system authority');
83+
const [[field, predicate]] = Object.entries(query?.where ?? {}) as Array<[string, any]>;
84+
if (predicate?.$null !== true) throw new Error('count without a $null predicate');
85+
return taskRows.filter((r) => r[field] === null || r[field] === undefined).length;
86+
}),
87+
};
88+
protocol = new ObjectStackProtocolImplementation(mockEngine);
89+
}
90+
91+
const refusalOf = async (run: () => Promise<unknown>): Promise<any> => {
92+
try {
93+
await run();
94+
} catch (err) {
95+
return err;
96+
}
97+
throw new Error('expected the save to be refused');
98+
};
99+
const masterFinding = (err: any) => (err.issues as any[]).find((i) => i.code === 'field_required_no_default' && i.field === 'project');
100+
101+
beforeEach(() => {
102+
ObjectStackProtocolImplementation.resetEnvWritableCache();
103+
resetEnvWritableMetadataTypes();
104+
taskRows = [
105+
{ id: 't1', name: 'orphan-1', project: null },
106+
{ id: 't2', name: 'orphan-2', project: null },
107+
{ id: 't3', name: 'owned', project: 'p1' },
108+
];
109+
boot();
110+
});
111+
112+
afterEach(() => {
113+
ObjectStackProtocolImplementation.resetEnvWritableCache();
114+
resetEnvWritableMetadataTypes();
115+
});
116+
117+
it('names how many stored rows have no master and that they become unreadable through every door', async () => {
118+
for (const mode of [undefined, 'draft'] as const) {
119+
mockEngine.count.mockClear();
120+
const err = await refusalOf(() => protocol.saveMetaItem({
121+
type: 'object', name: 'qow_task', item: controlledTask(), ...(mode ? { mode } : {}),
122+
}));
123+
expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 });
124+
const finding = masterFinding(err);
125+
expect(finding.message).toContain("2 existing rows have no 'project'");
126+
expect(finding.message).toContain('unreadable through every door');
127+
// The 409's own text carries it too: the faces that quote only the message.
128+
expect(err.message).toContain("2 existing rows have no 'project'");
129+
expect(mockEngine.count).toHaveBeenCalledTimes(1);
130+
expect(mockEngine.count).toHaveBeenCalledWith('qow_task', { where: { project: { $null: true } }, context: { isSystem: true } });
131+
}
132+
});
133+
134+
it('counts too when the detail already was controlled_by_parent and its master becomes required now', async () => {
135+
boot(storedTask({ sharingModel: 'controlled_by_parent' }));
136+
taskRows = [{ id: 't1', project: null }];
137+
const err = await refusalOf(() => protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask() }));
138+
expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 });
139+
expect(masterFinding(err).message).toContain("1 existing row has no 'project'");
140+
});
141+
142+
it('with no masterless row the finding is today\'s, unchanged', async () => {
143+
taskRows = [{ id: 't3', project: 'p1' }];
144+
const err = await refusalOf(() => protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask() }));
145+
expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 });
146+
expect(masterFinding(err).message).toBe(TODAY);
147+
expect(mockEngine.count).toHaveBeenCalledTimes(1);
148+
});
149+
150+
it('a count that cannot be answered leaves today\'s finding, and the 409 stands', async () => {
151+
mockEngine.count.mockRejectedValueOnce(Object.assign(new Error('no such table: qow_task'), { code: 'SQLITE_ERROR' }));
152+
const err = await refusalOf(() => protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask() }));
153+
expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 });
154+
expect(masterFinding(err).message).toBe(TODAY);
155+
});
156+
157+
it('force=true still saves, and asks the table nothing', async () => {
158+
const saved = await protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: controlledTask(), force: true });
159+
expect(saved).toMatchObject({ success: true });
160+
expect(mockEngine.count).not.toHaveBeenCalled();
161+
});
162+
163+
describe('every other save issues no count', () => {
164+
it('a master made required on a detail that is not controlled_by_parent: today\'s finding, no count', async () => {
165+
const err = await refusalOf(() => protocol.saveMetaItem({
166+
type: 'object', name: 'qow_task', item: controlledTask({ sharingModel: 'private' }),
167+
}));
168+
expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 });
169+
expect(masterFinding(err).message).toBe(TODAY);
170+
expect(mockEngine.count).not.toHaveBeenCalled();
171+
});
172+
173+
it('another field made required on a controlled_by_parent detail: no count', async () => {
174+
boot(storedTask({ sharingModel: 'controlled_by_parent', fields: { name: { name: 'name', type: 'text' }, project: masterField(true) } }));
175+
const err = await refusalOf(() => protocol.saveMetaItem({
176+
type: 'object', name: 'qow_task',
177+
item: controlledTask({ fields: { name: { name: 'name', type: 'text', required: true }, project: masterField(true) } }),
178+
}));
179+
expect(err).toMatchObject({ code: 'DESTRUCTIVE_CHANGE', status: 409 });
180+
expect((err.issues as any[]).map((i) => i.code)).toEqual(['field_required_no_default']);
181+
expect(mockEngine.count).not.toHaveBeenCalled();
182+
});
183+
184+
it('an ordinary save with no destructive change: no count', async () => {
185+
const saved = await protocol.saveMetaItem({ type: 'object', name: 'qow_task', item: storedTask({ label: 'Tasks', _provenance: undefined }) });
186+
expect(saved).toMatchObject({ success: true });
187+
expect(mockEngine.count).not.toHaveBeenCalled();
188+
});
189+
});
190+
});

0 commit comments

Comments
 (0)