@hotmeshio/hotmesh 0.14.10 → 0.16.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.14.10",
3
+ "version": "0.16.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -30,7 +30,7 @@ interface StateContext {
30
30
  export declare function setState(instance: StateContext, transaction?: ProviderTransaction): Promise<string>;
31
31
  export declare function getState(instance: StateContext): Promise<void>;
32
32
  export declare function setStatus(instance: StateContext, amount: number, transaction?: ProviderTransaction): Promise<void | any>;
33
- export declare function bindJobMetadata(instance: StateContext): void;
33
+ export declare function bindJobMetadata(_instance: StateContext): void;
34
34
  export declare function bindActivityMetadata(instance: StateContext): void;
35
35
  export declare function bindJobState(instance: StateContext, state: StringAnyType): Promise<void>;
36
36
  export declare function bindActivityState(instance: StateContext, state: StringAnyType): void;
@@ -68,8 +68,9 @@ async function setStatus(instance, amount, transaction) {
68
68
  }
69
69
  exports.setStatus = setStatus;
70
70
  //─── metadata binding ────────────────────────────────────────────────
71
- function bindJobMetadata(instance) {
72
- instance.context.metadata.ju = (0, utils_1.formatISODate)(new Date());
71
+ function bindJobMetadata(_instance) {
72
+ // ju (job_updated) is maintained by the jobs.updated_at trigger —
73
+ // no need to serialize it into jobs_attributes on every activity.
73
74
  }
74
75
  exports.bindJobMetadata = bindJobMetadata;
75
76
  function bindActivityMetadata(instance) {
@@ -676,9 +676,23 @@ class ExporterService {
676
676
  const metadata = state?.output?.metadata ?? state?.metadata;
677
677
  const stateData = state?.output?.data ?? state?.data;
678
678
  const jobCreated = metadata?.jc ?? stateData?.jc ?? metadata?.ac;
679
- const jobUpdated = metadata?.ju ?? stateData?.ju ?? metadata?.au;
680
679
  const startTime = parseTimestamp(jobCreated);
681
- const closeTime = parseTimestamp(jobUpdated);
680
+ // Derive close time from the latest activity update (au) in the
681
+ // timeline, falling back to metadata.au. ju is no longer updated
682
+ // after creation — jobs.updated_at is the authoritative source,
683
+ // but it's not available in the DurableJobExport format.
684
+ let latestAu = null;
685
+ for (const entry of raw.timeline || []) {
686
+ const val = entry.value;
687
+ const au = val?.au;
688
+ if (au) {
689
+ const parsed = parseTimestamp(au);
690
+ if (parsed && (!latestAu || parsed > latestAu)) {
691
+ latestAu = parsed;
692
+ }
693
+ }
694
+ }
695
+ const closeTime = latestAu ?? parseTimestamp(metadata?.au);
682
696
  // ── Synthetic workflow_execution_started ─────────────────────────
683
697
  if (startTime) {
684
698
  events.push(makeEvent(nextId++, 'workflow_execution_started', 'workflow', startTime, null, false, {
@@ -31,7 +31,7 @@ class CronHandler {
31
31
  */
32
32
  nextDelay(cronExpression) {
33
33
  if (!(0, utils_1.isValidCron)(cronExpression)) {
34
- throw new Error(`Invalid cron expression: ${cronExpression}`);
34
+ return -1;
35
35
  }
36
36
  const interval = (0, cron_parser_1.parseExpression)(cronExpression, { utc: true });
37
37
  const nextDate = interval.next().toDate();
@@ -35,7 +35,7 @@ exports.MDATA_SYMBOLS = {
35
35
  ],
36
36
  },
37
37
  JOB_UPDATE: {
38
- KEYS: ['ju', 'err'],
38
+ KEYS: ['err'],
39
39
  },
40
40
  };
41
41
  class SerializerService {
@@ -153,6 +153,20 @@ const KVTables = (context) => ({
153
153
  FOR EACH ROW EXECUTE FUNCTION ${schemaName}.update_jobs_updated_at();
154
154
  `);
155
155
  }
156
+ // v0.15.2: add dedicated is_live partial index for hot-path queries
157
+ const { rows: idxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = 'idx_jobs_key_live' AND schemaname = $1 LIMIT 1`, [schemaName]);
158
+ if (idxRows.length === 0) {
159
+ await client.query(`
160
+ CREATE INDEX IF NOT EXISTS idx_jobs_key_live
161
+ ON ${jobsTable} (key) WHERE is_live;
162
+ `);
163
+ }
164
+ // v0.15.2: partial index for sorted_set scheduler hot path
165
+ await client.query(`
166
+ CREATE INDEX IF NOT EXISTS idx_task_schedules_active
167
+ ON ${schemaName}.task_schedules (key, score)
168
+ WHERE expiry IS NULL;
169
+ `);
156
170
  },
157
171
  async createTables(client, appName) {
158
172
  try {
@@ -251,27 +265,40 @@ const KVTables = (context) => ({
251
265
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_context_gin
252
266
  ON ${fullTableName} USING GIN (context);
253
267
  `);
254
- // Create partitions using a DO block
268
+ // Create partitions with fillfactor 70: reserves 30% page space
269
+ // for HOT updates (status update cycle in setStatusAndCollateGuid)
255
270
  await client.query(`
256
271
  DO $$
257
272
  BEGIN
258
273
  FOR i IN 0..7 LOOP
259
274
  EXECUTE format(
260
- 'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
261
- FOR VALUES WITH (modulus 8, remainder %s)',
275
+ 'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
276
+ FOR VALUES WITH (modulus 8, remainder %s)
277
+ WITH (fillfactor = 70)',
262
278
  i, i
263
279
  );
264
280
  END LOOP;
265
281
  END$$;
266
282
  `);
267
- // Create optimized indexes
283
+ // Original index for queries that filter on expired_at directly
284
+ // (e.g. _hmget's WHERE expired_at IS NULL OR expired_at > NOW())
268
285
  await client.query(`
269
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
286
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
270
287
  ON ${fullTableName} (key, expired_at) INCLUDE (is_live);
271
288
  `);
289
+ // Dedicated partial index for the hot-path (WHERE key = $1 AND is_live).
290
+ // Covers the uniqueness trigger, hget, hgetall, hincrbyfloat, and
291
+ // the two-pass upsert — all of which use AND is_live directly.
272
292
  await client.query(`
273
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
274
- ON ${fullTableName} (entity, status);
293
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_live
294
+ ON ${fullTableName} (key)
295
+ WHERE is_live;
296
+ `);
297
+ // status in INCLUDE (not key) so semaphore increments don't
298
+ // force index key updates — allows HOT on the hottest write path.
299
+ await client.query(`
300
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
301
+ ON ${fullTableName} (entity) INCLUDE (status);
275
302
  `);
276
303
  await client.query(`
277
304
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
@@ -303,7 +330,9 @@ const KVTables = (context) => ({
303
330
  BEFORE INSERT OR UPDATE ON ${fullTableName}
304
331
  FOR EACH ROW EXECUTE PROCEDURE ${schemaName}.update_is_live();
305
332
  `);
306
- // Create function to enforce uniqueness of live jobs
333
+ // Enforce uniqueness of live jobs via trigger.
334
+ // Uses is_live partial index for the EXISTS check (existing rows
335
+ // have is_live maintained by trg_update_is_live).
307
336
  await client.query(`
308
337
  CREATE OR REPLACE FUNCTION ${schemaName}.enforce_live_job_uniqueness()
309
338
  RETURNS TRIGGER AS $$
@@ -312,8 +341,8 @@ const KVTables = (context) => ({
312
341
  PERFORM pg_advisory_xact_lock(hashtextextended(NEW.key, 0));
313
342
  IF EXISTS (
314
343
  SELECT 1 FROM ${fullTableName}
315
- WHERE key = NEW.key
316
- AND (expired_at IS NULL OR expired_at > NOW())
344
+ WHERE key = NEW.key
345
+ AND is_live
317
346
  AND id <> NEW.id
318
347
  ) THEN
319
348
  RAISE EXCEPTION 'A live job with key % already exists.', NEW.key;
@@ -384,14 +413,16 @@ const KVTables = (context) => ({
384
413
  FOR EACH ROW
385
414
  EXECUTE FUNCTION ${schemaName}.update_attributes_updated_at();
386
415
  `);
387
- // Create partitions for attributes table
416
+ // Create partitions with fillfactor 70: reserves 30% page space
417
+ // for HOT updates (frequent upserts in hincrbyfloat/collateLeg2Entry)
388
418
  await client.query(`
389
419
  DO $$
390
420
  BEGIN
391
421
  FOR i IN 0..7 LOOP
392
422
  EXECUTE format(
393
- 'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
394
- FOR VALUES WITH (modulus 8, remainder %s)',
423
+ 'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
424
+ FOR VALUES WITH (modulus 8, remainder %s)
425
+ WITH (fillfactor = 70)',
395
426
  i, i
396
427
  );
397
428
  END LOOP;
@@ -433,8 +464,16 @@ const KVTables = (context) => ({
433
464
  );
434
465
  `);
435
466
  await client.query(`
436
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
467
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
437
468
  ON ${fullTableName} (key, score, member);
469
+ `);
470
+ // Partial index for the scheduler hot path (zrangebyscore).
471
+ // Most entries have no expiry; this avoids the OR filter that
472
+ // prevents clean index-only scans.
473
+ await client.query(`
474
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_active
475
+ ON ${fullTableName} (key, score)
476
+ WHERE expiry IS NULL;
438
477
  `);
439
478
  break;
440
479
  default:
@@ -292,11 +292,19 @@ function _hset(context, key, fields, options) {
292
292
  params.push(key, fields[':'], options?.entity ?? null);
293
293
  }
294
294
  else {
295
- // Update existing job or insert new one
295
+ // Two-pass upsert: UPDATE existing live job, INSERT only if no
296
+ // live job exists. Avoids ON CONFLICT seq_scan on partitioned
297
+ // tables that lack a unique partial index.
296
298
  sql = `
299
+ WITH existing AS (
300
+ UPDATE ${targetTable}
301
+ SET status = $2
302
+ WHERE key = $1 AND is_live
303
+ RETURNING 1
304
+ )
297
305
  INSERT INTO ${targetTable} (id, key, status, entity)
298
- VALUES (gen_random_uuid(), $1, $2, $3)
299
- ON CONFLICT (key) WHERE is_live DO UPDATE SET status = EXCLUDED.status
306
+ SELECT gen_random_uuid(), $1, $2, $3
307
+ WHERE NOT EXISTS (SELECT 1 FROM existing)
300
308
  RETURNING 1 as count
301
309
  `;
302
310
  params.push(key, fields[':'], options?.entity ?? null);
@@ -503,31 +511,20 @@ function _hgetall(context, key) {
503
511
  const tableName = context.tableForKey(key, 'hash');
504
512
  const isJobsTableResult = (0, utils_1.isJobsTable)(tableName);
505
513
  if (isJobsTableResult) {
514
+ // Single CTE: reads jobs row once, then joins attributes
506
515
  const sql = `
507
516
  WITH valid_job AS (
508
517
  SELECT id, status, context
509
518
  FROM ${tableName}
510
519
  WHERE key = $1 AND is_live
511
- ),
512
- job_data AS (
513
- SELECT 'status' AS field, status::text AS value
514
- FROM ${tableName}
515
- WHERE key = $1 AND is_live
516
-
517
- UNION ALL
518
-
519
- SELECT 'context' AS field, context::text AS value
520
- FROM ${tableName}
521
- WHERE key = $1 AND is_live
522
- ),
523
- attribute_data AS (
524
- SELECT symbol || dimension AS field, value
525
- FROM ${tableName}_attributes
526
- WHERE job_id IN (SELECT id FROM valid_job)
527
520
  )
528
- SELECT * FROM job_data
521
+ SELECT 'status' AS field, status::text AS value FROM valid_job
522
+ UNION ALL
523
+ SELECT 'context' AS field, context::text AS value FROM valid_job
529
524
  UNION ALL
530
- SELECT * FROM attribute_data;
525
+ SELECT symbol || dimension AS field, value
526
+ FROM ${tableName}_attributes
527
+ WHERE job_id = (SELECT id FROM valid_job);
531
528
  `;
532
529
  return { sql, params: [key] };
533
530
  }
@@ -83,6 +83,17 @@ const zsetModule = (context) => ({
83
83
  _zrange(key, start, stop, facet) {
84
84
  const tableName = context.tableForKey(key, 'sorted_set');
85
85
  const selectColumns = facet === 'WITHSCORES' ? 'member, score' : 'member';
86
+ // Fast path: (0, -1) means "get all" — skip COUNT/ROW_NUMBER entirely
87
+ if (start === 0 && stop === -1) {
88
+ const sql = `
89
+ SELECT ${selectColumns}
90
+ FROM ${tableName}
91
+ WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
92
+ ORDER BY score ASC, member ASC;
93
+ `;
94
+ return { sql, params: [context.storageKey(key)] };
95
+ }
96
+ // General case: negative indices require COUNT for resolution
86
97
  const sql = `
87
98
  WITH total_entries AS (
88
99
  SELECT COUNT(*) - 1 AS max_index FROM ${tableName}
@@ -475,12 +475,10 @@ class PostgresStoreService extends __1.StoreService {
475
475
  const symVals = await this.getSymbolValues();
476
476
  this.serializer.resetSymbols(symKeys, symVals, dIds);
477
477
  const hashData = this.serializer.package(state, symbolNames);
478
- if (status !== null) {
479
- hashData[':'] = status.toString();
480
- }
481
- else {
482
- delete hashData[':'];
483
- }
478
+ // ':' (status) is NOT written to jobs_attributes — jobs.status is
479
+ // maintained by setStatus/setStatusAndCollateGuid. The attribute row
480
+ // was redundant, never read, and contended on every activity.
481
+ delete hashData[':'];
484
482
  await this.kvsql(transaction).hset(hashKey, hashData);
485
483
  return jobId;
486
484
  }
@@ -140,21 +140,31 @@ async function createTables(client, schemaName) {
140
140
  ) PARTITION BY HASH (stream_name);
141
141
  `);
142
142
  for (let i = 0; i < 8; i++) {
143
+ // fillfactor 70: reserves 30% page space for HOT updates (reserve/ack cycle)
143
144
  await client.query(`
144
145
  CREATE TABLE IF NOT EXISTS ${schemaName}.engine_streams_part_${i}
145
146
  PARTITION OF ${engineTable}
146
- FOR VALUES WITH (modulus 8, remainder ${i});
147
+ FOR VALUES WITH (modulus 8, remainder ${i})
148
+ WITH (fillfactor = 70);
147
149
  `);
148
150
  }
151
+ // Dedicated dequeue index: columns match the hot-path query exactly
152
+ // (stream_name = $1, visible_at <= NOW(), ORDER BY id) with partial
153
+ // filter on reserved_at IS NULL AND expired_at IS NULL.
154
+ // Replaces the old active_messages index that had reserved_at as a
155
+ // column (redundant with the WHERE filter, displacing visible_at).
149
156
  await client.query(`
150
- CREATE INDEX IF NOT EXISTS idx_engine_streams_active_messages
151
- ON ${engineTable} (stream_name, reserved_at, visible_at, id)
157
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_dequeue
158
+ ON ${engineTable} (stream_name, visible_at, id)
152
159
  WHERE reserved_at IS NULL AND expired_at IS NULL;
153
160
  `);
161
+ // Stale-reservation recovery: covers the timed-out reservation path
162
+ // (reserved_at < NOW() - INTERVAL) separately from the hot path so
163
+ // the planner can use each index cleanly without an OR.
154
164
  await client.query(`
155
- CREATE INDEX IF NOT EXISTS idx_engine_streams_message_fetch
156
- ON ${engineTable} (stream_name, visible_at, id)
157
- WHERE expired_at IS NULL;
165
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_stale_reservations
166
+ ON ${engineTable} (stream_name, reserved_at, visible_at, id)
167
+ WHERE reserved_at IS NOT NULL AND expired_at IS NULL;
158
168
  `);
159
169
  await client.query(`
160
170
  CREATE INDEX IF NOT EXISTS idx_engine_streams_expired_at
@@ -209,21 +219,25 @@ async function createTables(client, schemaName) {
209
219
  ) PARTITION BY HASH (stream_name);
210
220
  `);
211
221
  for (let i = 0; i < 8; i++) {
222
+ // fillfactor 70: reserves 30% page space for HOT updates (reserve/ack cycle)
212
223
  await client.query(`
213
224
  CREATE TABLE IF NOT EXISTS ${schemaName}.worker_streams_part_${i}
214
225
  PARTITION OF ${workerTable}
215
- FOR VALUES WITH (modulus 8, remainder ${i});
226
+ FOR VALUES WITH (modulus 8, remainder ${i})
227
+ WITH (fillfactor = 70);
216
228
  `);
217
229
  }
230
+ // Dedicated dequeue index (see engine_streams comments above)
218
231
  await client.query(`
219
- CREATE INDEX IF NOT EXISTS idx_worker_streams_active_messages
220
- ON ${workerTable} (stream_name, reserved_at, visible_at, id)
232
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_dequeue
233
+ ON ${workerTable} (stream_name, visible_at, id)
221
234
  WHERE reserved_at IS NULL AND expired_at IS NULL;
222
235
  `);
236
+ // Stale-reservation recovery (see engine_streams comments above)
223
237
  await client.query(`
224
- CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
225
- ON ${workerTable} (stream_name, visible_at, id)
226
- WHERE expired_at IS NULL;
238
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_stale_reservations
239
+ ON ${workerTable} (stream_name, reserved_at, visible_at, id)
240
+ WHERE reserved_at IS NOT NULL AND expired_at IS NULL;
227
241
  `);
228
242
  await client.query(`
229
243
  CREATE INDEX IF NOT EXISTS idx_worker_streams_expired_at
@@ -207,12 +207,17 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
207
207
  retries++;
208
208
  const batchSize = options?.batchSize || 1;
209
209
  const reservationTimeout = options?.reservationTimeout || enums_1.HMSH_RESERVATION_TIMEOUT_S;
210
- const res = await client.query(`UPDATE ${tableName}
210
+ // Two-pass dequeue: stale reservations first (FIFO — they have
211
+ // lower ids and have waited longest), then fresh messages. Split
212
+ // avoids an OR that prevents the planner from using partial
213
+ // indexes cleanly. Stale check is a fast no-op (empty index scan)
214
+ // when there are no timed-out reservations.
215
+ let res = await client.query(`UPDATE ${tableName}
211
216
  SET reserved_at = NOW(), reserved_by = $3
212
217
  WHERE id IN (
213
218
  SELECT id FROM ${tableName}
214
219
  WHERE stream_name = $1
215
- AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
220
+ AND reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds'
216
221
  AND expired_at IS NULL
217
222
  AND visible_at <= NOW()
218
223
  ORDER BY id
@@ -220,6 +225,22 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
220
225
  FOR UPDATE SKIP LOCKED
221
226
  )
222
227
  RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
228
+ // Fresh messages: unreserved, visible, not expired
229
+ if (res.rows.length === 0) {
230
+ res = await client.query(`UPDATE ${tableName}
231
+ SET reserved_at = NOW(), reserved_by = $3
232
+ WHERE id IN (
233
+ SELECT id FROM ${tableName}
234
+ WHERE stream_name = $1
235
+ AND reserved_at IS NULL
236
+ AND expired_at IS NULL
237
+ AND visible_at <= NOW()
238
+ ORDER BY id
239
+ LIMIT $2
240
+ FOR UPDATE SKIP LOCKED
241
+ )
242
+ RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
243
+ }
223
244
  const messages = res.rows.map((row) => {
224
245
  const data = (0, utils_1.parseStreamMessage)(row.message);
225
246
  const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
@@ -54,13 +54,16 @@ function getCreateProceduresSQL(schemaName) {
54
54
  SET search_path = ${schemaName}, pg_temp
55
55
  AS $$
56
56
  ${STREAM_ACCESS_CHECK}
57
+ -- Two-pass dequeue: stale reservations first (FIFO — lower ids,
58
+ -- waited longest), then fresh. Split avoids OR that prevents
59
+ -- partial index usage. Stale check is a fast no-op when empty.
57
60
  RETURN QUERY
58
61
  UPDATE ${workerTable} ws
59
62
  SET reserved_at = NOW(), reserved_by = p_consumer_id
60
63
  WHERE ws.id IN (
61
64
  SELECT ws2.id FROM ${workerTable} ws2
62
65
  WHERE ws2.stream_name = p_stream_name
63
- AND (ws2.reserved_at IS NULL OR ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL)
66
+ AND ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL
64
67
  AND ws2.expired_at IS NULL
65
68
  AND ws2.visible_at <= NOW()
66
69
  ORDER BY ws2.id
@@ -69,6 +72,25 @@ function getCreateProceduresSQL(schemaName) {
69
72
  )
70
73
  RETURNING ws.id, ws.message, ws.workflow_name, ws.max_retry_attempts,
71
74
  ws.backoff_coefficient, ws.maximum_interval_seconds, ws.retry_attempt;
75
+
76
+ -- Fresh messages: unreserved, visible, not expired
77
+ IF NOT FOUND THEN
78
+ RETURN QUERY
79
+ UPDATE ${workerTable} ws
80
+ SET reserved_at = NOW(), reserved_by = p_consumer_id
81
+ WHERE ws.id IN (
82
+ SELECT ws2.id FROM ${workerTable} ws2
83
+ WHERE ws2.stream_name = p_stream_name
84
+ AND ws2.reserved_at IS NULL
85
+ AND ws2.expired_at IS NULL
86
+ AND ws2.visible_at <= NOW()
87
+ ORDER BY ws2.id
88
+ LIMIT p_batch_size
89
+ FOR UPDATE SKIP LOCKED
90
+ )
91
+ RETURNING ws.id, ws.message, ws.workflow_name, ws.max_retry_attempts,
92
+ ws.backoff_coefficient, ws.maximum_interval_seconds, ws.retry_attempt;
93
+ END IF;
72
94
  END;
73
95
  $$;`,
74
96
  // -- worker_ack --
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.14.10",
3
+ "version": "0.16.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",