Skip to content
Draft
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
82 changes: 82 additions & 0 deletions .changeset/duration-unit-in-key-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
"@objectstack/spec": minor
---

feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.ttl` → `ttlSeconds`, `cache.databaseLoader.ttl` → `ttlMs`, tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519)

<!-- adr-0087: registered hook-timeout-to-timeout-ms, job-timeout-to-timeout-ms, metadata-manager-config-cache-ttl-unit-in-key, driver-options-timeout-to-timeout-ms, tenant-timeouts-unit-in-key -->

**BREAKING** rename of seven published authorable keys, shipped as `minor` under
the repo's launch-window convention for breaking changes; every rename is
registered under protocol major 18. Maintainer ruling 2026-09-02 on #14478
(director decision batch #14, verbatim 「14461 你不处理,其他同意」): **ruled B** —
a spec-source gate for duration-shaped number keys **with no grandfathered
baseline**, plus an ADR-0087 conversion of every offender the ruling named, in
one PR, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」.
⛔ No alias, no transition window: each old spelling is a `retiredKey()`
tombstone whose rejection names the new key.

## The defect

`kernel/metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines
apart: `cache.ttl` in **seconds** (default 3600) and `cache.databaseLoader.ttl`
in **milliseconds** (default 60000). Both descriptions named their unit; the
key names did not. An author who copied the outer number into the inner block
got a 3.6-second cache and no error anywhere — the number was valid, the type
was right, the cache was simply cold. `hook.timeout`, `job.timeout` and
`DriverOptions.timeout` had the same shape (milliseconds, said only in prose)
beside siblings that spell theirs (`backoffMs`, `intervalMs`, the body-level
`timeoutMs`). The two tenant keys were worse for the reader who matters most:
`.describe()` is what `content/docs/references/**` publishes and the JSDoc above
a key is not, so `idleTimeout` / `sessionTimeout` said "in seconds" in a source
comment and published a bare `300` / `3600` to the reference page (#14519).

## FROM → TO

| schema | before | after | value |
|:--|:--|:--|:--|
| `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) |
| `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) |
| `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) |
| `MetadataManagerConfigSchema` | `cache.ttl` | `cache.ttlSeconds` | unchanged (s, default 3600) |
| `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) |
| `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) |
| `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) |

```ts
// before
defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 });
defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeout: 300000 });
new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } });

// after — rename the key; the number is unchanged
defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 });
defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 });
new MetadataManager({ cache: { ttlSeconds: 3600, databaseLoader: { ttlMs: 60_000 } } });
```

**Migration.** Rename each key; no value changes. Authoring an old spelling
fails to compile (`tsc`: the input type is `never`) and fails to parse with a
prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a
mechanical D2 conversion (`hook-timeout-to-timeout-ms`,
`job-timeout-to-timeout-ms`, retired from the load path): run
`os migrate meta --from 17` to list the edits for existing sources and apply
them by hand; stored `sys_metadata` rows are rehydrated through the same chain.
The other five keys have no stack seam (runtime config, a per-call options
argument, cloud tenancy config) and carry a semantic entry each. The
`JobScheduleOptions` contract key that carries `job.timeoutMs` to the scheduler
is renamed in lockstep (`timeout` → `timeoutMs`), as is `DatabaseLoaderOptions.cache.ttl` → `ttlMs` in `@objectstack/metadata`.

## The gate

