diff --git a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md new file mode 100644 index 0000000000..148570312c --- /dev/null +++ b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md @@ -0,0 +1,11 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) + +The dashboard key catalogue `os explain` prints lists +`refreshIntervalSeconds` instead of `refreshInterval`, following the +`@objectstack/spec` rename of the authored key (the unit now lives in the key +name). Same key, same seconds; no other command output and no public surface of +this package changes. diff --git a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..c66ebc58f2 --- /dev/null +++ b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md @@ -0,0 +1,138 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) + + + +**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped +as `minor` under the repo's launch-window convention for breaking changes; the +hand-migration prescriptions are registered under protocol major 18. Maintainer +ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in +the key NAME, never only in its `.describe()` prose, and grandfathers no existing +offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 +(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 +(#15679) cleared `system/`. This card clears the remainder, and is the first +where the gate itself reads **`zero offenders`** and exits `0`. + +⚠️ That is green **for the gate's currently declared population** +(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has +already measured an offender outside this subtree, so the gate is expected to go +red again by design. This changeset does not claim #14478 is finished. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | +| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | +| `ConnectorTrigger.interval` | `intervalSeconds` | seconds | +| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | +| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | +| `TursoConfig.timeout` | `timeoutMs` | milliseconds | +| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | +| `ConversationAnalytics.duration` | `durationSeconds` | seconds | + +**Every value is unchanged** — only key names move. The two keys that carried a +default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to +60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare +none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything +under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and +`TursoConfig.timeoutMs` still refuse a zero or negative integer, and +`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old +spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site +(input type `never`) and fails the parse with the rename prescription rather than +a bare unrecognized-key error. + +`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` +— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming +the tombstone would have prescribed a key the shape refuses, which is the one +failure this rename could have introduced silently; a pin asserts all three. + +## ⚠️ `dashboard.refreshInterval` crosses a repository boundary + +This is the only rename in the whole stack whose consumer is in **another +repository**, so its reader could not move in this PR the way every other reader +in this card did. objectui's dashboard renderer reads the key, multiplies by +1000 to drive a `setInterval`, and republishes it as an authoring input the +console offers. Those sites move in a follow-up objectui card, sequenced behind +a release that actually ships this rename. + +Until that lands the renderer sees an absent key and simply does not start its +refresh timer — a dashboard still renders, and still refreshes when the user +asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards +and `os migrate meta` correct in the meantime. + +## ⚠️ An eighth key moves that the gate did not list + +`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` +named no unit at all, and the predicate judges prose against name. + +It moves anyway because it is not a second key. `persistence: { type: 'auto' }` +resolves to the same Node.js file adapter as `type: 'file'`, and this value is +forwarded to the same `FileSystemPersistenceAdapter` field, in the same +milliseconds, under the same `min(100)` bound. Renaming one arm and not the other +would have left one value with two spellings across sibling arms of one union, +and the driver reading both — the consumer-side dialect Prime Directive #12 +forbids. Its describe now names the unit too, and a pin asserts the refusal on +the arm the gate never listed, so a later reader cannot "restore" the bare +spelling as an over-application of the rule. + +## Dispositions — four D2 conversions, two semantic entries + +Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and +unlike card 4/6 this card's answer is split. + +**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` +are each a stack collection whose members are stored whole as `sys_metadata` +rows, so the conversion chain has a seam that sees them: +`dashboard-refresh-interval-to-refresh-interval-seconds`, +`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one +pass, emitting separately), +`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and +`turso-config-timeout-to-timeout-ms`. The two datasource conversions are +driver-aware for the reason `datasource-config-driver-key-aliases` records: a +bare `config.timeout` under another driver is that driver's own key and must not +be touched. + +**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime +and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument +reached only through `AggregationPipeline.options`. Neither is a stack collection +member or a stored row, so the chain has no seam — the disposition every +runtime-emitted measurement in this stack has taken. + +All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + +## A retirement tombstone is no longer read as a secret + +`refusedCredentialKeys` derives a driver's refused inline credentials by finding +`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a +`z.never()`, and until this card no driver contract carried one — so "never ⇒ +credential" held by accident of population rather than by construction. The first +tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a +millisecond budget was a secret: it was redacted off the datasource read path and +dragged a non-credential name into the fallback list every unrecognised driver is +scrubbed by. + +The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` +itself stamps. The exclusion is deliberately **negative** — skip declared +tombstones — rather than positive (keep only keys marked `format: 'password'`), +even though every credential slot in every builtin contract does carry that +marker today: under-redacting is the dangerous direction, so a future credential +key whose author forgets the marker is still scrubbed, and only a key that has +explicitly declared itself retired may drop out. Both directions are pinned. + +## Keys deliberately left alone + +`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` +already carried their unit — they are the same-shape neighbours that made the +bare `timeout` and `monitoringWindow` collisions visible, and pins assert they +did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every +number on `ConversationAnalytics` other than the duration is a count of messages, +tokens or events: a count has no unit to carry. The turso schema shipped by +`@objectstack/driver-turso` is a separate declaration outside this gate's +declared population and is not touched here; card 6/6 owns it, so the two +declarations disagree by design until that lands. diff --git a/.changeset/driver-memory-auto-save-interval-ms.md b/.changeset/driver-memory-auto-save-interval-ms.md new file mode 100644 index 0000000000..aa551961ff --- /dev/null +++ b/.changeset/driver-memory-auto-save-interval-ms.md @@ -0,0 +1,28 @@ +--- +"@objectstack/driver-memory": minor +--- + +feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) + + + +**BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and +`FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both +renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of +the authored keys on both persistence arms. + +Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The +option was always milliseconds — it is passed straight to `setInterval` — and the +spec's `min(100)` bound is what made the bare name dangerous rather than untidy: +100 reads as a plausible number of seconds, so an author who guessed the unit +wrong cleared the bound, was refused nowhere, and saved a thousand times more +often than intended. + +Both persistence arms move together: `type: 'auto'` resolves to this same file +adapter and forwards the same field, so this package reads exactly one spelling +rather than two. + +```diff +- new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) ++ new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) +``` diff --git a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md new file mode 100644 index 0000000000..6b6b872d5c --- /dev/null +++ b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md @@ -0,0 +1,12 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) + +The `metadataForms.dashboard` translation bundles key the auto-refresh field as +`refreshIntervalSeconds`, following the `@objectstack/spec` rename of the +authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the +hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across +the rename unchanged, because the field still means what it meant and each help +text already named the unit. diff --git a/.changeset/service-datasource-turso-timeout-ms-reader.md b/.changeset/service-datasource-turso-timeout-ms-reader.md new file mode 100644 index 0000000000..cd2e32c621 --- /dev/null +++ b/.changeset/service-datasource-turso-timeout-ms-reader.md @@ -0,0 +1,57 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) + +`buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — +still consulted `config.timeout` after #15680 renamed that authored key to +`timeoutMs` and tombstoned the old spelling. A turso datasource authored the +canonical way therefore reached the seam, matched nothing, and had its timeout +**silently dropped**: no diagnostic in any channel. + +The reader now consults `config.timeoutMs`. The DRIVER key it lands on is +unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is +published-but-inert (#16024), and renaming an inert key would ratify it as real, +which is what ADR-0049 exists to prevent. So this seam is the one place the +authored and driver spellings differ, and it now says so. + +## No fallback arm for the retired spelling — the seam's own precedent + +Both sibling arms in `default-datasource-driver-factory.ts` already answer this +in the same words: sqlite's "`filename` is the whole contract … so no `??` +tolerance survives here", mongo's "`url` is the one spelling". A renamed +datasource config key reaches a reader already canonical from two directions — +authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` +`never` plus a parse-time prescription), and a stored `sys_metadata` row replays +the full ADR-0087 chain including `retiredFromLoadPath` entries at +`loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion +`turso-config-timeout-to-timeout-ms` has rewritten the key before this table +sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a +spelling both doors have closed. + +`authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route +(host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs +itself, which never meets the authoring schema), not for a retired spelling. + +## Why the covering test did not catch it, and what replaces it + +`TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a +rename through it — the tombstone's type channel, which caught the alias tables +elsewhere in this stack, does not reach here. And the covering test authored the +**retired** spelling at all three of its turso `config` sites, so it was green +for exactly the behaviour that had become wrong. A test that pins the retired +spelling cannot notice this class of bug. + +The three sites now author the canonical spelling, and the file gains cases +DERIVED from the authoring contract rather than written against today's key +list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert +that (a) every canonical replacement is consulted by some reader, and (b) no +retired spelling is — probed at every JS type a reader could type-test, with a +vacuity guard so a mis-derived empty list fails instead of passing. They hold +for the next rename without being edited. + +The two sibling pins that author the same spec — `packages/cli`'s driver +correspondence check and `packages/runtime`'s cross-loader convergence check — +move to the canonical spelling with it; their assertions read driver keys and +are unchanged. diff --git a/content/docs/references/ai/conversation.mdx b/content/docs/references/ai/conversation.mdx index 06c181bcf0..cb39926f0b 100644 --- a/content/docs/references/ai/conversation.mdx +++ b/content/docs/references/ai/conversation.mdx @@ -58,7 +58,8 @@ const result = CodeContentSchema.parse(data); | **summarizationEvents** | `integer` | optional (default: `0`) | | | **tokensSavedByPruning** | `integer` | optional (default: `0`) | | | **tokensSavedBySummarization** | `integer` | optional (default: `0`) | | -| **duration** | `number` | optional | Session duration in seconds | +| **durationSeconds** | `number` | optional | Session duration in seconds | +| **duration** | `never` | optional | [REMOVED] `ConversationAnalytics.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged. | | **firstMessageAt** | `string` | optional | ISO 8601 timestamp | | **lastMessageAt** | `string` | optional | ISO 8601 timestamp | diff --git a/content/docs/references/data/driver-memory.mdx b/content/docs/references/data/driver-memory.mdx index 03e63e948c..2c40acb3b2 100644 --- a/content/docs/references/data/driver-memory.mdx +++ b/content/docs/references/data/driver-memory.mdx @@ -44,7 +44,8 @@ Auto-detect persistence configuration | :--- | :--- | :--- | :--- | | **type** | `'auto'` | ✅ | | | **path** | `string` | optional | File path override for Node.js environments | -| **autoSaveInterval** | `number` | optional | Auto-save interval override for Node.js environments | +| **autoSaveIntervalMs** | `number` | optional | Auto-save interval override for Node.js environments, in milliseconds | +| **autoSaveInterval** | `never` | optional | [REMOVED] `AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **key** | `string` | optional | localStorage key override for browser environments | @@ -60,7 +61,8 @@ File-system persistence configuration | :--- | :--- | :--- | :--- | | **type** | `'file'` | ✅ | | | **path** | `string` | optional | File path to persist data | -| **autoSaveInterval** | `number` | optional (default: `2000`) | Auto-save interval in ms | +| **autoSaveIntervalMs** | `number` | optional (default: `2000`) | Auto-save interval in ms | +| **autoSaveInterval** | `never` | optional | [REMOVED] `FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value (milliseconds) and the 2000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/content/docs/references/data/driver-nosql.mdx b/content/docs/references/data/driver-nosql.mdx index 5e79c0aa0e..b31d7ba7e9 100644 --- a/content/docs/references/data/driver-nosql.mdx +++ b/content/docs/references/data/driver-nosql.mdx @@ -29,7 +29,7 @@ const result = AggregationPipelineSchema.parse(data); | :--- | :--- | :--- | :--- | | **collection** | `string` | ✅ | Collection/table name | | **stages** | `{ operator: string; options: Record }[]` | ✅ | Aggregation pipeline stages | -| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeout?: integer; … }` | optional | Query options | +| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeoutMs?: integer; … }` | optional | Query options | ### Nested Shape: `AggregationPipeline.stages[number]` @@ -45,7 +45,8 @@ const result = AggregationPipelineSchema.parse(data); | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | | **projection** | `Record` | optional | Field projection | -| **timeout** | `integer` | optional | Query timeout (ms) | +| **timeoutMs** | `integer` | optional | Query timeout (ms) | +| **timeout** | `never` | optional | [REMOVED] `NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | | **profile** | `boolean` | optional | Enable query profiling | @@ -319,7 +320,8 @@ const result = AggregationPipelineSchema.parse(data); | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | | **projection** | `Record` | optional | Field projection | -| **timeout** | `integer` | optional | Query timeout (ms) | +| **timeoutMs** | `integer` | optional | Query timeout (ms) | +| **timeout** | `never` | optional | [REMOVED] `NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | | **profile** | `boolean` | optional | Enable query profiling | diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 19ca0d1567..c8233200b7 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -71,7 +71,8 @@ Turso / libSQL Connection Configuration | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | | **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) | -| **timeout** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **timeoutMs** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **timeout** | `never` | optional | [REMOVED] `turso config.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. 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. | | **mode** | `Enum<'local' \| 'replica' \| 'remote'>` | optional | Force a transport mode instead of inferring it from `url` | ### Nested Shape: `TursoConfig.sync` diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index f9f079593a..96c7ed31bf 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -148,7 +148,8 @@ Circuit breaker configuration | **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | | **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | | **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindow** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | @@ -272,7 +273,8 @@ Circuit breaker configuration | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `Connector.syncConfig` @@ -440,7 +442,8 @@ Connector health configuration | **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | | **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | | **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindow** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | @@ -597,7 +600,8 @@ Connector status | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -755,7 +759,8 @@ Connector type | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `DeclarativeConnectorEntry.syncConfig` diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index a43e73ba20..d78c62471b 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -34,7 +34,8 @@ const result = DashboardSchema.parse(data); | **widgets** | `{ id: string; title?: string \| Record; description?: string \| Record; type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; … }[]` | ✅ | Widgets to display | | **columns** | `integer` | optional | Number of grid columns (default 12) | | **gap** | `integer` | optional | Space between widgets, in steps of 0.25rem (4 = 1rem) | -| **refreshInterval** | `number` | optional | Auto-refresh interval in seconds | +| **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds | +| **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **dateRange** | `{ field?: string; defaultRange: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange: boolean }` | optional | Global dashboard date range filter configuration | | **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | | **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index e8942c5745..57481f53ba 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -18,7 +18,7 @@ const salesDashboard = { name: 'sales_overview', label: 'Sales Overview', description: 'Key sales metrics and pipeline analysis', - refreshInterval: 300, // Refresh every 5 minutes + refreshIntervalSeconds: 300, // Refresh every 5 minutes dateRange: { field: 'close_date', @@ -65,7 +65,7 @@ const salesDashboard = { | `label` | `string` | ✅ | Display label | | `description` | `string` | optional | Dashboard description | | `widgets` | `DashboardWidget[]` | ✅ | Chart and metric widgets | -| `refreshInterval` | `number` | optional | Auto-refresh interval (seconds) | +| `refreshIntervalSeconds` | `number` | optional | Auto-refresh interval (seconds) | | `dateRange` | `object` | optional | Global date range filter | | `globalFilters` | `GlobalFilter[]` | optional | Global filter controls | @@ -449,7 +449,7 @@ const projectDashboard = { name: 'project_overview', label: 'Project Overview', description: 'Real-time project health metrics', - refreshInterval: 60, + refreshIntervalSeconds: 60, dateRange: { field: 'created_at', diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 32c9efe2ab..59ea4b52eb 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -238,7 +238,7 @@ export const SCHEMAS: Record = { { name: 'label', type: 'string', description: 'Display name' }, { name: 'widgets', type: 'Widget[]', description: 'Dashboard widget definitions' }, { name: 'layout', type: 'GridLayout', description: 'Widget positioning' }, - { name: 'refreshInterval', type: 'number', description: 'Auto-refresh interval in seconds' }, + { name: 'refreshIntervalSeconds', type: 'number', description: 'Auto-refresh interval in seconds' }, ], example: `{ name: 'project_overview', diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index f1ed2ac785..e903ecf97a 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -474,7 +474,7 @@ describe('#7314 — the shared libSQL config builder against the real TursoDrive concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + timeoutMs: 9000, mode: 'replica', }, } as const; diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 24f354c0bb..1f2dc5ce39 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -201,9 +201,9 @@ export interface InMemoryDriverConfig { * - `'auto'` — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled) * - `'file'` — File-system persistence with defaults (Node.js only) * - `'local'` — localStorage persistence with defaults (Browser only) - * - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options + * - `{ type: 'file', path?: string, autoSaveIntervalMs?: number }` — File-system with options * - `{ type: 'local', key?: string }` — localStorage with options - * - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options + * - `{ type: 'auto', path?: string, key?: string, autoSaveIntervalMs?: number }` — Auto-detect with options * - `{ adapter: PersistenceAdapterInterface }` — Custom adapter * * Durability is **opt-in**, as #815 specified ("默认情况下不启用持久化(纯内存,行为不变)", @@ -222,7 +222,7 @@ export interface InMemoryDriverConfig { type?: 'file' | 'local' | 'auto'; path?: string; key?: string; - autoSaveInterval?: number; + autoSaveIntervalMs?: number; adapter?: PersistenceAdapterInterface; }; } @@ -2293,7 +2293,7 @@ export class InMemoryDriver implements IDataDriver { const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js'); this.persistenceAdapter = new FileSystemPersistenceAdapter({ path: persistence.path, - autoSaveInterval: persistence.autoSaveInterval, + autoSaveIntervalMs: persistence.autoSaveIntervalMs, }); this.logger.debug('Auto-detected Node.js environment, using file persistence'); } @@ -2301,7 +2301,7 @@ export class InMemoryDriver implements IDataDriver { const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js'); this.persistenceAdapter = new FileSystemPersistenceAdapter({ path: persistence.path, - autoSaveInterval: persistence.autoSaveInterval, + autoSaveIntervalMs: persistence.autoSaveIntervalMs, }); } else if (persistence.type === 'local') { const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js'); diff --git a/packages/drivers/driver-memory/src/persistence/file-adapter.ts b/packages/drivers/driver-memory/src/persistence/file-adapter.ts index 391e4debd4..1a5a4104a9 100644 --- a/packages/drivers/driver-memory/src/persistence/file-adapter.ts +++ b/packages/drivers/driver-memory/src/persistence/file-adapter.ts @@ -13,14 +13,14 @@ import * as path from 'node:path'; */ export class FileSystemPersistenceAdapter { private readonly filePath: string; - private readonly autoSaveInterval: number; + private readonly autoSaveIntervalMs: number; private dirty = false; private timer: ReturnType | null = null; private currentDb: Record | null = null; - constructor(options?: { path?: string; autoSaveInterval?: number }) { + constructor(options?: { path?: string; autoSaveIntervalMs?: number }) { this.filePath = options?.path || path.join('.objectstack', 'data', 'memory-driver.json'); - this.autoSaveInterval = options?.autoSaveInterval ?? 2000; + this.autoSaveIntervalMs = options?.autoSaveIntervalMs ?? 2000; } /** @@ -67,7 +67,7 @@ export class FileSystemPersistenceAdapter { await this.writeToDisk(this.currentDb); this.dirty = false; } - }, this.autoSaveInterval); + }, this.autoSaveIntervalMs); // Allow process to exit even if timer is running if (this.timer) { diff --git a/packages/drivers/driver-memory/src/persistence/persistence.test.ts b/packages/drivers/driver-memory/src/persistence/persistence.test.ts index f4c5433cb9..8da79c90ef 100644 --- a/packages/drivers/driver-memory/src/persistence/persistence.test.ts +++ b/packages/drivers/driver-memory/src/persistence/persistence.test.ts @@ -44,7 +44,7 @@ describe('InMemoryDriver Persistence', () => { it('should persist and restore data via file adapter', async () => { // Create and populate driver with file persistence const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.create('users', { id: '1', name: 'Alice' }); @@ -59,7 +59,7 @@ describe('InMemoryDriver Persistence', () => { // Create a new driver and verify data is restored const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); @@ -84,7 +84,7 @@ describe('InMemoryDriver Persistence', () => { it('should persist updates and deletes', async () => { const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); @@ -99,7 +99,7 @@ describe('InMemoryDriver Persistence', () => { // Restore const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); @@ -194,7 +194,7 @@ describe('InMemoryDriver Persistence', () => { it('should auto-detect Node.js environment and use file persistence with object config', async () => { const filePath = path.join(TEST_DATA_DIR, 'auto-test-db.json'); const driver1 = new InMemoryDriver({ - persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'auto', path: filePath, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.create('users', { id: '1', name: 'Alice' }); @@ -206,7 +206,7 @@ describe('InMemoryDriver Persistence', () => { // Restore from file const driver2 = new InMemoryDriver({ - persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'auto', path: filePath, autoSaveIntervalMs: 100 }, }); await driver2.connect(); const users = await driver2.find('users', {}); @@ -219,7 +219,7 @@ describe('InMemoryDriver Persistence', () => { describe('Bulk Operations with Persistence', () => { it('should persist bulk creates', async () => { const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.bulkCreate('items', [ @@ -231,7 +231,7 @@ describe('InMemoryDriver Persistence', () => { await driver1.disconnect(); const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); const items = await driver2.find('items', {}); @@ -278,7 +278,7 @@ describe('InMemoryDriver Persistence', () => { it('still persists when a host opts in explicitly', async () => { const filePath = path.join(TEST_DATA_DIR, 'opt-in.json'); const driver = new InMemoryDriver({ - persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'file', path: filePath, autoSaveIntervalMs: 100 }, }); await driver.connect(); await driver.create('items', { id: 'a', name: 'Widget' }); @@ -338,7 +338,7 @@ describe('InMemoryDriver Persistence', () => { process.env.VERCEL = '1'; const filePath = path.join(TEST_DATA_DIR, 'explicit-file-serverless.json'); const driver = new InMemoryDriver({ - persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'file', path: filePath, autoSaveIntervalMs: 100 }, }); await driver.connect(); await driver.create('items', { id: '1', name: 'Widget' }); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 46d6a81468..946bb9f9fb 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -942,8 +942,8 @@ export const enMetadataForms: NonNullable = { label: "Gap", helpText: "Space between widgets, in steps of 0.25rem (4 = 1rem)" }, - refreshInterval: { - label: "Refresh Interval", + refreshIntervalSeconds: { + label: "Refresh Interval Seconds", helpText: "Auto-refresh (seconds)" }, header: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index efa2656a43..798ede93ef 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const esESMetadataForms: NonNullable = label: "Separación", helpText: "Separación de cuadrícula (unidades Tailwind)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "Intervalo de actualización", helpText: "Actualización automática (segundos)" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 55aa8e3e3c..84015fc32f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const jaJPMetadataForms: NonNullable = label: "間隔", helpText: "グリッド間隔(Tailwind 単位)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "更新間隔", helpText: "自動更新(秒)" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 4a804896a8..3cb1ad259b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const zhCNMetadataForms: NonNullable = label: "间距", helpText: "栅格间距(Tailwind 单位)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "刷新间隔", helpText: "自动刷新间隔(秒)" }, diff --git a/packages/runtime/src/turso-driver-factory.convergence.test.ts b/packages/runtime/src/turso-driver-factory.convergence.test.ts index 6865ce1847..44834fc906 100644 --- a/packages/runtime/src/turso-driver-factory.convergence.test.ts +++ b/packages/runtime/src/turso-driver-factory.convergence.test.ts @@ -64,7 +64,9 @@ const FULL_SPEC: DatasourceConnectionSpec = { concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + // The AUTHORED key (#15680). The driver key it lands on is still `timeout` + // — asserted on the constructor argument below. + timeoutMs: 9000, mode: 'replica', }, }; diff --git a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts index b709fd160e..bdf742ae91 100644 --- a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts +++ b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts @@ -23,6 +23,7 @@ // authoring-door version lives in `turso-bound-secret-authoring.test.ts`. import { describe, it, expect } from 'vitest'; +import { getDriverConfigSchema } from '@objectstack/spec/data'; import { buildTursoDriverConfig, resolveTursoUrl, @@ -44,7 +45,7 @@ describe('buildTursoDriverConfig (#7314)', () => { concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + timeoutMs: 9000, mode: 'replica', }, }; @@ -75,7 +76,7 @@ describe('buildTursoDriverConfig (#7314)', () => { concurrency: 1, syncUrl: 'libsql://y', sync: {}, - timeout: 1, + timeoutMs: 1, mode: 'remote', }, }; @@ -92,12 +93,12 @@ describe('buildTursoDriverConfig (#7314)', () => { // Empty strings are unset, not credentials of length zero — the open-core // arm's own type-tests, carried over unchanged. The number keys deliberately - // have no truthiness check: `concurrency: 0` / `timeout: 0` are values the + // have no truthiness check: `concurrency: 0` / `timeoutMs: 0` are values the // driver reads. it('treats empty string credentials as unset and keeps zero-valued numbers', () => { const spec: DatasourceConnectionSpec = { driver: 'turso', - config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeout: 0 }, + config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeoutMs: 0 }, }; expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) .toEqual({ url: 'libsql://x', concurrency: 0, timeout: 0 }); @@ -204,6 +205,135 @@ describe('buildTursoDriverConfig (#7314)', () => { }); }); +// ── #16023 — the reader table reads the AUTHORED spelling, whatever it is now ── +// +// The defect this closes, and why the cases above could not have caught it. +// `TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a +// key rename through it: when `TursoConfig.timeout` became `timeoutMs` +// (#15680), the `timeout` reader kept consulting the spelling the authoring +// contract had just started REFUSING, and every case above stayed green — +// because every one of them authored the retired spelling too. A datasource +// authored the canonical way reached the seam and had its timeout dropped, with +// no diagnostic in any channel. +// +// "Grep by TYPE, not by name" — the discipline the rename stack used everywhere +// else — fails at an untyped seam BY CONSTRUCTION. So these cases are derived +// from the authoring contract rather than written against today's key list: +// they read `TursoConfigSchema`'s own tombstones and hold for the NEXT rename +// without being edited. A test that has to be remembered is the same defect one +// layer up. +describe('#16023 — every reader consults the spelling the authoring contract accepts', () => { + /** `retiredKey()` stamps this prefix on the description; that IS the marker. */ + const RETIRED_PREFIX = '[REMOVED] '; + + /** The turso authoring contract, through the published door the spec offers. */ + function tursoShape(): Record { success: boolean } }> { + const schema = getDriverConfigSchema('turso') as unknown as { shape?: unknown; def?: { shape?: unknown } }; + const raw = schema.shape ?? schema.def?.shape; + const shape = typeof raw === 'function' ? (raw as () => unknown)() : raw; + return shape as Record { success: boolean } }>; + } + + /** Retired authoring keys, and the canonical key each prescription names. */ + function retirements(): Array<{ retired: string; replacement: string | undefined }> { + return Object.entries(tursoShape()) + .filter(([, field]) => (field.description ?? '').startsWith(RETIRED_PREFIX)) + .map(([retired, field]) => ({ + retired, + replacement: /was renamed to `([A-Za-z0-9_]+)`/.exec(field.description ?? '')?.[1], + })); + } + + /** + * The JS types a reader can type-test for. Every probe below is run at all + * three rather than at one guessed type: a reader tests `typeof === 'number'` + * or `'string'`, so a probe of the wrong type passes vacuously. + */ + const CANDIDATES: readonly unknown[] = [424_242, 'SENTINEL-424242', true]; + + /** + * A value the LIVE field accepts, chosen by asking the field rather than by + * guessing its type. + * + * ⚠️ Only ever called on a canonical key. A retired key is `z.never()`, so + * NOTHING parses against it — asking a tombstone for a valid sample is a + * category error, and this throws rather than answering one. (It did, on the + * first run of this file: the guard is here because it fired.) + */ + function sampleFor(key: string): unknown { + const field = tursoShape()[key]; + for (const candidate of CANDIDATES) { + if (field?.safeParse(candidate).success) return candidate; + } + throw new Error( + `no sample value of a supported JS type parses against \`${key}\` — extend the candidate list ` + + 'rather than letting this case go vacuous', + ); + } + + const build = (config: Record) => { + const spec = { driver: 'turso', config } as const; + return buildTursoDriverConfig(spec, resolveTursoUrl(spec)); + }; + + // The vacuity guard. Every case below iterates the derived list, so an empty + // or mis-derived list would make all of them pass while testing nothing. + it('derives at least one retirement from the contract, including the known one', () => { + const retired = retirements().map((r) => r.retired); + expect(retired.length).toBeGreaterThan(0); + expect(retired).toContain('timeout'); + expect(retirements().find((r) => r.retired === 'timeout')?.replacement).toBe('timeoutMs'); + }); + + // ⭐ RED BEFORE THE FIX. The reproduction, stated as a property: a config + // authored the CANONICAL way must reach the driver. Before the fix this + // failed for `timeoutMs` — the built config was `{ url }` and the authored + // 424242 appeared nowhere. + it('reads every canonical replacement — an authored value reaches the driver config', () => { + for (const { retired, replacement } of retirements()) { + if (!replacement) continue; // a removal with no successor has nothing to read + const value = sampleFor(replacement); + const built = build({ url: 'libsql://x', [replacement]: value }); + expect( + Object.values(built), + `no reader consults the canonical \`${replacement}\` (retired: \`${retired}\`) — ` + + 'the rename moved the authoring contract and left this seam behind', + ).toContain(value); + } + }); + + // The other half, and the one that pins the DECISION rather than the fix: no + // reader keeps a tolerance arm for a retired spelling. Authoring is refused at + // the door and a stored row is canonicalized by the ADR-0087 chain before it + // arrives (`loadDatasourceRows` / `loadDatasourceRow`), so a `??` fallback + // here would be a consumer-side dialect for a spelling both doors closed — + // the sqlite `filename` and mongo `url` arms say exactly this. + it('reads NO retired spelling — a config authored the old way yields only `url`', () => { + for (const { retired } of retirements()) { + // Probed at every candidate type, not at the replacement's: a tolerance + // arm could have been written with any type-test, and the tombstone + // itself accepts nothing to sample from. + for (const value of CANDIDATES) { + const built = build({ url: 'libsql://x', [retired]: value }); + expect( + Object.keys(built), + `a reader still consults the retired \`${retired}\` (probed with ${typeof value})`, + ).toEqual(['url']); + } + } + }); + + // The named, readable instance of the property above — the case #16023 + // reported, spelled out so a reader of this file does not have to run the + // derivation in their head. + it('the reported instance: an authored `timeoutMs` lands on the driver `timeout`', () => { + expect(build({ url: 'libsql://x', timeoutMs: 9000 })) + .toEqual({ url: 'libsql://x', timeout: 9000 }); + // ...and the retired spelling is not a second way to say it. + expect(build({ url: 'libsql://x', timeout: 9000 })).toEqual({ url: 'libsql://x' }); + }); +}); + describe('resolveTursoUrl (#7314)', () => { it('trims, and reports a whitespace-only or absent url as none', () => { expect(resolveTursoUrl({ driver: 'turso', config: { url: ' libsql://x ' } })).toBe('libsql://x'); diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts index ecdd75d0d2..113d83dcd5 100644 --- a/packages/services/service-datasource/src/turso-driver-config.ts +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -71,7 +71,12 @@ export interface TursoDriverConfigInput { syncUrl?: string; /** Embedded-replica sync settings (requires `syncUrl`). */ sync?: { intervalSeconds?: number; onConnect?: boolean }; - /** Operation timeout in ms for remote operations. */ + /** + * Operation timeout in ms for remote operations. + * + * The DRIVER's key name. The datasource authors it as `config.timeoutMs` + * (#15680); this one keeps the bare spelling on purpose (#16024). + */ timeout?: number; /** Force a transport mode instead of detecting it from the url. */ mode?: 'local' | 'replica' | 'remote'; @@ -108,7 +113,7 @@ interface TursoConfigSource { * The type-tests are the open-core arm's — including the truthiness check on the * string keys (an empty `authToken` / `syncUrl` / `encryptionKey` is an unset one, * never a credential of length zero) and its absence on the number keys - * (`concurrency: 0` and `timeout: 0` are meaningful values the driver reads). + * (`concurrency: 0` and `timeoutMs: 0` are meaningful values the driver reads). * * A reader is not obliged to read only `config`: `schemaMode` and `authToken` * both consult the spec itself. `authToken`'s reason is a credential route, and @@ -167,7 +172,36 @@ const TURSO_CONFIG_READERS: { config.sync && typeof config.sync === 'object' ? (config.sync as TursoDriverConfigInput['sync']) : undefined, - timeout: ({ config }) => (typeof config.timeout === 'number' ? config.timeout : undefined), + /** + * The AUTHORED key is `timeoutMs`; the DRIVER key is `timeout`. + * + * This is the one reader whose two spellings differ, and the split is + * deliberate on both sides. `TursoConfig.timeout` was renamed to `timeoutMs` + * (#15680, ruling B on #14478) because the unit of a duration-shaped number + * belongs in the key name; `TursoDriverConfig.timeout` was NOT renamed with + * it, because renaming a published-but-inert driver key would ratify it as + * real (#16024) — which is the outcome ADR-0049 exists to prevent. + * + * ⚠️ NO fallback arm for the retired `config.timeout`, and that is the + * precedent rather than a new rule. Both sibling arms in + * `default-datasource-driver-factory.ts` say it in the same words: sqlite's + * "`filename` is the whole contract … so no `??` tolerance survives here", + * mongo's "`url` is the one spelling". Each renamed key reaches a reader + * ALREADY canonical, from two directions — authoring refuses the retired + * spelling at the door (`retiredKey()`, tsc `never` + a parse-time + * prescription), and a stored `sys_metadata` row replays the full ADR-0087 + * chain, retired entries included, at `loadDatasourceRows` / + * `loadDatasourceRow` in `datasource-admin-plugin.ts` — so the D2 conversion + * `turso-config-timeout-to-timeout-ms` has already rewritten the key before + * this table ever sees it. A `??` arm here would be a consumer-side dialect + * (Prime Directive #12) reintroducing the spelling both doors just closed. + * + * `authToken`'s legacy arm above is NOT a counter-precedent: it is kept for a + * LIVE route (host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it + * constructs itself, which never meets the authoring schema), not for a + * retired spelling. + */ + timeout: ({ config }) => (typeof config.timeoutMs === 'number' ? config.timeoutMs : undefined), mode: ({ config }) => typeof config.mode === 'string' ? (config.mode as TursoDriverConfigInput['mode']) : undefined, schemaMode: ({ spec }) => resolveDatasourceSchemaMode(spec), diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 21ccd54454..7897628cc2 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -45,7 +45,7 @@ "data/Field:required = false", "data/Field:searchable = false", "data/Field:sortable = true", - "data/FilePersistenceConfig:autoSaveInterval = 2000", + "data/FilePersistenceConfig:autoSaveIntervalMs = 2000", "data/FormatValidation:active = true", "data/FormatValidation:events = [\"insert\",\"update\"]", "data/FormatValidation:priority = 100", diff --git a/packages/spec/authorable-defaults/integration.json b/packages/spec/authorable-defaults/integration.json index 40ab04a917..2c74177ff7 100644 --- a/packages/spec/authorable-defaults/integration.json +++ b/packages/spec/authorable-defaults/integration.json @@ -4,7 +4,7 @@ "defaults": [ "integration/CircuitBreakerConfig:failureThreshold = 5", "integration/CircuitBreakerConfig:halfOpenMaxRequests = 1", - "integration/CircuitBreakerConfig:monitoringWindow = 60000", + "integration/CircuitBreakerConfig:monitoringWindowMs = 60000", "integration/CircuitBreakerConfig:resetTimeoutMs = 30000", "integration/Connector:authentication = {\"type\":\"none\"}", "integration/Connector:connectionTimeoutMs = 30000", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index 323933ab95..aad48547be 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -89,7 +89,8 @@ "ai/CodeContent:type", "ai/ConversationAnalytics:assistantMessages", "ai/ConversationAnalytics:averageTokensPerMessage", - "ai/ConversationAnalytics:duration", + "ai/ConversationAnalytics:duration [RETIRED]", + "ai/ConversationAnalytics:durationSeconds", "ai/ConversationAnalytics:firstMessageAt", "ai/ConversationAnalytics:lastMessageAt", "ai/ConversationAnalytics:peakTokenUsage", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 6a2c4222ea..4efcf67767 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -35,7 +35,8 @@ "data/AnalyticsQuery:timeDimensions", "data/AnalyticsQuery:timezone", "data/AnalyticsQuery:where", - "data/AutoPersistenceConfig:autoSaveInterval", + "data/AutoPersistenceConfig:autoSaveInterval [RETIRED]", + "data/AutoPersistenceConfig:autoSaveIntervalMs", "data/AutoPersistenceConfig:key", "data/AutoPersistenceConfig:path", "data/AutoPersistenceConfig:type", @@ -416,7 +417,8 @@ "data/FieldMaskingKeep:keepTail", "data/FieldReference:$field", "data/FieldReference:addDays", - "data/FilePersistenceConfig:autoSaveInterval", + "data/FilePersistenceConfig:autoSaveInterval [RETIRED]", + "data/FilePersistenceConfig:autoSaveIntervalMs", "data/FilePersistenceConfig:path", "data/FilePersistenceConfig:type", "data/FileValue:alt", @@ -609,7 +611,8 @@ "data/NoSQLQueryOptions:profile", "data/NoSQLQueryOptions:projection", "data/NoSQLQueryOptions:readFromSecondary", - "data/NoSQLQueryOptions:timeout", + "data/NoSQLQueryOptions:timeout [RETIRED]", + "data/NoSQLQueryOptions:timeoutMs", "data/NoSQLQueryOptions:useCursor", "data/NoSQLTransactionOptions:maxCommitTimeMS", "data/NoSQLTransactionOptions:readConcern", @@ -888,7 +891,8 @@ "data/TursoConfig:mode", "data/TursoConfig:sync", "data/TursoConfig:syncUrl", - "data/TursoConfig:timeout", + "data/TursoConfig:timeout [RETIRED]", + "data/TursoConfig:timeoutMs", "data/TursoConfig:url" ] } diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 7a9cbfe364..04e2dcceb3 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -6,7 +6,8 @@ "integration/CircuitBreakerConfig:failureThreshold", "integration/CircuitBreakerConfig:fallbackStrategy", "integration/CircuitBreakerConfig:halfOpenMaxRequests", - "integration/CircuitBreakerConfig:monitoringWindow", + "integration/CircuitBreakerConfig:monitoringWindow [RETIRED]", + "integration/CircuitBreakerConfig:monitoringWindowMs", "integration/CircuitBreakerConfig:resetTimeoutMs", "integration/Connector:_lock", "integration/Connector:_lockDocsUrl", @@ -64,7 +65,8 @@ "integration/ConnectorInstanceBearerAuth:type", "integration/ConnectorInstanceNoAuth:type", "integration/ConnectorTrigger:description", - "integration/ConnectorTrigger:interval", + "integration/ConnectorTrigger:interval [RETIRED]", + "integration/ConnectorTrigger:intervalSeconds", "integration/ConnectorTrigger:key", "integration/ConnectorTrigger:label", "integration/ConnectorTrigger:type", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7b90554c15..c17200d3b5 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -256,7 +256,8 @@ "ui/Dashboard:name", "ui/Dashboard:performance [RETIRED]", "ui/Dashboard:protection", - "ui/Dashboard:refreshInterval", + "ui/Dashboard:refreshInterval [RETIRED]", + "ui/Dashboard:refreshIntervalSeconds", "ui/Dashboard:widgets", "ui/DashboardHeader:actions", "ui/DashboardHeader:showDescription", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index e0cedc8009..af8580e934 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -160,9 +160,14 @@ "status": "live", "note": "objectui: DashboardRenderer.tsx:262 — grid gap." }, - "refreshInterval": { + "refreshIntervalSeconds": { "status": "live", - "note": "objectui: DashboardRenderer.tsx:385-386 — sets a setInterval(onRefresh) every N seconds. Caveat: only fires when the host passes an `onRefresh` handler; the renderer is the consumer." + "note": "objectui: DashboardRenderer.tsx:385-386 — sets a setInterval(onRefresh) every N seconds. Caveat: only fires when the host passes an `onRefresh` handler; the renderer is the consumer. RENAMED 2026-09-05 (#15680, #14478 ruling B) from `refreshInterval`: the unit lived only in the describe prose. The consumer is in ANOTHER REPOSITORY, so unlike every other rename in this stack its reader could not move in the same PR — objectui at pin a472b07167a3 reads `schema.refreshInterval` at packages/plugin-dashboard/src/DashboardRenderer.tsx:448-453 and DashboardGridLayout.tsx:146-151, multiplies by 1000, and publishes the input name at packages/plugin-dashboard/src/index.tsx:94 (which is what sdui.manifest.json:805 in this repo records). Those sites move in a follow-up objectui card sequenced BEHIND a release that ships this rename; until then the renderer sees an absent key and simply does not start its timer." + }, + "refreshInterval": { + "status": "dead", + "verifiedAt": "2026-09-05", + "note": "REMOVED 2026-09-05 (#15680, #14478 ruling B) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of stored sources by the protocol-18 conversion `dashboard-refresh-interval-to-refresh-interval-seconds`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `refreshIntervalSeconds` — rename the key, the value (seconds) is unchanged, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/ui/dashboard.zod.ts#refreshInterval. The three rename-hint aliases `refresh` / `autoRefresh` / `pollInterval` were repointed to the new spelling in the same edit." }, "globalFilters": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 207b7becff..41cec46c46 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -41,7 +41,7 @@ for both corollaries. | `page` | 23 | 0 | 0 | 0 | 1 | 24 | | `view` | 79 | 0 | 0 | 9 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | -| `dashboard` | 34 | 0 | 0 | 7 | 0 | 41 | +| `dashboard` | 34 | 0 | 0 | 8 | 0 | 42 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | | `query` | 16 | 0 | 0 | 5 | 0 | 21 | | `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **845** | **5** | **1** | **93** | **12** | **956** | +| **total** | **845** | **5** | **1** | **94** | **12** | **957** | diff --git a/packages/spec/src/ai/conversation.test.ts b/packages/spec/src/ai/conversation.test.ts index e39bc7d5ba..67a38a4ee0 100644 --- a/packages/spec/src/ai/conversation.test.ts +++ b/packages/spec/src/ai/conversation.test.ts @@ -427,7 +427,7 @@ describe('ConversationAnalyticsSchema', () => { summarizationEvents: 1, tokensSavedByPruning: 500, tokensSavedBySummarization: 2000, - duration: 1800, + durationSeconds: 1800, firstMessageAt: '2024-01-15T10:00:00Z', lastMessageAt: '2024-01-15T10:30:00Z', }; @@ -582,3 +582,45 @@ describe('Real-World Conversation Examples', () => { expect(() => ConversationSessionSchema.parse(session)).not.toThrow(); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — a bare throw assertion passes just as happily +// on the unrecognized-key error the rename is meant to replace. +// `ConversationAnalytics` is runtime-emitted, so the silent-strip alternative +// is the real hazard: this shape is not strict, and a producer still writing +// `duration` would have lost the one measurement on the row with no error at all. +describe('ConversationAnalytics.duration carries its unit (#15680)', () => { + const base = { + sessionId: 'session-1', + totalMessages: 10, + userMessages: 5, + assistantMessages: 5, + systemMessages: 0, + totalTokens: 1000, + averageTokensPerMessage: 100, + peakTokenUsage: 1000, + }; + + it('REFUSES the retired `duration` with the rename in the message', () => { + const result = ConversationAnalyticsSchema.safeParse({ ...base, duration: 1800 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`ConversationAnalytics.duration` was renamed to `durationSeconds`'); + }); + + it('accepts `durationSeconds` at the same magnitude and still refuses a negative one', () => { + expect(ConversationAnalyticsSchema.parse({ ...base, durationSeconds: 1800 }).durationSeconds).toBe(1800); + expect(ConversationAnalyticsSchema.safeParse({ ...base, durationSeconds: -1 }).success).toBe(false); + }); + + it('leaves the twelve sibling COUNTS alone — a count has no unit to carry', () => { + const parsed = ConversationAnalyticsSchema.parse({ ...base, pruningEvents: 3, tokensSavedByPruning: 500 }); + expect(parsed.totalMessages).toBe(10); + expect(parsed.totalTokens).toBe(1000); + expect(parsed.pruningEvents).toBe(3); + expect(parsed.tokensSavedByPruning).toBe(500); + }); +}); diff --git a/packages/spec/src/ai/conversation.zod.ts b/packages/spec/src/ai/conversation.zod.ts index e3c3bf2520..ec02509479 100644 --- a/packages/spec/src/ai/conversation.zod.ts +++ b/packages/spec/src/ai/conversation.zod.ts @@ -14,6 +14,7 @@ import { TokenUsageSchema } from './usage.zod'; * Message Role */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MessageRoleSchema = lazySchema(() => z.enum([ 'system', 'user', @@ -305,7 +306,19 @@ export const ConversationAnalyticsSchema = lazySchema(() => z.object({ tokensSavedBySummarization: z.number().int().nonnegative().default(0), /** Duration */ - duration: z.number().nonnegative().optional().describe('Session duration in seconds'), + // Renamed from `duration` (#15680, ruling B on #14478): the unit lived only in + // the describe prose, on a shape whose two neighbouring instants already spell + // themselves `firstMessageAt` / `lastMessageAt`. Every other number on this + // shape is a COUNT (messages, tokens, events), so the one measurement carrying + // a unit was the one key that named none. + durationSeconds: z.number().nonnegative().optional().describe('Session duration in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + duration: retiredKey( + '`ConversationAnalytics.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged.', + ), firstMessageAt: z.string().datetime().optional().describe('ISO 8601 timestamp'), lastMessageAt: z.string().datetime().optional().describe('ISO 8601 timestamp'), })); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 5dad0b918c..07e13ffe12 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8683,6 +8683,317 @@ const apiEndpointCacheTtlToCacheTtlSeconds: MetadataConversion = { }, }; +/** + * `dashboards[].refreshInterval` → `refreshIntervalSeconds` (protocol 18, + * #15680 for #14478) — the `ui` half of the same rename + * {@link hookTimeoutToTimeoutMs} documents, and the one key in this whole stack + * whose CONSUMER lives in another repository. + * + * Three rename-hint aliases already pointed at the old spelling — `refresh`, + * `autoRefresh`, `pollInterval` — which is the measure of how many spellings + * authors reach for; none of them named a unit either, so every door into the + * key left the cadence ambiguous. All three were repointed at the schema in the + * same edit, so an author arriving through any of them is now prescribed the + * unit-carrying name. + * + * ⚠️ objectui's dashboard renderer reads this key and multiplies by 1000, and + * republishes it as a registry input the console offers to authors. That reader + * could not move in this PR, so unlike every other rename in this card the + * consumer lags by a release: this conversion is what keeps stored dashboards + * and `os migrate meta` correct in the meantime. + * + * Same posture as its siblings: retired from the load path, tombstoned at the + * schema, replayable here. + */ +const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = { + id: 'dashboard-refresh-interval-to-refresh-interval-seconds', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'dashboard.refreshInterval', + summary: "dashboard key 'refreshInterval' → 'refreshIntervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'dashboards', (dashboard, path) => { + const renamed = renameKey(dashboard, 'refreshInterval', 'refreshIntervalSeconds'); + if (!renamed) return dashboard; + emit({ from: 'refreshInterval', to: 'refreshIntervalSeconds', path: `${path}.refreshIntervalSeconds` }); + return renamed; + }); + }, + fixture: { + before: { + dashboards: [ + { name: 'sales_overview', label: 'Sales Overview', widgets: [], refreshInterval: 300 }, + // A dashboard that never authored the key keeps its identity (copy-on-write). + { name: 'ops_overview', label: 'Ops Overview', widgets: [] }, + ], + }, + after: { + dashboards: [ + { name: 'sales_overview', label: 'Sales Overview', widgets: [], refreshIntervalSeconds: 300 }, + { name: 'ops_overview', label: 'Ops Overview', widgets: [] }, + ], + }, + expectedNotices: 1, + }, +}; + +/** + * The two connector duration keys whose name carried no unit → suffixed + * (protocol 18, #15680 for #14478): `health.circuitBreaker.monitoringWindow` → + * `monitoringWindowMs`, and `triggers[].interval` → `intervalSeconds`. + * + * One entry because they are one authored document and one authoring session — + * a connector and the resilience block that guards it. The circuit-breaker case + * is the sharpest in this card: `monitoringWindow` (ms) sat ONE key below + * `resetTimeoutMs`, which already spelled its unit, so a single six-key shape + * carried both conventions and a reader had no rule to apply, only two examples + * that disagreed. The trigger case is the widest: the bare token `interval` + * means MILLISECONDS elsewhere in this same spec, so the identical spelling + * carried two units a thousandfold apart. + * + * A published connector row lands whole in `sys_metadata` (`ConnectorSchema`'s + * own docblock says so, which is why #7990 forbids inline secrets on it), so + * the chain has a seam that sees both keys — hence a conversion rather than the + * semantic entries this card's two runtime-emitted keys took. + * + * The two are walked in one pass but emit SEPARATELY: a connector may author + * either, both, or neither, and an operator reading the notice list needs to see + * which of its own keys moved. Retired from the load path, tombstoned at the + * schema, replayable here. + */ +const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { + id: 'connector-health-and-trigger-durations-unit-in-key', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'connector.health.circuitBreaker.monitoringWindow, connector.triggers[].interval', + summary: "connector keys 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs' and 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; both values are unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'connectors', (connector, path) => { + let next = connector; + + const health = next.health; + if (isDict(health)) { + const breaker = health.circuitBreaker; + if (isDict(breaker)) { + const renamedBreaker = renameKey(breaker, 'monitoringWindow', 'monitoringWindowMs'); + if (renamedBreaker) { + emit({ + from: 'monitoringWindow', + to: 'monitoringWindowMs', + path: `${path}.health.circuitBreaker.monitoringWindowMs`, + }); + next = { ...next, health: { ...health, circuitBreaker: renamedBreaker } }; + } + } + } + + const triggers = next.triggers; + if (Array.isArray(triggers)) { + let triggersChanged = false; + const nextTriggers = triggers.map((trigger, i) => { + if (!isDict(trigger)) return trigger; + const renamed = renameKey(trigger, 'interval', 'intervalSeconds'); + if (!renamed) return trigger; + emit({ + from: 'interval', + to: 'intervalSeconds', + path: `${path}.triggers[${i}].intervalSeconds`, + }); + triggersChanged = true; + return renamed; + }); + if (triggersChanged) next = { ...next, triggers: nextTriggers }; + } + + return next; + }); + }, + fixture: { + before: { + connectors: [ + { + name: 'billing_api', + label: 'Billing API', + type: 'rest', + health: { + circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindow: 120000 }, + }, + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }, + // A webhook trigger authors no interval and keeps its identity. + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + // A connector that authored neither key keeps its identity (copy-on-write). + { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, + ], + }, + after: { + connectors: [ + { + name: 'billing_api', + label: 'Billing API', + type: 'rest', + health: { + circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindowMs: 120000 }, + }, + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `datasources[].config.persistence.autoSaveInterval` → `autoSaveIntervalMs` + * for the memory driver (protocol 18, #15680 for #14478). + * + * BOTH arms of the persistence union move, and that is the load-bearing detail. + * The gate listed only the `file` arm, because the `auto` arm's describe named + * no unit at all and the predicate judges prose against name. But `auto` + * resolves to the same Node.js file adapter and forwards the same value to the + * same `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + * same `min(100)` bound — so converting one arm and not the other would leave + * ONE value with TWO spellings across sibling arms of one union, and the driver + * reading both. That is the consumer-side dialect Prime Directive #12 forbids. + * + * Driver-awareness is load-bearing here for the reason + * {@link datasourceConfigDriverKeyAliases} states: `persistence` is a memory + * driver key, and a `persistence` block under some other driver is not this + * shape. `resolveDriverId` keeps the rewrite where it belongs. + * + * A string `persistence` (`'file'` / `'local'` / `'auto'`) and a custom-adapter + * block carry no interval and pass through untouched. + */ +const memoryPersistenceAutoSaveIntervalToMs: MetadataConversion = { + id: 'memory-persistence-auto-save-interval-to-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'datasource.config.persistence.autoSaveInterval', + summary: "memory datasource key 'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both the file and auto arms (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'memory') return ds; + const config = ds.config; + if (!isDict(config)) return ds; + const persistence = config.persistence; + if (!isDict(persistence)) return ds; + const renamed = renameKey(persistence, 'autoSaveInterval', 'autoSaveIntervalMs'); + if (!renamed) return ds; + emit({ + from: 'autoSaveInterval', + to: 'autoSaveIntervalMs', + path: `${path}.config.persistence.autoSaveIntervalMs`, + }); + return { ...ds, config: { ...config, persistence: renamed } }; + }); + }, + fixture: { + before: { + datasources: [ + { + name: 'local_cache', + driver: 'memory', + config: { persistence: { type: 'file', path: '/var/data/db.json', autoSaveInterval: 5000 } }, + }, + { + name: 'auto_cache', + driver: 'memory', + config: { persistence: { type: 'auto', autoSaveInterval: 5000 } }, + }, + // A memory datasource with string persistence carries no interval. + { name: 'scratch', driver: 'memory', config: { persistence: 'file' } }, + // Another driver's config is not this shape and is never touched. + { name: 'primary', driver: 'postgres', config: { url: 'postgres://db/app' } }, + ], + }, + after: { + datasources: [ + { + name: 'local_cache', + driver: 'memory', + config: { persistence: { type: 'file', path: '/var/data/db.json', autoSaveIntervalMs: 5000 } }, + }, + { + name: 'auto_cache', + driver: 'memory', + config: { persistence: { type: 'auto', autoSaveIntervalMs: 5000 } }, + }, + { name: 'scratch', driver: 'memory', config: { persistence: 'file' } }, + { name: 'primary', driver: 'postgres', config: { url: 'postgres://db/app' } }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `datasources[].config.timeout` → `timeoutMs` for the turso driver (protocol + * 18, #15680 for #14478). + * + * Driver-awareness is load-bearing exactly as it is for + * {@link datasourceConfigDriverKeyAliases}: a bare `config.timeout` under some + * OTHER driver is that driver's own key and must not be touched, so the rewrite + * is gated on `resolveDriverId(ds.driver) === 'turso'` (which also catches a + * stored `driver: 'libsql'` through the alias table). + * + * The neighbour is why this key was worth the rename: `sync.intervalSeconds`, + * two keys above, already spelled ITS unit. One shape, both conventions. + * + * ⚠️ This converts the SPEC's turso contract. The driver package ships its own + * parallel `turso.zod.ts` whose `timeout` is outside this card's declared + * population; it is renamed by the card that widens that population, and until + * then the two declarations disagree by design. + */ +const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { + id: 'turso-config-timeout-to-timeout-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'datasource.config.timeout (turso)', + summary: "turso datasource key 'config.timeout' → 'config.timeoutMs' (#14478 — the unit lived only in the description and a .meta() title no parse reads; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'turso') return ds; + const renamed = renameConfigKey(ds, 'timeout', 'timeoutMs'); + if (!renamed) return ds; + emit({ from: 'timeout', to: 'timeoutMs', path: `${path}.config.timeoutMs` }); + return renamed; + }); + }, + fixture: { + before: { + datasources: [ + { + name: 'edge_db', + driver: 'turso', + config: { url: 'libsql://app.turso.io', timeout: 30000 }, + }, + // A turso datasource that never authored the key keeps its identity. + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + // `timeout` under another driver is that driver's own key — untouched. + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', timeout: 1000 } }, + ], + }, + after: { + datasources: [ + { + name: 'edge_db', + driver: 'turso', + config: { url: 'libsql://app.turso.io', timeoutMs: 30000 }, + }, + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', timeout: 1000 } }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8775,6 +9086,10 @@ export const CONVERSIONS_BY_MAJOR: Readonly { + const tursoShape: any = (getDriverConfigSchema('turso') as any).shape; + const shape = typeof tursoShape === 'function' ? tursoShape() : tursoShape; + // The tombstone really is in the shape and really is a `z.never()` — without + // this leg the assertion below would pass just as well on a key that had + // been deleted outright, which is the silent strip the tombstone prevents. + expect(Object.keys(shape)).toContain('timeout'); + expect(String(shape.timeout.description)).toMatch(/^\[REMOVED\] /); + expect(refusedCredentialKeys('turso')).not.toContain('timeout'); + expect(redactableConfigKeys('turso')).not.toContain('timeout'); + // The credential beside it is untouched — the narrowing must not cost a + // single real refusal, which is the only direction that could leak. + expect(refusedCredentialKeys('turso')).toContain('authToken'); + }); + + it('the exclusion is negative, so an UNMARKED z.never() is still a credential', () => { + // Fail-safe direction. A future credential slot whose author forgets + // `refusedInlineCredentialKey`'s marker must still be scrubbed; only a key + // that has explicitly declared itself retired may drop out. Constructed, + // because no builtin driver ships a bare `z.never()` today. + const unmarked = z.object({ apiSecret: z.never().optional(), url: z.string() }); + expect(refusedCredentialPathsOfSchema(unmarked)).toEqual([['apiSecret']]); + + const tombstoned = z.object({ + legacyKey: z.never().optional().describe('[REMOVED] `legacyKey` was renamed to `legacyKeyMs`.'), + url: z.string(), + }); + expect(refusedCredentialPathsOfSchema(tombstoned)).toEqual([]); + }); + it('every z.never() key across every builtin driver is covered by the unknown-driver fallback', () => { // Guards the one hand-written canonical list: if a driver refuses a NEW // credential key, the fallback used for contract-less drivers must learn diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index 553deaf554..0bbfc19db0 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -28,6 +28,24 @@ * refusal list rather than re-typing it. A driver that refuses a new * credential key tomorrow is covered here the day it lands, which a * hand-maintained list in a consumer package would not be. + * + * ⚠️ **One exception, and it is declared on the schema:** a `retiredKey()` + * RETIREMENT TOMBSTONE is also a `z.never()`, and it is not a credential + * — it is a key that used to exist under another name. Until #15680 no + * driver contract carried one, so "never ⇒ credential" held by accident of + * population rather than by construction; the duration renames of #14478 + * put the first one in (`TursoConfig.timeout` → `timeoutMs`) and the + * derivation answered that a millisecond budget was a secret. Tombstones + * are excluded by the `[REMOVED] ` prefix `retiredKey()` itself stamps on + * the description — the producer's own marker, the same one the reference + * pages and the authorable-surface ratchet already read. + * + * The exclusion is deliberately NEGATIVE (skip declared tombstones) rather + * than POSITIVE (keep only keys marked `format: 'password'`), even though + * every credential slot in every builtin contract does carry that marker + * today. Under-redacting is the dangerous direction: a future credential + * key whose author forgets the marker must still be scrubbed, and only a + * key that has explicitly declared itself retired may drop out. * 2. **Former alias spellings** ({@link FORMER_CREDENTIAL_ALIASES}) — `passwd` * / `pwd` / `token` / `jwt` / `auth_token` / `authtoken` used to be * `aliases` that the parse RENAMED onto the canonical key; #8078 moved them @@ -247,6 +265,25 @@ function baseTypeOf(schema: unknown): string | undefined { return def?.type; } +/** + * Is this shape member a `retiredKey()` tombstone rather than a refused + * credential slot? Both are `z.never()`; only the tombstone carries the + * `[REMOVED] ` prescription prefix `retiredKey()` stamps on the description. + * + * Read from the member AND from its unwrapped base node: `retiredKey()` calls + * `.describe()` last, so the prefix sits on the outer `.optional()` clone, + * while a caller that re-wraps a tombstone could leave it further in. + */ +function isRetirementTombstone(member: unknown, node: unknown): boolean { + const described = (m: unknown): string | undefined => { + const d = (m as any)?.description; + if (typeof d === 'string') return d; + const meta = (m as any)?.meta?.(); + return typeof meta?.description === 'string' ? meta.description : undefined; + }; + return (described(member) ?? described(node) ?? '').startsWith('[REMOVED] '); +} + /** The `shape` record of an object-typed schema node, or `undefined`. */ function shapeOf(schema: unknown): Record | undefined { const raw = (schema as any)?.shape; @@ -279,7 +316,8 @@ export function refusedCredentialPathsOfSchema(schema: unknown): (readonly strin const def = node?.def ?? node?._def; const type: string | undefined = def?.type; if (type === 'never') { - out.push([...prefix, key]); + // A retirement tombstone is a `z.never()` that is not a credential. + if (!isRetirementTombstone(member, node)) out.push([...prefix, key]); continue; } if (type === 'object') { @@ -323,7 +361,8 @@ export function refusedCredentialKeys(driver: unknown): string[] { } if (!shape) return []; return Object.entries(shape) - .filter(([, member]) => baseTypeOf(member) === 'never') + .filter(([, member]) => baseTypeOf(member) === 'never' + && !isRetirementTombstone(member, baseNodeOf(member))) .map(([key]) => key); } diff --git a/packages/spec/src/data/driver-nosql.test.ts b/packages/spec/src/data/driver-nosql.test.ts index 0113ced4ae..e71eb985bf 100644 --- a/packages/spec/src/data/driver-nosql.test.ts +++ b/packages/spec/src/data/driver-nosql.test.ts @@ -224,7 +224,7 @@ describe('NoSQL Driver Protocol', () => { consistency: 'quorum' as const, readFromSecondary: true, projection: { name: 1, email: 1, _id: 0 }, - timeout: 5000, + timeoutMs: 5000, useCursor: true, batchSize: 100, profile: true, @@ -232,7 +232,7 @@ describe('NoSQL Driver Protocol', () => { }; const result = NoSQLQueryOptionsSchema.parse(options); - expect(result.timeout).toBe(5000); + expect(result.timeoutMs).toBe(5000); expect(result.batchSize).toBe(100); expect(result.hint).toBe('name_1_email_1'); }); @@ -266,7 +266,7 @@ describe('NoSQL Driver Protocol', () => { }, ], options: { - timeout: 10000, + timeoutMs: 10000, }, }; @@ -366,3 +366,28 @@ describe('NoSQL Driver Protocol', () => { }); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The shape is not strict, so without the +// tombstone a query authored with `timeout` would have run with NO deadline at +// all — the failure a driver timeout exists to prevent — and reported nothing. +describe('NoSQLQueryOptions.timeout carries its unit (#15680)', () => { + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = NoSQLQueryOptionsSchema.safeParse({ timeout: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`NoSQLQueryOptions.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts `timeoutMs` at the same magnitude and still refuses a non-positive one', () => { + expect(NoSQLQueryOptionsSchema.parse({ timeoutMs: 5000 }).timeoutMs).toBe(5000); + expect(NoSQLQueryOptionsSchema.safeParse({ timeoutMs: 0 }).success).toBe(false); + }); + + it('leaves the neighbouring `batchSize` alone — it is a COUNT of documents, not a duration', () => { + expect(NoSQLQueryOptionsSchema.parse({ batchSize: 100 }).batchSize).toBe(100); + }); +}); diff --git a/packages/spec/src/data/driver-nosql.zod.ts b/packages/spec/src/data/driver-nosql.zod.ts index b5d81c7254..b95b445ac7 100644 --- a/packages/spec/src/data/driver-nosql.zod.ts +++ b/packages/spec/src/data/driver-nosql.zod.ts @@ -8,6 +8,7 @@ import { DriverConfigSchema } from './driver.zod'; * Supported NoSQL database types */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const NoSQLDatabaseTypeSchema = lazySchema(() => z.enum([ 'mongodb', 'couchdb', @@ -306,8 +307,20 @@ export const NoSQLQueryOptionsSchema = lazySchema(() => z.object({ /** * Query timeout in milliseconds + * + * Renamed from `timeout` (#15680, ruling B on #14478): the unit lived only in + * the describe prose. It sits beside `batchSize`, a plain row COUNT, with + * nothing at the call site to tell a reader which of the two numbers carries + * a unit. */ - timeout: z.number().int().positive().optional().describe('Query timeout (ms)'), + timeoutMs: z.number().int().positive().optional().describe('Query timeout (ms)'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + timeout: retiredKey( + '`NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), /** * Use cursor for large result sets diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index ca80929848..f1c889fe94 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -96,18 +96,18 @@ describe('MemoryConfigSchema', () => { persistence: { type: 'file', path: '/tmp/data.json', - autoSaveInterval: 10000, + autoSaveIntervalMs: 10000, }, }); expect(config.persistence).toBeDefined(); - const p = config.persistence as { type: 'file'; path?: string; autoSaveInterval: number }; + const p = config.persistence as { type: 'file'; path?: string; autoSaveIntervalMs: number }; expect(p.type).toBe('file'); expect(p.path).toBe('/tmp/data.json'); - expect(p.autoSaveInterval).toBe(10000); + expect(p.autoSaveIntervalMs).toBe(10000); }); - it('should apply file persistence autoSaveInterval default', () => { + it('should apply file persistence autoSaveIntervalMs default', () => { const config = MemoryConfigSchema.parse({ persistence: { type: 'file', @@ -115,8 +115,8 @@ describe('MemoryConfigSchema', () => { }, }); - const p = config.persistence as { type: 'file'; autoSaveInterval: number }; - expect(p.autoSaveInterval).toBe(2000); + const p = config.persistence as { type: 'file'; autoSaveIntervalMs: number }; + expect(p.autoSaveIntervalMs).toBe(2000); }); it('should accept persistence with local object config', () => { @@ -138,15 +138,15 @@ describe('MemoryConfigSchema', () => { type: 'auto', path: '/var/data/memory.json', key: 'myapp:db', - autoSaveInterval: 5000, + autoSaveIntervalMs: 5000, }, }); - const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveInterval?: number }; + const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveIntervalMs?: number }; expect(p.type).toBe('auto'); expect(p.path).toBe('/var/data/memory.json'); expect(p.key).toBe('myapp:db'); - expect(p.autoSaveInterval).toBe(5000); + expect(p.autoSaveIntervalMs).toBe(5000); }); it('should accept auto persistence without overrides', () => { @@ -208,7 +208,7 @@ describe('MemoryConfigSchema', () => { persistence: { type: 'file', path: '/var/data/memory.json', - autoSaveInterval: 3000, + autoSaveIntervalMs: 3000, }, }); @@ -218,12 +218,12 @@ describe('MemoryConfigSchema', () => { expect(p.path).toBe('/var/data/memory.json'); }); - it('should reject file persistence with invalid autoSaveInterval', () => { + it('should reject file persistence with invalid autoSaveIntervalMs', () => { expect(() => MemoryConfigSchema.parse({ persistence: { type: 'file', path: '/tmp/data.json', - autoSaveInterval: 50, // Below minimum of 100 + autoSaveIntervalMs: 50, // Below minimum of 100 }, })).toThrow(); }); @@ -270,21 +270,21 @@ describe('FilePersistenceConfigSchema', () => { const config = FilePersistenceConfigSchema.parse({ type: 'file', path: '/data/store.json', - autoSaveInterval: 10000, + autoSaveIntervalMs: 10000, }); expect(config.type).toBe('file'); expect(config.path).toBe('/data/store.json'); - expect(config.autoSaveInterval).toBe(10000); + expect(config.autoSaveIntervalMs).toBe(10000); }); - it('should apply default autoSaveInterval', () => { + it('should apply default autoSaveIntervalMs', () => { const config = FilePersistenceConfigSchema.parse({ type: 'file', path: '/data/store.json', }); - expect(config.autoSaveInterval).toBe(2000); + expect(config.autoSaveIntervalMs).toBe(2000); }); it('should accept without path (uses default)', () => { @@ -426,7 +426,7 @@ describe('AutoPersistenceConfigSchema', () => { expect(config.type).toBe('auto'); expect(config.path).toBeUndefined(); expect(config.key).toBeUndefined(); - expect(config.autoSaveInterval).toBeUndefined(); + expect(config.autoSaveIntervalMs).toBeUndefined(); }); it('should accept auto config with all overrides', () => { @@ -434,19 +434,19 @@ describe('AutoPersistenceConfigSchema', () => { type: 'auto', path: '/data/store.json', key: 'myapp:db', - autoSaveInterval: 5000, + autoSaveIntervalMs: 5000, }); expect(config.type).toBe('auto'); expect(config.path).toBe('/data/store.json'); expect(config.key).toBe('myapp:db'); - expect(config.autoSaveInterval).toBe(5000); + expect(config.autoSaveIntervalMs).toBe(5000); }); - it('should reject auto config with invalid autoSaveInterval', () => { + it('should reject auto config with invalid autoSaveIntervalMs', () => { expect(() => AutoPersistenceConfigSchema.parse({ type: 'auto', - autoSaveInterval: 50, // Below minimum of 100 + autoSaveIntervalMs: 50, // Below minimum of 100 })).toThrow(); }); @@ -454,7 +454,7 @@ describe('AutoPersistenceConfigSchema', () => { it('rejects an unrecognised key instead of silently stripping it', () => { const result = AutoPersistenceConfigSchema.safeParse({ type: 'auto', - interval: 5000, // meant `autoSaveInterval`, not a real field + interval: 5000, // meant `autoSaveIntervalMs`, not a real field }); expect(result.success).toBe(false); @@ -486,7 +486,7 @@ describe('MemoryPersistenceConfigSchema', () => { type: 'file', path: '/tmp/data.json', }); - expect(config).toEqual({ type: 'file', path: '/tmp/data.json', autoSaveInterval: 2000 }); + expect(config).toEqual({ type: 'file', path: '/tmp/data.json', autoSaveIntervalMs: 2000 }); }); it('should accept local object config', () => { @@ -539,3 +539,45 @@ describe('MemoryDriverSpec', () => { expect(MemoryDriverSpec.icon).toBe('memory'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — both shapes ARE `strictObject`, so a bare throw +// assertion would pass identically on the unrecognized-key error the tombstone +// exists to replace, which is exactly the case that cannot carry a rename. +describe('memory persistence auto-save interval carries its unit (#15680)', () => { + it('REFUSES the retired `autoSaveInterval` on the file arm, with the rename in the message', () => { + const result = FilePersistenceConfigSchema.safeParse({ type: 'file', autoSaveInterval: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'autoSaveInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs`'); + }); + + // The `auto` arm was NOT on the gate's list — its describe named no unit, so + // the predicate never judged it. It moves anyway because it is the same value: + // `auto` resolves to the same Node.js file adapter and forwards this number to + // the same `FileSystemPersistenceAdapter` field. Renaming one arm and not the + // other would leave one value with two spellings across sibling arms of one + // union — the dialect Prime Directive #12 forbids. This pin is what stops a + // later reader "restoring" the bare spelling on the arm the gate never listed. + it('REFUSES the retired `autoSaveInterval` on the auto arm too, with its own prescription', () => { + const result = AutoPersistenceConfigSchema.safeParse({ type: 'auto', autoSaveInterval: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'autoSaveInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs`'); + }); + + it('accepts `autoSaveIntervalMs` on both arms, keeps the 2000 default and the min(100) bound', () => { + expect(FilePersistenceConfigSchema.parse({ type: 'file', autoSaveIntervalMs: 5000 }).autoSaveIntervalMs).toBe(5000); + expect(FilePersistenceConfigSchema.parse({ type: 'file' }).autoSaveIntervalMs).toBe(2000); + expect(AutoPersistenceConfigSchema.parse({ type: 'auto', autoSaveIntervalMs: 5000 }).autoSaveIntervalMs).toBe(5000); + // 100 reads as a plausible number of SECONDS — the bound is the whole + // reason the bare name was dangerous rather than merely untidy. + expect(FilePersistenceConfigSchema.safeParse({ type: 'file', autoSaveIntervalMs: 50 }).success).toBe(false); + expect(AutoPersistenceConfigSchema.safeParse({ type: 'auto', autoSaveIntervalMs: 50 }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index f5a2a21311..eb82214af9 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -60,6 +60,7 @@ const PERSISTENCE_HISTORY = * via `PersistenceAdapterInterface` in the driver implementation. */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const PersistenceAdapterSchema = lazySchema(() => strictObject( { surface: "this memory datasource's custom persistence adapter", @@ -108,8 +109,25 @@ export const FilePersistenceConfigSchema = lazySchema(() => strictObject( * config-material, not data. */ path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path to persist data'), - /** Auto-save interval in milliseconds. Default: 2000ms. */ - autoSaveInterval: z.number().min(100).default(2000).describe('Auto-save interval in ms'), + /** + * Auto-save interval in milliseconds. Default: 2000ms. + * + * Renamed from `autoSaveInterval` (#15680, ruling B on #14478): the unit + * lived only in the describe prose. The `min(100)` bound is the tell that + * made the bare name dangerous — 100 reads as a plausible number of + * SECONDS, so an author who guessed the unit wrong was refused nowhere and + * saved 1000x more often than intended. + */ + autoSaveIntervalMs: z.number().min(100).default(2000).describe('Auto-save interval in ms'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + autoSaveInterval: retiredKey( + '`FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value ' + + '(milliseconds) and the 2000 default are unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), }, ).describe('File-system persistence configuration')); @@ -185,8 +203,28 @@ export const AutoPersistenceConfigSchema = lazySchema(() => strictObject( * `file` branch's `path`; the auto-detected file adapter resolves nothing. */ path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path override for Node.js environments'), - /** Auto-save interval override when running in Node.js. */ - autoSaveInterval: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments'), + /** + * Auto-save interval override when running in Node.js, in milliseconds. + * + * Renamed from `autoSaveInterval` alongside the `file` arm's key (#15680, + * ruling B on #14478). It is not a second key: `type: 'auto'` resolves to + * the same Node.js file adapter, and this value is forwarded to the same + * `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + * same `min(100)` bound. Renaming one arm and not the other would have left + * ONE value with TWO spellings across sibling arms of one union — and the + * driver reading both, which is the consumer-side dialect Prime Directive + * #12 forbids. + */ + autoSaveIntervalMs: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments, in milliseconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + autoSaveInterval: retiredKey( + '`AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value ' + + '(milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** * localStorage key override when running in a browser. * `${…}` placeholder syntax is refused (#8495) — same judgment as the diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 7facb9df07..3125cd7893 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -27,7 +27,7 @@ describe('TursoConfigSchema', () => { { url: 'file:./data/objectstack.db' }, { url: ':memory:' }, { url: 'file:./local.db', syncUrl: 'libsql://my-db.turso.io', sync: { intervalSeconds: 60 } }, - { url: 'libsql://x.turso.io', concurrency: 10, timeout: 5000, mode: 'remote' }, + { url: 'libsql://x.turso.io', concurrency: 10, timeoutMs: 5000, mode: 'remote' }, ]) { const result = TursoConfigSchema.safeParse(config); expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); @@ -112,3 +112,35 @@ describe('TursoDriverSpec', () => { expect(Object.keys(json.properties ?? {})).toContain('url'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — this shape IS `strictObject`, so a bare throw +// assertion passes identically on the unrecognized-key error, which is precisely +// the error that cannot carry a FROM → TO mapping. +describe('TursoConfig.timeout carries its unit (#15680)', () => { + const base = { url: 'libsql://app.turso.io' }; + + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = TursoConfigSchema.safeParse({ ...base, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`turso config.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts `timeoutMs` at the same magnitude and still refuses a non-positive one', () => { + expect(TursoConfigSchema.parse({ ...base, timeoutMs: 30000 }).timeoutMs).toBe(30000); + expect(TursoConfigSchema.safeParse({ ...base, timeoutMs: 0 }).success).toBe(false); + }); + + it('leaves `sync.intervalSeconds` alone — it already carried its unit, and is the neighbour that made the bare `timeout` a collision', () => { + const parsed = TursoConfigSchema.parse({ + ...base, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 60 }, + }); + expect(parsed.sync!.intervalSeconds).toBe(60); + }); +}); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 4375edd1bd..9a394a8ad5 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { @@ -211,11 +212,27 @@ export const TursoConfigSchema = lazySchema(() => strictObject( onConnect: z.boolean().optional().describe('Sync immediately on connect'), }).optional().describe('Embedded-replica sync configuration (requires `syncUrl`)'), - /** Operation timeout in ms for remote operations (replica/remote modes). */ - timeout: z.number().int().positive().optional() + /** + * Operation timeout in ms for remote operations (replica/remote modes). + * + * Renamed from `timeout` (#15680, ruling B on #14478): the unit lived only + * in the describe prose and in a `.meta({ title })` no parse reads. It sat + * two keys below `sync.intervalSeconds`, which already spelled ITS unit — + * one shape carrying both conventions, and the suffixed one was the honest + * half. + */ + timeoutMs: z.number().int().positive().optional() .describe('Operation timeout in milliseconds for remote operations') .meta({ title: 'Timeout (ms)' }), + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + timeout: retiredKey( + '`turso config.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. 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.', + ), + /** Pin the transport instead of inferring it from `url`. */ mode: TursoTransportModeSchema.optional().meta({ title: 'Transport mode' }), }) diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index cb1fb8ef0f..3a1289e46d 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -29,6 +29,10 @@ import { HealthCheckConfigSchema, CircuitBreakerConfigSchema, ConnectorHealthSchema, + + // Trigger (declared-but-unread, #3197 — the pin block at the bottom judges + // its unit-carrying key name, not a runtime it does not have) + ConnectorTriggerSchema, // Types type Connector, @@ -558,7 +562,7 @@ describe('CircuitBreakerConfigSchema', () => { expect(config.failureThreshold).toBe(5); expect(config.resetTimeoutMs).toBe(30000); expect(config.halfOpenMaxRequests).toBe(1); - expect(config.monitoringWindow).toBe(60000); + expect(config.monitoringWindowMs).toBe(60000); }); it('should accept full circuit breaker config', () => { @@ -567,7 +571,7 @@ describe('CircuitBreakerConfigSchema', () => { failureThreshold: 10, resetTimeoutMs: 60000, halfOpenMaxRequests: 3, - monitoringWindow: 120000, + monitoringWindowMs: 120000, fallbackStrategy: 'cache', }); @@ -1473,3 +1477,43 @@ describe('[#14676] ADR-0087 registration', () => { expect(step.conversionIds).toContain('connector-error-mapping-removed'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. Neither shape is strict, so without the +// tombstones the old keys would be STRIPPED in silence: a breaker would fall +// back to its 60-second default window while the author believed they had +// widened it, and a polling trigger would lose its cadence entirely. +describe('connector durations carry their unit (#15680)', () => { + it('REFUSES the retired `monitoringWindow` with the rename in the message', () => { + const result = CircuitBreakerConfigSchema.safeParse({ enabled: true, monitoringWindow: 120000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'monitoringWindow'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs`'); + }); + + it('REFUSES the retired trigger `interval` with the rename in the message', () => { + const result = ConnectorTriggerSchema.safeParse({ + key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`ConnectorTrigger.interval` was renamed to `intervalSeconds`'); + }); + + it('accepts both new spellings and keeps the 60000 breaker default', () => { + expect(CircuitBreakerConfigSchema.parse({ enabled: true }).monitoringWindowMs).toBe(60000); + expect(CircuitBreakerConfigSchema.parse({ enabled: true, monitoringWindowMs: 120000 }).monitoringWindowMs).toBe(120000); + expect(ConnectorTriggerSchema.parse({ + key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60, + }).intervalSeconds).toBe(60); + }); + + it('leaves `resetTimeoutMs` alone — it already carried its unit, and is the neighbour that made the bare `monitoringWindow` a collision', () => { + expect(CircuitBreakerConfigSchema.parse({ enabled: true }).resetTimeoutMs).toBe(30000); + }); +}); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 988ef8cbeb..6e83add4c0 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -538,7 +538,20 @@ export const CircuitBreakerConfigSchema = lazySchema(() => z.object({ failureThreshold: z.number().optional().default(5).describe('Failures before opening circuit'), resetTimeoutMs: z.number().optional().default(30000).describe('Time in open state before half-open'), halfOpenMaxRequests: z.number().optional().default(1).describe('Requests allowed in half-open state'), - monitoringWindow: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), + // Renamed from `monitoringWindow` (#15680, ruling B on #14478): the unit lived + // only in the describe prose, one key below `resetTimeoutMs`, which already + // spelled ITS unit. One shape carrying both conventions — the suffixed one was + // the honest half. + monitoringWindowMs: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + monitoringWindow: retiredKey( + '`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `monitoringWindowMs`; the value ' + + '(milliseconds) and the 60000 default are unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), fallbackStrategy: z.enum(['cache', 'default_value', 'error', 'queue']).optional().describe('Fallback strategy when circuit is open'), }).describe('Circuit breaker configuration')); @@ -672,7 +685,19 @@ export const ConnectorTriggerSchema = lazySchema(() => z.object({ label: z.string().describe('Trigger label'), description: z.string().optional(), type: z.enum(['polling', 'webhook']).describe('Trigger type'), - interval: z.number().optional().describe('Polling interval in seconds'), + // Renamed from `interval` (#15680, ruling B on #14478): the unit lived only in + // the describe prose, and a polling cadence is exactly the number a reader + // guesses at — the same bare `interval` means MILLISECONDS elsewhere in this + // spec, so the identical name carried two units a thousandfold apart. + intervalSeconds: z.number().optional().describe('Polling interval in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + interval: retiredKey( + '`ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), })); export type ConnectorTrigger = z.input; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts new file mode 100644 index 0000000000..be10bd55db --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): +// a duration-shaped `z.number()` key carries its unit in its NAME, and no +// existing offender is grandfathered. `ConversationAnalytics.duration` said +// "Session duration in seconds" in prose and nothing else, on a shape where +// every OTHER number is a count (messages, tokens, pruning events) and the two +// neighbouring instants already spell themselves `firstMessageAt` / +// `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. +// Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare +// deletion would strip the key in silence and the analytics row would lose the +// one measurement it carries, with no error anywhere. No D2 conversion: +// conversation analytics are computed and emitted at runtime, never authored +// and never a stored `sys_metadata` row, so the chain has no seam that sees +// one. See `ai-conversation-analytics-duration-unit-in-key`. +export const entry = 'ai/ConversationAnalytics:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts new file mode 100644 index 0000000000..cccb824566 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card +// that the gate did NOT list. It is here because it is not a second key: the +// `auto` persistence arm resolves to the same Node.js file adapter as the +// `file` arm, and this value is forwarded to the same +// `FileSystemPersistenceAdapter` field, in the same milliseconds, under the +// same `min(100)` bound. Its describe named no unit at all, which is why the +// predicate skipped it — and precisely why renaming only the `file` arm would +// have left ONE value with TWO spellings across sibling arms of one union, with +// the driver reading both. That is the consumer-side dialect Prime Directive +// #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` +// and its describe now names the unit too. Tombstoned with `retiredKey()`; +// covered by `memory-persistence-auto-save-interval-to-ms`, which converts both +// arms in one pass. +export const entry = 'data/AutoPersistenceConfig:autoSaveInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts new file mode 100644 index 0000000000..1b8cb1904b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. +// `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in +// prose and nothing else. Its `min(100)` bound is what made the bare name +// dangerous rather than merely untidy: 100 reads as a plausible number of +// SECONDS, so an author who guessed the unit wrong cleared the bound, was +// refused nowhere, and saved a thousand times more often than intended. +// Renamed to `autoSaveIntervalMs`; the value and the 2000 default are +// unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so +// a bare deletion is not silent, but an unknown-key rejection cannot carry the +// FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 +// conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource +// is a `datasources[]` stack collection member whose `config` is stored whole in +// `sys_metadata`, so the chain has a seam that sees it. +export const entry = 'data/FilePersistenceConfig:autoSaveInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts new file mode 100644 index 0000000000..7f191b9d52 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` +// said "Query timeout (ms)" in prose and nothing else, directly beside +// `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a +// unit and one not, with nothing at the call site to tell them apart. Renamed +// to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the +// shape is not `.strict()`, so a bare deletion would strip in silence and the +// query would run without the limit its author set. No D2 conversion: query +// options are a per-call driver argument reached only through +// `AggregationPipeline.options`, which no `stack.zod.ts` collection declares +// and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. +export const entry = 'data/NoSQLQueryOptions:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts new file mode 100644 index 0000000000..181d258858 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said +// "Operation timeout in milliseconds" in prose and carried a `.meta({ title: +// 'Timeout (ms)' })` no parse reads — and sat two keys below +// `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying +// both conventions, and the suffixed one was the honest half. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the +// shape IS `strictObject`, so the tombstone is here for the prescription an +// unknown-key rejection cannot carry. Covered by the D2 conversion +// `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` +// stack collection member whose `config` is stored whole in `sys_metadata`. +// ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). +// The driver package ships its own parallel `turso.zod.ts` whose `timeout` is +// outside this card's declared population and is renamed by the card that +// widens that population. +export const entry = 'data/TursoConfig:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts new file mode 100644 index 0000000000..4e6b33bcfa --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. +// `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure +// count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, +// which already spelled its unit, on the same six-key shape. That is the +// sharpest case in this card: a single schema already carried both +// conventions, so a reader had no rule to apply, only two examples that +// disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default +// are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, +// so a bare deletion would strip in silence and the breaker would fall back to +// its default window while the author believed they had widened it. Covered by +// the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: +// `connectors:` is a stack collection and a published connector row lands whole +// in `sys_metadata`, so the chain has a seam that sees it. +export const entry = 'integration/CircuitBreakerConfig:monitoringWindow'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts new file mode 100644 index 0000000000..724af5404b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` +// said "Polling interval in seconds" in prose and nothing else. A polling +// cadence is exactly the number a reader guesses at, and the bare name `interval` +// means MILLISECONDS elsewhere in this same spec — the identical spelling +// carrying two units a thousandfold apart is the collision that got this whole +// population ruled rather than merely noted. Renamed to `intervalSeconds`; the +// value is unchanged. Tombstoned with `retiredKey()`; the shape is not +// `.strict()`, so a bare deletion would strip in silence. Covered by the D2 +// conversion `connector-health-and-trigger-durations-unit-in-key`. +// ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven +// by it). The rename does not change that; it makes the declaration honest +// about its unit for whoever implements the loop. +export const entry = 'integration/ConnectorTrigger:interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts new file mode 100644 index 0000000000..e4bab96f3c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` +// said "Auto-refresh interval in seconds" in prose and nothing else. The three +// rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — +// measure how many spellings authors actually reach for, and not one of them +// named a unit either, so every door into this key left the cadence ambiguous. +// All three were repointed to the new spelling in the same edit. Renamed to +// `refreshIntervalSeconds`; the value is unchanged. Tombstoned with +// `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the +// prescription an unknown-key rejection cannot carry. Covered by the D2 +// conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: +// `dashboards:` is a stack collection and a dashboard is a registered metadata +// kind stored as a row. +// ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's +// dashboard renderer reads this key and multiplies by 1000, and publishes it as +// a registry input, so its reader could not move in this PR the way every other +// reader in this card did. Sequenced as a follow-up card behind a release that +// actually ships the rename; until then the renderer sees an absent key and +// does not start its timer. +export const entry = 'ui/Dashboard:refreshInterval'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts new file mode 100644 index 0000000000..df74927cb7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ai-conversation-analytics-duration-unit-in-key', + surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' + + 'unit (ai/conversation.zod.ts)', + replacement: 'durationSeconds — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' + + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' + + 'that carried a unit was the one field that did not say so, sitting in a block of ' + + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' + + 'already spelled themselves; the measurement between them did not. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'an emitter writing the old spelling would lose the value with no error anywhere. ' + + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' + + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + + 'same disposition every runtime-emitted measurement in this stack has taken. ' + + '#15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' + + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' + + 'bound rides along with the renamed key so a negative session length is still refused. ' + + 'The migration is proved correct when no source in the tree spells a bare duration on ' + + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' + + 'them has read a count as a duration and over-applied the rule.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts new file mode 100644 index 0000000000..9ec8d4ebfc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-nosql-query-options-timeout-unit-in-key', + surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' + + 'unit (data/driver-nosql.zod.ts)', + replacement: 'timeoutMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file. The neighbour is what ' + + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' + + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' + + 'respectively with nothing at the call site to separate them. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'the query would run with no deadline at all while its author believed one was set — ' + + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + + 'conversion: these options are a per-call driver argument, reached only through ' + + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' + + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' + + 'the positive-integer bound rides along with the renamed key so a zero or negative ' + + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' + + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' + + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 113667b626..ac9c223ffb 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5347,7 +5347,20 @@ const step18: MigrationStep = { 'milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the ' + 'tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the ' + 'reference pages never published (#14519) — are retiredKey tombstones with a ' + - 'semantic entry each, naming the suffixed key.', + 'semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and ' + + '`integration` remainder closes the same sweep: `dashboard.refreshInterval` → ' + + '`refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` ' + + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds`, and the two ' + + 'datasource config keys `memory config.persistence.autoSaveInterval` → ' + + '`autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the ' + + 'same file adapter, so splitting them would have left one value with two spellings) ' + + 'and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a ' + + 'connector and a datasource are stack collection members stored as rows; the two with ' + + 'no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, ' + + 'and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey ' + + 'tombstones with a semantic entry each. That remainder is what takes ' + + '`check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate ' + + 'goes red again by design when its declared population widens beyond that subtree.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5368,6 +5381,10 @@ const step18: MigrationStep = { 'hook-timeout-to-timeout-ms', 'job-timeout-to-timeout-ms', 'api-endpoint-cache-ttl-to-cache-ttl-seconds', + 'dashboard-refresh-interval-to-refresh-interval-seconds', + 'connector-health-and-trigger-durations-unit-in-key', + 'memory-persistence-auto-save-interval-to-ms', + 'turso-config-timeout-to-timeout-ms', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -5527,6 +5544,38 @@ const step18: MigrationStep = { + 'tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ' + 'ever read the container, so removing it removes no behaviour.', }, + { + id: 'ai-conversation-analytics-duration-unit-in-key', + surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' + + 'unit (ai/conversation.zod.ts)', + replacement: 'durationSeconds — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' + + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' + + 'that carried a unit was the one field that did not say so, sitting in a block of ' + + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' + + 'already spelled themselves; the measurement between them did not. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'an emitter writing the old spelling would lose the value with no error anywhere. ' + + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' + + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + + 'same disposition every runtime-emitted measurement in this stack has taken. ' + + '#15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' + + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' + + 'bound rides along with the renamed key so a negative session length is still refused. ' + + 'The migration is proved correct when no source in the tree spells a bare duration on ' + + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' + + 'them has read a count as a duration and over-applied the rule.', + }, { id: 'analytics-authorable-unknown-keys-refused', // Same-major bookkeeping (#10414): batch D also closed the nested @@ -6101,6 +6150,33 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + { + id: 'data-nosql-query-options-timeout-unit-in-key', + surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' + + 'unit (data/driver-nosql.zod.ts)', + replacement: 'timeoutMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file. The neighbour is what ' + + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' + + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' + + 'respectively with nothing at the call site to separate them. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'the query would run with no deadline at all while its author believed one was set — ' + + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + + 'conversion: these options are a per-call driver argument, reached only through ' + + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' + + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' + + 'the positive-integer bound rides along with the renamed key so a zero or negative ' + + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' + + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' + + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', + }, { id: 'datasource-config-mongo-options-credential-refused', surface: 'datasource.config.options.auth.password (mongodb) — a login credential written ' + @@ -9653,6 +9729,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): + // a duration-shaped `z.number()` key carries its unit in its NAME, and no + // existing offender is grandfathered. `ConversationAnalytics.duration` said + // "Session duration in seconds" in prose and nothing else, on a shape where + // every OTHER number is a count (messages, tokens, pruning events) and the two + // neighbouring instants already spell themselves `firstMessageAt` / + // `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. + // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare + // deletion would strip the key in silence and the analytics row would lose the + // one measurement it carries, with no error anywhere. No D2 conversion: + // conversation analytics are computed and emitted at runtime, never authored + // and never a stored `sys_metadata` row, so the chain has no seam that sees + // one. See `ai-conversation-analytics-duration-unit-in-key`. + 'ai/ConversationAnalytics:duration', // #15677 (stack card 2/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): // a duration-shaped `z.number()` key carries its unit in its NAME, and no // existing offender is grandfathered. `ApiEndpoint.cacheTtl` said "Response @@ -10085,6 +10175,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // construction configuration, never a stored row; the semantic entry // `websocket-durations-unit-in-key` carries the prescription. 'api/WebSocketServerConfig:heartbeatInterval', + // #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card + // that the gate did NOT list. It is here because it is not a second key: the + // `auto` persistence arm resolves to the same Node.js file adapter as the + // `file` arm, and this value is forwarded to the same + // `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + // same `min(100)` bound. Its describe named no unit at all, which is why the + // predicate skipped it — and precisely why renaming only the `file` arm would + // have left ONE value with TWO spellings across sibling arms of one union, with + // the driver reading both. That is the consumer-side dialect Prime Directive + // #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` + // and its describe now names the unit too. Tombstoned with `retiredKey()`; + // covered by `memory-persistence-auto-save-interval-to-ms`, which converts both + // arms in one pass. + 'data/AutoPersistenceConfig:autoSaveInterval', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in @@ -10097,6 +10201,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `driver-options-timeout-to-timeout-ms` carries the prescription. Registered // under 18 for the launch-window reason its neighbours state. 'data/DriverOptions:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. + // `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in + // prose and nothing else. Its `min(100)` bound is what made the bare name + // dangerous rather than merely untidy: 100 reads as a plausible number of + // SECONDS, so an author who guessed the unit wrong cleared the bound, was + // refused nowhere, and saved a thousand times more often than intended. + // Renamed to `autoSaveIntervalMs`; the value and the 2000 default are + // unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so + // a bare deletion is not silent, but an unknown-key rejection cannot carry the + // FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 + // conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource + // is a `datasources[]` stack collection member whose `config` is stored whole in + // `sys_metadata`, so the chain has a seam that sees it. + 'data/FilePersistenceConfig:autoSaveInterval', // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape // one level up). `filters` was a declared, authorable per-metric raw-SQL // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a @@ -10124,6 +10242,46 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // conversion `metric-filters-removed`, which strips the key from every metric // in `analyticsCubes[].measures`. 'data/Metric:filters', + // #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` + // said "Query timeout (ms)" in prose and nothing else, directly beside + // `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a + // unit and one not, with nothing at the call site to tell them apart. Renamed + // to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the + // shape is not `.strict()`, so a bare deletion would strip in silence and the + // query would run without the limit its author set. No D2 conversion: query + // options are a per-call driver argument reached only through + // `AggregationPipeline.options`, which no `stack.zod.ts` collection declares + // and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. + 'data/NoSQLQueryOptions:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said + // "Operation timeout in milliseconds" in prose and carried a `.meta({ title: + // 'Timeout (ms)' })` no parse reads — and sat two keys below + // `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying + // both conventions, and the suffixed one was the honest half. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the + // shape IS `strictObject`, so the tombstone is here for the prescription an + // unknown-key rejection cannot carry. Covered by the D2 conversion + // `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` + // stack collection member whose `config` is stored whole in `sys_metadata`. + // ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). + // The driver package ships its own parallel `turso.zod.ts` whose `timeout` is + // outside this card's declared population and is renamed by the card that + // widens that population. + 'data/TursoConfig:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. + // `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure + // count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, + // which already spelled its unit, on the same six-key shape. That is the + // sharpest case in this card: a single schema already carried both + // conventions, so a reader had no rule to apply, only two examples that + // disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default + // are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, + // so a bare deletion would strip in silence and the breaker would fall back to + // its default window while the author believed they had widened it. Covered by + // the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: + // `connectors:` is a stack collection and a published connector row lands whole + // in `sys_metadata`, so the chain has a seam that sees it. + 'integration/CircuitBreakerConfig:monitoringWindow', // #14676 — ADR-0049 enforce-or-remove on `ConnectorSchema.errorMapping` (triage // ruling 2026-09-02: removal via the `spec-property-retirement` playbook; the // split condition — a downstream consumer in objectui or a customer stack — @@ -10151,6 +10309,19 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #12497 / #13823 grading). 'integration/Connector:errorMapping', + // #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` + // said "Polling interval in seconds" in prose and nothing else. A polling + // cadence is exactly the number a reader guesses at, and the bare name `interval` + // means MILLISECONDS elsewhere in this same spec — the identical spelling + // carrying two units a thousandfold apart is the collision that got this whole + // population ruled rather than merely noted. Renamed to `intervalSeconds`; the + // value is unchanged. Tombstoned with `retiredKey()`; the shape is not + // `.strict()`, so a bare deletion would strip in silence. Covered by the D2 + // conversion `connector-health-and-trigger-durations-unit-in-key`. + // ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven + // by it). The rename does not change that; it makes the declaration honest + // about its unit for whoever implements the loop. + 'integration/ConnectorTrigger:interval', // #14676 — the same tombstone seen through the second carrier. // `DeclarativeConnectorEntrySchema` is `ConnectorSchema.superRefine(...)`, so the // `errorMapping` tombstone on the base is inherited by the shape that @@ -11453,6 +11624,25 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // parse) and the D3 semantic entry named below. // D3 semantic entry: `training-deadline-keys-retired`. 'system/TrainingPlan:reminderDaysBefore', + // #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` + // said "Auto-refresh interval in seconds" in prose and nothing else. The three + // rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — + // measure how many spellings authors actually reach for, and not one of them + // named a unit either, so every door into this key left the cadence ambiguous. + // All three were repointed to the new spelling in the same edit. Renamed to + // `refreshIntervalSeconds`; the value is unchanged. Tombstoned with + // `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the + // prescription an unknown-key rejection cannot carry. Covered by the D2 + // conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: + // `dashboards:` is a stack collection and a dashboard is a registered metadata + // kind stored as a row. + // ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's + // dashboard renderer reads this key and multiplies by 1000, and publishes it as + // a registry input, so its reader could not move in this PR the way every other + // reader in this card did. Sequenced as a follow-up card behind a release that + // actually ships the rename; until then the renderer sees an absent key and + // does not start its timer. + 'ui/Dashboard:refreshInterval', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its // renderers/basic/elements.tsx header deferred the element to "owning plugins" diff --git a/packages/spec/src/ui/dashboard.form.ts b/packages/spec/src/ui/dashboard.form.ts index be4041f89e..e116b29a98 100644 --- a/packages/spec/src/ui/dashboard.form.ts +++ b/packages/spec/src/ui/dashboard.form.ts @@ -23,7 +23,7 @@ export const dashboardForm = defineForm({ fields: [ { field: 'columns', type: 'number', colSpan: 1, helpText: 'Grid columns (default 12)' }, { field: 'gap', type: 'number', colSpan: 1, helpText: 'Space between widgets, in steps of 0.25rem (4 = 1rem)' }, - { field: 'refreshInterval', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, + { field: 'refreshIntervalSeconds', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, { field: 'header', type: 'composite', colSpan: 3, helpText: 'Dashboard header config (title, subtitle, actions)' }, ], }, diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index bea614f18f..c67e50f14e 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -292,7 +292,7 @@ describe('DashboardSchema', () => { it('supports columns/gap/refresh/dateRange/globalFilters', () => { const d = DashboardSchema.parse({ - name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshInterval: 60, + name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshIntervalSeconds: 60, dateRange: { field: 'close_date', defaultRange: 'this_quarter' }, globalFilters: [{ field: 'owner', type: 'lookup' }], widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }], @@ -695,3 +695,41 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(c.aria).toEqual({ ariaLabel: 'Sidebar' }); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — `DashboardSchema` IS `strictObject`, so a bare +// throw assertion passes identically on the unrecognized-key error, which is +// exactly the error that cannot carry the rename. +// +// The alias half is the part with no other guard: `refresh` / `autoRefresh` / +// `pollInterval` were rename hints pointing at the OLD spelling, and a hint +// left pointing at a tombstone would prescribe a key the shape refuses. +describe('dashboard.refreshInterval carries its unit (#15680)', () => { + const base = { name: 'dash_x', label: 'D', widgets: [] }; + + it('REFUSES the retired `refreshInterval` with the rename in the message', () => { + const result = DashboardSchema.safeParse({ ...base, refreshInterval: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'refreshInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`'); + }); + + it('accepts `refreshIntervalSeconds` at the same magnitude', () => { + expect(DashboardSchema.parse({ ...base, refreshIntervalSeconds: 60 }).refreshIntervalSeconds).toBe(60); + }); + + it('prescribes the NEW spelling from all three rename-hint aliases', () => { + for (const alias of ['refresh', 'autoRefresh', 'pollInterval']) { + const result = DashboardSchema.safeParse({ ...base, [alias]: 60 }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('refreshIntervalSeconds'); + // A hint still naming the tombstone would send the author to a key the + // shape refuses — the one failure this rename could introduce silently. + expect(message).not.toMatch(/`refreshInterval`(?!Seconds)/); + } + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 758a16a238..81ad8eadce 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -871,7 +871,7 @@ export const DashboardSchema = lazySchema(() => strictObject({ filters: 'globalFilters', globalFilter: 'globalFilters', grid: 'columns', columnCount: 'columns', spacing: 'gap', - refresh: 'refreshInterval', autoRefresh: 'refreshInterval', pollInterval: 'refreshInterval', + refresh: 'refreshIntervalSeconds', autoRefresh: 'refreshIntervalSeconds', pollInterval: 'refreshIntervalSeconds', dateFilter: 'dateRange', timeRange: 'dateRange', }, guidance: { @@ -902,8 +902,25 @@ export const DashboardSchema = lazySchema(() => strictObject({ /** Space between widgets, in steps of 0.25rem (4 = 1rem) */ gap: z.number().int().min(0).optional().describe('Space between widgets, in steps of 0.25rem (4 = 1rem)'), - /** Auto-refresh */ - refreshInterval: z.number().optional().describe('Auto-refresh interval in seconds'), + /** + * Auto-refresh + * + * Renamed from `refreshInterval` (#15680, ruling B on #14478): the unit lived + * only in the describe prose. The three rename-hint aliases beside it + * (`refresh` / `autoRefresh` / `pollInterval`) are the measure of how many + * spellings authors reach for, and not one of them names a unit either — so + * every door into this key left the cadence ambiguous until the canonical + * spelling carried it. + */ + refreshIntervalSeconds: z.number().optional().describe('Auto-refresh interval in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + refreshInterval: retiredKey( + '`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 ' + + '— the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** Dashboard Date Range (Global time filter) */ dateRange: strictObject({ diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 44750af4f2..80236e9a52 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -145,7 +145,7 @@ export const SalesDashboard: Dashboard = { label: 'Sales Performance', columns: 12, gap: 4, - refreshInterval: 180, // seconds; auto-refresh + refreshIntervalSeconds: 180, // auto-refresh cadence header: { showTitle: true,