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
59 changes: 59 additions & 0 deletions .changeset/declared-workspace-imports-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
"@objectstack/metadata-core": minor
"@objectstack/objectql": patch
"@objectstack/service-automation": patch
"@objectstack/service-datasource": patch
---

feat(devx,datasource,automation): published `src/**` may only import workspace packages it declares (#10062)

A package's non-test `src/**` was free to import any workspace package,
declared or not, and nothing checked it. The class was filed with one member
and a mitigation — the import was type-only, so nothing reached the emitted
JavaScript and rollup-plugin-dts inlined the declaration rather than naming an
unresolvable module. It grew to four members with no signal, and one of them
killed the mitigation: `service-automation/src/flow-precedence.ts` **value**
imports from `@objectstack/objectql`, which it does not declare, and because
the shared tsup config externalises only `dependencies`/`peerDependencies`, the
bundler answered by inlining objectql's implementation into
`service-automation/dist/index.js` — a second copy of another package's code,
kept correct by build configuration alone.

`pnpm check:undeclared-dep-imports` is the gate, and the per-member fixes here
are decided one at a time rather than by a uniform policy — declaring makes a
coupling real and installable, routing it away removes it, and the two are not
interchangeable:

* **`@objectstack/service-datasource`** now declares `@objectstack/driver-sql`
and `@objectstack/driver-memory` as **dependencies**. Both are loaded through
an *unguarded* `await import(...)` on the postgres, mysql, sqlite and memory
arms, so a consumer reaching one of those paths needed a package it was never
told to install, and would have met `ERR_MODULE_NOT_FOUND` rather than a
diagnosis. The three *guarded* driver arms — `@objectstack/driver-sqlite-wasm`,
`@objectstack/driver-mongodb`, `@objectstack/driver-turso` — are deliberately
left undeclared: each load sits in a `try`/`catch` that answers an absent
package with the fault, the consequence and the install command, and each
rides as an optional install. Declaring them would install them (turso drags
`@libsql/client`'s native bindings) and, measured on this branch, takes a live
assertion out of the tree: `default-datasource-driver-factory.test.ts` reaches
the missing-package arm with no stub precisely because the package does not
resolve from here.
* **`@objectstack/metadata-core`** now owns the ADR-0029 D9.6 provenance pair,
`isCodeArtifactBody` and `isTenantAuthored`, sunk out of
`@objectstack/objectql`'s registry by the same criterion as the write-verb
dispatch predicates and the audit governance table beside them: a second layer
needs the answer and the reverse import would either close a cycle or make the
consumer depend on the whole data engine for one predicate. `objectql`
re-exports `isCodeArtifactBody` from its original path, so its public API is
unchanged; `service-automation` imports it from `metadata-core`, which it
already declared, and its bundle no longer carries a copy of objectql's code.

Two members stay recorded rather than remediated, because the tree already
carries the decision not to declare them together with its reason
(`@objectstack/runtime` → `@objectstack/driver-turso`, whose bare `import()` is
a host-replaceable default thunk under #6268; `@objectstack/rest` →
`@objectstack/objectql`, whose absence must degrade to `501 NOT_IMPLEMENTED`
rather than fail module load). Their ledger rows carry mechanical evidence and
go red the moment that evidence stops holding — in particular, a `type-only`
row reds on the day its import becomes a value import, which is exactly the
transition nothing caught the first time.
21 changes: 21 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -390,6 +390,27 @@ jobs:
node scripts/check-comment-mask-adoption.mjs --self-test
node scripts/check-comment-mask-adoption.mjs

# A package's published `src/**` may only import workspace packages its own
# manifest DECLARES (#10062). The class was filed with one member and a
# mitigation — "type-only, so nothing lands in the emitted JavaScript" —
# and grew to four with no signal, one of them a VALUE import whose
# implementation the bundler then INLINED into a second package's dist.
# Both halves of the mitigation are now ledger EVIDENCE instead of prose:
# a `type-only` row reds the moment its import stops being type-only.
# Node builtins plus the shared comment mask only — no node_modules, so a
# reviewer can run it in place. Its `--self-test` runs FIRST: the
# production run over a fixed tree is green by construction, so it cannot
# tell a working matcher from a dead one, and the specifier FLOOR is the
# other half (a dead matcher finds nothing, and nothing is what success
# looks like — so it refuses with exit 2 rather than passing).
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
# GATE INVOCATION IDIOM note at the top of this file.
# Scans ~2k non-test src files across 78 workspace packages, no spawns; ~1s.
- name: Published src imports only declared workspace deps
run: |
node scripts/check-undeclared-dep-imports.mjs --self-test
node scripts/check-undeclared-dep-imports.mjs

# The bash-3.2 floor, over every shell file the repo ships (#12221).
# `/usr/bin/env bash` is bash 3.2.57 on macOS -- Apple ships no bash 4+,
# for licensing reasons -- and THIS RUNNER IS BASH 5, where every construct
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,7 @@
"check:test-source-alias": "node scripts/check-test-source-alias.mjs --self-test && node scripts/check-test-source-alias.mjs",
"check:refd-timer-probe": "node scripts/check-refd-timer-probe.mjs --self-test && node scripts/check-refd-timer-probe.mjs",
"check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.mjs",
"check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs",
"check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs",
"check:published-readme-exports": "node scripts/check-published-readme-exports.mjs --self-test && node scripts/check-published-readme-exports.mjs",
"check:published-readme-links": "node scripts/check-published-readme-links.mjs --self-test && node scripts/check-published-readme-links.mjs",
Expand Down
61 changes: 61 additions & 0 deletions packages/metadata-core/src/code-artifact-provenance.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Does a code package ship this name?" — the ADR-0029 D9.6 provenance test.
*
* [#10062] Sunk here from `@objectstack/objectql`'s registry by the same
* criterion as the write-verb dispatch predicates and the audit governance
* table above it in `index.ts`: a second layer needs the answer, and the
* reverse import would either close a cycle or make the consumer depend on the
* whole data engine for one predicate.
*
* The second layer is `@objectstack/service-automation`'s ADR-0048 flow
* precedence, which asks exactly this question to decide which contender for a
* flow name wins. It reached it by importing `@objectstack/objectql` directly —
* a package it does not declare — and because the shared tsup config
* externalises only `dependencies`/`peerDependencies`, the bundler INLINED
* objectql's implementation into `service-automation/dist/index.js`: a second
* copy of another package's code, kept correct by build configuration alone.
* `@objectstack/metadata-core` is the package both sides already declare and it
* depends on neither, so the answer now has one home and one implementation.
*
* `objectql` re-exports `isCodeArtifactBody` from `registry.ts`, so its public
* API is unchanged.
*/

/**
* Is this registered item a TENANT-authored overlay rather than a code-shipped
* artifact? (ADR-0010 `_provenance`: `'package'` for loader-introduced items,
* `'org'` for tenant-authored.)
*
* `_packageId !== 'sys_metadata'` alone cannot answer it. That sentinel marks
* one thing only — an overlay row bound to no package. A row that IS bound to
* one is keyed by its real package id on BOTH sides that register it: the save
* path (#4636 PR1) and the boot-time rehydration of `sys_metadata` (#4636 PR2).
* Either way the key is `app.<slug>`, which is exactly what every code-shipped
* item carries too, so the sentinel test cannot tell them apart. A tenant's own
* overlay came back from a kernel rebuild looking like a code
* artifact, and the protocol's overlay gate refused the next write to it with
* `not_overridable` — an app the user had just built through Studio/AI became
* permanently un-editable at the first kernel rebuild (cloud#970). Provenance is
* the axis that actually distinguishes the two, so ask it.
*/
export function isTenantAuthored(item: unknown): boolean {
return (item as { _provenance?: unknown } | null | undefined)?._provenance === 'org';
}

/**
* [ADR-0029 D9.6] Is this registered body a CODE-shipped artifact?
*
* The exact test `SchemaRegistry.getArtifactItem` has always applied, factored
* out so the object branch and the D9.8 hydration discriminator
* (`SchemaRegistry.getPackagedObjectOwner`) cannot drift into two different
* answers to one question — "does a code package ship this name?". Truthy
* `_packageId`, not the `'sys_metadata'` rehydration sentinel, and not tenant
* provenance.
*/
export function isCodeArtifactBody(item: unknown): boolean {
const it = item as { _packageId?: unknown } | null | undefined;
if (!it || !it._packageId || it._packageId === 'sys_metadata') return false;
return !isTenantAuthored(it);
}
11 changes: 11 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,17 @@ export * from './engine-findone-predicate.js';
// reporting two.
export * from './audit-field-governance.js';

// [#10062] The ADR-0029 D9.6 provenance pair, sunk here from
// `@objectstack/objectql`'s registry by the same criterion as everything above:
// `@objectstack/service-automation` needs the same "does a code package ship
// this name?" answer for ADR-0048 flow precedence, and was reaching it by
// importing objectql — a package it does not declare, so the bundler inlined a
// copy of objectql's implementation into service-automation's dist. Both sides
// already declare THIS package, and it depends on neither. `objectql`
// re-exports `isCodeArtifactBody` from `registry.ts`, so its public API is
// unchanged.
export * from './code-artifact-provenance.js';

// [#6562] The served-document injection/strip pair over the injected-system-
// column definition tables, sunk here by the same criterion and for the same
// cycle as the governance table above. The DEFINITION tables themselves and the
Expand Down
52 changes: 16 additions & 36 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,15 @@ import {
// from one place. Re-exported below under its original name.
ITEM_KEY_DISCRIMINATORS,
readDiscriminatorValue as discriminatorValue,
// [#10062] The ADR-0029 D9.6 provenance pair, sunk into metadata-core for the
// same reason as the table above: `@objectstack/service-automation`'s flow
// precedence asks the same question ("does a code package ship this name?")
// and reached it by importing this package, which it does not declare — so
// the bundler inlined a copy of this implementation into ITS dist. Both names
// keep their original spelling here, and `isCodeArtifactBody` is re-exported
// below, so this package's public API is unchanged.
isCodeArtifactBody,
isTenantAuthored,
} from '@objectstack/metadata-core';
// [#8460] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather
// than re-spelled: the object FOLD asks the same question one layer down (has
Expand DownExpand Up@@ -1144,42 +1153,13 @@ export class NamespaceConflictError extends Error {
}
}

/**
* Is this registered item a TENANT-authored overlay rather than a code-shipped
* artifact? (ADR-0010 `_provenance`: `'package'` for loader-introduced items,
* `'org'` for tenant-authored.)
*
* `_packageId !== 'sys_metadata'` alone cannot answer it. That sentinel marks
* one thing only — an overlay row bound to no package. A row that IS bound to
* one is keyed by its real package id on BOTH sides that register it: the save
* path (#4636 PR1) and the boot-time rehydration of `sys_metadata` (#4636 PR2).
* Either way the key is `app.<slug>`, which is exactly what every code-shipped
* item carries too, so the sentinel test cannot tell them apart. A tenant's own
* overlay came back from a kernel rebuild looking like a code
* artifact, and the protocol's overlay gate refused the next write to it with
* `not_overridable` — an app the user had just built through Studio/AI became
* permanently un-editable at the first kernel rebuild (cloud#970). Provenance is
* the axis that actually distinguishes the two, so ask it.
*/
function isTenantAuthored(item: unknown): boolean {
return (item as { _provenance?: unknown } | null | undefined)?._provenance === 'org';
}

/**
* [ADR-0029 D9.6] Is this registered body a CODE-shipped artifact?
*
* The exact test {@link SchemaRegistry.getArtifactItem} has always applied,
* factored out so the object branch and the D9.8 hydration discriminator
* ({@link SchemaRegistry.getPackagedObjectOwner}) cannot drift into two
* different answers to one question — "does a code package ship this name?".
* Truthy `_packageId`, not the `'sys_metadata'` rehydration sentinel, and not
* tenant provenance.
*/
export function isCodeArtifactBody(item: unknown): boolean {
const it = item as { _packageId?: unknown } | null | undefined;
if (!it || !it._packageId || it._packageId === 'sys_metadata') return false;
return !isTenantAuthored(it);
}
// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
// They now live in `@objectstack/metadata-core`
// (`code-artifact-provenance.ts`), imported at the top of this file and
// re-exported immediately below, so every caller's spelling — including
// `@objectstack/objectql`'s own public export — is unchanged. Their full
// rationale moved with them.
export { isCodeArtifactBody };

// ============================================================================
// i18n bundles — metadata types whose identity is (name, <discriminator>)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,14 @@
// larger change, and ADR-0048 does not ask for it — the ADR's answer for this
// case is a precedence plus a warning, both of which are here.

import { isCodeArtifactBody } from '@objectstack/objectql';
// [#10062] From `@objectstack/metadata-core`, which this package DECLARES —
// not from `@objectstack/objectql`, which it does not. The predicate is the
// same one (it was sunk into metadata-core and objectql re-exports it), but the
// import used to be an undeclared workspace dependency, and because the shared
// tsup config externalises only `dependencies`/`peerDependencies` the bundler
// answered it by inlining objectql's implementation into this package's dist.
// `pnpm check:undeclared-dep-imports` is the gate over that class.
import { isCodeArtifactBody } from '@objectstack/metadata-core';
import type { FlowContender, FlowShadowingRecord } from './engine.js';

/** One flow name's resolved winner, plus the receipt when it displaced others. */
Expand Down
4 changes: 2 additions & 2 deletions packages/services/service-datasource/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,14 +27,14 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"pg-connection-string": "^2.14.0"
},
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-mongodb": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/driver-sqlite-wasm": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/plugin-hono-server": "workspace:*",
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading