@objectstack/service-cluster-redis 17.0.0-rc.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,212 @@
1
1
  # @objectstack/service-cluster-redis
2
2
 
3
+ ## 17.0.0-rc.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
8
+
9
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
10
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
11
+ inside the npm package and is what an upgrading agent greps after the tombstone
12
+ error." That delivery path was severed for 68 of the 69 publishable packages:
13
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
14
+ older npm versions — not `CHANGELOG.md`, and the canonical
15
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
16
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
17
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
18
+ explicitly.
19
+
20
+ The tombstone-error scenario is precisely the one where the repo is out of
21
+ reach — the upgrading agent has `node_modules` and nothing else — so the
22
+ migration text has to ride in the tarball. Every publishable package now
23
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
24
+ `["dist", "README.md", "CHANGELOG.md"]`.
25
+
26
+ The other half is the gate: `check:published-files` gains a fifth invariant,
27
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
28
+ always-required lint job, so the next package cannot silently sever the path
29
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
30
+ into the canonical set.
31
+
32
+ Consumer-visible change: one more file per install (the package's changelog,
33
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
34
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
35
+ promised.
36
+
37
+ - cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248)
38
+
39
+ These 20 packages declared no `files` field, so npm fell back to packing the
40
+ whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks`
41
+ listed **21 files** — 15 under `src/`, three of them unit tests
42
+ (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the
43
+ build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at
44
+ publish time rather than instead of it, so consumers were installing the
45
+ TypeScript sources and the test suite alongside the artifact they asked for.
46
+
47
+ Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages
48
+ that already did. Nothing a consumer imports moves: every `main` / `types` /
49
+ `exports` target in all 20 already resolved inside `dist/`, which the new
50
+ `check:published-files` guard verifies rather than assumes. The visible change
51
+ is a smaller install and a smaller dependency-scanning surface — `npm pack` on
52
+ `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`.
53
+
54
+ The other half of the fix is the gate. Half the packages declaring `files` and
55
+ half not was the #3786 shape — a hand-copied convention with nothing enforcing
56
+ it, where whoever forgets the line gets no signal at all. `check:published-files`
57
+ (new, wired into the always-required `lint` job) holds every non-private
58
+ workspace package to four invariants: `files` is **declared**; it is
59
+ **sufficient** (covers every entry point, so tightening a whitelist cannot ship
60
+ a package that fails to resolve); it is **minimal** (admits no test, test-harness
61
+ config or build script); and anything beyond `dist` + `README.md` is
62
+ **registered** with a reason, reconciled in both directions so a stale exemption
63
+ is an error rather than dead text. `@objectstack/spec` is the one package with
64
+ registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and
65
+ `CHANGELOG.md` are product, not build input.
66
+
67
+ This also closes an assumption #4206 was resting on. Excluding `<pkg>/scripts/**`
68
+ from the docs-drift implementation test is sound only while no package publishes
69
+ `scripts/` as runtime code; that held, but it held because someone read all three
70
+ offenders by hand. It is now checked on every PR.
71
+
72
+ - Updated dependencies [6a67d7a]
73
+ - Updated dependencies [0ecc656]
74
+ - Updated dependencies [06772eb]
75
+ - Updated dependencies [270650f]
76
+ - Updated dependencies [3aef718]
77
+ - Updated dependencies [1ea6bce]
78
+ - Updated dependencies [c1dcacd]
79
+ - Updated dependencies [ad303ed]
80
+ - Updated dependencies [32ccb23]
81
+ - Updated dependencies [f5a4ef0]
82
+ - Updated dependencies [2d3e255]
83
+ - Updated dependencies [7d7521f]
84
+ - Updated dependencies [5dc4d02]
85
+ - Updated dependencies [05154a1]
86
+ - Updated dependencies [9b6fe7c]
87
+ - Updated dependencies [8c711fb]
88
+ - Updated dependencies [09e4547]
89
+ - Updated dependencies [91f4c78]
90
+ - Updated dependencies [820eff9]
91
+ - Updated dependencies [8d895ff]
92
+ - Updated dependencies [f6472d7]
93
+ - Updated dependencies [78caf51]
94
+ - Updated dependencies [62a789b]
95
+ - Updated dependencies [789ad63]
96
+ - Updated dependencies [2af1988]
97
+ - Updated dependencies [2e836de]
98
+ - Updated dependencies [12a19a8]
99
+ - Updated dependencies [41dcda3]
100
+ - Updated dependencies [c8124e5]
101
+ - Updated dependencies [a1a4140]
102
+ - Updated dependencies [217e2e6]
103
+ - Updated dependencies [86a71d1]
104
+ - Updated dependencies [d5c75e2]
105
+ - Updated dependencies [03d26f7]
106
+ - Updated dependencies [4384921]
107
+ - Updated dependencies [3c628ce]
108
+ - Updated dependencies [7cb922e]
109
+ - Updated dependencies [1d22114]
110
+ - Updated dependencies [b5f9397]
111
+ - Updated dependencies [ed77493]
112
+ - Updated dependencies [58a03d2]
113
+ - Updated dependencies [dc530b4]
114
+ - Updated dependencies [e59786e]
115
+ - Updated dependencies [bcf1112]
116
+ - Updated dependencies [9774b78]
117
+ - Updated dependencies [b07d829]
118
+ - Updated dependencies [a648e96]
119
+ - Updated dependencies [a47ac06]
120
+ - Updated dependencies [e4c61a7]
121
+ - Updated dependencies [cc60165]
122
+ - Updated dependencies [081aa6f]
123
+ - Updated dependencies [91f4c78]
124
+ - Updated dependencies [e8d0c21]
125
+ - Updated dependencies [c1d44f7]
126
+ - Updated dependencies [ab9fb5c]
127
+ - Updated dependencies [f985b3f]
128
+ - Updated dependencies [9a4932a]
129
+ - Updated dependencies [f9fc874]
130
+ - Updated dependencies [011b386]
131
+ - Updated dependencies [7777e8f]
132
+ - Updated dependencies [507b92a]
133
+ - Updated dependencies [7309c81]
134
+ - Updated dependencies [20bc1ec]
135
+ - Updated dependencies [90c2b15]
136
+ - Updated dependencies [42eeb7d]
137
+ - Updated dependencies [01e124d]
138
+ - Updated dependencies [7ce02eb]
139
+ - Updated dependencies [a13827e]
140
+ - Updated dependencies [7733604]
141
+ - Updated dependencies [40e420f]
142
+ - Updated dependencies [d13004a]
143
+ - Updated dependencies [be7360c]
144
+ - Updated dependencies [cc2de0e]
145
+ - Updated dependencies [5b47ab5]
146
+ - Updated dependencies [b09d8d9]
147
+ - Updated dependencies [b09d8d9]
148
+ - Updated dependencies [8675db6]
149
+ - Updated dependencies [b09d8d9]
150
+ - Updated dependencies [3eb1b2b]
151
+ - Updated dependencies [59b85c0]
152
+ - Updated dependencies [6e357ed]
153
+ - Updated dependencies [d6938bf]
154
+ - Updated dependencies [31e0be9]
155
+ - Updated dependencies [4bfd455]
156
+ - Updated dependencies [ffd2ce2]
157
+ - Updated dependencies [62f8017]
158
+ - Updated dependencies [a831df1]
159
+ - Updated dependencies [f752ee3]
160
+ - Updated dependencies [a1b61e0]
161
+ - Updated dependencies [cd6b9f2]
162
+ - Updated dependencies [2cb6d3c]
163
+ - Updated dependencies [af2a095]
164
+ - Updated dependencies [ec796d5]
165
+ - Updated dependencies [e87fea1]
166
+ - Updated dependencies [c65e529]
167
+ - Updated dependencies [3ca34c1]
168
+ - Updated dependencies [239c3a3]
169
+ - Updated dependencies [94a0bbc]
170
+ - Updated dependencies [d6bfb3d]
171
+ - Updated dependencies [a2266a6]
172
+ - Updated dependencies [d25a0ec]
173
+ - Updated dependencies [667b83e]
174
+ - Updated dependencies [627b188]
175
+ - Updated dependencies [8d4eae7]
176
+ - Updated dependencies [65a3a84]
177
+ - Updated dependencies [ccd9397]
178
+ - Updated dependencies [bca935b]
179
+ - Updated dependencies [c54c822]
180
+ - Updated dependencies [8dcc0f5]
181
+ - Updated dependencies [75b9e51]
182
+ - Updated dependencies [0a2f233]
183
+ - Updated dependencies [8621cdd]
184
+ - Updated dependencies [6f23667]
185
+ - Updated dependencies [5d21a48]
186
+ - Updated dependencies [19365b7]
187
+ - Updated dependencies [b7ed26d]
188
+ - Updated dependencies [b3a3d83]
189
+ - Updated dependencies [7a55913]
190
+ - Updated dependencies [35accbf]
191
+ - Updated dependencies [6038de7]
192
+ - Updated dependencies [eb95d97]
193
+ - Updated dependencies [e4c2dc8]
194
+ - Updated dependencies [1bd2795]
195
+ - Updated dependencies [8186a70]
196
+ - Updated dependencies [a329cca]
197
+ - Updated dependencies [6eec18c]
198
+ - Updated dependencies [4d7bebf]
199
+ - Updated dependencies [821ac7a]
200
+ - Updated dependencies [8f81731]
201
+ - Updated dependencies [8b50cb3]
202
+ - Updated dependencies [8c2db68]
203
+ - Updated dependencies [22b5e54]
204
+ - Updated dependencies [0166bd5]
205
+ - Updated dependencies [9b702dc]
206
+ - Updated dependencies [ab16331]
207
+ - @objectstack/spec@17.0.0-rc.1
208
+ - @objectstack/service-cluster@17.0.0-rc.1
209
+
3
210
  ## 17.0.0-rc.0
4
211
 
5
212
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/service-cluster-redis",
3
- "version": "17.0.0-rc.0",
3
+ "version": "17.0.0-rc.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.",
6
6
  "type": "module",
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "ioredis": "^5.11.1",
18
- "@objectstack/service-cluster": "17.0.0-rc.0",
19
- "@objectstack/spec": "17.0.0-rc.0"
18
+ "@objectstack/service-cluster": "17.0.0-rc.1",
19
+ "@objectstack/spec": "17.0.0-rc.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^26.1.1",
@@ -38,8 +38,14 @@
38
38
  "url": "https://github.com/objectstack-ai/objectstack.git",
39
39
  "directory": "packages/services/service-cluster-redis"
40
40
  },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "CHANGELOG.md"
45
+ ],
41
46
  "scripts": {
42
47
  "build": "tsup",
43
- "test": "vitest run"
48
+ "test": "vitest run",
49
+ "typecheck": "tsc --noEmit"
44
50
  }
45
51
  }
@@ -1,22 +0,0 @@
1
-
2
- > @objectstack/service-cluster-redis@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/services/service-cluster-redis
3
- > tsup
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /home/runner/work/objectstack/objectstack/packages/services/service-cluster-redis/tsup.config.ts
9
- CLI Target: es2020
10
- CLI Cleaning output folder
11
- ESM Build start
12
- CJS Build start
13
- ESM dist/index.js 13.20 KB
14
- ESM dist/index.js.map 37.77 KB
15
- ESM ⚡️ Build success in 733ms
16
- CJS dist/index.cjs 14.57 KB
17
- CJS dist/index.cjs.map 35.25 KB
18
- CJS ⚡️ Build success in 740ms
19
- DTS Build start
20
- DTS ⚡️ Build success in 10919ms
21
- DTS dist/index.d.ts 9.99 KB
22
- DTS dist/index.d.cts 9.99 KB
package/src/client.ts DELETED
@@ -1,75 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { Redis, type RedisOptions } from 'ioredis';
4
-
5
- /**
6
- * Options accepted by {@link createRedisClient}.
7
- *
8
- * Either pass `url` (e.g. `redis://user:pass@host:6379/0`) or a full
9
- * ioredis `options` bag. The two are merged with `options` taking
10
- * precedence so callers can override individual fields parsed from the
11
- * URL.
12
- *
13
- * This factory is intended to be the single Redis-client construction
14
- * point across ObjectStack — `service-cluster-redis` uses it for the
15
- * driver, and `service-cache` will eventually share the same connection
16
- * pool via {@link CreateRedisOptions.existing}.
17
- */
18
- export interface CreateRedisOptions {
19
- /** Connection URL. Ignored when `existing` is provided. */
20
- url?: string;
21
- /** Extra ioredis options merged on top of `url`. */
22
- options?: RedisOptions;
23
- /**
24
- * Bring-your-own client. When provided, this exact instance is
25
- * returned and `url`/`options` are ignored — useful for sharing one
26
- * pool across cluster + cache.
27
- */
28
- existing?: Redis;
29
- /**
30
- * If true, the returned client is created in lazyConnect mode so the
31
- * TCP connection happens on first command rather than at construction.
32
- * Default: true (matches ObjectStack's "fail at usage, not boot" stance).
33
- */
34
- lazyConnect?: boolean;
35
- }
36
-
37
- /**
38
- * Construct (or reuse) an ioredis client with ObjectStack defaults.
39
- *
40
- * Defaults applied when `existing` is absent:
41
- * - `lazyConnect: true` — don't crash boot if Redis is briefly down
42
- * - `maxRetriesPerRequest: 3` — bound the failure window before surfacing
43
- * - `enableAutoPipelining: true` — modest throughput boost for batch workloads
44
- *
45
- * @example
46
- * const client = createRedisClient({ url: 'redis://localhost:6379' });
47
- *
48
- * @example reuse one pool across services
49
- * const shared = createRedisClient({ url });
50
- * const clusterClient = createRedisClient({ existing: shared });
51
- * const cacheClient = createRedisClient({ existing: shared });
52
- */
53
- export function createRedisClient(opts: CreateRedisOptions = {}): Redis {
54
- if (opts.existing) return opts.existing;
55
-
56
- const base: RedisOptions = {
57
- lazyConnect: opts.lazyConnect ?? true,
58
- maxRetriesPerRequest: 3,
59
- enableAutoPipelining: true,
60
- };
61
-
62
- if (opts.url) {
63
- return new Redis(opts.url, { ...base, ...opts.options });
64
- }
65
- return new Redis({ ...base, ...opts.options });
66
- }
67
-
68
- /**
69
- * Duplicate a Redis client so that pub/sub commands (which monopolize
70
- * the connection) don't block regular commands. Mirrors the standard
71
- * ioredis "two clients" pattern.
72
- */
73
- export function duplicateForPubSub(client: Redis): Redis {
74
- return client.duplicate();
75
- }
package/src/counter.ts DELETED
@@ -1,63 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Redis } from 'ioredis';
4
- import type { ICounter, CounterIncrOptions } from '@objectstack/spec/contracts';
5
-
6
- export interface RedisCounterOptions {
7
- client: Redis;
8
- keyPrefix?: string;
9
- }
10
-
11
- /**
12
- * Redis-backed monotonic counter using INCRBY. Each counter key lives at
13
- * `{prefix}ctr:{key}` so the same Redis instance can host multiple
14
- * tenants without collision.
15
- *
16
- * Values are stored as ASCII decimals (Redis convention). We surface
17
- * them as `bigint` to match the contract — INCRBY accepts up to a
18
- * signed 64-bit range, well beyond Number.MAX_SAFE_INTEGER.
19
- */
20
- export class RedisCounter implements ICounter {
21
- private readonly client: Redis;
22
- private readonly keyPrefix: string;
23
- private closed = false;
24
-
25
- constructor(opts: RedisCounterOptions) {
26
- this.client = opts.client;
27
- this.keyPrefix = opts.keyPrefix ?? 'os:';
28
- }
29
-
30
- async incr(key: string, opts: CounterIncrOptions = {}): Promise<bigint> {
31
- if (this.closed) throw new Error('RedisCounter is closed');
32
- const by = opts.by ?? 1;
33
- const next = await this.client.incrby(this.ctrKey(key), by);
34
- return BigInt(next);
35
- }
36
-
37
- async peek(key: string): Promise<bigint> {
38
- const raw = await this.client.get(this.ctrKey(key));
39
- if (raw === null) return 0n;
40
- try {
41
- return BigInt(raw);
42
- } catch {
43
- return 0n;
44
- }
45
- }
46
-
47
- async reset(key: string, value: bigint = 0n): Promise<void> {
48
- if (this.closed) throw new Error('RedisCounter is closed');
49
- if (value === 0n) {
50
- await this.client.del(this.ctrKey(key));
51
- return;
52
- }
53
- await this.client.set(this.ctrKey(key), value.toString());
54
- }
55
-
56
- async close(): Promise<void> {
57
- this.closed = true;
58
- }
59
-
60
- private ctrKey(key: string): string {
61
- return `${this.keyPrefix}ctr:${key}`;
62
- }
63
- }
package/src/index.ts DELETED
@@ -1,111 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * @objectstack/service-cluster-redis
5
- *
6
- * Redis driver for `@objectstack/service-cluster`. Import this package
7
- * once at process start to register the `'redis'` driver:
8
- *
9
- * ```ts
10
- * import '@objectstack/service-cluster-redis';
11
- * import { defineCluster } from '@objectstack/service-cluster';
12
- *
13
- * const cluster = defineCluster({
14
- * driver: 'redis',
15
- * url: 'redis://localhost:6379',
16
- * nodeId: 'web-1',
17
- * });
18
- * ```
19
- *
20
- * The driver also exports raw constructors so callers who already own
21
- * an ioredis client can compose primitives by hand.
22
- */
23
-
24
- import type { Redis } from 'ioredis';
25
- import {
26
- ComposedClusterService,
27
- registerClusterDriver,
28
- type DriverFactoryConfig,
29
- } from '@objectstack/service-cluster';
30
- import { createRedisClient } from './client.js';
31
- import { RedisPubSub } from './pubsub.js';
32
- import { RedisLock } from './lock.js';
33
- import { RedisKV } from './kv.js';
34
- import { RedisCounter } from './counter.js';
35
-
36
- // Re-exports for advanced composition.
37
- export { createRedisClient, duplicateForPubSub, type CreateRedisOptions } from './client.js';
38
- export { RedisPubSub, type RedisPubSubOptions } from './pubsub.js';
39
- export { RedisLock, type RedisLockOptions } from './lock.js';
40
- export { RedisKV, VersionMismatchError, type RedisKVOptions } from './kv.js';
41
- export { RedisCounter, type RedisCounterOptions } from './counter.js';
42
-
43
- /**
44
- * Driver-specific options accepted under `driverOptions` in
45
- * `ClusterCapabilityConfig`. All are optional — supply a `client` to
46
- * share a Redis pool with other services (e.g. `service-cache`).
47
- */
48
- export interface RedisDriverOptions {
49
- /** Pre-built ioredis client. When provided, `url` is ignored. */
50
- client?: Redis;
51
- /** Key prefix applied to every Redis key (default: 'os:'). */
52
- keyPrefix?: string;
53
- /** Default lock TTL in ms. Overridden by `LockAcquireOptions.ttlMs`. */
54
- lockTtlMs?: number;
55
- /** Pub/sub subscriber error sink. */
56
- onError?: (err: unknown, channel: string) => void;
57
- }
58
-
59
- /**
60
- * Factory consumed by `defineCluster({ driver: 'redis' })`. Builds an
61
- * `IClusterService` backed by Redis. Owns the underlying ioredis client
62
- * iff it created it (i.e. when caller didn't pass `driverOptions.client`).
63
- */
64
- function redisDriverFactory(config: DriverFactoryConfig) {
65
- const driverOpts = (config.driverOptions ?? {}) as RedisDriverOptions;
66
- const ownsClient = !driverOpts.client;
67
- const client =
68
- driverOpts.client ??
69
- createRedisClient({ url: config.url });
70
-
71
- const keyPrefix = driverOpts.keyPrefix ?? 'os:';
72
- const ttlMs = config.lockTtlMs ?? driverOpts.lockTtlMs;
73
-
74
- const pubsub = new RedisPubSub({
75
- client,
76
- nodeId: config.nodeId,
77
- keyPrefix,
78
- onError: driverOpts.onError,
79
- });
80
- const lock = new RedisLock({
81
- client,
82
- keyPrefix,
83
- defaultTtlMs: ttlMs,
84
- nodeId: config.nodeId,
85
- });
86
- const kv = new RedisKV({ client, keyPrefix });
87
- const counter = new RedisCounter({ client, keyPrefix });
88
-
89
- const facade = new ComposedClusterService(
90
- config.nodeId,
91
- 'redis',
92
- pubsub,
93
- lock,
94
- kv,
95
- counter,
96
- );
97
-
98
- // Wrap close() so we also tear down the publisher client if we own it.
99
- const originalClose = facade.close.bind(facade);
100
- facade.close = async () => {
101
- await originalClose();
102
- if (ownsClient) {
103
- try { await client.quit(); } catch { /* swallow */ }
104
- }
105
- };
106
-
107
- return facade;
108
- }
109
-
110
- // Module-load registration — importing this package is enough.
111
- registerClusterDriver('redis', redisDriverFactory);
package/src/kv.ts DELETED
@@ -1,187 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Redis } from 'ioredis';
4
- import type { IKV, KVEntry, KVSetOptions } from '@objectstack/spec/contracts';
5
-
6
- /**
7
- * Stored payload shape: `{v: <value>, ver: <bigint as string>}`.
8
- * Versions are stored as strings because Redis values are bytes and
9
- * bigints can exceed JSON-safe-integer range.
10
- */
11
- interface StoredKV {
12
- v: unknown;
13
- ver: string;
14
- }
15
-
16
- export class VersionMismatchError extends Error {
17
- constructor(
18
- public readonly key: string,
19
- public readonly expected: bigint,
20
- public readonly actual: bigint,
21
- ) {
22
- super(
23
- `KV version mismatch on "${key}": expected v${expected}, found v${actual}`,
24
- );
25
- this.name = 'VersionMismatchError';
26
- }
27
- }
28
-
29
- export interface RedisKVOptions {
30
- client: Redis;
31
- keyPrefix?: string;
32
- }
33
-
34
- /**
35
- * Redis-backed coordination KV with optimistic concurrency via WATCH/MULTI.
36
- *
37
- * Each `set()` performs:
38
- * 1. WATCH key
39
- * 2. GET key → read current version
40
- * 3. compare to ifVersion (if provided)
41
- * 4. MULTI / SET / [PEXPIRE] / EXEC
42
- *
43
- * If a competing writer modifies the key between WATCH and EXEC the
44
- * transaction aborts and we throw `VersionMismatchError`, matching
45
- * memory driver semantics.
46
- *
47
- * **Not** a cache — uses small JSON envelopes and per-key versioning.
48
- * Use service-cache for high-throughput caching.
49
- */
50
- export class RedisKV implements IKV {
51
- private readonly client: Redis;
52
- private readonly keyPrefix: string;
53
- private closed = false;
54
-
55
- constructor(opts: RedisKVOptions) {
56
- this.client = opts.client;
57
- this.keyPrefix = opts.keyPrefix ?? 'os:';
58
- }
59
-
60
- async get<T = unknown>(key: string): Promise<KVEntry<T> | undefined> {
61
- const raw = await this.client.get(this.kvKey(key));
62
- if (raw === null) return undefined;
63
- const parsed = this.parse(raw);
64
- if (!parsed) return undefined;
65
- const pttl = await this.client.pttl(this.kvKey(key));
66
- const expiresAt = pttl > 0 ? Date.now() + pttl : undefined;
67
- return {
68
- key,
69
- value: parsed.v as T,
70
- version: BigInt(parsed.ver),
71
- expiresAt,
72
- };
73
- }
74
-
75
- async set<T = unknown>(
76
- key: string,
77
- value: T,
78
- opts: KVSetOptions = {},
79
- ): Promise<KVEntry<T>> {
80
- if (this.closed) throw new Error('RedisKV is closed');
81
- const physical = this.kvKey(key);
82
- const ttlMs = opts.ttl && opts.ttl > 0 ? opts.ttl * 1000 : undefined;
83
-
84
- // Optimistic-concurrency loop — Redis WATCH aborts the MULTI on
85
- // any intervening write.
86
- // eslint-disable-next-line no-constant-condition
87
- while (true) {
88
- await this.client.watch(physical);
89
- const raw = await this.client.get(physical);
90
- const existing = raw ? this.parse(raw) : undefined;
91
- const existingVersion = existing ? BigInt(existing.ver) : 0n;
92
-
93
- if (opts.ifVersion !== undefined && opts.ifVersion !== existingVersion) {
94
- await this.client.unwatch();
95
- throw new VersionMismatchError(key, opts.ifVersion, existingVersion);
96
- }
97
-
98
- const newVersion = existingVersion + 1n;
99
- const payload: StoredKV = { v: value, ver: newVersion.toString() };
100
- const encoded = JSON.stringify(payload);
101
-
102
- const multi = this.client.multi();
103
- if (ttlMs) {
104
- multi.set(physical, encoded, 'PX', ttlMs);
105
- } else {
106
- multi.set(physical, encoded);
107
- }
108
- const result = await multi.exec();
109
- // exec() returns null when WATCH detected a concurrent change.
110
- if (result === null) continue;
111
-
112
- return {
113
- key,
114
- value,
115
- version: newVersion,
116
- expiresAt: ttlMs ? Date.now() + ttlMs : undefined,
117
- };
118
- }
119
- }
120
-
121
- async delete(key: string, opts: { ifVersion?: bigint } = {}): Promise<boolean> {
122
- if (this.closed) throw new Error('RedisKV is closed');
123
- const physical = this.kvKey(key);
124
-
125
- if (opts.ifVersion === undefined) {
126
- const removed = await this.client.del(physical);
127
- return removed > 0;
128
- }
129
-
130
- // eslint-disable-next-line no-constant-condition
131
- while (true) {
132
- await this.client.watch(physical);
133
- const raw = await this.client.get(physical);
134
- if (!raw) {
135
- await this.client.unwatch();
136
- return false;
137
- }
138
- const parsed = this.parse(raw);
139
- if (!parsed) {
140
- await this.client.unwatch();
141
- return false;
142
- }
143
- const currentVersion = BigInt(parsed.ver);
144
- if (opts.ifVersion !== currentVersion) {
145
- await this.client.unwatch();
146
- throw new VersionMismatchError(key, opts.ifVersion, currentVersion);
147
- }
148
- const multi = this.client.multi();
149
- multi.del(physical);
150
- const result = await multi.exec();
151
- if (result === null) continue;
152
- return (result[0]?.[1] as number) > 0;
153
- }
154
- }
155
-
156
- async cas<T = unknown>(
157
- key: string,
158
- expectedVersion: bigint,
159
- next: T,
160
- opts: Omit<KVSetOptions, 'ifVersion'> = {},
161
- ): Promise<KVEntry<T> | undefined> {
162
- try {
163
- return await this.set(key, next, { ...opts, ifVersion: expectedVersion });
164
- } catch (err) {
165
- if (err instanceof VersionMismatchError) return undefined;
166
- throw err;
167
- }
168
- }
169
-
170
- async close(): Promise<void> {
171
- this.closed = true;
172
- }
173
-
174
- private kvKey(key: string): string {
175
- return `${this.keyPrefix}kv:${key}`;
176
- }
177
-
178
- private parse(raw: string): StoredKV | undefined {
179
- try {
180
- const obj = JSON.parse(raw) as StoredKV;
181
- if (typeof obj.ver !== 'string') return undefined;
182
- return obj;
183
- } catch {
184
- return undefined;
185
- }
186
- }
187
- }
package/src/lock.ts DELETED
@@ -1,205 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Redis } from 'ioredis';
4
- import type {
5
- ILock,
6
- LockAcquireOptions,
7
- LockHandle,
8
- } from '@objectstack/spec/contracts';
9
-
10
- const DEFAULT_TTL_MS = 15_000;
11
- const POLL_INTERVAL_MS = 50;
12
-
13
- /**
14
- * Lua script for safe release: only DEL when the value matches our
15
- * fencing token. Prevents a delayed release from kicking out the
16
- * legitimate next holder.
17
- */
18
- const RELEASE_SCRIPT = `
19
- if redis.call("GET", KEYS[1]) == ARGV[1] then
20
- return redis.call("DEL", KEYS[1])
21
- else
22
- return 0
23
- end
24
- `;
25
-
26
- /**
27
- * Lua script for renew: PEXPIRE only when we still hold the lock.
28
- * Returns 1 on success, 0 if the lock was lost.
29
- */
30
- const RENEW_SCRIPT = `
31
- if redis.call("GET", KEYS[1]) == ARGV[1] then
32
- return redis.call("PEXPIRE", KEYS[1], ARGV[2])
33
- else
34
- return 0
35
- end
36
- `;
37
-
38
- export interface RedisLockOptions {
39
- client: Redis;
40
- /** Counter client for fencing-token allocation. Same Redis is fine. */
41
- counterClient?: Redis;
42
- keyPrefix?: string;
43
- defaultTtlMs?: number;
44
- /** Stable node id baked into lock values for debugging. */
45
- nodeId?: string;
46
- }
47
-
48
- /**
49
- * Redis-backed distributed lock with TTL fencing.
50
- *
51
- * Algorithm (single-instance Redis, NOT Redlock — adequate for
52
- * typical ObjectStack deployments with one Redis primary):
53
- *
54
- * acquire = SET key {nodeId}:{token} NX PX ttl
55
- * release = Lua: GET == expected ? DEL : 0
56
- * renew = Lua: GET == expected ? PEXPIRE : 0
57
- *
58
- * Fencing tokens come from a Redis counter (INCR on `{prefix}fence:{key}`)
59
- * so that two clients in a split-brain see strictly increasing tokens
60
- * downstream.
61
- *
62
- * For multi-master Redis (Sentinel failover, etc.) consider switching to
63
- * a Redlock variant — not implemented yet.
64
- */
65
- export class RedisLock implements ILock {
66
- private readonly client: Redis;
67
- private readonly counterClient: Redis;
68
- private readonly keyPrefix: string;
69
- private readonly defaultTtlMs: number;
70
- private readonly nodeId: string;
71
- private closed = false;
72
-
73
- constructor(opts: RedisLockOptions) {
74
- this.client = opts.client;
75
- this.counterClient = opts.counterClient ?? opts.client;
76
- this.keyPrefix = opts.keyPrefix ?? 'os:';
77
- this.defaultTtlMs = opts.defaultTtlMs ?? DEFAULT_TTL_MS;
78
- this.nodeId = opts.nodeId ?? 'node';
79
- }
80
-
81
- async acquire(key: string, opts: LockAcquireOptions = {}): Promise<LockHandle | null> {
82
- if (this.closed) throw new Error('RedisLock is closed');
83
- const ttlMs = opts.ttlMs ?? this.defaultTtlMs;
84
- const waitMs = opts.waitMs ?? 0;
85
- const deadline = Date.now() + waitMs;
86
- const lockKey = this.lockKey(key);
87
-
88
- // Allocate a fresh fencing token up-front.
89
- const fencingToken = BigInt(
90
- await this.counterClient.incr(this.fenceKey(key)),
91
- );
92
- const value = `${this.nodeId}:${fencingToken}`;
93
-
94
- // First attempt — fast path.
95
- if (await this.trySet(lockKey, value, ttlMs)) {
96
- return this.makeHandle(key, lockKey, value, fencingToken, ttlMs);
97
- }
98
- if (waitMs <= 0) return null;
99
-
100
- // Polling loop: simple, correct, good enough for typical
101
- // contention. Future: pub/sub-driven wakeup on release.
102
- while (Date.now() < deadline) {
103
- await sleep(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
104
- if (await this.trySet(lockKey, value, ttlMs)) {
105
- return this.makeHandle(key, lockKey, value, fencingToken, ttlMs);
106
- }
107
- }
108
- return null;
109
- }
110
-
111
- async withLock<T>(
112
- key: string,
113
- fn: (h: LockHandle) => Promise<T>,
114
- opts?: LockAcquireOptions,
115
- ): Promise<T | null> {
116
- const handle = await this.acquire(key, opts);
117
- if (!handle) return null;
118
- try {
119
- return await fn(handle);
120
- } finally {
121
- await handle.release();
122
- }
123
- }
124
-
125
- async close(): Promise<void> {
126
- this.closed = true;
127
- }
128
-
129
- private async trySet(lockKey: string, value: string, ttlMs: number): Promise<boolean> {
130
- // SET key value NX PX ttlMs — atomic acquire.
131
- const res = await this.client.set(lockKey, value, 'PX', ttlMs, 'NX');
132
- return res === 'OK';
133
- }
134
-
135
- private makeHandle(
136
- logicalKey: string,
137
- lockKey: string,
138
- value: string,
139
- fencingToken: bigint,
140
- ttlMs: number,
141
- ): LockHandle {
142
- const self = this;
143
- let released = false;
144
- let currentTtl = ttlMs;
145
- // Local expiry timer — mirrors memory driver so `isHeld()` flips
146
- // on TTL without a Redis roundtrip. Not authoritative (clocks may
147
- // drift), but matches contract test expectations.
148
- let timer: NodeJS.Timeout | undefined = setTimeout(() => {
149
- released = true;
150
- }, ttlMs);
151
-
152
- return {
153
- key: logicalKey,
154
- fencingToken,
155
- isHeld(): boolean {
156
- return !released;
157
- },
158
- async renew(extendMs?: number): Promise<void> {
159
- if (released) {
160
- throw new Error(`Lock "${logicalKey}" already released`);
161
- }
162
- const next = extendMs ?? currentTtl;
163
- const result = await self.client.eval(
164
- RENEW_SCRIPT,
165
- 1,
166
- lockKey,
167
- value,
168
- String(next),
169
- );
170
- if (result !== 1) {
171
- released = true;
172
- if (timer) { clearTimeout(timer); timer = undefined; }
173
- throw new Error(
174
- `Lock "${logicalKey}" no longer held (fence=${fencingToken})`,
175
- );
176
- }
177
- currentTtl = next;
178
- if (timer) clearTimeout(timer);
179
- timer = setTimeout(() => { released = true; }, next);
180
- },
181
- async release(): Promise<void> {
182
- if (released) return;
183
- released = true;
184
- if (timer) { clearTimeout(timer); timer = undefined; }
185
- try {
186
- await self.client.eval(RELEASE_SCRIPT, 1, lockKey, value);
187
- } catch {
188
- /* swallow — release is best-effort */
189
- }
190
- },
191
- };
192
- }
193
-
194
- private lockKey(key: string): string {
195
- return `${this.keyPrefix}lock:${key}`;
196
- }
197
-
198
- private fenceKey(key: string): string {
199
- return `${this.keyPrefix}fence:${key}`;
200
- }
201
- }
202
-
203
- function sleep(ms: number): Promise<void> {
204
- return new Promise((r) => setTimeout(r, ms));
205
- }
package/src/pubsub.ts DELETED
@@ -1,188 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Redis } from 'ioredis';
4
- import type {
5
- IPubSub,
6
- PubSubHandler,
7
- PublishOptions,
8
- SubscribeOptions,
9
- Unsubscribe,
10
- } from '@objectstack/spec/contracts';
11
- import { duplicateForPubSub } from './client.js';
12
-
13
- /**
14
- * Wire-format envelope sent over Redis. Adds `fromNode` and
15
- * `publishedAt` so subscribers see the same surface as the memory
16
- * driver. The user payload is nested under `p` to avoid colliding with
17
- * reserved keys.
18
- */
19
- interface RedisPubSubEnvelope {
20
- n?: string;
21
- t: number;
22
- p: unknown;
23
- }
24
-
25
- export interface RedisPubSubOptions {
26
- /** Already-connected client used for PUBLISH. */
27
- client: Redis;
28
- /** Optional node id surfaced as `fromNode` on every delivered message. */
29
- nodeId?: string;
30
- /** Key namespace prefix applied to every channel (default: 'os:'). */
31
- keyPrefix?: string;
32
- /** Error sink for subscriber handler exceptions. */
33
- onError?: (err: unknown, channel: string) => void;
34
- }
35
-
36
- /**
37
- * Redis pub/sub implementation of {@link IPubSub}.
38
- *
39
- * Uses two ioredis clients under the hood:
40
- * - `publisher` (caller-provided) — runs PUBLISH commands
41
- * - `subscriber` (auto-duplicated) — held in subscribe mode, can't run
42
- * regular commands per Redis protocol
43
- *
44
- * Delivery semantics match Redis pub/sub: at-most-once, fire-and-forget,
45
- * no persistence. For at-least-once + replay use the planned `streams`
46
- * adapter (separate driver).
47
- *
48
- * Channel names are prefixed with `keyPrefix` before being sent to
49
- * Redis, so the same Redis instance can host multiple isolated
50
- * ObjectStack deployments.
51
- */
52
- export class RedisPubSub implements IPubSub {
53
- private readonly publisher: Redis;
54
- private readonly subscriber: Redis;
55
- private readonly nodeId?: string;
56
- private readonly keyPrefix: string;
57
- private readonly onError: (err: unknown, channel: string) => void;
58
- private readonly subs = new Map<string, Set<PubSubHandler<unknown>>>();
59
- private closed = false;
60
-
61
- constructor(opts: RedisPubSubOptions) {
62
- this.publisher = opts.client;
63
- this.subscriber = duplicateForPubSub(opts.client);
64
- this.nodeId = opts.nodeId;
65
- this.keyPrefix = opts.keyPrefix ?? 'os:';
66
- this.onError =
67
- opts.onError ??
68
- ((err, channel) => {
69
- // eslint-disable-next-line no-console
70
- console.error(`[RedisPubSub] handler error on "${channel}":`, err);
71
- });
72
-
73
- this.subscriber.on('message', (raw: string, data: string) => {
74
- this.dispatch(raw, data);
75
- });
76
- }
77
-
78
- /**
79
- * Durability contract (P1-5): this awaits the Redis `PUBLISH` command (so the
80
- * message left this node), but Redis pub/sub is **at-most-once** — there is no
81
- * delivery guarantee to subscribers and no replay for a node that was down or
82
- * slow at publish time. This is acceptable **only** for events that are pure
83
- * cache-invalidation hints, never the source of truth.
84
- *
85
- * In particular `metadata.changed` is such a hint: the durable record of every
86
- * metadata mutation is the transactional write to `sys_metadata`
87
- * (+ `sys_metadata_history`). A subscriber that misses the event keeps serving
88
- * its cached schema until its next reload and **loses no data** — it self-heals
89
- * on restart / reload against the DB. Do not route any state that must be
90
- * delivered exactly-once through this channel; use a durable outbox instead.
91
- */
92
- async publish<T = unknown>(
93
- channel: string,
94
- payload: T,
95
- _opts?: PublishOptions,
96
- ): Promise<void> {
97
- if (this.closed) throw new Error('RedisPubSub is closed');
98
- const envelope: RedisPubSubEnvelope = {
99
- n: this.nodeId,
100
- t: Date.now(),
101
- p: payload,
102
- };
103
- await this.publisher.publish(this.prefixed(channel), JSON.stringify(envelope));
104
- }
105
-
106
- subscribe<T = unknown>(
107
- channel: string,
108
- handler: PubSubHandler<T>,
109
- _opts?: SubscribeOptions,
110
- ): Unsubscribe {
111
- if (this.closed) throw new Error('RedisPubSub is closed');
112
- const prefixed = this.prefixed(channel);
113
- let bucket = this.subs.get(prefixed);
114
- if (!bucket) {
115
- bucket = new Set();
116
- this.subs.set(prefixed, bucket);
117
- // Fire-and-forget; if subscribe fails the next publish will
118
- // simply not deliver — caller can resubscribe.
119
- void this.subscriber.subscribe(prefixed).catch((err) => {
120
- this.onError(err, channel);
121
- });
122
- }
123
- const wrapped = handler as PubSubHandler<unknown>;
124
- bucket.add(wrapped);
125
-
126
- let disposed = false;
127
- return () => {
128
- if (disposed) return;
129
- disposed = true;
130
- const b = this.subs.get(prefixed);
131
- if (!b) return;
132
- b.delete(wrapped);
133
- if (b.size === 0) {
134
- this.subs.delete(prefixed);
135
- void this.subscriber.unsubscribe(prefixed).catch(() => { /* swallow */ });
136
- }
137
- };
138
- }
139
-
140
- async close(): Promise<void> {
141
- if (this.closed) return;
142
- this.closed = true;
143
- this.subs.clear();
144
- try { await this.subscriber.quit(); } catch { /* swallow */ }
145
- // We don't quit `publisher` — caller owns it.
146
- }
147
-
148
- private prefixed(channel: string): string {
149
- return `${this.keyPrefix}ps:${channel}`;
150
- }
151
-
152
- private dispatch(prefixedChannel: string, data: string): void {
153
- const bucket = this.subs.get(prefixedChannel);
154
- if (!bucket || bucket.size === 0) return;
155
-
156
- let envelope: RedisPubSubEnvelope;
157
- try {
158
- envelope = JSON.parse(data) as RedisPubSubEnvelope;
159
- } catch (err) {
160
- this.onError(err, prefixedChannel);
161
- return;
162
- }
163
-
164
- // Strip our keyPrefix so the handler sees the logical channel.
165
- const logical = prefixedChannel.startsWith(`${this.keyPrefix}ps:`)
166
- ? prefixedChannel.slice(`${this.keyPrefix}ps:`.length)
167
- : prefixedChannel;
168
-
169
- const snapshot = Array.from(bucket);
170
- for (const handler of snapshot) {
171
- try {
172
- const result = handler({
173
- channel: logical,
174
- payload: envelope.p,
175
- publishedAt: envelope.t,
176
- fromNode: envelope.n,
177
- });
178
- if (result && typeof (result as Promise<void>).then === 'function') {
179
- (result as Promise<void>).catch((err) =>
180
- this.onError(err, logical),
181
- );
182
- }
183
- } catch (err) {
184
- this.onError(err, logical);
185
- }
186
- }
187
- }
188
- }
@@ -1,163 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Driver contract tests for the Redis cluster driver, run against
5
- * `ioredis-mock` so they execute without a real Redis instance in CI.
6
- *
7
- * The same suites can be invoked against a live Redis by setting
8
- * `RUN_REAL_REDIS=1` and providing `REDIS_URL` — see the conditional
9
- * `describe.skipIf` blocks at the bottom.
10
- */
11
-
12
- // @ts-expect-error — ioredis-mock has no published types
13
- import RedisMock from 'ioredis-mock';
14
- import { describe, expect, it, vi } from 'vitest';
15
- import {
16
- runLockContract,
17
- runKVContract,
18
- runCounterContract,
19
- } from '@objectstack/service-cluster/testing';
20
- // Load the driver entrypoint at module eval — importing it registers the
21
- // 'redis' cluster driver as a side-effect, which `defineCluster({ driver:
22
- // 'redis' })` then resolves. Doing this here (not via `await import()` inside
23
- // the timed test bodies) keeps the one-time cold module-load cost out of the
24
- // per-test 5s timeout — the wiring tests below were flaky on slow CI for
25
- // exactly that reason (the first test paid the full import cost and timed out).
26
- import './index.js';
27
- import { defineCluster } from '@objectstack/service-cluster';
28
-
29
- import { RedisPubSub } from './pubsub.js';
30
- import { RedisLock } from './lock.js';
31
- import { RedisKV } from './kv.js';
32
- import { RedisCounter } from './counter.js';
33
-
34
- // ioredis-mock shares state across instances by default, so each
35
- // primitive gets a unique key-prefix per test to ensure isolation.
36
- let suffix = 0;
37
- const uniquePrefix = () => `t${++suffix}:`;
38
- const makeClient = () => new RedisMock();
39
-
40
- runLockContract('redis(mock)', async () =>
41
- new RedisLock({ client: makeClient(), keyPrefix: uniquePrefix() }),
42
- );
43
- runKVContract('redis(mock)', async () =>
44
- new RedisKV({ client: makeClient(), keyPrefix: uniquePrefix() }),
45
- );
46
- runCounterContract('redis(mock)', async () =>
47
- new RedisCounter({ client: makeClient(), keyPrefix: uniquePrefix() }),
48
- );
49
-
50
- // PubSub: Redis delivery is async (network roundtrip even for mock), so
51
- // we can't reuse the synchronous contract suite. Cover the same surface
52
- // here with explicit waits.
53
- describe('IPubSub contract — redis(mock)', () => {
54
- const flush = () => new Promise<void>((r) => setTimeout(r, 10));
55
-
56
- it('delivers published messages to subscribers', async () => {
57
- const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
58
- const received: unknown[] = [];
59
- bus.subscribe<{ n: number }>('ch', (msg) => { received.push(msg.payload); });
60
- await flush(); // let SUBSCRIBE register
61
- await bus.publish('ch', { n: 1 });
62
- await bus.publish('ch', { n: 2 });
63
- await flush();
64
- expect(received).toEqual([{ n: 1 }, { n: 2 }]);
65
- await bus.close();
66
- });
67
-
68
- it('does not deliver to other channels', async () => {
69
- const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
70
- const h = vi.fn();
71
- bus.subscribe('a', h);
72
- await flush();
73
- await bus.publish('b', { x: 1 });
74
- await flush();
75
- expect(h).not.toHaveBeenCalled();
76
- await bus.close();
77
- });
78
-
79
- it('supports multiple subscribers per channel', async () => {
80
- const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
81
- const a = vi.fn();
82
- const b = vi.fn();
83
- bus.subscribe('ch', a);
84
- bus.subscribe('ch', b);
85
- await flush();
86
- await bus.publish('ch', 'hi');
87
- await flush();
88
- expect(a).toHaveBeenCalledTimes(1);
89
- expect(b).toHaveBeenCalledTimes(1);
90
- await bus.close();
91
- });
92
-
93
- it('unsubscribe stops delivery and is idempotent', async () => {
94
- const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
95
- const h = vi.fn();
96
- const off = bus.subscribe('ch', h);
97
- await flush();
98
- await bus.publish('ch', 1);
99
- await flush();
100
- off();
101
- off();
102
- await bus.publish('ch', 2);
103
- await flush();
104
- expect(h).toHaveBeenCalledTimes(1);
105
- await bus.close();
106
- });
107
-
108
- it('isolates handler errors from siblings', async () => {
109
- const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
110
- bus.subscribe('ch', () => { throw new Error('boom'); });
111
- const ok = vi.fn();
112
- bus.subscribe('ch', ok);
113
- await flush();
114
- await expect(bus.publish('ch', 1)).resolves.toBeUndefined();
115
- await flush();
116
- expect(ok).toHaveBeenCalledTimes(1);
117
- await bus.close();
118
- });
119
-
120
- it('close rejects further publishes', async () => {
121
- const bus = new RedisPubSub({ client: makeClient(), keyPrefix: uniquePrefix() });
122
- await bus.close();
123
- await expect(bus.publish('ch', 1)).rejects.toThrow(/closed/);
124
- });
125
- });
126
-
127
- describe('Redis driver — wiring', () => {
128
- it('exports a registerable driver and defineCluster picks it up', async () => {
129
- const client = makeClient();
130
- const cluster = defineCluster({
131
- driver: 'redis',
132
- nodeId: 'mock-node',
133
- driverOptions: { client, keyPrefix: uniquePrefix() },
134
- });
135
- expect(cluster.driver).toBe('redis');
136
- expect(cluster.nodeId).toBe('mock-node');
137
-
138
- expect(await cluster.counter.incr('seq')).toBe(1n);
139
- expect(await cluster.counter.incr('seq')).toBe(2n);
140
-
141
- await cluster.kv.set('k', { hello: 'world' });
142
- const got = await cluster.kv.get<{ hello: string }>('k');
143
- expect(got?.value).toEqual({ hello: 'world' });
144
-
145
- const handle = await cluster.lock.acquire('foo');
146
- expect(handle).not.toBeNull();
147
- expect(handle!.fencingToken).toBeGreaterThan(0n);
148
- await handle!.release();
149
-
150
- await cluster.close();
151
- });
152
-
153
- it('does NOT quit caller-owned client on close', async () => {
154
- const client = makeClient();
155
- const cluster = defineCluster({
156
- driver: 'redis',
157
- nodeId: 'mock-node-2',
158
- driverOptions: { client, keyPrefix: uniquePrefix() },
159
- });
160
- await cluster.close();
161
- await expect(client.set('post-close', '1')).resolves.toBe('OK');
162
- });
163
- });
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src",
6
- "types": ["node"]
7
- },
8
- "include": ["src"],
9
- "exclude": ["node_modules", "dist"]
10
- }
package/tsup.config.ts DELETED
@@ -1,14 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { defineConfig } from 'tsup';
4
-
5
- export default defineConfig({
6
- entry: ['src/index.ts'],
7
- splitting: true,
8
- sourcemap: true,
9
- clean: true,
10
- dts: !process.env.OS_SKIP_DTS,
11
- format: ['esm', 'cjs'],
12
- target: 'es2020',
13
- external: ['vitest', 'ioredis'],
14
- });