@hotmeshio/long-tail 0.21.1 → 0.21.2

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) {
@@ -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.21.2",
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.29.1",
79
79
  "@modelcontextprotocol/sdk": "^1.27.1",
80
80
  "@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
81
81
  "@opentelemetry/resources": "^2.5.1",