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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it by os-steve · Pull Request #14042 · objectstack-ai/objectstack · GitHub
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
56 changes: 56 additions & 0 deletions .changeset/host-importer-esm-condition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/types": patch
"@objectstack/service-cluster": minor
"@objectstack/cli": patch
---

fix(types,cli): resolve host-declared packages through the `import` condition, and read the cluster registry instead of assuming it (#13330)

`createHostImporter`'s declared leg resolved with `hostRequire.resolve(pkg)` — a
**CommonJS** resolution, which answers the `require` condition. Every `tsup`
dual build publishes `{ "import": "./dist/index.js", "require": "./dist/index.cjs" }`,
so a package loaded through that leg evaluated as its **CommonJS** build while
the callers (`packages/cli` is `"type": "module"`) held the **ESM** build of the
same package. The process ended up with two instances of everything the loaded
package shares with its caller, each with its own module-scope state.

Measured consequence, on the shipped EE multi-node path (ADR-0018): `os serve`
loaded `@objectstack/service-cluster-redis` through this leg, the driver's
load-time `registerClusterDriver('redis', …)` ran against the CommonJS copy of
`@objectstack/service-cluster`, and the ESM `Runtime` read the ESM copy and
found nothing — `OS_CLUSTER_DRIVER=redis` died at `defineCluster()` with
`Cluster driver "redis" is not registered`, about a package that was installed,
declared and resolvable. Any module-scope registry crossing this seam had the
same defect; the cluster driver is the instance that shipped.

**The seam.** The declared leg now imports the entry the `import` condition
names. The host anchor is untouched — the CJS resolver still answers *where*
the package is, because no flagless Node API resolves a bare specifier against
an arbitrary parent; only the *condition* is re-decided, by reading that
package's own `exports` map. Deliberately narrow at the **resolution** level —
no load that works today resolves differently unless the package itself
publishes a valid, existing import-condition target: a package with no
`exports` map is untouched (CJS resolution already returned `main`), a package
publishing no import-condition target is untouched, and anything unreadable or
absent on disk falls back to the CJS-resolved path. That narrowness does not
extend to **evaluation**: a dual-published package whose `import` build exists
but throws while its `require` build works used to mask that break by silently
loading the CJS build, and now surfaces it — arguably the correct reading of a
broken published build, but a behaviour change, not a no-op.

**The reading.** A residual split is still possible above the seam — two
*physical* copies of one package are two instances in any module system, and no
resolver condition merges them — so `os serve` no longer assumes the driver
registered. `@objectstack/service-cluster` exports `listClusterDrivers()`, the
registry `defineCluster()` itself consults, and `serve` queries it after the
load. The silent `catch` is gone: a driver that loaded but stayed invisible, one
that could not be resolved, and one that resolved and then crashed now read as
three different diagnoses instead of arriving as `not registered` one line
later. An app on an older `@objectstack/service-cluster` has no accessor to
call; that case is silent — `serve` declines to claim either answer rather
than printing one.

No behaviour downstream of the diagnosis changed: an absent driver still reaches
`defineCluster()`'s documented error (`cluster.mdx` §8.1) rather than silently
downgrading to the in-memory cluster, and the only documented downgrade here —
a multi-node gate denial — is untouched.
111 changes: 103 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2406,7 +2406,11 @@ export default class Serve extends Command {
// The remote driver self-registers on import; import it dynamically so it
// works in BOTH config-boot and compiled-artifact mode. Open-core ships
// only the in-memory driver — remote drivers (e.g. redis) come from the EE
// distribution; if absent we fall back to the in-memory cluster.
// distribution. An absent driver does NOT fall back to the in-memory
// cluster: `clusterConfig` still names it and `defineCluster()` raises
// its documented error (cluster.mdx §8.1). The only documented downgrade
// here is a multi-node GATE DENIAL, below. (#13330 — this sentence said
// the opposite for as long as the silent catch below agreed with it.)
let clusterConfig: { driver: string; url?: string } | undefined;
// The gate's verdict, held for the operator-facing telemetry emitted near
// the end of boot (#12667). The gate is consulted exactly once per
Expand All@@ -2432,9 +2436,15 @@ export default class Serve extends Command {
// '@objectstack/service-cluster'` and took the whole boot down — while
// app-side code loaded the very same package fine.
const __clusterPkg: string = '@objectstack/service-cluster';
const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as {
// The whole namespace, not just the gate: the DRIVER REGISTRY read
// further down has to come from this same module instance, because
// that is the instance `defineCluster()` consults (#13330).
const __clusterModule = (await importFromHost(__clusterPkg)) as {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
};
const { checkMultiNodeAllowed } = __clusterModule;
// Ask the gate about the topology the operator actually DECLARED.
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
// has nothing to clamp against — so the licensed-overflow verdict was
Expand DownExpand Up@@ -2467,12 +2477,97 @@ export default class Serve extends Command {
const __capAdvisory = formatMultiNodeCapAdvisory(__gate);
if (__capAdvisory) console.warn(__capAdvisory);
// Same host-anchored resolution as the gate above — the shipped
// drivers (`-redis`, `-postgres`, …) are app-declared too. The catch
// stays deliberately silent: the driver may already have been
// registered by the loaded config, and an absent driver is a
// documented fall-back to the in-memory cluster, not a boot failure.
try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); }
catch { /* may already be registered by the loaded config */ }
// drivers (`-redis`, `-postgres`, …) are app-declared too.
//
// ── Why this is no longer a silent catch (#13330) ────────────────
//
// A driver package's entire contract is a load-time SIDE EFFECT:
// `registerClusterDriver('<driver>', …)` into the module-scope
// registry of `@objectstack/service-cluster`, which `defineCluster()`
// reads two statements below. Whether that side effect landed is a
// fact about THIS process, so it is read here rather than assumed.
//
// It used to be assumed. The catch was silent on two stated grounds —
// "may already be registered by the loaded config" and "an absent
// driver is a documented fall-back to the in-memory cluster" — and a
// single EE boot measured both wrong at once:
//
// • the load SUCCEEDED and the registration was invisible. The
// declared leg of `importFromHost` resolved with CommonJS
// semantics, so the driver ran as its `.cjs` build and registered
// into a SECOND instance of the registry, while the ESM Runtime
// read the first. Fixed at the seam (`@objectstack/types/node`);
// this reading is what makes any residual split audible instead
// of arriving as "not registered" one line later.
// • an absent driver falls back to nothing HERE — `clusterConfig`
// below names the driver either way, so `defineCluster()` raises
// its documented error (cluster.mdx §8.1). That is left exactly
// as it is: downgrading to in-memory instead would boot a silent
// single node for an operator who explicitly asked for a remote
// driver, and on the multi-replica deployments this matters for,
// the ADR-0010 split-brain guard throws on that downgrade anyway.
// What changes is only that the reason is no longer swallowed.
//
// Nothing below throws: every branch is a diagnosis printed ahead of
// behaviour that is unchanged.
let __driverLoadError: unknown;
try {
await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`);
} catch (err) {
__driverLoadError = err;
}
// `undefined` ⇒ the app's `@objectstack/service-cluster` predates
// `listClusterDrivers`, so the registry cannot be read from here.
// That is NOT MEASURED — it is not "registered" and not "missing",
// and no branch below claims either.
const __registeredDrivers =
typeof __clusterModule.listClusterDrivers === 'function'
? __clusterModule.listClusterDrivers()
: undefined;
const __driverVisible =
__registeredDrivers === undefined
? undefined
: __registeredDrivers.indexOf(__clusterDriver) >= 0;
if (__driverVisible !== true) {
if (__driverLoadError !== undefined) {
// Resolution failures carry a kind and are already worded for an
// operator by `createHostImporter`; anything else RESOLVED and
// then crashed while evaluating. Swallowing the second is how a
// driver with a broken dependency reported as "not registered",
// sending operators to look for a package already installed.
const __kind = hostImportFailureKind(__driverLoadError);
if (__kind !== undefined) {
console.warn(
`[cluster] driver "${__clusterDriver}" was requested but could not be ` +
`loaded (${__kind}):\n${
__driverLoadError instanceof Error
? __driverLoadError.message
: String(__driverLoadError)
}`,
);
} else {
console.warn(
`[cluster] driver "${__clusterDriver}" resolved but threw while loading — ` +
`this is the driver package's own failure, not a missing package:`,
__driverLoadError,
);
}
} else if (__driverVisible === false) {
// Loaded cleanly and still not in the registry: two live
// instances of `@objectstack/service-cluster` in one process,
// which is a PHYSICAL-copy split no resolver condition can merge.
console.warn(
`[cluster] driver "${__clusterDriver}" loaded but did not register: ` +
`@objectstack/service-cluster-${__clusterDriver} evaluated without error, yet the ` +
`registry this boot reads holds [${__registeredDrivers?.join(', ') || 'nothing'}]. ` +
`Two instances of @objectstack/service-cluster are live in this process and the ` +
`driver registered into the other one — look for two physical copies (a version ` +
`skew between the app and the framework, or a bundled one). Importing ` +
`"@objectstack/service-cluster-${__clusterDriver}" from objectstack.config.ts ` +
`registers into the instance the Runtime reads.`,
);
}
}
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
}
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13330 — the driver registry is READABLE, and what it reads is what
* `defineCluster()` consults.
*
* A driver package's whole contract is a load-time side effect into the
* module-scope `driverRegistry` here. Until now a booting process could only
* discover whether that side effect had landed by calling `defineCluster()`
* and catching the throw — which constructs a real cluster on success, so it
* is not a probe anyone can run first. `os serve` therefore ASSUMED the
* registration, in a silent `catch`, and a shipped EE boot proved the
* assumption wrong: the driver had loaded into a second, CommonJS instance of
* this module, and the ESM Runtime read this one and found nothing.
*
* The accessor exists so that boot can read instead of assume. Its whole value
* rests on agreeing with `defineCluster()` — an accessor that could drift from
* the lookup it reports on would make `serve`'s diagnosis a phantom check —
* so the agreement is pinned here in both directions, not just the shape of
* the list.
*/