`pnpm --filter @objectstack/spec check:duration-unit-keys`
(`packages/spec/scripts/check-duration-unit-keys.ts`, wired into `lint.yml`):
a property whose value is a `z.number()` / `z.int()` / `z.coerce.number()`
chain and whose `.describe()` names a time unit must carry that unit as a token
of its key name (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`, and the
knex-inherited `Millis`), and the token must agree with the prose — `ttlMs`
described "in seconds" is refused too. A `{ value, unit }` pair is recognised
by its sibling `unit` key; duration literals are strings and outside the
population. Calendar positions ("day of the month") and rates ("requests per
second") are skipped. There is no baseline and no `gen:`; a red is a rename
under an ADR-0087 conversion or a describe to fix.
23 changes: 23 additions & 0 deletions .changeset/metadata-database-loader-ttl-ms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/metadata": minor
---

feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478)

<!-- adr-0087: registered metadata-manager-config-cache-ttl-unit-in-key -->

**BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape
(`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the
launch-window convention. `MetadataManager` hands `config.cache.databaseLoader`
straight to `new DatabaseLoader({ cache })`, so this option is the spec key
`cache.databaseLoader.ttlMs` one layer down and renames with it: a loader
configured with `ttlMs: 60_000` expires entries after 60 seconds exactly as
`ttl: 60_000` did. The README example and the kernel metadata-service docs page
spell the new key.

```ts
// before
new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttl: 60_000 } });
// after
new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttlMs: 60_000 } });
```
10 changes: 10 additions & 0 deletions .changeset/objectql-hook-timeout-ms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the declarative hook wrapper reads the renamed `hook.timeoutMs` (#14478)

`wrapDeclarativeHook` reads its wall-clock abort budget from `meta.timeoutMs`
instead of `meta.timeout`, following the `@objectstack/spec` rename of the
authored key (the unit now lives in the key name). Same value, same magnitude,
same abort; no public surface of this package changes.
10 changes: 10 additions & 0 deletions .changeset/runtime-job-timeout-ms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/runtime": patch
---

fix(runtime): `AppPlugin` threads the authored `job.timeoutMs` to the scheduler as `timeoutMs` (#14478)

The declarative job door passes `{ retryPolicy, timeoutMs }` to
`IJobService.schedule`, following the `@objectstack/spec` rename of the
authored key and of the `JobScheduleOptions` contract key that carries it. Same
value, same per-attempt limit.
11 changes: 11 additions & 0 deletions .changeset/service-job-timeout-ms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/service-job": patch
---

fix(service-job): `runWithPolicy` and the DB job adapter read `JobScheduleOptions.timeoutMs` (#14478)

The per-attempt time limit is read from `options.timeoutMs`, following the
`@objectstack/spec` rename of both the authored `job.timeoutMs` and the
`JobScheduleOptions` contract key that carries it. Same value, same per-attempt
race, same `JobTimeoutError`; `withoutPolicy` strips the renamed key so the
timer adapter downstream never runs a second budget.
18 changes: 18 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3928,6 +3928,24 @@ jobs:
node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test
node packages/lint/scripts/check-reference-carrier-shape.mjs

# #14478 (maintainer ruling 2026-09-02, "ruled B"): a duration-shaped
# `z.number()` key in packages/spec/src/** whose `.describe()` names a
# time unit must carry that unit in its NAME (`Ms` / `Seconds` /
# `Minutes` / `Hours` / `Days`), or the schema uses a unit-carrying
# VALUE (`'14d'` literals, `{ value, unit }` pairs). The founding
# specimen was two keys spelled `ttl` fourteen lines apart, one in
# seconds and one in milliseconds, each unit named only in prose — an
# author copying the outer number into the inner block got a 3.6-second
# cache and no error anywhere. No baseline, by ruling: the gate is green
# only at zero offenders, and a red is a rename under an ADR-0087
# conversion (or a describe to fix), never a ledger row. Placed LAST in
# this job on purpose: it is a new tree-wide ratchet, and the job aborts
# at the first non-zero exit — nothing behind it would be masked by its
# red. Reads src/ through tsx (no build); self-tests first, wired into
# the package script as `check:self-test-wired` requires.
- name: Duration-shaped spec keys carry their unit in the key name
run: pnpm --filter @objectstack/spec check:duration-unit-keys

# Lane 1 of 4 behind the required `TypeScript Type Check` context. The
# aggregator job at the bottom of this file explains the split, holds the
# contract, and is the thing the merge queue actually requires — read it
Expand Down
2 changes: 1 addition & 1 deletion content/docs/automation/jobs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const HealthSweepJob = defineJob({
schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' },
handler: 'sweepProjectHealth',
retryPolicy: { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 },
timeout: 300000,
timeoutMs: 300000,
});
```

Expand Down
4 changes: 2 additions & 2 deletions content/docs/protocol/kernel/metadata-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,14 @@ parse-time error carrying the prescription.)

### 3. DatabaseLoader Read-Through Cache

`DatabaseLoader` wraps `load` / `loadMany` / `list` / `stat` results in a generic LRU cache (lazy TTL, promote-on-get, write invalidation). Reads always observe writes performed through the same loader instance; out-of-band SQL writes are honored within `ttl` milliseconds.
`DatabaseLoader` wraps `load` / `loadMany` / `list` / `stat` results in a generic LRU cache (lazy TTL, promote-on-get, write invalidation). Reads always observe writes performed through the same loader instance; out-of-band SQL writes are honored within `ttlMs` milliseconds.

```typescript
new MetadataManager({
datasource: 'default',
cache: {
enabled: true,
databaseLoader: { enabled: true, maxSize: 500, ttl: 60_000 },
databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 },
},
});
```
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/data/driver.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ const result = DriverCapabilitiesSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **transaction** | `any` | optional | Transaction handle |
| **timeout** | `number` | optional | Timeout in ms |
| **timeoutMs** | `number` | optional | Operation timeout in milliseconds |
| **timeout** | `never` | optional | [REMOVED] `DriverOptions.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. |
| **skipCache** | `boolean` | optional | Bypass cache |
| **traceContext** | `Record<string, string>` | optional | OpenTelemetry context or request ID |
| **tenantId** | `string` | optional | Tenant Isolation identifier |
Expand Down
7 changes: 4 additions & 3 deletions content/docs/references/kernel/metadata-loader.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const result = MetadataFallbackStrategySchema.parse(data);
| **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | optional (default: `"none"`) | Fallback strategy when datasource is unavailable |
| **rootDir** | `string` | optional | Root directory path |
| **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | optional (default: `["typescript","json","yaml"]`) | Enabled formats |
| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings |
| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings |
| **watch** | `boolean` | optional (default: `false`) | Enable file watching |
| **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options |
| **validation** | `{ strict: boolean; throwOnError: boolean }` | optional | Validation settings |
Expand All @@ -61,9 +61,10 @@ const result = MetadataFallbackStrategySchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | optional (default: `true`) | Enable caching |
| **ttl** | `integer` | optional (default: `3600`) | Cache TTL in seconds |
| **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds |
| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. |
| **maxSize** | `integer` | optional | Max cache size in bytes |
| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttl: integer }` | optional | DatabaseLoader read-through cache |
| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache |

### Nested Shape: `MetadataManagerConfig.watchOptions`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | optional (default: `"none"`) | Fallback strategy when datasource is unavailable |
| **rootDir** | `string` | optional | Root directory path |
| **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | optional (default: `["typescript","json","yaml"]`) | Enabled formats |
| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings |
| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings |
| **watch** | `boolean` | optional (default: `false`) | Enable file watching |
| **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options |
| **validation** | `{ strict: boolean; throwOnError: boolean }` | optional | Validation settings |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/system/job.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ const result = CronScheduleSchema.parse(data);
| **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration |
| **handler** | `string` | ✅ | Handler function name (must match a key in `defineStack({ functions })`) |
| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in. |
| **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. |
| **timeoutMs** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. |
| **timeout** | `never` | optional | [REMOVED] `job.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **enabled** | `boolean` | optional (default: `true`) | Whether the job is enabled |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand Down
7 changes: 4 additions & 3 deletions content/docs/references/system/metadata-persistence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ Metadata file format
| **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | optional (default: `"none"`) | Fallback strategy when datasource is unavailable |
| **rootDir** | `string` | optional | Root directory path |
| **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | optional (default: `["typescript","json","yaml"]`) | Enabled formats |
| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings |
| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings |
| **watch** | `boolean` | optional (default: `false`) | Enable file watching |
| **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options |
| **validation** | `{ strict: boolean; throwOnError: boolean }` | optional | Validation settings |
Expand All @@ -254,9 +254,10 @@ Metadata file format
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | optional (default: `true`) | Enable caching |
| **ttl** | `integer` | optional (default: `3600`) | Cache TTL in seconds |
| **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds |
| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. |
| **maxSize** | `integer` | optional | Max cache size in bytes |
| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttl: integer }` | optional | DatabaseLoader read-through cache |
| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache |

### Nested Shape: `MetadataManagerConfig.watchOptions`

Expand Down
Loading
Loading