@objectstack/service-cluster 5.1.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.
@@ -0,0 +1,106 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ IPubSub,
5
+ PubSubHandler,
6
+ PublishOptions,
7
+ SubscribeOptions,
8
+ Unsubscribe,
9
+ } from '@objectstack/spec/contracts';
10
+
11
+ /**
12
+ * In-memory PubSub for single-process deployments and tests.
13
+ *
14
+ * Behavior:
15
+ * - Synchronous fan-out: every subscriber's handler is invoked in the
16
+ * same tick that `publish()` resolves. Handler errors are swallowed
17
+ * and logged via `onError` (so one bad subscriber can't poison the bus).
18
+ * - At-least-once semantics held vacuously (a single in-process delivery).
19
+ * - No cross-process delivery — use the redis/postgres/nats driver for
20
+ * real multi-node setups.
21
+ */
22
+ export interface MemoryPubSubOptions {
23
+ /** Optional error sink for handler exceptions. Defaults to console.error. */
24
+ onError?: (err: unknown, channel: string) => void;
25
+ /** Optional node id surfaced as `fromNode` on every message. */
26
+ nodeId?: string;
27
+ }
28
+
29
+ interface Subscription {
30
+ channel: string;
31
+ handler: PubSubHandler<unknown>;
32
+ }
33
+
34
+ export class MemoryPubSub implements IPubSub {
35
+ private readonly subs = new Map<string, Set<Subscription>>();
36
+ private readonly onError: (err: unknown, channel: string) => void;
37
+ private readonly nodeId?: string;
38
+ private closed = false;
39
+
40
+ constructor(opts: MemoryPubSubOptions = {}) {
41
+ this.onError =
42
+ opts.onError ??
43
+ ((err, channel) => {
44
+ // eslint-disable-next-line no-console
45
+ console.error(`[MemoryPubSub] handler error on channel "${channel}":`, err);
46
+ });
47
+ this.nodeId = opts.nodeId;
48
+ }
49
+
50
+ async publish<T = unknown>(
51
+ channel: string,
52
+ payload: T,
53
+ _opts?: PublishOptions,
54
+ ): Promise<void> {
55
+ if (this.closed) throw new Error('MemoryPubSub is closed');
56
+ const bucket = this.subs.get(channel);
57
+ if (!bucket || bucket.size === 0) return;
58
+ const publishedAt = Date.now();
59
+ // Snapshot so handler-driven unsubscribes during dispatch are safe.
60
+ const snapshot = Array.from(bucket);
61
+ for (const sub of snapshot) {
62
+ try {
63
+ const result = sub.handler({
64
+ channel,
65
+ payload,
66
+ publishedAt,
67
+ fromNode: this.nodeId,
68
+ });
69
+ if (result && typeof (result as Promise<void>).then === 'function') {
70
+ (result as Promise<void>).catch((err) => this.onError(err, channel));
71
+ }
72
+ } catch (err) {
73
+ this.onError(err, channel);
74
+ }
75
+ }
76
+ }
77
+
78
+ subscribe<T = unknown>(
79
+ channel: string,
80
+ handler: PubSubHandler<T>,
81
+ _opts?: SubscribeOptions,
82
+ ): Unsubscribe {
83
+ if (this.closed) throw new Error('MemoryPubSub is closed');
84
+ let bucket = this.subs.get(channel);
85
+ if (!bucket) {
86
+ bucket = new Set();
87
+ this.subs.set(channel, bucket);
88
+ }
89
+ const sub: Subscription = { channel, handler: handler as PubSubHandler<unknown> };
90
+ bucket.add(sub);
91
+ let disposed = false;
92
+ return () => {
93
+ if (disposed) return;
94
+ disposed = true;
95
+ const b = this.subs.get(channel);
96
+ if (!b) return;
97
+ b.delete(sub);
98
+ if (b.size === 0) this.subs.delete(channel);
99
+ };
100
+ }
101
+
102
+ async close(): Promise<void> {
103
+ this.closed = true;
104
+ this.subs.clear();
105
+ }
106
+ }
@@ -0,0 +1,90 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { IClusterService } from '@objectstack/spec/contracts';
5
+
6
+ /**
7
+ * Bridges the cluster pub/sub bus to the metadata service so that
8
+ * metadata mutations on one node invalidate registry caches on peer
9
+ * nodes. Implements the "first real consumer" of the cluster API.
10
+ *
11
+ * Implementation detail: this plugin lives in `@objectstack/service-cluster`
12
+ * (not in `@objectstack/metadata`) to avoid forcing every metadata
13
+ * consumer to pull the cluster service. The metadata package only needs
14
+ * the `IPubSub` interface, which lives in `@objectstack/spec/contracts`.
15
+ *
16
+ * Activates only when both services are present and the metadata service
17
+ * exposes `attachClusterPubSub()`. Late binding is achieved via the
18
+ * `kernel:ready` lifecycle hook.
19
+ *
20
+ * Channel: `metadata.changed` — payload shape defined by
21
+ * `ClusterMetadataChangedPayload` in `@objectstack/metadata`.
22
+ *
23
+ * See `content/docs/concepts/cluster-semantics.mdx` §5.
24
+ */
25
+ export class MetadataClusterBridgePlugin implements Plugin {
26
+ name = 'com.objectstack.service.metadata-cluster-bridge';
27
+ version = '1.0.0';
28
+ type = 'standard';
29
+
30
+ private detach?: () => void;
31
+
32
+ async init(ctx: PluginContext): Promise<void> {
33
+ ctx.hook('kernel:ready', async () => {
34
+ let cluster: IClusterService | undefined;
35
+ let md: unknown;
36
+ try {
37
+ cluster = ctx.getService<IClusterService>('cluster');
38
+ } catch {
39
+ ctx.logger.debug(
40
+ 'MetadataClusterBridgePlugin: no "cluster" service registered, skipping',
41
+ );
42
+ return;
43
+ }
44
+ try {
45
+ md = ctx.getService<unknown>('metadata');
46
+ } catch {
47
+ ctx.logger.debug(
48
+ 'MetadataClusterBridgePlugin: no "metadata" service registered, skipping',
49
+ );
50
+ return;
51
+ }
52
+
53
+ const attach = (md as { attachClusterPubSub?: unknown })
54
+ .attachClusterPubSub;
55
+ if (typeof attach !== 'function') {
56
+ ctx.logger.warn(
57
+ 'MetadataClusterBridgePlugin: metadata service does not expose attachClusterPubSub(); cross-node cache invalidation disabled',
58
+ );
59
+ return;
60
+ }
61
+
62
+ try {
63
+ this.detach = (attach as (
64
+ pubsub: IClusterService['pubsub'],
65
+ nodeId: string,
66
+ ) => () => void).call(md, cluster.pubsub, cluster.nodeId);
67
+ ctx.logger.info(
68
+ `MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=${cluster.nodeId})`,
69
+ );
70
+ } catch (err) {
71
+ ctx.logger.error(
72
+ 'MetadataClusterBridgePlugin: attach failed',
73
+ err as Error,
74
+ );
75
+ }
76
+ });
77
+
78
+ ctx.hook('kernel:shutdown', async () => {
79
+ try {
80
+ this.detach?.();
81
+ } catch (err) {
82
+ ctx.logger.error(
83
+ 'MetadataClusterBridgePlugin: detach error',
84
+ err as Error,
85
+ );
86
+ }
87
+ this.detach = undefined;
88
+ });
89
+ }
90
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,310 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Generic contract tests for cluster primitives.
5
+ *
6
+ * These are written once and run against any driver. The memory driver
7
+ * suite calls them directly; future postgres/redis driver packages will
8
+ * `import { runPubSubContract } from '@objectstack/service-cluster/testing'`
9
+ * to get the same coverage for free.
10
+ */
11
+
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import type {
14
+ IPubSub,
15
+ ILock,
16
+ IKV,
17
+ ICounter,
18
+ } from '@objectstack/spec/contracts';
19
+
20
+ export interface ContractFactories {
21
+ makePubSub: () => Promise<IPubSub>;
22
+ makeLock: () => Promise<ILock>;
23
+ makeKV: () => Promise<IKV>;
24
+ makeCounter: () => Promise<ICounter>;
25
+ }
26
+
27
+ export function runPubSubContract(name: string, make: () => Promise<IPubSub>) {
28
+ describe(`IPubSub contract — ${name}`, () => {
29
+ it('delivers published messages to subscribers', async () => {
30
+ const bus = await make();
31
+ const received: unknown[] = [];
32
+ bus.subscribe<{ n: number }>('ch', (msg) => {
33
+ received.push(msg.payload);
34
+ });
35
+ await bus.publish('ch', { n: 1 });
36
+ await bus.publish('ch', { n: 2 });
37
+ expect(received).toEqual([{ n: 1 }, { n: 2 }]);
38
+ await bus.close();
39
+ });
40
+
41
+ it('does not deliver to other channels', async () => {
42
+ const bus = await make();
43
+ const h = vi.fn();
44
+ bus.subscribe('a', h);
45
+ await bus.publish('b', { x: 1 });
46
+ expect(h).not.toHaveBeenCalled();
47
+ await bus.close();
48
+ });
49
+
50
+ it('supports multiple subscribers per channel', async () => {
51
+ const bus = await make();
52
+ const a = vi.fn();
53
+ const b = vi.fn();
54
+ bus.subscribe('ch', a);
55
+ bus.subscribe('ch', b);
56
+ await bus.publish('ch', 'hi');
57
+ expect(a).toHaveBeenCalledTimes(1);
58
+ expect(b).toHaveBeenCalledTimes(1);
59
+ await bus.close();
60
+ });
61
+
62
+ it('unsubscribe stops delivery and is idempotent', async () => {
63
+ const bus = await make();
64
+ const h = vi.fn();
65
+ const off = bus.subscribe('ch', h);
66
+ await bus.publish('ch', 1);
67
+ off();
68
+ off(); // idempotent
69
+ await bus.publish('ch', 2);
70
+ expect(h).toHaveBeenCalledTimes(1);
71
+ await bus.close();
72
+ });
73
+
74
+ it('isolates handler errors from siblings', async () => {
75
+ const errors: unknown[] = [];
76
+ const bus = await make();
77
+ // Memory driver allows override via constructor; for the
78
+ // generic contract we just verify no throw leaks to publish().
79
+ bus.subscribe('ch', () => {
80
+ throw new Error('boom');
81
+ });
82
+ const ok = vi.fn();
83
+ bus.subscribe('ch', ok);
84
+ await expect(bus.publish('ch', 1)).resolves.toBeUndefined();
85
+ expect(ok).toHaveBeenCalledTimes(1);
86
+ await bus.close();
87
+ void errors;
88
+ });
89
+
90
+ it('close rejects further publishes', async () => {
91
+ const bus = await make();
92
+ await bus.close();
93
+ await expect(bus.publish('ch', 1)).rejects.toThrow(/closed/);
94
+ });
95
+ });
96
+ }
97
+
98
+ export function runLockContract(name: string, make: () => Promise<ILock>) {
99
+ describe(`ILock contract — ${name}`, () => {
100
+ it('acquires and releases a free lock', async () => {
101
+ const l = await make();
102
+ const h = await l.acquire('k');
103
+ expect(h).not.toBeNull();
104
+ expect(h!.isHeld()).toBe(true);
105
+ await h!.release();
106
+ expect(h!.isHeld()).toBe(false);
107
+ await l.close();
108
+ });
109
+
110
+ it('fails fast when waitMs=0 and lock is held', async () => {
111
+ const l = await make();
112
+ const h1 = await l.acquire('k');
113
+ expect(h1).not.toBeNull();
114
+ const h2 = await l.acquire('k');
115
+ expect(h2).toBeNull();
116
+ await h1!.release();
117
+ await l.close();
118
+ });
119
+
120
+ it('hands off lock to a waiter on release', async () => {
121
+ const l = await make();
122
+ const h1 = await l.acquire('k');
123
+ const waiterP = l.acquire('k', { waitMs: 1000 });
124
+ await new Promise((r) => setTimeout(r, 10));
125
+ await h1!.release();
126
+ const h2 = await waiterP;
127
+ expect(h2).not.toBeNull();
128
+ expect(h2!.isHeld()).toBe(true);
129
+ await h2!.release();
130
+ await l.close();
131
+ });
132
+
133
+ it('TTL auto-releases a stuck holder', async () => {
134
+ const l = await make();
135
+ const h1 = await l.acquire('k', { ttlMs: 30 });
136
+ expect(h1).not.toBeNull();
137
+ await new Promise((r) => setTimeout(r, 60));
138
+ expect(h1!.isHeld()).toBe(false);
139
+ const h2 = await l.acquire('k');
140
+ expect(h2).not.toBeNull();
141
+ await h2!.release();
142
+ await l.close();
143
+ });
144
+
145
+ it('fencing tokens are monotonically increasing per key', async () => {
146
+ const l = await make();
147
+ const h1 = await l.acquire('k');
148
+ const t1 = h1!.fencingToken;
149
+ await h1!.release();
150
+ const h2 = await l.acquire('k');
151
+ expect(h2!.fencingToken).toBeGreaterThan(t1);
152
+ await h2!.release();
153
+ await l.close();
154
+ });
155
+
156
+ it('renew extends the lease', async () => {
157
+ const l = await make();
158
+ const h = await l.acquire('k', { ttlMs: 40 });
159
+ await new Promise((r) => setTimeout(r, 20));
160
+ await h!.renew(100);
161
+ await new Promise((r) => setTimeout(r, 30));
162
+ // total 50ms in, original TTL would have expired at 40ms.
163
+ expect(h!.isHeld()).toBe(true);
164
+ await h!.release();
165
+ await l.close();
166
+ });
167
+
168
+ it('renew on a lost lock throws', async () => {
169
+ const l = await make();
170
+ const h = await l.acquire('k', { ttlMs: 20 });
171
+ await new Promise((r) => setTimeout(r, 50));
172
+ await expect(h!.renew()).rejects.toThrow();
173
+ await l.close();
174
+ });
175
+
176
+ it('withLock returns fn result and auto-releases', async () => {
177
+ const l = await make();
178
+ const result = await l.withLock('k', async () => 42);
179
+ expect(result).toBe(42);
180
+ // Lock should be free immediately.
181
+ const h = await l.acquire('k');
182
+ expect(h).not.toBeNull();
183
+ await h!.release();
184
+ await l.close();
185
+ });
186
+
187
+ it('withLock returns null without calling fn on timeout', async () => {
188
+ const l = await make();
189
+ const h = await l.acquire('k');
190
+ const fn = vi.fn();
191
+ const result = await l.withLock('k', fn, { waitMs: 0 });
192
+ expect(result).toBeNull();
193
+ expect(fn).not.toHaveBeenCalled();
194
+ await h!.release();
195
+ await l.close();
196
+ });
197
+
198
+ it('release is idempotent', async () => {
199
+ const l = await make();
200
+ const h = await l.acquire('k');
201
+ await h!.release();
202
+ await expect(h!.release()).resolves.toBeUndefined();
203
+ await l.close();
204
+ });
205
+ });
206
+ }
207
+
208
+ export function runKVContract(name: string, make: () => Promise<IKV>) {
209
+ describe(`IKV contract — ${name}`, () => {
210
+ it('set then get round-trips and increments version', async () => {
211
+ const kv = await make();
212
+ const a = await kv.set('k', { v: 1 });
213
+ expect(a.version).toBe(1n);
214
+ const got = await kv.get<{ v: number }>('k');
215
+ expect(got?.value).toEqual({ v: 1 });
216
+ const b = await kv.set('k', { v: 2 });
217
+ expect(b.version).toBe(2n);
218
+ await kv.close();
219
+ });
220
+
221
+ it('get on missing key returns undefined', async () => {
222
+ const kv = await make();
223
+ expect(await kv.get('absent')).toBeUndefined();
224
+ await kv.close();
225
+ });
226
+
227
+ it('delete removes the key', async () => {
228
+ const kv = await make();
229
+ await kv.set('k', 1);
230
+ expect(await kv.delete('k')).toBe(true);
231
+ expect(await kv.get('k')).toBeUndefined();
232
+ expect(await kv.delete('k')).toBe(false);
233
+ await kv.close();
234
+ });
235
+
236
+ it('ifVersion=0n requires absent key', async () => {
237
+ const kv = await make();
238
+ await kv.set('k', 'first', { ifVersion: 0n });
239
+ await expect(kv.set('k', 'dup', { ifVersion: 0n })).rejects.toThrow(
240
+ /version mismatch/i,
241
+ );
242
+ await kv.close();
243
+ });
244
+
245
+ it('cas succeeds on match, fails on mismatch', async () => {
246
+ const kv = await make();
247
+ const e1 = await kv.set('k', 1);
248
+ const e2 = await kv.cas('k', e1.version, 2);
249
+ expect(e2?.value).toBe(2);
250
+ const failed = await kv.cas('k', e1.version, 3);
251
+ expect(failed).toBeUndefined();
252
+ await kv.close();
253
+ });
254
+
255
+ it('TTL expires entries', async () => {
256
+ const kv = await make();
257
+ await kv.set('k', 'v', { ttl: 0.03 }); // 30ms
258
+ expect((await kv.get('k'))?.value).toBe('v');
259
+ await new Promise((r) => setTimeout(r, 60));
260
+ expect(await kv.get('k')).toBeUndefined();
261
+ await kv.close();
262
+ });
263
+ });
264
+ }
265
+
266
+ export function runCounterContract(name: string, make: () => Promise<ICounter>) {
267
+ describe(`ICounter contract — ${name}`, () => {
268
+ it('starts at 0, incr returns new value', async () => {
269
+ const c = await make();
270
+ expect(await c.peek('k')).toBe(0n);
271
+ expect(await c.incr('k')).toBe(1n);
272
+ expect(await c.incr('k')).toBe(2n);
273
+ expect(await c.peek('k')).toBe(2n);
274
+ await c.close();
275
+ });
276
+
277
+ it('incr by custom delta', async () => {
278
+ const c = await make();
279
+ expect(await c.incr('k', { by: 5 })).toBe(5n);
280
+ expect(await c.incr('k', { by: -2 })).toBe(3n);
281
+ await c.close();
282
+ });
283
+
284
+ it('reset', async () => {
285
+ const c = await make();
286
+ await c.incr('k', { by: 10 });
287
+ await c.reset('k', 100n);
288
+ expect(await c.peek('k')).toBe(100n);
289
+ await c.reset('k');
290
+ expect(await c.peek('k')).toBe(0n);
291
+ await c.close();
292
+ });
293
+
294
+ it('isolates keys', async () => {
295
+ const c = await make();
296
+ await c.incr('a');
297
+ await c.incr('b', { by: 7 });
298
+ expect(await c.peek('a')).toBe(1n);
299
+ expect(await c.peek('b')).toBe(7n);
300
+ await c.close();
301
+ });
302
+ });
303
+ }
304
+
305
+ export function runFullContract(name: string, f: ContractFactories) {
306
+ runPubSubContract(name, f.makePubSub);
307
+ runLockContract(name, f.makeLock);
308
+ runKVContract(name, f.makeKV);
309
+ runCounterContract(name, f.makeCounter);
310
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
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 ADDED
@@ -0,0 +1,14 @@
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', 'src/testing.ts'],
7
+ splitting: true,
8
+ sourcemap: true,
9
+ clean: true,
10
+ dts: true,
11
+ format: ['esm', 'cjs'],
12
+ target: 'es2020',
13
+ external: ['vitest'],
14
+ });