@hotmeshio/long-tail 0.21.1 → 0.22.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.
@@ -46,8 +46,11 @@ export declare function accumulateItemBySignalKey(input: {
46
46
  } & Omit<AccumulateItemInput, 'assertClaim'>, auth: LTApiAuth): Promise<LTApiResult>;
47
47
  /**
48
48
  * Add selecting the container by metadata facet, the faceted sibling of
49
- * {@link accumulateItem}. RBAC folds into the SDK's atomic facet selection
50
- * as a flat role filter. Schema enforcement is two-phase when enforcing
49
+ * {@link accumulateItem}. The facet names a container, so only pending rows
50
+ * carrying the accumulator declaration are candidates: a release or
51
+ * remediation row that shares the facet is never picked, and when no open
52
+ * container shares it the answer is 404. RBAC folds into the SDK's atomic
53
+ * facet selection as a flat role filter. Schema enforcement is two-phase when enforcing
51
54
  * roles exist and a payload is present: the row is picked first, validated
52
55
  * against ITS schema, then written by asserted id; a row that went terminal
53
56
  * between phases surfaces as a 409.
@@ -65,6 +65,25 @@ function isAccumulator(escalation) {
65
65
  function isBatch(escalation) {
66
66
  return Array.isArray(escalation.metadata?.[escalation_1.ESCALATION_BATCH_KEYS.PENDING]);
67
67
  }
68
+ const NO_ACCUMULATOR_FOR_FACET = 'No pending accumulator found for this metadata';
69
+ /**
70
+ * The container a facet names: the highest priority pending row that carries
71
+ * the facet AND the accumulator declaration, within the given roles (null =
72
+ * unscoped). A release or remediation row sharing the facet is never a
73
+ * candidate, so a by-facet add lands on the open container or nowhere.
74
+ */
75
+ async function pickAccumulatorByFacet(key, value, roles) {
76
+ if (roles !== null && roles.length === 0)
77
+ return null;
78
+ const found = await escalationService.searchByFacets({
79
+ roles: roles ?? undefined,
80
+ facets: { [key]: value },
81
+ status: 'pending',
82
+ exists: [escalation_1.ESCALATION_ACCUMULATE_KEYS.COUNT],
83
+ limit: 1,
84
+ });
85
+ return found.escalations[0] ?? null;
86
+ }
68
87
  function reciprocalSelectorCount(r) {
69
88
  return (r.id ? 1 : 0) + (r.signalKey ? 1 : 0) + (r.key !== undefined ? 1 : 0);
70
89
  }
@@ -89,8 +108,7 @@ async function gateReciprocal(input, auth) {
89
108
  }
90
109
  else {
91
110
  const writeScope = await (0, helpers_1.getEscalationWriteScope)(auth.userId);
92
- const found = await escalationService.findByMetadata(input.key, String(input.value), 'pending', 1, 0, writeScope.global ? undefined : { allRoles: writeScope.allRoles, meUserId: auth.userId });
93
- row = found.escalations[0] ?? null;
111
+ row = await pickAccumulatorByFacet(input.key, String(input.value), writeScope.global ? null : writeScope.allRoles);
94
112
  }
95
113
  if (!row)
96
114
  return { error: { status: 404, error: 'Reciprocal escalation not found' } };
@@ -220,8 +238,11 @@ async function accumulateItemBySignalKey(input, auth) {
220
238
  }
