Skip to content

Commit c85a265

Browse files
feat(spec): remove the dangling postgres and nats values from ClusterDriverSchema (#14128)
* feat(spec): remove dangling postgres/nats values from ClusterDriverSchema Both values validated in ClusterDriverSchema while no package implemented them — the only non-test registerClusterDriver() caller is service-cluster-redis — so a schema-valid driver value reached defineCluster()'s unconditional "not registered" throw at runtime. Removes the two enum values, corrects the now-false postgres-only prose around useExistingPool/url (the ledgered field itself stays), replaces the postgres doc examples with redis, registers the semantic migration entry under protocol major 18, and pins the shipped roster plus the by-name rejection of the two removed spellings. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 * test(service-cluster): use redis as the unlisted-driver sample in the registry pin The registry read-vs-throw agreement pin used postgres as its schema-valid but never-registered sample; with postgres removed from ClusterDriverSchema the config now fails parse before the registry is consulted, so the expected "not registered" throw is unreachable. redis keeps the pin's exact intent: in the enum, but this suite never imports the package whose load-time side effect registers it, so in this module instance it is the requested-but-not- registered case — the same shape as the EE boot the suite's header records. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 20b79be commit c85a265

13 files changed

Lines changed: 217 additions & 48 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-cluster": patch
4+
---
5+
6+
feat(spec): remove the dangling `postgres` and `nats` values from `ClusterDriverSchema` (#13393)
7+
8+
<!-- adr-0087: registered cluster-driver-dangling-values-removed -->
9+
10+
**BREAKING** accept-set narrowing on `ClusterDriverSchema`
11+
(`kernel/cluster.zod.ts`), shipped as `minor` under the repo's launch-window
12+
convention for breaking changes; the migration prescription is registered
13+
under protocol major 18.
14+
15+
`postgres` and `nats` validated in `ClusterDriverSchema` but no package
16+
implemented either — the only non-test `registerClusterDriver()` caller is
17+
`@objectstack/service-cluster-redis` — so `defineCluster({ driver: 'postgres' })`
18+
(or `'nats'`) passed schema validation and then reached the unconditional
19+
`Cluster driver "<name>" is not registered` throw at runtime. Maintainer
20+
ruling on objectstack-ai/cloud#1626 (2026-08-24, option B adopted): the
21+
DB-first postgres driver is not built absent concrete customer pull, and —
22+
the ruling's principle rider — a schema-valid value must not be an
23+
unconditional runtime throw. The honest schema states the accept set the
24+
runtime serves.
25+
26+
FROM → TO:
27+
28+
- `cluster: { driver: 'postgres' }` → `cluster: { driver: 'redis', url }`
29+
(`@objectstack/service-cluster-redis`, the production recommendation), or
30+
`cluster: { driver: 'custom' }` + `registerClusterDriver(name, factory)`
31+
for a self-provided transport. Same mapping for `'nats'`. One-line fix:
32+
pick a driver that ships. No stored config breaks at rest — a config
33+
naming either value never survived boot in the first place.
34+
- `ClusterDriver` (the `z.input` type) no longer includes the two spellings;
35+
TypeScript call sites typing them fail `tsc` on upgrade with the same
36+
remedy.
37+
- The `useExistingPool` field **stays** (it is a ledgered authorable field);
38+
only its postgres-only prose was corrected — it is forwarded verbatim to
39+
the registered driver factory and is meaningful for database-backed
40+
`custom` drivers.
41+
42+
If a future ruling flips under the recorded reversal condition (a concrete
43+
multi-node customer/contract), a value returns to the enum in the same
44+
release that ships its implementation.
45+
46+
`@objectstack/service-cluster` patch: doc comments no longer instruct the
47+
removed spellings (`defineCluster({ driver: 'postgres' })` →
48+
`{ driver: 'redis' }` in the `registerClusterDriver()` example); no runtime
49+
behaviour change.

‎content/docs/kernel/cluster.mdx‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -595,14 +595,17 @@ operator declares a multi-node topology via `OS_EXPECT_MULTI_NODE=true` or
595595
|--------------|-----------------------|----------------------------|---------------------|----------------|-------------|
596596
| `memory` | in-process fan-out | per-key FIFO queue + TTL | Map | Map of bigints | ✅ `@objectstack/service-cluster` |
597597
| `redis` | `PUBLISH`/`SUBSCRIBE` (at-most-once) | `SET … NX PX` + Lua release/renew | `WATCH`/`MULTI` | `INCRBY` | ✅ `@objectstack/service-cluster-redis` |
598-
| `postgres` | `LISTEN/NOTIFY` | advisory locks | dedicated KV table | sequence | ❌ not built |
599-
| `nats` | NATS subjects + JetStream | KV bucket lock | KV bucket | KV INCR | ❌ not built |
600598

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

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

710716
## 11. Non-goals (v1)
711717

‎content/docs/references/kernel/cluster.mdx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ description: Cluster protocol schemas
99

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

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

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

6263
* `memory`
6364
* `redis`
64-
* `postgres`
65-
* `nats`
6665
* `custom`
6766

6867

‎packages/services/service-cluster/src/cluster-driver-registry.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,15 @@ describe('the driver registry can be read, not only written (#13330)', () => {
5050
});
5151

5252
it('agrees with defineCluster — unlisted means the documented throw', () => {
53-
// The other direction. `postgres` is accepted by the schema and shipped by
54-
// nobody, which is exactly the "requested but not registered" case.
55-
expect(listClusterDrivers()).not.toContain('postgres');
56-
expect(() => defineCluster({ driver: 'postgres' })).toThrow(
57-
/Cluster driver "postgres" is not registered/,
53+
// The other direction. `redis` is accepted by the schema, but this suite
54+
// never imports the driver package whose load-time side effect registers
55+
// it, so in THIS module instance it is exactly the "requested but not
56+
// registered" case — the same shape as the EE boot in the header. (The
57+
// original sample value `postgres` left the schema in #13393: it now
58+
// fails parse inside defineCluster before the registry is consulted.)
59+
expect(listClusterDrivers()).not.toContain('redis');
60+
expect(() => defineCluster({ driver: 'redis' })).toThrow(
61+
/Cluster driver "redis" is not registered/,
5862
);
5963
});
6064
});

‎packages/services/service-cluster/src/cluster.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export class ComposedClusterService implements IClusterService {
4545

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

8585
// ---------------------------------------------------------------------------
86-
// Driver registry (for postgres/redis/nats/custom drivers)
86+
// Driver registry (for redis/custom drivers)
8787
// ---------------------------------------------------------------------------
8888

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

104104
/**
105105
* Register a custom cluster driver. Driver packages (e.g.
106-
* `@objectstack/service-cluster-postgres`) should call this at module
107-
* load time so `defineCluster({ driver: 'postgres' })` resolves them.
106+
* `@objectstack/service-cluster-redis`) should call this at module
107+
* load time so `defineCluster({ driver: 'redis' })` resolves them.
108108
*/
109109
export function registerClusterDriver(
110110
name: string,

‎packages/services/service-cluster/src/memory/counter.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ICounter, CounterIncrOptions } from '@objectstack/spec/contracts';
44

55
/**
66
* In-memory monotonic counter. Single-process only — for cross-node id
7-
* allocation, use the postgres or redis driver.
7+
* allocation, use the redis driver.
88
*/
99
export class MemoryCounter implements ICounter {
1010
private readonly counters = new Map<string, bigint>();

‎packages/services/service-cluster/src/memory/pubsub.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import type {
2222
* to at that moment is a silent no-op. This matches what `IPubSub`
2323
* documents: no shipped driver exceeds at-most-once, so handlers must be
2424
* idempotent **and** tolerate loss.
25-
* - No cross-process delivery — use the redis/postgres/nats driver for
26-
* real multi-node setups.
25+
* - No cross-process delivery — use the redis driver (or a registered
26+
* custom driver) for real multi-node setups.
2727
*/
2828
export interface MemoryPubSubOptions {
2929
/** Optional error sink for handler exceptions. Defaults to console.error. */

‎packages/services/service-cluster/src/testing.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Generic contract tests for cluster primitives.
55
*
66
* These are written once and run against any driver. The memory driver
7-
* suite calls them directly; future postgres/redis driver packages will
7+
* suite calls them directly; driver packages (the redis driver today,
8+
* any future ones)
89
* `import { runPubSubContract } from '@objectstack/service-cluster/testing'`
910
* to get the same coverage for free.
1011
*/

‎packages/spec/src/contracts/cluster-service.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ export interface ICounter {
263263
export interface IClusterService {
264264
/** Stable identifier of this node within the cluster. */
265265
readonly nodeId: string;
266-
/** Driver name in use ('memory' | 'redis' | 'postgres' | 'nats' | 'custom'). */
266+
/** Driver name in use ('memory' | 'redis' | 'custom', or a runtime-registered driver name). */
267267
readonly driver: string;
268268
readonly pubsub: IPubSub;
269269
readonly lock: ILock;

‎packages/spec/src/kernel/cluster.test.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,12 @@ describe('cluster.zod', () => {
144144
expect(parsed.useExistingPool).toBe(true);
145145
});
146146

147-
it('parses a postgres driver config', () => {
147+
it('parses a custom driver config', () => {
148148
const parsed = ClusterCapabilityConfigSchema.parse({
149-
driver: 'postgres',
149+
driver: 'custom',
150150
nodeId: 'node-prod-1',
151151
});
152-
expect(parsed.driver).toBe('postgres');
152+
expect(parsed.driver).toBe('custom');
153153
expect(parsed.nodeId).toBe('node-prod-1');
154154
});
155155

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

165+
it('enumerates exactly the drivers that ship plus custom', () => {
166+
// Pin the roster: a value must not re-enter this enum without an
167+
// implementation behind it (cloud#1626 ruling, 2026-08-24).
168+
expect(ClusterDriverSchema.options).toEqual(['memory', 'redis', 'custom']);
169+
});
170+
171+
it('rejects the removed dangling drivers postgres and nats by name', () => {
172+
for (const removed of ['postgres', 'nats']) {
173+
const result = ClusterDriverSchema.safeParse(removed);
174+
expect(result.success).toBe(false);
175+
if (!result.success) {
176+
const issue = result.error.issues[0];
177+
// zod v4 invalid_value issue: `values` is the accept set — the
178+
// removed spelling must not be in it.
179+
expect(issue.code).toBe('invalid_value');
180+
expect((issue as { values?: unknown[] }).values).toEqual([
181+
'memory', 'redis', 'custom',
182+
]);
183+
}
184+
const config = ClusterCapabilityConfigSchema.safeParse({ driver: removed });
185+
expect(config.success).toBe(false);
186+
}
187+
});
188+
165189
it('rejects unknown driver', () => {
166190
expect(() =>
167191
ClusterDriverSchema.parse('etcd'),

0 commit comments

Comments
 (0)