Skip to content

Commit 6ee8c61

Browse files
fix(service-settings)!: a json setting persists a JSON object or array, and the console sends one (#31279)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi59ATBeDy7jECXRUFiXAgGB)_ Fixes #31241 Clause-②: yes (narrowing) — the settings save door and the env door start refusing a `json` value that is not a JSON object or array, which they accept today; ADR-0007:123 already declares `json` persists an object, so the narrowing enforces the declared contract and widens nothing Metadata-protocol: none Skills-docs: updated here — `content/docs/protocol/kernel/config-resolution.mdx` (the env coercion paragraph said an unparseable value is used as the raw string; a `json` key now rejects it). `skills/**` names the three lifecycle keys but never spells how a `json` value is sent, so nothing it teaches changes. > **BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`). `PUT /api/settings/:namespace` refuses a string, number or boolean under a `json` key with `400 SETTINGS_VALIDATION` and one `invalid_type` field error, and stores nothing; an `OS_*` override for a `json` key whose text does not parse to an object or array is not in force (one `error` line, the key resolves from the next layer). **Migration:** send the parsed value, not its text: `{ "quotas": { "sys_audit_log": 1000 } }`, not `{ "quotas": "{\"sys_audit_log\": 1000}" }`. Values already stored as JSON text: quota text is read as the object it holds, with one warning; retention-override text is not applied until an administrator saves the Data Lifecycle page once: the page opens with that text already marked as an unsaved change and sends it as an object, so one Save with no edit puts it in force (one error names the key and the fix until then). ADR-0087 disposition: `not-required (no-migration-prescription)`, one marker, in `.changeset/31241-json-setting-persists-object.md`. ## 维护者速读 - **改了什么:** 「数据生命周期」设置页的三个 JSON 框(保留期覆盖、行数配额、按类默认配额)以前保存的是一段文字,平台读不懂,配额和覆盖全部静默失效。现在控制台保存时先把文字解析成对象再发送,JSON 写错会在该行提示并拦下保存;服务端保存口和环境变量口也不再接受文字。以前存成文字的 JSON 值,打开设置页时会自动标成 1 处未保存并在该行提示,管理员直接点保存就改存为对象。 - **为什么改:** 管理员看到「已保存」,以为平台在执行配额和保留期,实际上什么都没执行,也没有任何日志,直到存储或合规出问题才发现(#31241,p2)。 - **风险与回滚:** 以前存成文字的配额升级后开始告警,每条打一次 warn;保留期覆盖的旧文字不会生效,只报一次 error,需要管理员打开「数据生命周期」页点一次保存才生效(页面打开就显示未保存,不用改任何内容),所以不会因为旧值意外删数据。打开含旧文字的设置页时离开页面会先确认,因为它被算作未保存的更改。直接调 API 把 JSON 当文字发送的脚本会收到 400。回滚方式是 revert。 - **先看:** `packages/objectql/src/lifecycle/lifecycle-service.ts` 的 `governanceMap`(旧文字的读取与告警);`packages/services/service-settings/src/settings-service.ts` 的 `jsonShapeMiss` 与它在 `validatePatch`、`effectiveEnvOverride` 的两处调用;`ui/apps/console/src/pages/settings/SettingsView.tsx` 的 `outgoingSettingsPatch` 与 `storedJsonTextDraft`(加载时把已存文字放进 draft)。 - **两分钟验证:** `cd packages/services/service-settings && pnpm exec vitest run src/settings-json-shape-31241.test.ts`,预期 8 个全过;其中第一个用控制台的旧请求体,得到 400 和 `invalid_type`,什么都不存。 - **需要你做的:** 无,席位落地。 ## What changes - **`@objectstack/service-settings`: one shape judgment for both doors (`jsonShapeMiss`).** A `json` key takes a plain JSON object or an array; `null` is a clear. `validatePatch` refuses anything else (a string, a blank string included, a number, a boolean) with one `FieldError` per key: `code: 'invalid_type'`, `constraint: { type: 'json' }`, a message that names what to send and never the value. It judges only the keys the save touches, like every other family, so a string already stored under another key does not block an unrelated save. `effectiveEnvOverride` rejects an override that did not parse to an object or array through the existing `reportRejectedEnvOverride` (one `error` line per variable and value, carrying the value's shape, never its content). - **`@objectstack/objectql`: values already stored as text (`LifecycleService.governanceMap`).** `loadGovernance` reads `retention_overrides`, `quotas` and `quota_defaults` through it, at the global read and at each organization's read. For `quotas` and `quota_defaults`, text that parses to a plain object is applied as that object, with one `warn` per stored value naming the key (and the organization, for a tenant-scope row) and the fix: save the page again. They only raise alerts and never delete. For `retention_overrides` (`LEGACY_TEXT_NOT_APPLIED_KEYS`), stored text is **not** applied: the declared windows stay in force and one `error` per stored value names the key (and the organization) and the fix, open Setup → Data Lifecycle, check the value and save the page once. The reaper deletes on these windows, so a window that was never in force must not start deleting rows on upgrade without an administrator acting (seat review 6713609079). Anything else that is not a plain object applies nothing, as before, and is now said once at `error`. **Choice:** a reader repair, not a migration. These three keys are the only `json` specifiers in `packages/` and `LifecycleService` is their only reader, so the repair needs no rewrite of `sys_setting` / `sys_platform_setting` rows, and the settings read API keeps returning what is stored. Saving the page once stores the object, and the warning or error stops. - **`@object-ui/console`: the field sends the value, never the text, and offers a value stored as text for one save (`SettingsView`).** At Save, each `json` key in the draft is parsed: valid JSON is sent as the object or array, blank text as `null`. Text that does not parse blocks Save on that row through the existing pre-save path (no request, the row marked, focus on it) with the widget's own translated sentence `fields.object.invalidJson`. Only a key in the draft is judged. At load, `storedJsonTextDraft` puts every `json` key whose stored value is a string, and is not locked, into the draft as it stands, so the page opens with the save bar (one unsaved change per such key) and the row carries a note, `console.settingsView.jsonStoredAsText` (new key, all 11 packs; zh-Hant regenerated from zh-Hans). One Save with no edit sends the parsed object. Stored text that does not parse is seeded too and stops Save on its row until it is fixed or cleared. A locked row (env override) is neither seeded nor noted. Seeding also arms the existing leave guard, since the page holds an unsaved change. - **Docs:** `config-resolution.mdx` states the `json` exception to "the raw string is used". - **Changeset:** `.changeset/31241-json-setting-persists-object.md`, `patch` for all three packages, with both declaration lines, the BREAKING section, its migration and the ADR-0087 marker. ## Tests - `packages/services/service-settings/src/settings-json-shape-31241.test.ts` (8), over real `ObjectQL`, the platform setting objects, the real route and the shipped `lifecycleSettingsManifest`: the console's old body is refused (400, `invalid_type` on both keys, nothing stored); a number, a boolean and a blank string are refused; an object and an array are stored as themselves; a clear falls back to the default; a stored string does not block an unrelated save; env JSON text of an object is in force, while unparseable text and a scalar are not, each with one `error` line. - `packages/objectql/src/lifecycle/lifecycle-service.governance-map-text.test.ts` (6): an object control alerts with no log; quotas stored as text alert `quota-exceeded` and warn once over two sweeps; `quota_defaults` as text applies; a global `retention_overrides` as text moves the reap cutoff to 90d; an organization's `retention_overrides` as text runs that organization's pass on its 2y window and the global pass excludes it; text that is not a JSON object applies nothing and errors once. - `ui/apps/console/src/pages/settings/__tests__/SettingsView.jsonSendsObject-31241.test.tsx` (7): the patch carries the object, an array and a clear; text that does not parse blocks Save; a locked stored string is not seeded and does not block an unrelated save; a stored string shows the save bar and the note on load; Save with no edit PUTs `{ quotas: { sys_audit_log: 1000 } }` and the bar and note go; stored text that does not parse is seeded and stops Save on its row. ## Acceptance notes - **Measured `retention_overrides` this time.** Global and tenant scope both, through a sweep (the lifecycle test above), not code-read. - **A JSON value that parses but is not an object or array** (for example `42`) is not blocked by the console. It is sent parsed and the server refuses it inline with its own sentence, which is English. The server already refuses it, so the page does not add a second refusal. - **The claim's file surface** did not name `content/docs/protocol/kernel/config-resolution.mdx` (the env paragraph became false with this change) or the 11 locale packs `ui/packages/i18n/src/locales/*.ts` (the new note key, round 2), so both are changed here; the seat amends the claim. - **The changeset carries the ADR-0087 marker.** This body states the disposition in prose, because a body sent to GitHub carries no HTML comment other than the attribution line. ## Verification Ablations, each through `scripts/ablation-replace.mjs` (mutation landed, test red, restored blob equal to HEAD and `git diff HEAD` empty), on head `4246cad1` (the code under them is unchanged since, except the A3/A4 file's error line now calls its logger channel directly): | # | Mutation | Red | |:--|:--|:--| | A1 | `validatePatch`: `jsonShapeMiss(type, value)` → `null` | 2/8: the old console body answered `expected 200 to be 400`; the scalar case too | | A2 | env door: `jsonShapeMiss(...)` → `null` | 2/8: `expected '{"sys_audit_log": 1000' to deeply equal {}`, `expected 5 to deeply equal {}` | | A3 | `governanceMap`: `isPlainRecord(parsed)` → `false && …` | 4/6: both quota cases `expected [] to deeply equal [quota-exceeded]`, both retention cases | | A4 | `loadGovernance`: the map keys bypass `governanceMap` | 5/6, the error case included (`called 0 times`) | | A5 | console: `outgoingSettingsPatch(...)` → `draft` | 3/4: `expected { quotas: '[1, 2]', … }` and the text cases | | A6 | console: the parse refusal disabled | 1/4: `expected null to be truthy` (no alert on the row) | | A7 | console: load seeds `{}` instead of `storedJsonTextDraft(p)` | 3/7: the three stored-text cases (no save bar, no request, no row alert) | | A8 | console: `!resolved.locked` dropped from `isStoredJsonText` | 1/7: the locked case (save bar drawn on load, `expected … to be null`) | | A9 | console: the note condition set to `false` | 1/7: the load case (no note on the row) | Local runs (shared container, under `os-verify-lock`; package suites before the `origin/main` merge, the three new files and `src/lifecycle/` re-run on the final head `e4d9a02a`, green): `@objectstack/service-settings` test 72 files / 1379 tests and typecheck, exit 0; `@objectstack/objectql` test 565 files / 11431 tests, exit 0, typecheck and `src/lifecycle/` 6 files / 150 tests, exit 0; console `pages/settings/` 39 files / 317 tests, exit 0. Console `type-check` NOT MEASURED locally (it needs every `ui/` package built; CI's Type Check UI carries it); `tsc --noEmit` on the console shows no error in the two changed files beyond the unbuilt-module family. Round 2 on `29159a9b`: console `pages/settings/` 39 files / 320 tests, `ui/packages/i18n` 1804 tests, the seven `check:i18n*` scripts, all exit 0. **Gates, on head `29159a9b`:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 101 commands; each ran with stdin from /dev/null, all `:: exit 0`. `--ran`: "101 derived, 101 run, 0 NOT-MEASURED". Round 1 on `e4d9a02a`: 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN. The first round (before the merge) caught one real red, `check:logger-receiver-detach` on the new error line in `governanceMap` (`logger.error ?? logger.warn` read into a local), fixed in `748e6b27`; its `check:dts-closure` and `check:dual-build-cjs-loads` reds were a closure build running alongside and a missing `dist/` (exit 3), both green on the final head. `pnpm lint`: exit 0. `ui` eslint on the two changed console files: 0 errors (4 warnings, all on lines this diff does not touch). **Dogfood (narrowing arm), full battery on the final head `e4d9a02a`:** closure built with `pnpm --filter '@objectstack/dogfood^...' build`, then `pnpm --filter @objectstack/dogfood test` under the lock, no shard and no path: `Test Files 435 passed | 3 skipped (438)`, `Tests 3609 passed | 41 skipped (3650)`, `VERDICT command-exit 0`. Not re-run for round 2: it changes only the console, the locale packs and the changeset, none of which the battery loads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- signed: domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 604a2b3 commit 6ee8c61

19 files changed

Lines changed: 1032 additions & 7 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
'@objectstack/objectql': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
fix(service-settings)!: a `json` setting persists a JSON object or array, and the console sends one; data lifecycle quotas and retention overrides saved as text are read and applied
8+
9+
Clause-②: yes (narrowing) — the settings save door and the env door start refusing a `json` value that is not a JSON object or array, which they accept today; ADR-0007:123 already declares `json` persists an object, so the narrowing enforces the declared contract and widens nothing
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes. What narrows is the value a settings door accepts for a `json` key: a stored value is runtime state in `sys_setting` / `sys_platform_setting` and an `OS_*` override is a deployment's environment, so there is nothing for a migration to rewrite. Values already stored as JSON text are handled by their one reader (`LifecycleService`): quota text is read as the object it holds, with a warning, and retention-override text waits for an administrator's save. -->
13+
14+
**BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`): a `json` key takes a JSON object or array, as ADR-0007 declares.
15+
16+
- **Before.** `PUT /api/settings/:namespace` stored any value under a `json` key. The console's `json` field sent the textarea's text, so Setup → Data Lifecycle saved `lifecycle.quotas`, `lifecycle.quota_defaults` and `lifecycle.retention_overrides` as strings, answered "saved", and the lifecycle sweep read every quota and override as absent: no `quota-exceeded` alert, no override applied, nothing logged.
17+
- **Now.** The save door refuses a string, number or boolean under a `json` key with `400 SETTINGS_VALIDATION` and one `invalid_type` field error for the key, and stores nothing. An object, an array and `null` (a clear) are accepted. A value already stored under a key the save does not touch does not block it. An `OS_*` override for a `json` key whose text does not parse to an object or array is not in force: one `error` line names the variable, and the key resolves from the next layer of the cascade.
18+
- **Who moves.** The console now parses the text at Save: valid JSON is sent as the object or array, blank text as a clear, and text that does not parse blocks Save on that row with "Invalid JSON". A `json` value the page loads as text (and is not locked) is put in the draft as it stands, so the page opens with one unsaved change per such key and a note on its row saying the value is stored as text; Save sends the object. A script that PUTs JSON text for a `json` key gets the 400.
19+
20+
**The remedy.** Send the parsed value, not its text: `{ "quotas": { "sys_audit_log": 1000 } }`, not `{ "quotas": "{\"sys_audit_log\": 1000}" }`. For an `OS_*` override, set JSON text of an object, such as `OS_LIFECYCLE_QUOTAS='{"sys_audit_log":1000}'`.
21+
22+
**Values saved as text before this release** (`@objectstack/objectql`). A deployment that saved the Data Lifecycle page through the old console holds JSON text, which applied nothing. After the upgrade the lifecycle service treats the three keys by what they drive:
23+
24+
- `quotas` and `quota_defaults` only raise alerts, so text that holds a JSON object is read as that object and applied, with one warning per stored value: `lifecycle.quotas is stored as JSON text, not an object … Save the Data Lifecycle settings page again to store it as an object.`
25+
- `retention_overrides` drives the reaper and the archiver, which delete rows, so its text is **not** applied: the declared retention windows stay in force and no row is reaped or archived on the old windows. One error per stored value names the key (and the organization, for an organization's own value) and the fix: open Setup → Data Lifecycle, check the value and save the page once. The page opens with the stored text already marked as an unsaved change and sends it as an object, so that one Save, with no edit, is what puts the window in force.
26+
27+
A stored value that is not a JSON object still applies nothing, as before, and is now reported once at `error`.

‎content/docs/protocol/kernel/config-resolution.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ OS_ALLOWED_ORIGINS=["a","b","c"] # default is array/object → JSON.parse
261261
```
262262

263263
If the raw value cannot be parsed for the expected type, the raw string is used.
264+
The exception is a `json` specifier, which persists a JSON object or array
265+
(ADR-0007): an override whose text does not parse to one is not in force, one
266+
`error` line names the variable, and the key resolves from the next layer of the
267+
cascade. The save door refuses the same values for a `json` key with an
268+
`invalid_type` field error, so a client sends the parsed value, never its text.
264269

265270
**What is stored beneath a pin is listed by the namespace read.** An override in
266271
force answers `source: 'env'`, `locked: true`, and `get()` / `getMany()` build
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#31241] A map-valued `lifecycle` setting stored as JSON TEXT is read as the
5+
* map it holds.
6+
*
7+
* `retention_overrides`, `quotas` and `quota_defaults` are `json` specifiers,
8+
* which persist an object (ADR-0007). The console's `json` field used to send
9+
* the textarea's text and the settings save door stored it, so a deployment
10+
* that saved the Data Lifecycle page holds a string where the map belongs.
11+
* `loadGovernance` indexed that string, so every override and quota read as
12+
* absent, with nothing logged. The save door now refuses a string
13+
* (`service-settings`), but rows already stored are not re-judged. This reader
14+
* splits them by what the key drives:
15+
*
16+
* - `quotas` / `quota_defaults` only alert: text that holds a JSON object is
17+
* applied, with one warning per stored value.
18+
* - `retention_overrides` drives deletion: its text is NOT applied (the
19+
* declared windows stay in force, no row is reaped on the old window) and
20+
* one error per stored value says how to apply it — save the page once.
21+
*
22+
* Anything else that is not a plain object is ignored as before, now said once
23+
* at `error`.
24+
*/
25+
26+
import { describe, it, expect, vi } from 'vitest';
27+
import { LifecycleService, type LifecycleObjectLike } from './lifecycle-service.js';
28+
import { parseLifecycleDuration } from './duration.js';
29+
30+
const FIXED_NOW = 1_700_000_000_000;
31+
const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString();
32+
33+
const JOB_RUN: LifecycleObjectLike = {
34+
name: 'sys_job_run',
35+
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } } as any,
36+
};
37+
38+
/** Settings double: a value map at the global rung, plus per-organization rows. */
39+
function settingsOf(global: Record<string, unknown>, tenant: Record<string, Record<string, unknown>> = {}) {
40+
return {
41+
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
42+
const org = ctx?.tenantId as string | undefined;
43+
if (org && tenant[org] && key in tenant[org]) return { value: tenant[org][key], source: 'tenant' };
44+
if (key in global) return { value: global[key], source: 'global' };
45+
return { value: undefined, source: 'default' };
46+
},
47+
};
48+
}
49+
50+
/** Engine double: one declared object, a counting driver, captured deletes and candidate reads. */
51+
function engineOf(rows = 1_500, organizations: string[] = [], stored: Array<{ id: string; created_at: string }> = []) {
52+
const deletes: Array<{ where: unknown }> = [];
53+
const reads: Array<{ object: string; where: unknown }> = [];
54+
/** Ids of `stored` rows a bulk delete's `created_at < cutoff` predicate removed. */
55+
const reaped: string[] = [];
56+
const driver = { name: 'default', count: vi.fn(async () => rows) };
57+
const engine: any = {
58+
registry: { getAllObjects: () => [JOB_RUN] },
59+
async delete(_object: string, options: any) {
60+
deletes.push({ where: options?.where });
61+
const cutoff = options?.where?.created_at?.$lt as string | undefined;
62+
let deletedCount = 0;
63+
if (cutoff) {
64+
for (const row of stored) {
65+
if (row.created_at < cutoff && !reaped.includes(row.id)) {
66+
reaped.push(row.id);
67+
deletedCount += 1;
68+
}
69+
}
70+
}
71+
return { deletedCount };
72+
},
73+
getDriverForObject: () => driver,
74+
datasource() {
75+
throw new Error('no datasource');
76+
},
77+
};
78+
if (organizations.length > 0) {
79+
engine.find = async (object: string, options: any) => {
80+
if (object === 'sys_organization') return organizations.map((id) => ({ id }));
81+
reads.push({ object, where: options?.where });
82+
const org = (options?.where as Record<string, unknown> | undefined)?.organization_id;
83+
return [{ id: typeof org === 'string' ? `${org}_row` : 'global_row' }];
84+
};
85+
}
86+
return { engine, deletes, reads, reaped };
87+
}
88+
89+
function serviceOf(engine: unknown, settings: unknown) {
90+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
91+
const svc = new LifecycleService({
92+
getEngine: () => engine as any,
93+
getSettings: () => settings as any,
94+
logger,
95+
now: () => FIXED_NOW,
96+
initialDelayMs: 1,
97+
sweepIntervalMs: 10,
98+
onAlert: () => {},
99+
});
100+
return { svc, logger };
101+
}
102+
103+
describe('[#31241] a map setting stored as JSON text', () => {
104+
it('CONTROL: quotas stored as an object alert, and nothing is logged about them', async () => {
105+
const { engine } = engineOf(1_500);
106+
const { svc, logger } = serviceOf(engine, settingsOf({ quotas: { sys_job_run: 1_000 } }));
107+
108+
const report = await svc.sweep();
109+
110+
expect(report.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 1_000 }]);
111+
expect(logger.warn).not.toHaveBeenCalled();
112+
expect(logger.error).not.toHaveBeenCalled();
113+
});
114+
115+
it('quotas stored as the text the old console saved are applied, with one warning naming the key', async () => {
116+
const { engine } = engineOf(1_500);
117+
const { svc, logger } = serviceOf(engine, settingsOf({ quotas: '{\n "sys_job_run": 1000\n}' }));
118+
119+
const first = await svc.sweep();
120+
const second = await svc.sweep();
121+
122+
expect(first.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 1_000 }]);
123+
expect(second.alerts).toEqual(first.alerts);
124+
// Once per stored value, not once per sweep.
125+
expect(logger.warn).toHaveBeenCalledTimes(1);
126+
expect(logger.warn.mock.calls[0][0]).toContain('lifecycle.quotas is stored as JSON text, not an object');
127+
expect(logger.warn.mock.calls[0][0]).toContain('Save the Data Lifecycle settings page again');
128+
expect(logger.error).not.toHaveBeenCalled();
129+
});
130+
131+
it('a class default stored as text applies through quota_defaults', async () => {
132+
const { engine } = engineOf(1_500);
133+
const { svc } = serviceOf(engine, settingsOf({ quota_defaults: '{"telemetry": 500}' }));
134+
135+
const report = await svc.sweep();
136+
137+
expect(report.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 1_500, quota: 500 }]);
138+
});
139+
140+
/** A row 10 days old: inside the declared 30d window, past a 1d override. */
141+
const TEN_DAYS_OLD = [{ id: 'row_10d', created_at: new Date(FIXED_NOW - 10 * 86_400_000).toISOString() }];
142+
143+
it('CONTROL: a retention override stored as an object reaps on its window', async () => {
144+
const { engine, deletes, reaped } = engineOf(1_500, [], TEN_DAYS_OLD);
145+
const { svc, logger } = serviceOf(engine, settingsOf({ retention_overrides: { sys_job_run: { maxAge: '1d' } } }));
146+
147+
await svc.sweep();
148+
149+
expect(deletes).toEqual([{ where: { created_at: { $lt: isoCutoff('1d') } } }]);
150+
expect(reaped).toEqual(['row_10d']);
151+
expect(logger.error).not.toHaveBeenCalled();
152+
});
153+
154+
it('a retention override stored as text is NOT applied: no row is reaped on it, and one error says how to apply it', async () => {
155+
const { engine, deletes, reaped } = engineOf(1_500, [], TEN_DAYS_OLD);
156+
const { svc, logger } = serviceOf(engine, settingsOf({ retention_overrides: '{"sys_job_run": {"maxAge": "1d"}}' }));
157+
158+
await svc.sweep();
159+
await svc.sweep();
160+
161+
// The declared 30d window, not the old 1d text: the 10-day-old row stays.
162+
expect(deletes.map((d) => d.where)).toEqual([
163+
{ created_at: { $lt: isoCutoff('30d') } },
164+
{ created_at: { $lt: isoCutoff('30d') } },
165+
]);
166+
expect(reaped).toEqual([]);
167+
expect(logger.error).toHaveBeenCalledTimes(1);
168+
const line = logger.error.mock.calls[0][0] as string;
169+
expect(line).toContain('lifecycle.retention_overrides is stored as JSON text, not an object');
170+
expect(line).toContain('so it is NOT applied');
171+
expect(line).toContain('Setup → Data Lifecycle');
172+
expect(line).not.toContain('sys_job_run');
173+
expect(logger.warn).not.toHaveBeenCalled();
174+
});
175+
176+
it("an organization's retention override stored as text runs no pass of its own, and is named in the error", async () => {
177+
const { engine, reads } = engineOf(1_500, ['org_reg', 'org_plain']);
178+
const { svc, logger } = serviceOf(
179+
engine,
180+
settingsOf({}, { org_reg: { retention_overrides: '{"sys_job_run": {"maxAge": "1d"}}' } }),
181+
);
182+
183+
await svc.sweep();
184+
185+
// One global pass on the declared window; no organization is carved out.
186+
const passes = reads.filter((r) => r.object === 'sys_job_run').map((r) => r.where);
187+
expect(passes).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
188+
expect(logger.error.mock.calls[0][0]).toContain("organization org_reg's lifecycle.retention_overrides");
189+
expect(logger.error.mock.calls[0][0]).toContain('so it is NOT applied');
190+
});
191+
192+
it("CONTROL: the same organization's override stored as an object runs that organization's pass", async () => {
193+
const { engine, reads } = engineOf(1_500, ['org_reg', 'org_plain']);
194+
const { svc } = serviceOf(engine, settingsOf({}, { org_reg: { retention_overrides: { sys_job_run: { maxAge: '1d' } } } }));
195+
196+
await svc.sweep();
197+
198+
const passes = reads.filter((r) => r.object === 'sys_job_run').map((r) => r.where);
199+
expect(passes).toEqual([
200+
{ created_at: { $lt: isoCutoff('1d') }, organization_id: 'org_reg' },
201+
{ created_at: { $lt: isoCutoff('30d') }, organization_id: { $nin: ['org_reg'] } },
202+
]);
203+
});
204+
205+
it('text that is not a JSON object applies nothing, and says so once at error', async () => {
206+
const { engine } = engineOf(1_500);
207+
const { svc, logger } = serviceOf(engine, settingsOf({ quotas: '{ "sys_job_run": 1000' }));
208+
209+
const first = await svc.sweep();
210+
await svc.sweep();
211+
212+
expect(first.alerts).toEqual([]);
213+
expect(logger.error).toHaveBeenCalledTimes(1);
214+
expect(logger.error.mock.calls[0][0]).toContain('lifecycle.quotas is not an object');
215+
expect(logger.error.mock.calls[0][0]).not.toContain('sys_job_run');
216+
});
217+
});

0 commit comments

Comments
 (0)