221
239
  /**
222
240
  * Add selecting the container by metadata facet, the faceted sibling of
223
- * {@link accumulateItem}. RBAC folds into the SDK's atomic facet selection
224
- * as a flat role filter. Schema enforcement is two-phase when enforcing
241
+ * {@link accumulateItem}. The facet names a container, so only pending rows
242
+ * carrying the accumulator declaration are candidates: a release or
243
+ * remediation row that shares the facet is never picked, and when no open
244
+ * container shares it the answer is 404. RBAC folds into the SDK's atomic
245
+ * facet selection as a flat role filter. Schema enforcement is two-phase when enforcing
225
246
  * roles exist and a payload is present: the row is picked first, validated
226
247
  * against ITS schema, then written by asserted id; a row that went terminal
227
248
  * between phases surfaces as a 409.
@@ -251,12 +272,13 @@ async function accumulateItemByMetadata(input, auth) {
251
272
  roles: allowedRoles ?? undefined,
252
273
  reciprocal: reciprocal.reciprocal,
253
274
  });
275
+ if (result.outcome === 'not-found')
276
+ return { status: 404, error: NO_ACCUMULATOR_FOR_FACET };
254
277
  return accumulateOutcomeResult(result, itemKey, result.escalation?.workflow_id ?? null);
255
278
  }
256
- const found = await escalationService.findByMetadata(key, value, 'pending', 1, 0, allowedRoles === null ? undefined : { allRoles: allowedRoles, meUserId: auth.userId });
257
- const row = found.escalations[0];
279
+ const row = await pickAccumulatorByFacet(key, value, allowedRoles);
258
280
  if (!row)
259
- return { status: 404, error: 'No pending escalation found for this metadata' };
281
+ return { status: 404, error: NO_ACCUMULATOR_FOR_FACET };
260
282
  if (!isAccumulator(row))
261
283
  return { status: 400, error: 'Escalation is not an accumulator' };
262
284
  const payload = await gatePayload(row, input.payload, auth.userId);
@@ -358,6 +380,8 @@ async function removeItemByMetadata(input, auth) {
358
380
  const result = await escalationService.removeAccumulatedItemByMetadata(key, value, {
359
381
  itemKey, actor: auth.userId, roles: allowedRoles ?? undefined, reciprocal: reciprocal.reciprocal,
360
382
  });
383
+ if (result.outcome === 'not-found')
384
+ return { status: 404, error: NO_ACCUMULATOR_FOR_FACET };
361
385
  return removeOutcomeResult(result, itemKey);
362
386
  }
363
387
  catch (err) {
@@ -1,12 +1,17 @@
1
- import { Client, Pool } from 'pg';
1
+ import { Client, Pool, type ClientConfig } from 'pg';
2
+ /** `pg.Client` that carries an 'error' listener from construction. */
3
+ declare class GuardedClient extends Client {
4
+ constructor(config?: string | ClientConfig);
5
+ }
2
6
  export declare function getPool(): Pool;
3
7
  export declare function closePool(): Promise<void>;
4
8
  /**
5
- * HotMesh connection descriptor: `{ class: Client, options: postgres_options }`.
9
+ * HotMesh connection descriptor: `{ class: GuardedClient, options: postgres_options }`.
6
10
  * Use this everywhere HotMesh / Durable APIs need a connection config
7
11
  * instead of importing `pg` and `postgres_options` directly.
8
12
  */
9
13
  export declare function getConnection(): {
10
- class: typeof Client;
14
+ class: typeof GuardedClient;
11
15
  options: Record<string, unknown>;
12
16
  };
17
+ export {};
@@ -5,10 +5,29 @@ exports.closePool = closePool;
5
5
  exports.getConnection = getConnection;
6
6
  const pg_1 = require("pg");
7
7
  const config_1 = require("../../modules/config");
8
+ const logger_1 = require("../logger");
9
+ /** A new connection that cannot be established within this window fails instead of hanging. */
10
+ const CONNECT_TIMEOUT_MS = 10_000;
8
11
  let pool = null;
12
+ /** A lost socket fails the pending query; its 'error' event must never end the process. */
13
+ function onConnectionError(error) {
14
+ logger_1.loggerRegistry.debug(`[lt-db] connection error: ${error.message}`);
15
+ }
16
+ function onIdleConnectionLost(error) {
17
+ logger_1.loggerRegistry.warn(`[lt-db] pooled connection lost: ${error.message}`);
18
+ }
19
+ /** `pg.Client` that carries an 'error' listener from construction. */
20
+ class GuardedClient extends pg_1.Client {
21
+ constructor(config) {
22
+ super(config);
23
+ this.on('error', onConnectionError);
24
+ }
25
+ }
9
26
  function getPool() {
10
27
  if (!pool) {
11
28
  pool = new pg_1.Pool({
29
+ connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
30
+ keepAlive: true,
12
31
  ...config_1.postgres_options,
13
32
  // Long-tail's shared tables live in public. Postgres's default
14
33
  // search_path is `"$user", public`: if any schema shares the connecting
@@ -20,6 +39,9 @@ function getPool() {
20
39
  // the engine fully qualifies its app schemas.
21
40
  options: '-c search_path=public',
22
41
  });
42
+ pool.on('error', onIdleConnectionLost);
43
+ // pg-pool drops its own listener while a client is checked out
44
+ pool.on('connect', (client) => client.on('error', onConnectionError));
23
45
  }
24
46
  return pool;
25
47
  }
@@ -30,10 +52,10 @@ async function closePool() {
30
52
  }
31
53
  }
