@hotmeshio/hotmesh 0.14.3 → 0.14.5

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.
Files changed (31) hide show
  1. package/build/modules/enums.d.ts +6 -0
  2. package/build/modules/enums.js +8 -2
  3. package/build/package.json +4 -3
  4. package/build/services/activities/hook.d.ts +10 -1
  5. package/build/services/activities/hook.js +45 -6
  6. package/build/services/dba/index.d.ts +1 -0
  7. package/build/services/dba/index.js +20 -3
  8. package/build/services/durable/client.js +13 -3
  9. package/build/services/durable/handle.d.ts +8 -1
  10. package/build/services/durable/handle.js +9 -1
  11. package/build/services/durable/worker.js +4 -0
  12. package/build/services/durable/workflow/signal.d.ts +1 -1
  13. package/build/services/durable/workflow/signal.js +2 -1
  14. package/build/services/mapper/index.d.ts +57 -2
  15. package/build/services/mapper/index.js +57 -2
  16. package/build/services/pipe/index.d.ts +444 -10
  17. package/build/services/pipe/index.js +444 -10
  18. package/build/services/store/index.d.ts +15 -2
  19. package/build/services/store/providers/postgres/kvtables.d.ts +1 -0
  20. package/build/services/store/providers/postgres/kvtables.js +46 -1
  21. package/build/services/store/providers/postgres/postgres.d.ts +25 -2
  22. package/build/services/store/providers/postgres/postgres.js +121 -4
  23. package/build/services/stream/registry.d.ts +1 -0
  24. package/build/services/stream/registry.js +12 -8
  25. package/build/services/task/index.d.ts +4 -1
  26. package/build/services/task/index.js +34 -6
  27. package/build/services/worker/index.js +2 -0
  28. package/build/types/dba.d.ts +11 -0
  29. package/build/types/hotmesh.d.ts +8 -0
  30. package/package.json +4 -3
  31. package/vitest.config.mts +1 -1
@@ -754,16 +754,133 @@ class PostgresStoreService extends __1.StoreService {
754
754
  return patterns;
755
755
  }
756
756
  }
757
+ /**
758
+ * Leg1: set hook signal, atomically detecting a pending signal.
759
+ *
760
+ * Standalone (no transaction): single CTE query that reads any existing
761
+ * pending value, then inserts the hook signal (overwriting pending or
762
+ * expired entries). Returns `{success, pendingData}` in one round trip.
763
+ *
764
+ * In a transaction: queues the setnxex; pending detection deferred.
765
+ */
757
766
  async setHookSignal(hook, transaction) {
758
767
  const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
759
768
  const { topic, resolved, jobId } = hook;
760
769
  const signalKey = `${topic}:${resolved}`;
761
- await this.kvsql(transaction).setnxex(`${key}:${signalKey}`, jobId, Math.max(hook.expire, enums_1.HMSH_SIGNAL_EXPIRE));
770
+ const fullKey = `${key}:${signalKey}`;
771
+ const delay = Math.max(hook.expire, enums_1.HMSH_SIGNAL_EXPIRE);
772
+ if (transaction) {
773
+ await this.kvsql(transaction).setnxex(fullKey, jobId, delay);
774
+ return { success: true };
775
+ }
776
+ const kv = this.kvsql();
777
+ const tableName = kv.tableForKey(fullKey);
778
+ const storedKey = kv.storageKey(fullKey);
779
+ const sql = `
780
+ WITH pre AS (
781
+ SELECT value FROM ${tableName}
782
+ WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
783
+ ),
784
+ ins AS (
785
+ INSERT INTO ${tableName} (key, value, expiry)
786
+ VALUES ($1, $2, NOW() + INTERVAL '${delay} seconds')
787
+ ON CONFLICT (key) DO UPDATE
788
+ SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
789
+ WHERE ${tableName}.expiry IS NULL
790
+ OR ${tableName}.expiry <= NOW()
791
+ OR ${tableName}.value LIKE '$pending::%'
792
+ RETURNING true as success
793
+ )
794
+ SELECT
795
+ COALESCE((SELECT success FROM ins), false) as success,
796
+ (SELECT value FROM pre) as existing_value
797
+ `;
798
+ try {
799
+ const res = await this.pgClient.query(sql, [storedKey, jobId]);
800
+ const row = res.rows[0] || {};
801
+ const success = row.success === true;
802
+ const existing = row.existing_value;
803
+ if (success && existing?.startsWith('$pending::')) {
804
+ return {
805
+ success: true,
806
+ pendingData: existing.slice('$pending::'.length),
807
+ };
808
+ }
809
+ return { success };
810
+ }
811
+ catch (error) {
812
+ if (error?.message?.includes('closed') ||
813
+ error?.message?.includes('queryable')) {
814
+ return { success: false };
815
+ }
816
+ throw error;
817
+ }
762
818
  }
