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
49 changes: 49 additions & 0 deletions .changeset/cluster-driver-dangling-values-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/spec": minor
"@objectstack/service-cluster": patch
---

feat(spec): remove the dangling `postgres` and `nats` values from `ClusterDriverSchema` (#13393)

<!-- adr-0087: registered cluster-driver-dangling-values-removed -->

**BREAKING** accept-set narrowing on `ClusterDriverSchema`
(`kernel/cluster.zod.ts`), shipped as `minor` under the repo's launch-window
convention for breaking changes; the migration prescription is registered
under protocol major 18.

`postgres` and `nats` validated in `ClusterDriverSchema` but no package
implemented either — the only non-test `registerClusterDriver()` caller is
`@objectstack/service-cluster-redis` — so `defineCluster({ driver: 'postgres' })`
(or `'nats'`) passed schema validation and then reached the unconditional
`Cluster driver "<name>" is not registered` throw at runtime. Maintainer
ruling on objectstack-ai/cloud#1626 (2026-08-24, option B adopted): the
DB-first postgres driver is not built absent concrete customer pull, and —
the ruling's principle rider — a schema-valid value must not be an
unconditional runtime throw. The honest schema states the accept set the
runtime serves.

FROM → TO:

- `cluster: { driver: 'postgres' }` → `cluster: { driver: 'redis', url }`
(`@objectstack/service-cluster-redis`, the production recommendation), or
`cluster: { driver: 'custom' }` + `registerClusterDriver(name, factory)`
for a self-provided transport. Same mapping for `'nats'`. One-line fix:
pick a driver that ships. No stored config breaks at rest — a config
naming either value never survived boot in the first place.
- `ClusterDriver` (the `z.input` type) no longer includes the two spellings;
TypeScript call sites typing them fail `tsc` on upgrade with the same
remedy.
- The `useExistingPool` field **stays** (it is a ledgered authorable field);
only its postgres-only prose was corrected — it is forwarded verbatim to
the registered driver factory and is meaningful for database-backed
`custom` drivers.

If a future ruling flips under the recorded reversal condition (a concrete
multi-node customer/contract), a value returns to the enum in the same
release that ships its implementation.

`@objectstack/service-cluster` patch: doc comments no longer instruct the
removed spellings (`defineCluster({ driver: 'postgres' })` →
`{ driver: 'redis' }` in the `registerClusterDriver()` example); no runtime
behaviour change.
24 changes: 15 additions & 9 deletions content/docs/kernel/cluster.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,17 @@ operator declares a multi-node topology via `OS_EXPECT_MULTI_NODE=true` or
|--------------|-----------------------|----------------------------|---------------------|----------------|-------------|
| `memory` | in-process fan-out | per-key FIFO queue + TTL | Map | Map of bigints | ✅ `@objectstack/service-cluster` |
| `redis` | `PUBLISH`/`SUBSCRIBE` (at-most-once) | `SET … NX PX` + Lua release/renew | `WATCH`/`MULTI` | `INCRBY` | ✅ `@objectstack/service-cluster-redis` |
| `postgres` | `LISTEN/NOTIFY` | advisory locks | dedicated KV table | sequence | ❌ not built |
| `nats` | NATS subjects + JetStream | KV bucket lock | KV bucket | KV INCR | ❌ not built |

Only `memory` and `redis` are implemented. `postgres` and `nats` are accepted
by `ClusterDriverSchema` but no package provides them — `defineCluster()`
throws `Cluster driver "<name>" is not registered` for either. The `custom`
driver value resolves whatever a plugin registered via
`registerClusterDriver(name, factory)`.
`ClusterDriverSchema` accepts exactly the drivers that ship — `memory` and
`redis` — plus `custom`, which resolves whatever a plugin registered via
`registerClusterDriver(name, factory)`. It used to also accept `postgres` and
`nats` with no package behind either, so a schema-valid config reached
`defineCluster()`'s unconditional `Cluster driver "<name>" is not registered`
throw; both values were removed from the enum (maintainer ruling on cloud#1626,
2026-08-24 — a schema-valid value must not be an unconditional runtime throw).
Their design sketches — `postgres` as `LISTEN/NOTIFY` + advisory locks + a KV
table + a sequence, `nats` as subjects + JetStream KV — stay recorded in
Phase 5 below for whoever builds one.

The `redis` driver is the recommended starting point for production
ObjectStack deployments: it accepts a pre-built ioredis client via
Expand Down Expand Up @@ -704,8 +707,11 @@ of the declarative wiring below exists yet.
Postgres driver downgraded to community/optional — useful for the
"one binary, one container" deployment archetype that wants to skip
Redis. NATS deferred until a customer reports throughput needs that
exceed Redis PUBSUB. No protocol changes required at that point — only
a new implementation that calls `registerClusterDriver()`.
exceed Redis PUBSUB. A community driver ships under `driver: 'custom'`
via `registerClusterDriver()` with no protocol change; a first-party
driver restores its enum value in the same release that ships the
implementation (the cloud#1626 ruling records that reversal condition —
the value returns only with an implementation behind it).

## 11. Non-goals (v1)

Expand Down
11 changes: 5 additions & 6 deletions content/docs/references/kernel/cluster.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ description: Cluster protocol schemas

Defines the runtime semantics required for ObjectStack to behave correctly
when more than one Node.js process is involved. The protocol layer codifies
**intent** (scope, delivery, leadership); concrete implementations
(`memory`, `redis`, `postgres`, `nats`) live in `@objectstack/service-cluster`.
**intent** (scope, delivery, leadership); concrete implementations live in
`@objectstack/service-cluster` (`memory`) and
`@objectstack/service-cluster-redis` (`redis`).

The full design rationale is in
`content/docs/kernel/cluster.mdx`. Read it before changing
Expand Down Expand Up @@ -41,9 +42,9 @@ Cluster capability configuration for the stack.

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **driver** | `Enum<'memory' \| 'redis' \| 'postgres' \| 'nats' \| 'custom'>` | optional (default: `"memory"`) | Cluster transport driver. Defaults to in-memory single-process. |
| **driver** | `Enum<'memory' \| 'redis' \| 'custom'>` | optional (default: `"memory"`) | Cluster transport driver. Defaults to in-memory single-process. |
| **url** | `string` | optional | Driver-specific connection URL. |
| **useExistingPool** | `boolean` | optional (default: `true`) | Reuse the main DB pool for the postgres driver. |
| **useExistingPool** | `boolean` | optional (default: `true`) | Reuse the main DB pool for database-backed custom drivers. |
| **nodeId** | `string` | optional | Stable node identifier. Auto-generated when absent. |
| **heartbeatMs** | `integer` | optional (default: `5000`) | Leader-election heartbeat interval in milliseconds. |
| **lockTtlMs** | `integer` | optional (default: `15000`) | Leader-election lock TTL in milliseconds (≥ 3× heartbeatMs). |
Expand All @@ -61,8 +62,6 @@ Cluster transport driver.

* `memory`
* `redis`
* `postgres`
* `nats`
* `custom`


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ describe('the driver registry can be read, not only written (#13330)', () => {
});

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/,
// The other direction. `redis` is accepted by the schema, but this suite
// never imports the driver package whose load-time side effect registers
// it, so in THIS module instance it is exactly the "requested but not
// registered" case — the same shape as the EE boot in the header. (The
// original sample value `postgres` left the schema in #13393: it now
// fails parse inside defineCluster before the registry is consulted.)
expect(listClusterDrivers()).not.toContain('redis');
expect(() => defineCluster({ driver: 'redis' })).toThrow(
/Cluster driver "redis" is not registered/,
);
});
});
10 changes: 5 additions & 5 deletions packages/services/service-cluster/src/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export class ComposedClusterService implements IClusterService {

/**
* Build an `IClusterService` from a `ClusterCapabilityConfig`. The only
* driver shipped from this package is `memory`; other drivers (postgres,
* redis, nats) live in dedicated packages and register themselves via
* driver shipped from this package is `memory`; other drivers (e.g.
* `redis`) live in dedicated packages and register themselves via
* `registerClusterDriver()`.
*
* @example
Expand Down Expand Up @@ -83,7 +83,7 @@ export function defineCluster(
}

// ---------------------------------------------------------------------------
// Driver registry (for postgres/redis/nats/custom drivers)
// Driver registry (for redis/custom drivers)
// ---------------------------------------------------------------------------

export interface DriverFactoryConfig {
Expand All @@ -103,8 +103,8 @@ const driverRegistry = new Map<string, ClusterDriverFactory>();

/**
* Register a custom cluster driver. Driver packages (e.g.
* `@objectstack/service-cluster-postgres`) should call this at module
* load time so `defineCluster({ driver: 'postgres' })` resolves them.
* `@objectstack/service-cluster-redis`) should call this at module
* load time so `defineCluster({ driver: 'redis' })` resolves them.
*/
export function registerClusterDriver(
name: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-cluster/src/memory/counter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ICounter, CounterIncrOptions } from '@objectstack/spec/contracts';

/**
* In-memory monotonic counter. Single-process only — for cross-node id
* allocation, use the postgres or redis driver.
* allocation, use the redis driver.
*/
export class MemoryCounter implements ICounter {
private readonly counters = new Map<string, bigint>();
Expand Down
4 changes: 2 additions & 2 deletions packages/services/service-cluster/src/memory/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import type {
* to at that moment is a silent no-op. This matches what `IPubSub`
* documents: no shipped driver exceeds at-most-once, so handlers must be
* idempotent **and** tolerate loss.
* - No cross-process delivery — use the redis/postgres/nats driver for
* real multi-node setups.
* - No cross-process delivery — use the redis driver (or a registered
* custom driver) for real multi-node setups.
*/
export interface MemoryPubSubOptions {
/** Optional error sink for handler exceptions. Defaults to console.error. */
Expand Down
3 changes: 2 additions & 1 deletion packages/services/service-cluster/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* Generic contract tests for cluster primitives.
*
* These are written once and run against any driver. The memory driver
* suite calls them directly; future postgres/redis driver packages will
* suite calls them directly; driver packages (the redis driver today,
* any future ones)
* `import { runPubSubContract } from '@objectstack/service-cluster/testing'`
* to get the same coverage for free.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/contracts/cluster-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export interface ICounter {
export interface IClusterService {
/** Stable identifier of this node within the cluster. */
readonly nodeId: string;
/** Driver name in use ('memory' | 'redis' | 'postgres' | 'nats' | 'custom'). */
/** Driver name in use ('memory' | 'redis' | 'custom', or a runtime-registered driver name). */
readonly driver: string;
readonly pubsub: IPubSub;
readonly lock: ILock;
Expand Down
30 changes: 27 additions & 3 deletions packages/spec/src/kernel/cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,12 @@ describe('cluster.zod', () => {
expect(parsed.useExistingPool).toBe(true);
});

it('parses a postgres driver config', () => {
it('parses a custom driver config', () => {
const parsed = ClusterCapabilityConfigSchema.parse({
driver: 'postgres',
driver: 'custom',
nodeId: 'node-prod-1',
});
expect(parsed.driver).toBe('postgres');
expect(parsed.driver).toBe('custom');
expect(parsed.nodeId).toBe('node-prod-1');
});

Expand All @@ -162,6 +162,30 @@ describe('cluster.zod', () => {
expect(parsed.url).toBe('redis://localhost:6379');
});

it('enumerates exactly the drivers that ship plus custom', () => {
// Pin the roster: a value must not re-enter this enum without an
// implementation behind it (cloud#1626 ruling, 2026-08-24).
expect(ClusterDriverSchema.options).toEqual(['memory', 'redis', 'custom']);
});

it('rejects the removed dangling drivers postgres and nats by name', () => {
for (const removed of ['postgres', 'nats']) {
const result = ClusterDriverSchema.safeParse(removed);
expect(result.success).toBe(false);
if (!result.success) {
const issue = result.error.issues[0];
// zod v4 invalid_value issue: `values` is the accept set — the
// removed spelling must not be in it.
expect(issue.code).toBe('invalid_value');
expect((issue as { values?: unknown[] }).values).toEqual([
'memory', 'redis', 'custom',
]);
}
const config = ClusterCapabilityConfigSchema.safeParse({ driver: removed });
expect(config.success).toBe(false);
}
});

it('rejects unknown driver', () => {
expect(() =>
ClusterDriverSchema.parse('etcd'),
Expand Down
36 changes: 21 additions & 15 deletions packages/spec/src/kernel/cluster.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { lazySchema } from '../shared/lazy-schema';
*
* Defines the runtime semantics required for ObjectStack to behave correctly
* when more than one Node.js process is involved. The protocol layer codifies
* **intent** (scope, delivery, leadership); concrete implementations
* (`memory`, `redis`, `postgres`, `nats`) live in `@objectstack/service-cluster`.
* **intent** (scope, delivery, leadership); concrete implementations live in
* `@objectstack/service-cluster` (`memory`) and
* `@objectstack/service-cluster-redis` (`redis`).
*
* The full design rationale is in
* `content/docs/kernel/cluster.mdx`. Read it before changing
Expand Down Expand Up @@ -202,16 +203,21 @@ export type ServiceClusterAnnotationsParsed = z.infer<typeof ServiceClusterAnnot
* Cluster driver identifier.
*
* Selects which transport implements the four primitives (PubSub, Lock,
* KV, Counter). The protocol enumerates the drivers we expect to ship;
* additional drivers can be registered at runtime by plugins.
* KV, Counter). The protocol enumerates only the drivers that actually
* ship; additional drivers can be registered at runtime by plugins and
* selected via `custom`.
*
* The formerly declared `postgres` and `nats` values were removed: no
* package implemented them, so a schema-valid config was an unconditional
* runtime throw (maintainer ruling on objectstack-ai/cloud#1626,
* 2026-08-24 — a value returns only together with an implementation
* behind it).
*
* @see content/docs/kernel/cluster.mdx §8
*/
export const ClusterDriverSchema = z.enum([
'memory', // single-process; in-EventEmitter + Map + mutex + int
'redis', // Redis Pub/Sub + SETNX-with-TTL + GET/SET + INCR
'postgres', // LISTEN/NOTIFY + advisory locks + KV table + sequence
'nats', // NATS subjects + KV bucket lock + KV bucket + KV INCR
'custom', // Plugin-provided driver; runtime looks it up by name.
]).describe('Cluster transport driver.');

Expand Down Expand Up @@ -243,8 +249,8 @@ export type ClusterTenantIsolation = z.input<typeof ClusterTenantIsolationSchema
* ```ts
* defineStack({
* cluster: {
* driver: 'postgres',
* useExistingPool: true,
* driver: 'redis',
* url: 'redis://cache.internal:6379',
* nodeId: process.env.NODE_ID,
* },
* })
Expand All @@ -260,21 +266,21 @@ export const ClusterCapabilityConfigSchema = lazySchema(() => z.object({
.describe('Cluster transport driver. Defaults to in-memory single-process.'),

/**
* Driver-specific connection string. Required for `redis` and `nats`,
* optional for `postgres` (defaults to the main DB pool when
* `useExistingPool` is true).
* Driver-specific connection string. Required for `redis`; a `custom`
* driver reads it as its factory defines.
*/
url: z.string().url().optional()
.describe('Driver-specific connection URL.'),

/**
* When `driver === 'postgres'`, reuse the main application database
* pool instead of opening a dedicated one. Recommended for small/medium
* deployments — zero new infrastructure.
* Reuse the main application database pool instead of opening a
* dedicated one. Forwarded verbatim to the registered driver factory;
* meaningful only for database-backed `custom` drivers — the built-in
* `memory` and `redis` drivers ignore it.
* @default true
*/
useExistingPool: z.boolean().optional().default(true)
.describe('Reuse the main DB pool for the postgres driver.'),
.describe('Reuse the main DB pool for database-backed custom drivers.'),

/**
* Stable identifier for this node. Used by leader election and trace
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'cluster-driver-dangling-values-removed',
surface: 'kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) '
+ '- the `postgres` and `nats` enum values',
replacement: 'the drivers that actually ship - `memory` (single-process '
+ 'default), `redis` (@objectstack/service-cluster-redis, the production '
+ 'recommendation), or `custom` + registerClusterDriver(name, factory) for '
+ 'a self-provided transport. A config naming `postgres` or `nats` never '
+ 'worked: pick `redis`, or register the transport yourself under `custom`',
reason:
'Maintainer ruling on objectstack-ai/cloud#1626 (2026-08-24, option B '
+ 'adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud '
+ 'differentiation, and a DB-first postgres cluster driver is not built '
+ 'absent concrete customer pull. The ruling\'s principle rider decides '
+ 'this entry: a schema-valid value must not be an unconditional runtime '
+ 'throw. Both removed values were dangling by the same measurement - the '
+ 'only non-test registerClusterDriver() caller is service-cluster-redis, '
+ 'so `driver: \'postgres\'` or `driver: \'nats\'` passed schema '
+ 'validation and then reached defineCluster()\'s unconditional `Cluster '
+ 'driver "<name>" is not registered` throw. It is a SEMANTIC entry '
+ 'rather than a mechanical conversion because the right replacement is a '
+ 'deployment decision (which transport actually backs this cluster), not '
+ 'a rename a codemod could apply; nothing at rest breaks, because a '
+ 'stored config naming either value never survived boot in the first '
+ 'place. The ruling records its own reversal condition: a value returns '
+ 'to the enum only in the release that ships an implementation behind '
+ 'it. No authorable KEY was retired (the `useExistingPool` field stays, '
+ 'reworded), so nothing lands in RETIRED_KEYS_BY_MAJOR.',
acceptanceCriteria:
'No `cluster.driver` config names `postgres` or `nats`; '
+ '`ClusterDriverSchema.parse` on the chosen driver value succeeds; a '
+ 'deployment that needed a distributed transport boots on `redis` (or '
+ 'its `custom` registration) and `defineCluster()` no longer throws '
+ '`Cluster driver "<name>" is not registered` at startup. TypeScript '
+ 'call sites that typed the removed spellings against `ClusterDriver` '
+ 'fail tsc on upgrade; the fix is choosing a shipped driver, never '
+ 'widening a local mirror of the enum.',
};
Loading
Loading