32
54
  /**
33
- * HotMesh connection descriptor: `{ class: Client, options: postgres_options }`.
55
+ * HotMesh connection descriptor: `{ class: GuardedClient, options: postgres_options }`.
34
56
  * Use this everywhere HotMesh / Durable APIs need a connection config
35
57
  * instead of importing `pg` and `postgres_options` directly.
36
58
  */
37
59
  function getConnection() {
38
- return { class: pg_1.Client, options: config_1.postgres_options };
60
+ return { class: GuardedClient, options: config_1.postgres_options };
39
61
  }
@@ -36,7 +36,10 @@ function ensureFacetReady() {
36
36
  ready = (async () => {
37
37
  await (0, client_1.ensureEscalationCompatView)(); // hmsh_escalations + lt_escalations view
38
38
  await (0, db_1.getPool)().query(facet_sql_1.ENSURE_ORIGIN_INDEX);
39
- })();
39
+ })().catch((err) => {
40
+ ready = null;
41
+ throw err;
42
+ });
40
43
  }
41
44
  return ready;
42
45
  }
@@ -704,13 +704,13 @@ export declare const patchUserPropertiesSchema: z.ZodObject<{
704
704
  rename: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
705
705
  }, "strip", z.ZodTypeAny, {
706
706
  user_id: string;
707
- set?: Record<string, any> | undefined;
708
707
  remove?: string[] | undefined;
708
+ set?: Record<string, any> | undefined;
709
709
  rename?: Record<string, string> | undefined;
710
710
  }, {
711
711
  user_id: string;
712
- set?: Record<string, any> | undefined;
713
712
  remove?: string[] | undefined;
713
+ set?: Record<string, any> | undefined;
714
714
  rename?: Record<string, string> | undefined;
715
715
  }>;
716
716
  export declare const listRolesSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