763
- async getHookSignal(topic, resolved) {
819
+ /**
820
+ * Leg2: get hook signal OR atomically set a pending signal.
821
+ *
822
+ * When `pendingData` is provided and no hook signal exists, the
823
+ * pending value is inserted in the SAME SQL statement — no second
824
+ * round trip. This is the transactional edge that prevents the
825
+ * signal from being lost: by the time the query returns, the
826
+ * pending key is already visible to leg1's setnxex.
827
+ *
828
+ * When `pendingData` is omitted, behaves as a plain read.
829
+ */
830
+ async getHookSignal(topic, resolved, pendingData, pendingExpire) {
764
831
  const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
765
- const response = await this.kvsql().get(`${key}:${topic}:${resolved}`);
766
- return response ? response.toString() : undefined;
832
+ const fullKey = `${key}:${topic}:${resolved}`;
833
+ if (!pendingData) {
834
+ //plain read (used by deleteWebHookSignal path, tests, etc.)
835
+ const response = await this.kvsql().get(fullKey);
836
+ if (!response)
837
+ return undefined;
838
+ const value = response.toString();
839
+ if (value.startsWith('$pending::'))
840
+ return undefined;
841
+ return value;
842
+ }
843
+ //atomic get-or-set-pending: one round trip
844
+ const kv = this.kvsql();
845
+ const tableName = kv.tableForKey(fullKey);
846
+ const storedKey = kv.storageKey(fullKey);
847
+ const expire = pendingExpire || enums_1.HMSH_PENDING_SIGNAL_EXPIRE;
848
+ const pendingValue = `$pending::${pendingData}`;
849
+ const sql = `
850
+ WITH existing AS (
851
+ SELECT value FROM ${tableName}
852
+ WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
853
+ ),
854
+ pending AS (
855
+ INSERT INTO ${tableName} (key, value, expiry)
856
+ SELECT $1, $2, NOW() + INTERVAL '${expire} seconds'
857
+ WHERE NOT EXISTS (SELECT 1 FROM existing)
858
+ ON CONFLICT (key) DO UPDATE
859
+ SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
860
+ WHERE ${tableName}.expiry IS NULL OR ${tableName}.expiry <= NOW()
861
+ RETURNING true as inserted
862
+ )
863
+ SELECT
864
+ (SELECT value FROM existing) as hook_value,
865
+ (SELECT inserted FROM pending) as pending_inserted
866
+ `;
867
+ try {
868
+ const res = await this.pgClient.query(sql, [storedKey, pendingValue]);
869
+ const row = res.rows[0] || {};
870
+ const hookValue = row.hook_value;
871
+ if (hookValue && !hookValue.startsWith('$pending::')) {
872
+ return hookValue;
873
+ }
874
+ //no hook signal; pending was inserted (or already existed)
875
+ return undefined;
876
+ }
877
+ catch (error) {
878
+ if (error?.message?.includes('closed') ||
879
+ error?.message?.includes('queryable')) {
880
+ return undefined;
881
+ }
882
+ throw error;
883
+ }
767
884
  }
768
885
  async deleteHookSignal(topic, resolved) {
769
886
  const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
@@ -23,6 +23,7 @@ declare class StreamConsumerRegistry {
23
23
  }, logger: ILogger, config?: {
24
24
  reclaimDelay?: number;
25
25
  reclaimCount?: number;
26
+ readonly?: boolean;
26
27
  retry?: any;
27
28
  }): Promise<void>;
28
29
  /**
@@ -29,6 +29,7 @@ class StreamConsumerRegistry {
29
29
  topic: taskQueue,
30
30
  reclaimDelay: config?.reclaimDelay,
31
31
  reclaimCount: config?.reclaimCount,
32
+ readonly: config?.readonly || false,
32
33
  throttle,
33
34
  retry: config?.retry,
34
35
  }, stream, logger);
@@ -39,14 +40,17 @@ class StreamConsumerRegistry {
39
40
  logger,
40
41
  };
41
42
  StreamConsumerRegistry.workerConsumers.set(key, entry);
42
- // Create the dispatch callback that routes by workflow_name
43
- const dispatchCallback = StreamConsumerRegistry.createWorkerDispatcher(key);
44
- // Start consuming from the task queue stream
45
- const streamKey = stream.mintKey(key_1.KeyType.STREAMS, {
46
- appId,
47
- topic: taskQueue,
48
- });
49
- router.consumeMessages(streamKey, 'WORKER', guid, dispatchCallback);
43
+ // Only start consuming if not readonly
44
+ if (!config?.readonly) {
45
+ // Create the dispatch callback that routes by workflow_name
46
+ const dispatchCallback = StreamConsumerRegistry.createWorkerDispatcher(key);
47
+ // Start consuming from the task queue stream
48
+ const streamKey = stream.mintKey(key_1.KeyType.STREAMS, {
49
+ appId,
50
+ topic: taskQueue,
51
+ });
52
+ router.consumeMessages(streamKey, 'WORKER', guid, dispatchCallback);
53
+ }
50
54
  }
51
55
  // Register the callback for this workflow name
52
56
  entry.callbacks.set(workflowName, callback);
@@ -29,7 +29,10 @@ declare class TaskService {
29
29
  processTimeHooks(timeEventCallback: (jobId: string, gId: string, activityId: string, type: WorkListTaskType) => Promise<void>, listKey?: string): Promise<void>;
30
30
  cancelCleanup(): void;
31
31
  getHookRule(topic: string): Promise<HookRule | undefined>;
32
- registerWebHook(topic: string, context: JobState, dad: string, expire: number, transaction?: ProviderTransaction): Promise<string>;
32
+ registerWebHook(topic: string, context: JobState, dad: string, expire: number): Promise<{
33
+ jobId: string;
34
+ pending?: string;
35
+ }>;
33
36
  processWebHookSignal(topic: string, data: Record<string, unknown>): Promise<[string, string, string, string] | undefined>;
34
37
  deleteWebHookSignal(topic: string, data: Record<string, unknown>): Promise<number>;
35
38
  /**
@@ -134,7 +134,7 @@ class TaskService {
134
134
  const rules = await this.store.getHookRules();
135
135
  return rules?.[topic]?.[0];
136
136
  }
137
- async registerWebHook(topic, context, dad, expire, transaction) {
137
+ async registerWebHook(topic, context, dad, expire) {
138
138
  const hookRule = await this.getHookRule(topic);
139
139
  if (hookRule) {
140
140
  const mapExpression = hookRule.conditions.match[0].expected;
@@ -150,8 +150,27 @@ class TaskService {
150
150
  jobId: compositeJobKey,
151
151
  expire,
152
152
  };
153
- await this.store.setHookSignal(hook, transaction);
154
- return jobId;
153
+ //called standalone (no transaction) so the single CTE query can
154
+ //atomically detect and return pending signal data on collision
155
+ const result = await this.store.setHookSignal(hook);
156
+ if (result.pendingData) {
157
+ this.logger.warn('task-signal-race-pending-consumed', {
158
+ topic,
159
+ resolved,
160
+ jobId,
161
+ });
162
+ return { jobId, pending: result.pendingData };
163
+ }
164
+ if (!result.success) {
165
+ //setnxex failed but no pending signal; likely a retry where
166
+ //our own hook signal was already set. continue normally.
167
+ this.logger.debug('task-signal-hook-already-set', {
168
+ topic,
169
+ resolved,
170
+ jobId,
171
+ });
172
+ }
173
+ return { jobId };
155
174
  }
156
175
  else {
157
176
  throw new Error('signaler.registerWebHook:error: hook rule not found');
@@ -165,10 +184,19 @@ class TaskService {
165
184
  const context = { $self: { hook: { data } }, $hook: { data } };
166
185
  const mapExpression = hookRule.conditions.match[0].actual;
167
186
  const resolved = pipe_1.Pipe.resolve(mapExpression, context);
168
- const hookSignalId = await this.store.getHookSignal(topic, resolved);
187
+ //resolve $expire override from the signal data (e.g., '1h', '30d')
188
+ const pendingExpire = typeof data.$expire === 'string'
189
+ ? (0, utils_1.s)(data.$expire)
190
+ : enums_1.HMSH_PENDING_SIGNAL_EXPIRE;
191
+ //atomic: returns the hook signal, or stores a pending signal
192
+ //in the same SQL statement if no hook is registered yet
193
+ const hookSignalId = await this.store.getHookSignal(topic, resolved, JSON.stringify(data), pendingExpire);
169
194
  if (!hookSignalId) {
170
- //messages can be double-processed; not an issue; return `undefined`
171
- //users can also provide a bogus topic; not an issue; return `undefined`
195
+ this.logger.warn('task-signal-race-pending-stored', {
196
+ topic,
197
+ resolved,
198
+ expire: pendingExpire,
199
+ });
172
200
  return undefined;
173
201
  }
174
202
  //`aid` is part of composite key, but the hook `topic` is its public interface;
@@ -51,6 +51,7 @@ class WorkerService {
51
51
  await registry_1.StreamConsumerRegistry.registerWorker(namespace, appId, guid, worker.topic, worker.workflowName, worker.callback, service.stream, service.store, logger, {
52
52
  reclaimDelay: worker.reclaimDelay,
53
53
  reclaimCount: worker.reclaimCount,
54
+ readonly: worker.readonly,
54
55
  retry: worker.retry,
55
56
  });
56
57
  // Still need a router for publishing responses back to engine
@@ -114,6 +115,7 @@ class WorkerService {
114
115
  reclaimDelay: worker.reclaimDelay,
115
116
  reclaimCount: worker.reclaimCount,
116
117
  throttle,
118
+ readonly: worker.readonly || false,
117
119
  retry: worker.retry,
118
120
  }, this.stream, logger);
119
121
  }
@@ -103,6 +103,15 @@ export interface PruneOptions {
103
103
  * @default false
104
104
  */
105
105
  keepHmark?: boolean;
106
+ /**
107
+ * If true, hard-deletes expired rows from `signal_registry`.
108
+ * These include consumed hook signals and stale pending signals
109
+ * (signals that arrived before hook registration but were never
110
+ * claimed). All signal_registry entries have a natural `expiry`
111
+ * column; this operation removes rows whose expiry has passed.
112
+ * @default true
113
+ */
114
+ signals?: boolean;
106
115
  }
