|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { IndexSchema, resolveInjectedSystemColumns } from '@objectstack/spec/data'; |
| 5 | +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; |
| 6 | +import { SysJob } from './sys-job.object.js'; |
| 7 | + |
| 8 | +/** |
| 9 | + * #8578 — `sys_job`'s declared uniqueness is installation-wide, and the reading |
| 10 | + * that makes it so is pinned here. |
| 11 | + * |
| 12 | + * ## The fork this card was filed on, and how it was decided |
| 13 | + * |
| 14 | + * The card deliberately asserted no defect. A DECLARED index's bare |
| 15 | + * `unique: true` is the positional spelling of `'global'` (the listed columns |
| 16 | + * verbatim), so `(name)` materialized as an installation-wide unique index on |
| 17 | + * an object that carries a kernel-injected `organization_id` — the shape of the |
| 18 | + * #8323 cross-tenant-oracle class. But `sys_job` is `managedBy: 'engine-owned'` |
| 19 | + * and calls itself a "Catalogue of registered background jobs", so the card |
| 20 | + * left the direction open and named the reading that settles it: |
| 21 | + * |
| 22 | + * > does anything write `sys_job` rows **per organization**? |
| 23 | + * |
| 24 | + * Nothing does. Five independent lines of evidence, all measured on `main`: |
| 25 | + * |
| 26 | + * 1. **The sole writer has no organization dimension.** `DbJobAdapter` |
| 27 | + * (`services/service-job`) is the only thing that writes this table — |
| 28 | + * `upsertJobRow` on `schedule()`, `setActive` on `cancel()`, `bumpJob` |
| 29 | + * after every run. All three write under |
| 30 | + * `SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }` and all |
| 31 | + * three locate their row with `find('sys_job', { where: { name }, limit: 1 })` |
| 32 | + * — keyed on `name` ALONE. The adapter therefore already assumes the global |
| 33 | + * key; a per-organization constraint would make that lookup ambiguous |
| 34 | + * (an arbitrary org's row would win) rather than fix anything. |
| 35 | + * 2. **The `job` metadata type is closed to tenants on all three flags** — |
| 36 | + * `allowOrgOverride: false`, `allowRuntimeCreate: false`, |
| 37 | + * `supportsOverlay: false`. The registry comment says the deciding half in |
| 38 | + * words: *"no per-org job fork"*. |
| 39 | + * 3. **No generic write path exists at all.** `enable.apiMethods` is |
| 40 | + * `['get', 'list']` (ADR-0103 engine-owned): reads stay open for the Setup |
| 41 | + * grid, every write verb is absent, for every caller — tenant or not. |
| 42 | + * 4. **Every `schedule()` call site is registration-time and |
| 43 | + * installation-scoped**: `AppPlugin` on `kernel:ready` from the app |
| 44 | + * bundle's declared jobs, `JobServicePlugin` replaying those registrations, |
| 45 | + * the schedule / time-relative flow triggers (keyed `<prefix>:${flowName}`, |
| 46 | + * and `flow` is itself `allowOrgOverride: false`), the approvals and |
| 47 | + * reports plugins' fixed names, and wait-node timers keyed |
| 48 | + * `flow-wait:${runId}:${nodeId}` on a server-minted run id. |
| 49 | + * 5. **ADR-0120 names this exact key.** Its S5 row — "engine idempotency keys |
| 50 | + * written by sudo (org NULL)" — lists `sys_job.name` first among the nine, |
| 51 | + * with the After spelling `'global'` and "zero drift". |
| 52 | + * `types/src/unique-scope-install-gate.ts` names it twice more as |
| 53 | + * platform-wide by construction. |
| 54 | + * |
| 55 | + * So the card's first branch wins: the object is tenant-scoped only |
| 56 | + * incidentally (`organization_id` is kernel-injected, never authored), the |
| 57 | + * installation-wide constraint is correct, and the remedy is to state it — |
| 58 | + * plus correct the field `description`, which published the bare claim. |
| 59 | + * |
| 60 | + * ## What this file pins, and why that is the point |
| 61 | + * |
| 62 | + * The card's verification bar asks for a test that fails **if the opposite |
| 63 | + * becomes true**. The spelling assertions alone cannot do that: they would stay |
| 64 | + * green on the day someone opens a per-organization write path, and the |
| 65 | + * constraint would silently become wrong. So the four assertions under |
| 66 | + * "the reading" below pin the *premises*, not the conclusion — each one is a |
| 67 | + * door that is currently shut, and each goes red the moment it opens. A future |
| 68 | + * author who legitimately opens one is then told, by a failing test naming this |
| 69 | + * card, that the uniqueness scope has to be re-decided with it. |
| 70 | + */ |
| 71 | +describe('sys_job — declared uniqueness is installation-wide (#8578)', () => { |
| 72 | + const uniqueIndexes = (SysJob.indexes ?? []).filter((i: any) => i.unique); |
| 73 | + |
| 74 | + describe('the spelling', () => { |
| 75 | + it('declares exactly one unique index, on (name)', () => { |
| 76 | + expect(uniqueIndexes).toHaveLength(1); |
| 77 | + expect((uniqueIndexes[0] as any).fields).toEqual(['name']); |
| 78 | + }); |
| 79 | + |
| 80 | + it("spells the scope 'global' — NOT bare `true`", () => { |
| 81 | + // ⛔ Asserted by EQUALITY, never by truthiness. Bare `true` is the exact |
| 82 | + // value this card removed, and it is truthy — a `toBeTruthy()` here would |
| 83 | + // pass on the defect itself. |
| 84 | + expect((uniqueIndexes[0] as any).unique).toBe('global'); |
| 85 | + expect((uniqueIndexes[0] as any).unique).not.toBe(true); |
| 86 | + }); |
| 87 | + |
| 88 | + it('is not left as a positional default in EITHER direction (ADR-0120 D1)', () => { |
| 89 | + // The card's closing condition: whichever branch the reading landed on, |
| 90 | + // the end state must STATE its scope. Written so it would also have held |
| 91 | + // had the reading gone the other way. |
| 92 | + expect(['global', 'organization']).toContain((uniqueIndexes[0] as any).unique); |
| 93 | + }); |
| 94 | + |
| 95 | + it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { |
| 96 | + expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ |
| 97 | + fields: ['name'], |
| 98 | + unique: 'global', |
| 99 | + }); |
| 100 | + }); |
| 101 | + |
| 102 | + it('keeps the physical shape byte-identical (ADR-0120 D2 — zero drift)', () => { |
| 103 | + // `'global'` IS today's verbatim semantics, so this respelling is |
| 104 | + // semantic bookkeeping and NOT a migration: the materialized index is |
| 105 | + // still `(name)` over exactly the listed columns, with no tenant key part |
| 106 | + // prepended. Asserted on the BUILT value — `ObjectSchema.create` |
| 107 | + // normalizes an authored `{ fields }` into `{ fields, unique: false }`. |
| 108 | + expect(SysJob.indexes).toEqual([ |
| 109 | + { fields: ['name'], unique: 'global' }, |
| 110 | + { fields: ['active'], unique: false }, |
| 111 | + ]); |
| 112 | + }); |
| 113 | + }); |
| 114 | + |
| 115 | + describe('the reading — these are the doors that must stay shut', () => { |
| 116 | + // Each assertion below is a premise of the `'global'` verdict. If one goes |
| 117 | + // red, a per-organization population became possible and the uniqueness |
| 118 | + // scope must be re-decided (the #8323 arm) — not merely re-spelled. |
| 119 | + |
| 120 | + it('is engine-owned, and advertises NO generic write verb (ADR-0103)', () => { |
| 121 | + expect((SysJob as any).managedBy).toBe('engine-owned'); |
| 122 | + const apiMethods: string[] = (SysJob as any).enable?.apiMethods ?? []; |
| 123 | + expect(apiMethods).toEqual(['get', 'list']); |
| 124 | + // Spelled out as an exclusion too, so widening the array fails here |
| 125 | + // rather than only in the equality above. |
| 126 | + for (const verb of ['create', 'update', 'delete', 'upsert']) { |
| 127 | + expect(apiMethods).not.toContain(verb); |
| 128 | + } |
| 129 | + }); |
| 130 | + |
| 131 | + it('the `job` metadata type is closed to tenants on all three flags', () => { |
| 132 | + // The deciding fact, quoted from the registry: "no per-org job fork". |
| 133 | + // Opening `allowOrgOverride` or `allowRuntimeCreate` is exactly the |
| 134 | + // "a tenant can register or shadow a job" branch the card named. |
| 135 | + const job = DEFAULT_METADATA_TYPE_REGISTRY.find((t: any) => t.type === 'job'); |
| 136 | + expect(job).toBeDefined(); |
| 137 | + expect((job as any).allowOrgOverride).toBe(false); |
| 138 | + expect((job as any).allowRuntimeCreate).toBe(false); |
| 139 | + expect((job as any).supportsOverlay).toBe(false); |
| 140 | + }); |
| 141 | + |
| 142 | + it('the `flow` type is closed too — flow-derived job names cannot fork per org', () => { |
| 143 | + // The schedule / time-relative triggers key their job names on the flow |
| 144 | + // name (`<prefix>:${flowName}`). Those names are installation-wide |
| 145 | + // identities only for as long as a flow cannot be org-forked; if it can, |
| 146 | + // two organizations produce the same job name and this constraint turns |
| 147 | + // into the cross-tenant oracle after all. |
| 148 | + const flow = DEFAULT_METADATA_TYPE_REGISTRY.find((t: any) => t.type === 'flow'); |
| 149 | + expect(flow).toBeDefined(); |
| 150 | + expect((flow as any).allowOrgOverride).toBe(false); |
| 151 | + }); |
| 152 | + |
| 153 | + it('carries an injected organization_id — so the scope is a real choice, not a default', () => { |
| 154 | + // `sys_job` IS tenant-scoped structurally (this is why the sweep flagged |
| 155 | + // it at all). The column exists; the verdict is that no writer ever |
| 156 | + // populates it per organization. Pinning this keeps the `'global'` |
| 157 | + // spelling an argued decision rather than an artifact of the column |
| 158 | + // being absent — and if the injection is ever switched off, the reading |
| 159 | + // above needs re-checking from a different direction (ADR-0120 S11). |
| 160 | + const plan = resolveInjectedSystemColumns(SysJob); |
| 161 | + expect((SysJob as any).tenancy).toBeUndefined(); |
| 162 | + expect(plan.tenant).toBe(true); |
| 163 | + expect(plan.names.has('organization_id')).toBe(true); |
| 164 | + }); |
| 165 | + }); |
| 166 | + |
| 167 | + describe('the record correction', () => { |
| 168 | + it('the `name` field no longer publishes a bare "unique" claim', () => { |
| 169 | + // The half that was never in question (#8468 ruling): the description |
| 170 | + // used to say only "Unique job identifier (snake_case)", which asserts a |
| 171 | + // boundary-free uniqueness to every admin and AI author reading the |
| 172 | + // generated reference. It must now name the boundary it actually has. |
| 173 | + const help = String((SysJob.fields as any).name.description ?? ''); |
| 174 | + expect(help).toMatch(/across the whole installation/); |
| 175 | + expect(help).not.toBe('Unique job identifier (snake_case)'); |
| 176 | + }); |
| 177 | + }); |
| 178 | +}); |
0 commit comments