@@ -67,6 +67,8 @@ A batch wait may add `partialOnTimeout: true`; it stores `envelope.batch_partial
67
67
  | Read the collection | `GET /api/escalations/:id/items`, `GET /api/escalations/items-by-signal-key` | `getItems`, `getItemsBySignalKey` | `get_escalation_items` (admin; `id` or `signalKey`) |
68
68
  | Scan | the `accumulate` verb; item mode answers `no_open_container` when the container closed; templates read `{claim.<facet>}` and `{item.<facet>}` | | |
69
69
 
70
+ A facet selector (`accumulateByMetadata`, `removeItemByMetadata`, a reciprocal named by `key` + `value`, the scan verb's item mode) considers only pending rows that carry the accumulator declaration, ordered by priority then age. A release or remediation row sharing the container's facet is never picked; with no open container the answer is `not-found`, and the scan answers `no_open_container`.
71
+
70
72
  An add takes `itemKey` (1 to 128 characters), an optional `payload` (validated against the container role's versioned form when present), an optional `metadata` patch (reserved keys rejected), `assertClaim` (by id), and an optional `reciprocal`.
71
73
 
72
74
  ### Outcomes
@@ -78,7 +80,7 @@ An add takes `itemKey` (1 to 128 characters), an optional `payload` (validated a
78
80
  | `duplicate-item` | 409 | the key is already held (`unique`), row untouched |
79
81
  | `full` | 409 | `max` held and the key is new (`resolveAtMax: false`, or a race with the completing add) |
80
82
  | `not-accumulator` | 400 | the row carries no `accumulate` declaration |
81
- | `not-found` | 404 | unknown row, or outside the caller's scope on the ingress forms |
83
+ | `not-found` | 404 | unknown row, outside the caller's scope on the ingress forms, or (by facet) no pending accumulator shares the facet; rows without the declaration are never candidates |
82
84
  | `already-resolved`, `already-expired`, `already-cancelled` | 409 | the row is terminal |
83
85
  | `claimed-by-other`, `claim-expired` | 409 | the `assertClaim` assertion failed |
84
86
  | `reciprocal-not-found` | 404 | the reciprocal selector matched nothing the caller may act on |
@@ -110,4 +112,4 @@ Every accepted add and removal publishes `escalation.updated` for the container
110
112
 
111
113
  ## Versions
112
114
 
113
- HotMesh 0.29.0 (`accumulate`, `partialOnTimeout`, timeout delivery, reciprocal statement) and long-tail 0.21.1 (root export of `conditionalAccumulator`, `no_open_container`, claim and item template tokens, items by signal key).
115
+ HotMesh 0.29.0 (`accumulate`, `partialOnTimeout`, timeout delivery, reciprocal statement) and long-tail 0.21.1 (root export of `conditionalAccumulator`, `no_open_container`, claim and item template tokens, items by signal key); HotMesh 0.29.1 and long-tail 0.21.2 (facet selectors pick pending accumulators only).
@@ -1166,7 +1166,7 @@ Add ONE item to an open accumulator escalation (a `conditionalAccumulator` wait
1166
1166
  | `assertClaim` | `boolean` | By id only. Require the caller's own live claim, asserted atomically |
1167
1167
  | `reciprocal` | `object` | A second accumulator row written in the same statement, both or neither. Exactly one of `id`, `signalKey`, or `key` + `value`; optional `payload` stored on its entry |
1168
1168
 
1169
- **By signal key:** `{ signalKey, itemKey, payload?, metadata?, reciprocal? }`. **By metadata:** `{ key, value, itemKey, payload?, metadata?, restrictRoles?, reciprocal? }` selects the highest priority pending accumulator whose metadata contains the key/value, within the caller's write scope intersected with `restrictRoles`.
1169
+ **By signal key:** `{ signalKey, itemKey, payload?, metadata?, reciprocal? }`. **By metadata:** `{ key, value, itemKey, payload?, metadata?, restrictRoles?, reciprocal? }` selects the highest priority pending accumulator whose metadata contains the key/value, within the caller's write scope intersected with `restrictRoles`. Only rows carrying the accumulator declaration are candidates: a release or remediation row that shares the facet is never picked, and when no open container shares it the answer is 404 `No pending accumulator found for this metadata`. The same rule selects a reciprocal named by `key` + `value`.
1170
1170
 
1171
1171
  **Response 200 (interim):**
1172
1172
 
@@ -887,7 +887,7 @@ await lt.escalations.accumulateByMetadata({ key: 'binKey', value: binKey, itemKe
887
887
  | `metadata` | `object` | No | Merge patch for the container metadata, same statement; reserved `accumulate_*` keys rejected |
888
888
  | `assertClaim` | `boolean` | No | By id only: require the caller's own live claim |
889
889
  | `reciprocal` | `object` | No | `{ id \| signalKey \| key + value, payload? }`: a second accumulator row written in the same statement, both or neither |
890
- | `restrictRoles` | `string[]` | No | By metadata only: expected queues, intersected with the caller's write scope |
890
+ | `restrictRoles` | `string[]` | No | By metadata only: expected queues, intersected with the caller's write scope. The facet selects among pending accumulator rows only; other rows sharing it are never candidates, and none open answers 404 |
891
891
 
892
892
  **Returns:** `LTApiResult<{ outcome: 'accepted' \| 'completed', count, remaining, escalationId, signaled?, workflowId?, reciprocal? }>`. `remaining` is `null` on an unbounded accumulator. 409 on `Item already held`, `Accumulator is full`, claim blocks, terminal rows, and reciprocal blocks; 400 on a non-accumulator; 404 non-disclosure.
893
893
 
@@ -118,7 +118,9 @@ through the scheme facet, reads that facet from the row (a bag row carrying
118
118
  `binKey: "B-7"`), and adds the target to the pending accumulator whose
119
119
  metadata carries the same value, writing the item's row as the reciprocal in
120
120
  the same statement (`reciprocal: false` skips it; `containerRoles` names the
121
- container queues). Without it the scanned target is the CONTAINER and the step
121
+ container queues). The container pick considers only pending rows that carry
122
+ the accumulator declaration, so a release row sharing the facet during a
123
+ pack-out is never the target. Without it the scanned target is the CONTAINER and the step
122
124
  adds `params.itemKey` (a template) to it. An item with no row or no container
123
125
  facet falls through to the next step; a container already holding the item
124
126
  reports a conflict. When the item's row exists but no pending container
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -75,7 +75,7 @@
75
75
  "@anthropic-ai/sdk": "^0.92.0",
76
76
  "@aws-sdk/client-s3": "^3.1017.0",
77
77
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
78
- "@hotmeshio/hotmesh": "^0.29.0",
78
+ "@hotmeshio/hotmesh": "^0.30.0",
79
79
  "@modelcontextprotocol/sdk": "^1.27.1",
80
80
  "@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
81
81
  "@opentelemetry/resources": "^2.5.1",