Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/datasource-mapping-is-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/objectql": minor
"@objectstack/service-datasource": minor
"@objectstack/runtime": minor
---

A `datasourceMapping` rule is routing, not a hint — an object mapped to an
unreachable datasource no longer silently reads and writes the DEFAULT store
(#4462).

**Observable behavior change; read this before upgrading.** Measured on `main`
during the v17 verification: map an object to a Postgres datasource with a bad
URL and the boot succeeds, `/ready` answers `200`, the datasource name appears in
**zero** log lines, `POST /api/v1/data/<mapped object>` returns `201` — and the
row is physically in the default store. The operator finds out by opening the
database they declared and finding it empty. ADR-0062 D2's phase-1 note called a
mapping-only datasource "decorative" to keep an example byte-for-byte unchanged;
what that bought was a silent data-placement bug.

The fix is a pair, and each half is what makes the other correct:

1. **Routing stops falling through** (`@objectstack/objectql`). `getDriver` step
2: a mapping rule that MATCHES and names a datasource with no live driver now
throws — `DatasourceUnavailableError` when the connect layer recorded a
verdict, otherwise an error naming the object, the datasource and the two
remedies. `default` still resolves onward: the default driver keeps its
natural name (#3826), so step 5 is how routing to it works.
2. **ADR-0062 D2 grows gate (d)** (`@objectstack/service-datasource`,
`@objectstack/runtime`). A datasource a mapping rule routes at least one
object to is auto-connected at boot, and a boot-time connect failure is
**fatal** with an operator-readable reason — the same call gate (b) already
makes for an explicit `object.datasource` binding, now correct for (d)
because half 1 removed the fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still
degrades the boot instead, as for every other fatal connect.

The mapped-object list is resolved by the boot path from the engine's own
matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) and passed to
`connectDeclared({ mappedObjects })`; the connection service never re-derives
rule matching. Two matchers drifting by one clause would connect a datasource
routing never uses, or route to one nothing connects — the defect again.

**What to do if this breaks your boot.** It means a `datasourceMapping` rule in
your stack points at a datasource that cannot be connected. Either fix the
datasource configuration, or delete the rule — the second is what
`examples/app-crm` did in this change, and it is what keeps that example's
runtime behavior identical: its rules routed everything to an unconnected
`:memory:` datasource, i.e. to the default store by fall-through.
25 changes: 25 additions & 0 deletions .changeset/form-layout-lint-wired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/cli": minor
---

Wire `validateFormLayout` into the authoring-rule registry, and close the
registry from the other direction (#4449).

`validateFormLayout` was implemented, unit-tested, exported from
`@objectstack/lint` and given published rule ids (`form-field-unknown`,
`absolute-colspan-discouraged`) — and **no command ever called it**. It ran on
zero stacks for as long as it existed, so a form section referencing a field
that is not on the bound object, or pinning an absolute `colSpan` under a
per-surface derived column count, produced no output anywhere. It is now an
`advisory` entry in `AUTHORING_RULES`, so `os validate`, `os build` and
`os lint` all run it. It is a pure structured-metadata walk with no lazy
dependency, so all three commands pay nothing measurable.

The wiring guard (#4409) could not have found this. Every one of its invariants
starts FROM a registry and looks at the commands, which is blind by construction
to a rule that never entered a registry — the same shape as #4402's name list
guarding only the names on it, one layer up. The guard now also runs the reverse
subtraction: every `validate*` / `lint*` symbol on `@objectstack/lint`'s public
barrel, minus `AUTHORING_RULES` ∪ `REFERENCE_INTEGRITY_RULES`, must be empty or
carry a written reason in `UNWIRED_RULE_LEDGER`. The ledger ships empty: today's
difference was exactly this one rule.
27 changes: 27 additions & 0 deletions .changeset/meta-canonical-type-segment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/metadata-protocol": patch
---

One canonical type key at the `/meta` read/write/delete boundary (#4432).

#3985 made the per-type gates accept both spellings of the `/meta` type segment
(`/meta/actions` and `/meta/action`). It did not FOLD them, so the two spellings
addressed two different namespaces and the layers below disagreed about which
one an item lived in. `saveMetaItem`, `getMetaItem`, `getMetaItems`,
`getMetaItemLayered`, `getMetaItemCached` and `deleteMetaItem` now fold the type
to its canonical singular (Prime Directive #3) as their first act, so every layer
below them reads one key.

The damaging consequence was not the duplicate row — it was the shadowing.
`getMetaItems` hydrated overlay rows back into the SchemaRegistry under the
CALLER's spelling, so one plural-spelled read minted a plural registry entry;
from the next read on, `listItems('actions')` was no longer empty, the singular
fallback that had been supplying every code-authored action stopped running, and
a single overlay row hid the entire code-authored listing — on a spelling no
DELETE could address, because the delete path resolved the singular. Listing and
dispatch then disagreed about an item that had been deleted.

Reads of data AT REST still try the other spelling as a fallback: rows written
under a plural `type` before this fix are real, and nothing rewrites them on
upgrade. What changed is that nothing WRITES or REGISTERS a non-canonical key any
more.
36 changes: 36 additions & 0 deletions .changeset/reference-id-embedded-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/spec": patch
---

A stored reference value that is an embedded record is no longer a valid id
(#4455).

`os migrate value-shapes` is the evidence half of the ADR-0104 D1 per-deployment
gate, and its own header names the case it exists for: "a `location` stored as
`{latitude, longitude}` **or a `lookup` holding an expanded record object**". The
second case was not detected. `ReferenceIdValueSchema` was
`z.string().min(1)`, and in a SQL deployment a legacy embedded reference reaches
storage as JSON *text* in a TEXT column — a non-empty string. So a deployment
carrying exactly the values the gate exists to find ran the scan, was told it was
clean, and closed the gate with `--apply`; because the scan deliberately imports
the write-path predicate, the write path was equally blind and the value survived
future writes too.

`ReferenceIdValueSchema` now rejects a value whose first non-space character is
`{` or `[`, in both the stored and the expanded form (`$expand` produces an
object, never its serialization).

The rejection is deliberately narrower than the issue's first suggestion. Its
file sibling `FileReferenceIdValueSchema` can bound its charset because a
`sys_file` id is minted by the platform and by nothing else; a reference id is
whatever the target object's primary key holds, including an external key an
ADR-0015 federated datasource supplies. So this rejects the shape that is
provably not an id (`{"id":"acc_1","name":"embedded"}`) and leaves the id
alphabet to the object that owns it — `CB0-2026-0001`, `SFDC:001xx…` and
`ops/eu-west/tenant-7` all remain valid. Widening it further needs evidence about
real external keys, not a guess.

Reaches authors through the ADR-0104 warn-first path (a `[value-shape]` log line)
until a deployment opts into strict, so nothing starts rejecting writes on
upgrade — but the scan now counts these values, and a deployment holding them can
no longer close the gate.
20 changes: 15 additions & 5 deletions content/docs/data-modeling/external-datasources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,22 @@ A declared datasource auto-connects when it is **meaningfully addressed**:

1. it is **external** (`schemaMode !== 'managed'`), **or**
2. an object **explicitly** binds to it via `object.datasource === <name>`, **or**
3. it sets **`autoConnect: true`**.
3. it sets **`autoConnect: true`**, **or**
4. a **`datasourceMapping` rule routes at least one object to it**.

A `managed` datasource that nothing explicitly binds to (for example one that is
only referenced by a `datasourceMapping` rule) stays *metadata-only* — visible in
Setup, but not connected — so existing apps are unchanged. Use `autoConnect: true`
to opt such a datasource into a live connection at boot.
A `managed` datasource that nothing routes to stays *metadata-only* — visible in
Setup, but not connected. Use `autoConnect: true` to opt such a datasource into a
live connection at boot.

<Callout type="warn">
**A mapping rule is routing, not a hint.** If a `datasourceMapping` rule routes an
object to a datasource that cannot be connected, the boot **fails** with the
connect error, and a query against that object throws rather than resolving the
default store. Before v17 it fell through silently: the app booted clean, `/ready`
answered `200`, and the object's rows were written to the *default* database
instead of the one it declared. If you want a declared datasource that routes
nothing, remove the mapping rule rather than relying on the fall-through.
</Callout>

<Callout type="info">
**Escape hatch.** An `onEnable` hook calling `ctx.drivers.register(driver)` is
Expand Down
9 changes: 9 additions & 0 deletions docs/adr/0062-external-datasource-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ Introduce a single service that, given a datasource definition, builds a driver
Auto-connect must not change apps that today declare datasources that are *decorative* or routed via `datasourceMapping` (e.g. `examples/app-crm`'s `crm_primary`/`crm_analytics`). Gate auto-connect so a declared datasource is only connected when it is meaningfully addressed: **(a)** it is `external` (`schemaMode !== 'managed'`), or **(b)** an object/`datasourceMapping` actually routes to it, or **(c)** it sets an explicit `autoConnect: true`. A managed datasource that nothing routes to stays metadata-only (today's behavior). The `default` datasource keeps its current dedicated bootstrap. This is the load-bearing backward-compat decision.

> **Phase 1 implementation note (#2163) — gate (b) is "explicit `object.datasource`", not "mapped".** Implementing D2 against `examples/app-crm` surfaced a conflict between "an object/`datasourceMapping` routes to it" and the "byte-for-byte unchanged" mandate. `app-crm`'s `crm_primary` (`:memory:`, `managed`) *is* referenced by a `datasourceMapping` rule (and is the `default:true` fallback) but has **no** `onEnable` driver, so today `engine.getDriver` finds no `crm_primary` driver and its objects fall through to the `default` driver. Auto-connecting it on the strength of the mapping rule would build a fresh, empty `:memory:` driver and silently divert those objects — a behavior change. So the gate **does not** auto-connect on a `datasourceMapping` rule alone: a *managed* datasource that is only mapped (namespace/package/`default`) is treated as decorative and left metadata-only. Gate (b) fires only when an object **explicitly** binds via `object.datasource === <name>` — a binding that today *throws* when the driver is unregistered, so auto-connecting it is a strict improvement, never a change. External datasources (a) and `autoConnect:true` (c) are unaffected. See `isDatasourceAddressed()` in `@objectstack/service-datasource`.
>
> **Amendment (#4462) — the phase-1 note is REVERSED: gate (d) is "a mapping rule routes objects here", and mapping-only is no longer decorative.** The note above priced the trade-off with only one side on the table. The other side, measured on `main` during the v17 verification, is what a mapping to an **unreachable** datasource does today: the boot succeeds, `/ready` answers `200`, the datasource name appears in **zero** log lines, `POST /api/v1/data/<mapped object>` returns `201` — and the row is physically in the DEFAULT store. The operator discovers it by opening the database they declared and finding it empty. Weighed against that, "decorative" is not a backward-compatibility guarantee; it is a silent data-placement bug wearing one. `datasourceMapping` reads as routing to every author who writes it, and Route-ownership rule #3 ("absence must be loud; prefer failing to falling back") applies to a routing decision as much as to a mounted surface.
>
> The amendment is a **pair**, and each half is what makes the other correct:
>
> 1. **Routing stops falling through.** `ObjectQLEngine.getDriver` step 2: a mapping rule that MATCHES and names a datasource with no live driver now throws — `DatasourceUnavailableError` when the connect layer recorded a verdict (framework#3828), otherwise a "mapped for object … is not registered" error naming the two remedies. `default` is the one name that still resolves onward: the default driver keeps its natural name (#3826), so `drivers.has('default')` is false by construction and step 5 is how routing to it works.
> 2. **The D2 gate grows (d).** A datasource a mapping rule routes at least one registered object to is auto-connected at boot, and a `declared-auto` failure is **fatal** — the same argument (b) already makes, now true of (d) because half 1 removed the fallback. The object list is resolved by the boot path from the engine's own matcher (`ObjectQLEngine.resolveMappedDatasource`), never re-derived in the connection service: two matchers drifting by one clause would connect a datasource routing never uses, or route to one nothing connects, which is the defect again.
>
> `examples/app-crm`'s mapping was **deleted** in the same change, and that is what keeps the example byte-for-byte unchanged rather than what breaks it: its `namespace: 'crm'` rule never matched (`namespace` is deprecated and no object sets it), and its `default: true → crm_primary` rule routed everything to an unconnected `:memory:` datasource, i.e. to the `default` store by fall-through. Honouring that rule would move the entire app — platform objects included — onto a database that is empty on every boot. Removing the rule states what the example actually does. The general lesson is the one #2163 half-saw: a rule the runtime ignores is not compatibility, it is an unpaid bill.

### D3 — Credentials resolved at connect via `SecretBinder`/`ICryptoProvider`

Expand Down
17 changes: 13 additions & 4 deletions examples/app-crm/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,20 @@ export default defineStack({
requires: ['ui', 'automation'],

// Infrastructure
//
// No `datasourceMapping`. These two datasources are declared to exercise the
// metadata surface, not to route anything: both are `:memory:`, and every
// object here has always been served by the host's `default` store. The
// mapping that used to sit here (`namespace: 'crm'` + `default: true` →
// `crm_primary`) was decorative — `namespace` is deprecated and no object
// sets it, and `crm_primary` had no live driver, so routing fell through to
// `default`. #4462 stopped routing from falling through, because that
// fall-through is what silently put a mapped object's rows in a different
// database than the one it declared. Deleting the rule is what keeps this
// example's behavior IDENTICAL under the new posture; keeping it would move
// the whole app — platform objects included — onto an in-memory database
// that is empty on every boot.
datasources: [CrmDatasource, CrmAnalyticsDatasource],
datasourceMapping: [
{ namespace: 'crm', datasource: 'crm_primary' },
{ default: true, datasource: 'crm_primary' },
],

// Internationalisation
translations: [CrmTranslationBundle],
Expand Down
Loading
Loading