diff --git a/.changeset/tame-jars-shake.md b/.changeset/tame-jars-shake.md new file mode 100644 index 0000000000..394a949582 --- /dev/null +++ b/.changeset/tame-jars-shake.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': minor +--- + +feat(spec,platform-objects): add `degraded` to the job status vocabulary (#7072) + +`JobExecutionStatus` and the two `sys_job*` selects now carry a fifth value, +`degraded` — "the run finished, but its work did not happen". This is the +consumer-side half of the `JobRunOutcome` producer shape #6617 shipped on +`contracts/job-service.ts`, and it executes the 2026-08-08 maintainer ruling on +#5548 verbatim: + +> **Vocabulary stays minimal** — one additional outcome meaning "completed +> without accomplishing the work". ⛔ Do not open an enum family; a second key +> would need its own pull. + +Three declaration sites had to move together, because the two platform-object +selects are *enforced* — ObjectQL's record validator refuses an +out-of-vocabulary `select` value with `invalid_option`, and `DbJobAdapter` +swallows that rejection in a best-effort `try/catch`. A value legal in the spec +enum but absent from the selects would therefore be a silently dropped write +that leaves the run row `running` forever, not a type error: + +- `packages/spec/src/system/job.zod.ts` — `JobExecutionStatus` +- `packages/platform-objects/src/audit/sys-job-run.object.ts` — `status` +- `packages/platform-objects/src/audit/sys-job.object.ts` — `last_status` + +**`degraded` is not a failure and never retries.** Retry and failure are driven +exclusively by a rejected handler promise, so a resolved +`{ outcome: 'degraded' }` never re-runs the job. + +A degraded run's `reason` rides the existing `error` / `last_error` columns and +leaves `failure_count` flat — the ruling's minimal-vocabulary spirit applied to +columns as to enum members. The cost is recorded in the TSDoc at the enum: a +column labelled "Error" may carry a non-error operator note whenever +`status === 'degraded'`, so readers must gate on the status first. + +Additive only: no existing value changed meaning, and nothing yet produces +`degraded` — wiring `DbJobAdapter` to map the outcome is #5548, which this +unblocks. Locale bundles (en / zh-CN / ja-JP / es-ES) carry the new option. diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index f60f4ddeba..68589a286d 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -83,7 +83,7 @@ const result = CronScheduleSchema.parse(data); | **jobId** | `string` | ✅ | Job identifier | | **startedAt** | `string` | ✅ | ISO 8601 datetime when execution started | | **completedAt** | `string` | optional | ISO 8601 datetime when execution completed | -| **status** | `Enum<'running' \| 'success' \| 'failed' \| 'timeout'>` | ✅ | Execution status | +| **status** | `Enum<'running' \| 'success' \| 'failed' \| 'timeout' \| 'degraded'>` | ✅ | Execution status | | **error** | `string` | optional | Error message if failed | | **durationMs** | `integer` | optional | Execution duration in milliseconds | @@ -98,6 +98,7 @@ const result = CronScheduleSchema.parse(data); * `success` * `failed` * `timeout` +* `degraded` --- diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index d6674f1bd2..7b1f0be65b 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2343,7 +2343,8 @@ export const enObjects: NonNullable = { success: "success", failed: "failed", timeout: "timeout", - running: "running" + running: "running", + degraded: "degraded" } }, last_error: { @@ -2380,7 +2381,8 @@ export const enObjects: NonNullable = { running: "running", success: "success", failed: "failed", - timeout: "timeout" + timeout: "timeout", + degraded: "degraded" } }, started_at: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 28902a28e9..03c9c35ceb 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -2343,7 +2343,8 @@ export const esESObjects: NonNullable = { success: "Correcto", failed: "Fallido", timeout: "Tiempo de espera agotado", - running: "En ejecución" + running: "En ejecución", + degraded: "Degradado" } }, last_error: { @@ -2380,7 +2381,8 @@ export const esESObjects: NonNullable = { running: "En ejecución", success: "Correcto", failed: "Fallido", - timeout: "Tiempo de espera agotado" + timeout: "Tiempo de espera agotado", + degraded: "Degradado" } }, started_at: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index de728fa2aa..730d750c7f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -2343,7 +2343,8 @@ export const jaJPObjects: NonNullable = { success: "成功", failed: "失敗", timeout: "タイムアウト", - running: "実行中" + running: "実行中", + degraded: "縮退" } }, last_error: { @@ -2380,7 +2381,8 @@ export const jaJPObjects: NonNullable = { running: "実行中", success: "成功", failed: "失敗", - timeout: "タイムアウト" + timeout: "タイムアウト", + degraded: "縮退" } }, started_at: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 39c01b9b0d..6b3cd9a451 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -2343,7 +2343,8 @@ export const zhCNObjects: NonNullable = { success: "成功", failed: "失败", timeout: "超时", - running: "运行中" + running: "运行中", + degraded: "降级" } }, last_error: { @@ -2380,7 +2381,8 @@ export const zhCNObjects: NonNullable = { running: "运行中", success: "成功", failed: "失败", - timeout: "超时" + timeout: "超时", + degraded: "降级" } }, started_at: { diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index 2daca95793..378f546285 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -46,8 +46,15 @@ export const SysJobRun = ObjectSchema.create({ group: 'Identity', }), + // [#7072] `degraded` = ran to completion, work did not happen (#5548's + // ruling: one additional outcome, no enum family). This list is *enforced* + // — ObjectQL's record validator refuses an unlisted value with + // `invalid_option` — and must stay identical to `JobExecutionStatus` in + // `@objectstack/spec` (`system/job.zod.ts`) and to `sys_job.last_status`. + // A degraded run puts its reason in `error` below and does not bump the + // job's `failure_count`. status: Field.select( - ['running', 'success', 'failed', 'timeout'], + ['running', 'success', 'failed', 'timeout', 'degraded'], { label: 'Status', required: true, defaultValue: 'running', group: 'State' }, ), diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index 037911fc69..79db69e5f1 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -70,8 +70,14 @@ export const SysJob = ObjectSchema.create({ }), last_run_at: Field.datetime({ label: 'Last Run At', required: false, group: 'State' }), + // [#7072] `degraded` mirrors `sys_job_run.status` (#5548's ruling: one + // additional outcome meaning "completed without accomplishing the work"). + // Enforced by ObjectQL's record validator, so it must stay in step with + // `JobExecutionStatus` in `@objectstack/spec` and with `sys_job_run.status`. + // A degraded run leaves `failure_count` below untouched and puts its reason + // in `last_error` — that column may therefore carry a non-error note. last_status: Field.select( - ['success', 'failed', 'timeout', 'running'], + ['success', 'failed', 'timeout', 'running', 'degraded'], { label: 'Last Status', required: false, group: 'State' }, ), last_error: Field.textarea({ label: 'Last Error', required: false, group: 'State' }), diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts index d179d484d4..3933508c23 100644 --- a/packages/spec/src/system/job.test.ts +++ b/packages/spec/src/system/job.test.ts @@ -400,12 +400,36 @@ describe('JobExecutionStatus', () => { expect(() => JobExecutionStatus.parse('success')).not.toThrow(); expect(() => JobExecutionStatus.parse('failed')).not.toThrow(); expect(() => JobExecutionStatus.parse('timeout')).not.toThrow(); + // [#7072] The fifth value, per #5548's ruling: "completed without + // accomplishing the work". Not a failure, never retried. + expect(() => JobExecutionStatus.parse('degraded')).not.toThrow(); }); it('should reject invalid execution statuses', () => { expect(() => JobExecutionStatus.parse('pending')).toThrow(); expect(() => JobExecutionStatus.parse('cancelled')).toThrow(); expect(() => JobExecutionStatus.parse('')).toThrow(); + // [#7072] The ruling closes the vocabulary at five: "⛔ Do not open an enum + // family; a second key would need its own pull." These are the near-misses a + // future adapter is most likely to reach for; they stay refused. + expect(() => JobExecutionStatus.parse('partial')).toThrow(); + expect(() => JobExecutionStatus.parse('skipped')).toThrow(); + expect(() => JobExecutionStatus.parse('Degraded')).toThrow(); + }); + + it('should carry exactly the five ruled values, in declaration order', () => { + // [#7072] Pins the vocabulary itself, not just membership: the two + // `platform-objects` selects (`sys_job_run.status`, `sys_job.last_status`) + // are *enforced* by ObjectQL's record validator, so this enum growing a + // value they do not carry is a silently swallowed write rather than a type + // error. Any change here needs the same change there. + expect(JobExecutionStatus.options).toEqual([ + 'running', + 'success', + 'failed', + 'timeout', + 'degraded', + ]); }); }); @@ -463,8 +487,27 @@ describe('JobExecutionSchema', () => { expect(parsed.status).toBe('timeout'); }); + it('should accept degraded execution', () => { + // [#7072] A degraded run's `reason` rides the existing `error` field — the + // ruling's minimal vocabulary applies to columns too, so no `reason` member + // was added. Note the cost this pins: `error` carries a non-error operator + // note whenever the status is `degraded`. + const execution = { + jobId: 'job-321', + startedAt: '2024-01-15T13:00:00Z', + completedAt: '2024-01-15T13:00:02Z', + status: 'degraded', + error: 'STORE_UNAVAILABLE', + durationMs: 2000, + }; + + const parsed = JobExecutionSchema.parse(execution); + expect(parsed.status).toBe('degraded'); + expect(parsed.error).toBe('STORE_UNAVAILABLE'); + }); + it('should accept all execution statuses', () => { - const statuses: Array = ['running', 'success', 'failed', 'timeout']; + const statuses: Array = ['running', 'success', 'failed', 'timeout', 'degraded']; statuses.forEach(status => { const execution = { diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 37b2f08193..74490fdaf1 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -174,12 +174,42 @@ export function defineJob(config: z.input): JobParsed { /** * Job Execution Status Enum * Status of job execution + * + * [#7072] `degraded` executes the 2026-08-08 maintainer ruling on #5548, quoted + * verbatim: 「**Vocabulary stays minimal** — one additional outcome meaning + * "completed without accomplishing the work". ⛔ Do not open an enum family; a + * second key would need its own pull.」 It is the consumer-side half of the + * `JobRunOutcome` producer shape #6617 shipped on `contracts/job-service.ts`, + * and it is declared in exactly three places that must agree: this enum, + * `sys_job_run.status` and `sys_job.last_status` (both in + * `@objectstack/platform-objects`). The platform-object selects are *enforced* + * — ObjectQL's record validator refuses an out-of-vocabulary `select` value + * with `invalid_option` — so a value legal here and absent there is a silently + * swallowed write, not a type error. + * + * ⚠️ **`degraded` is NOT a failure and never retries.** It means the run ran to + * completion and its work did not happen (a store was unavailable, zero rows + * matched a precondition). Retry and failure are driven exclusively by a + * *rejected* handler promise; a resolved `{ outcome: 'degraded' }` never + * re-runs the job. See {@link JobHandler} in `contracts/job-service.ts` for the + * three-outcome table this mirrors. + * + * **Where the reason goes, and what that costs.** A degraded run's `reason` + * rides the existing `error` column (`sys_job.last_error` for the job-level + * mirror) and leaves `failure_count` flat — the ruling's minimal-vocabulary + * spirit applied to columns as to enum members, decided at the `domain:services` + * seat on #7072. The cost is stated here rather than left for the next reader: a + * column labelled **"Error"** may carry a non-error operator note whenever + * `status === 'degraded'`, so a reader must gate on the status before reading + * that column as a failure. Adding a distinct `reason` column would be the + * "second key" the ruling reserves for its own pull. */ export const JobExecutionStatus = z.enum([ 'running', 'success', 'failed', 'timeout', + 'degraded', ]); export type JobExecutionStatus = z.input;