107
116
  /**
108
117
  * Result returned by `DBA.prune()`, providing deletion
@@ -123,4 +132,6 @@ export interface PruneResult {
123
132
  transient: number;
124
133
  /** Number of jobs marked as pruned (pruned_at set) */
125
134
  marked: number;
135
+ /** Number of expired signal_registry rows hard-deleted */
136
+ signals: number;
126
137
  }
@@ -284,6 +284,14 @@ type HotMeshWorker = {
284
284
  user: string;
285
285
  password: string;
286
286
  };
287
+ /**
288
+ * If true, the worker's router will not consume messages from the
289
+ * stream. The worker can still publish responses but will never
290
+ * dequeue or process messages. This is inherited from the
291
+ * connection's `readonly` flag by the Durable layer.
292
+ * @default false
293
+ */
294
+ readonly?: boolean;
287
295
  };
288
296
  type HotMeshConfig = {
289
297
  appId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "obfuscate": "ts-node scripts/obfuscate.ts",
15
15
  "clean-build": "npm run clean && npm run build",
16
16
  "clean-build-obfuscate": "npm run clean-build && npm run obfuscate",
17
- "docs": "typedoc",
18
- "docs:clean": "rimraf ./docs/hotmesh && typedoc",
17
+ "docs": "typedoc && cp -R docs/hotmesh/* docs/ && rm -rf docs/hotmesh",
18
+ "docs:clean": "rimraf ./docs/hotmesh && typedoc && cp -R docs/hotmesh/* docs/ && rm -rf docs/hotmesh",
19
19
  "lint": "eslint . --ext .ts",
20
20
  "lint:fix": "eslint . --fix --ext .ts",
21
21
  "start": "ts-node src/index.ts",
@@ -47,6 +47,7 @@
47
47
  "test:durable:retrypolicy": "vitest run tests/durable/retry-policy",
48
48
  "test:durable:sleep": "vitest run tests/durable/sleep/postgres.test.ts",
49
49
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
50
+ "test:durable:readonly": "docker compose --profile readonly up -d --build && docker compose exec hotmesh-readonly npx vitest run --config tests/durable/readonly/vitest.config.mts",
50
51
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
51
52
  "test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
52
53
  "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
package/vitest.config.mts CHANGED
@@ -5,7 +5,7 @@ export default defineConfig({
5
5
  globals: true,
6
6
  environment: 'node',
7
7
  include: ['tests/**/*.test.ts'],
8
- exclude: ['node_modules', 'build', 'config'],
8
+ exclude: ['node_modules', 'build', 'config', 'tests/durable/readonly/**'],
9
9
  testTimeout: 60_000,
10
10
  hookTimeout: 120_000,
11
11
  fileParallelism: false,