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
41 changes: 41 additions & 0 deletions .changeset/tame-jars-shake.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/system/job.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -98,6 +98,7 @@ const result = CronScheduleSchema.parse(data);
* `success`
* `failed`
* `timeout`
* `degraded`


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2343,7 +2343,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
success: "success",
failed: "failed",
timeout: "timeout",
running: "running"
running: "running",
degraded: "degraded"
}
},
last_error: {
Expand Down Expand Up @@ -2380,7 +2381,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
running: "running",
success: "success",
failed: "failed",
timeout: "timeout"
timeout: "timeout",
degraded: "degraded"
}
},
started_at: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2343,7 +2343,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
success: "Correcto",
failed: "Fallido",
timeout: "Tiempo de espera agotado",
running: "En ejecución"
running: "En ejecución",
degraded: "Degradado"
}
},
last_error: {
Expand Down Expand Up @@ -2380,7 +2381,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
running: "En ejecución",
success: "Correcto",
failed: "Fallido",
timeout: "Tiempo de espera agotado"
timeout: "Tiempo de espera agotado",
degraded: "Degradado"
}
},
started_at: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2343,7 +2343,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
success: "成功",
failed: "失敗",
timeout: "タイムアウト",
running: "実行中"
running: "実行中",
degraded: "縮退"
}
},
last_error: {
Expand Down Expand Up @@ -2380,7 +2381,8 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
running: "実行中",
success: "成功",
failed: "失敗",
timeout: "タイムアウト"
timeout: "タイムアウト",
degraded: "縮退"
}
},
started_at: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2343,7 +2343,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
success: "成功",
failed: "失败",
timeout: "超时",
running: "运行中"
running: "运行中",
degraded: "降级"
}
},
last_error: {
Expand Down Expand Up @@ -2380,7 +2381,8 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
running: "运行中",
success: "成功",
failed: "失败",
timeout: "超时"
timeout: "超时",
degraded: "降级"
}
},
started_at: {
Expand Down
9 changes: 8 additions & 1 deletion packages/platform-objects/src/audit/sys-job-run.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
),

Expand Down
8 changes: 7 additions & 1 deletion packages/platform-objects/src/audit/sys-job.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
45 changes: 44 additions & 1 deletion packages/spec/src/system/job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});

Expand Down Expand Up @@ -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<JobExecution['status']> = ['running', 'success', 'failed', 'timeout'];
const statuses: Array<JobExecution['status']> = ['running', 'success', 'failed', 'timeout', 'degraded'];

statuses.forEach(status => {
const execution = {
Expand Down
30 changes: 30 additions & 0 deletions packages/spec/src/system/job.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,42 @@ export function defineJob(config: z.input<typeof JobSchema>): 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<typeof JobExecutionStatus>;
Expand Down
Loading