import { describe, it, expect } from 'vitest';
import type { IClusterService } from '@objectstack/spec/contracts';
import { defineCluster, listClusterDrivers, registerClusterDriver } from './cluster.js';

/** A factory whose product is identifiable without connecting to anything. */
const marker = { driver: 'fixture-marker' } as unknown as IClusterService;

describe('the driver registry can be read, not only written (#13330)', () => {
it('CONTROL: the reader can return both answers, so an empty list is a reading', () => {
// Nothing has registered yet in this module instance, and the reader is not
// stuck on that answer — every assertion below depends on it moving.
expect(listClusterDrivers()).toEqual([]);
registerClusterDriver('custom', () => marker);
expect(listClusterDrivers()).toEqual(['custom']);
});

it('omits `memory`, which defineCluster special-cases rather than registers', () => {
// A true reading of what the REGISTRY holds. Listing `memory` here would
// make an empty registry look populated to the one caller that needs to
// tell those apart.
expect(listClusterDrivers()).not.toContain('memory');
expect(defineCluster({ driver: 'memory' }).driver).toBe('memory');
});

it('agrees with defineCluster — listed means resolvable', () => {
expect(listClusterDrivers()).toContain('custom');
expect(defineCluster({ driver: 'custom' })).toBe(marker);
});

it('agrees with defineCluster — unlisted means the documented throw', () => {
// The other direction. `postgres` is accepted by the schema and shipped by
// nobody, which is exactly the "requested but not registered" case.
expect(listClusterDrivers()).not.toContain('postgres');
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
/Cluster driver "postgres" is not registered/,
);
});
});
23 changes: 23 additions & 0 deletions packages/services/service-cluster/src/cluster.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,29 @@ export function registerClusterDriver(
driverRegistry.set(name, factory);
}

/**
* The driver names currently in this module instance's registry.
*
* Exported so a boot sequence can READ whether a driver package's load-time
* `registerClusterDriver()` actually landed, instead of assuming it did.
* `defineCluster()` consults this same `Map`, so an answer from here is an
* answer about the call that comes next — which is the whole point (#13330:
* `os serve` loaded a driver that registered into a SECOND, CommonJS instance
* of this module and then failed one line later in `defineCluster` with
* "not registered", with nothing between the two to say so).
*
* `memory` is deliberately absent: it is not registered, it is special-cased
* inside `defineCluster`. This lists what the REGISTRY holds, so an empty array
* is a true and useful reading rather than a misleading one.
*
* A list rather than a `has()` predicate because the caller that needs the
* boolean also needs to print what WAS there when the answer is no — one call,
* both readings, no way for the two to drift.
*/
export function listClusterDrivers(): string[] {
return [...driverRegistry.keys()];
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
export {
defineCluster,
registerClusterDriver,
listClusterDrivers,
ComposedClusterService,
type ClusterDriverFactory,
type DriverFactoryConfig,
Expand Down
Loading
Loading