@empyria/restate 0.1.21 → 0.1.22

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.
package/lib/Admin.js CHANGED
@@ -212,15 +212,54 @@ export async function listHandlers({ restateAdminURL, name }) {
212
212
  * @property {object} payload
213
213
  * @typedef {BaseMessage & {key: string}} WorkflowMessage Message targeting a Restate
214
214
  * workflow — `key` is required to address the workflow instance.
215
- * @typedef {BaseMessage & {key?: string, message: string}} ServiceMessage Message
216
- * targeting a Restate service or virtual object handler. `key` is the Virtual
217
- * Object key (omit for plain services); `message` is the handler name.
215
+ * @typedef {BaseMessage & {key?: string, message: string, idempotencyKey?: string}} ServiceMessage
216
+ * Message targeting a Restate service or virtual object handler. `key` is the Virtual
217
+ * Object key (omit for plain services); `message` is the handler name. `idempotencyKey`,
218
+ * when set, is sent as Restate's `idempotency-key` header: resending the same message
219
+ * with the same key (e.g. after a network timeout) attaches to the original invocation
220
+ * instead of running the handler twice.
218
221
  * @typedef {Object} InvocationSubmission Response returned by Restate when an
219
222
  * invocation is enqueued asynchronously.
220
223
  * @property {string} invocationId
221
224
  * @property {'Accepted'|'PreviouslyAccepted'} status
222
225
  */
223
226
 
227
+ /**
228
+ * Separator {@link childKey} joins key segments with. Not `/`: keys are interpolated
229
+ * straight into ingress URL paths (see {@link submitWorkflow}), where a `/` would split
230
+ * the key into extra path segments.
231
+ */
232
+ export const CHILD_KEY_SEPARATOR = ':'
233
+
234
+ /**
235
+ * Derives a sub-workflow's key deterministically from its parent's, so a replayed parent
236
+ * addresses the very same child instead of starting a new one. Never use a random ID
237
+ * for a child: a workflow's `run` executes once per key, and the key is what makes the
238
+ * call idempotent across replays.
239
+ *
240
+ * childKey('order-42', 'approval') // 'order-42:approval'
241
+ * childKey('order-42', 'shipment', 3) // 'order-42:shipment:3'
242
+ * @param {string} parentKey key of the calling workflow (`ctx.key`)
243
+ * @param {string} nodePath stable name of the calling step within the parent's flow
244
+ * @param {number|string} [iteration] loop index, when the step runs more than once
245
+ * @returns {string}
246
+ * @throws {TypeError} If a segment is empty or contains {@link CHILD_KEY_SEPARATOR} or `/`.
247
+ */
248
+ export function childKey(parentKey, nodePath, iteration) {
249
+ const segments = iteration === undefined ? [nodePath] : [nodePath, String(iteration)]
250
+ for (const segment of segments) {
251
+ if (typeof segment !== 'string' || segment === '' || /[:/]/.test(segment)) {
252
+ throw new TypeError(
253
+ `Invalid child key segment '${segment}': must be a non-empty string without '${CHILD_KEY_SEPARATOR}' or '/'`,
254
+ )
255
+ }
256
+ }
257
+ if (typeof parentKey !== 'string' || parentKey === '') {
258
+ throw new TypeError('childKey requires a non-empty parentKey')
259
+ }
260
+ return [parentKey, ...segments].join(CHILD_KEY_SEPARATOR)
261
+ }
262
+
224
263
  /**
225
264
  * Starts a sub-workflow and returns a handle to the invocation. Submitting against a
226
265
  * `workflowKey` that already has a running or completed workflow is a safe no-op on
@@ -280,7 +319,10 @@ export function callSubWorkflow(ctx, workflowName, workflowKey, payload) {
280
319
  * @param {Object} options
281
320
  * @param {string} options.restateURL - Restate endpoint URL
282
321
  * @param {string} options.name - Workflow name
283
- * @param {string} [options.key] - Unique workflow instance key
322
+ * @param {string} [options.key] - Unique workflow instance key. This is also the workflow's
323
+ * idempotency key: resubmitting the same key is `PreviouslyAccepted`, not a second run.
324
+ * Omitting it generates a random key, so a retried submission starts a duplicate run —
325
+ * pass a business key (e.g. the order ID) whenever the caller may retry.
284
326
  * @param {TPayload} options.payload - Request payload
285
327
  * @returns {Promise<InvocationSubmission>}
286
328
  */
@@ -366,17 +408,28 @@ export async function pollInvocation({ restateURL, invocationId, intervalMs = 25
366
408
  }
367
409
  }
368
410
 
411
+ /**
412
+ * Request headers for an ingress call, adding Restate's `idempotency-key` when given.
413
+ * @param {string} [idempotencyKey]
414
+ * @returns {Record<string, string>}
415
+ */
416
+ function ingressHeaders(idempotencyKey) {
417
+ const headers = { 'Content-Type': 'application/json' }
418
+ if (idempotencyKey) headers['idempotency-key'] = idempotencyKey
419
+ return headers
420
+ }
421
+
369
422
  /**
370
423
  * Sends a message to a Restate service with configurable retry policy.
371
424
  * @template TResult
372
425
  * @param {ServiceMessage} params
373
426
  * @returns {Promise<TResult>}
374
427
  */
375
- export async function sendMessage({ restateURL, name, message, key, payload }) {
428
+ export async function sendMessage({ restateURL, name, message, key, payload, idempotencyKey }) {
376
429
  const path = key ? `${name}/${key}/${message}` : `${name}/${message}`
377
430
  const response = await fetch(`${restateURL}/${path}`, {
378
431
  method: 'POST',
379
- headers: { 'Content-Type': 'application/json' },
432
+ headers: ingressHeaders(idempotencyKey),
380
433
  body: JSON.stringify(payload),
381
434
  })
382
435
 
@@ -395,11 +448,18 @@ export async function sendMessage({ restateURL, name, message, key, payload }) {
395
448
  * @param {ServiceMessage} params
396
449
  * @returns {Promise<InvocationSubmission>}
397
450
  */
398
- export async function sendMessageAsync({ restateURL, name, message, key, payload }) {
451
+ export async function sendMessageAsync({
452
+ restateURL,
453
+ name,
454
+ message,
455
+ key,
456
+ payload,
457
+ idempotencyKey,
458
+ }) {
399
459
  const path = key ? `${name}/${key}/${message}` : `${name}/${message}`
400
460
  const response = await fetch(`${restateURL}/${path}/send`, {
401
461
  method: 'POST',
402
- headers: { 'Content-Type': 'application/json' },
462
+ headers: ingressHeaders(idempotencyKey),
403
463
  body: JSON.stringify(payload),
404
464
  })
405
465
 
@@ -429,10 +489,10 @@ export async function sendMessageAsync({ restateURL, name, message, key, payload
429
489
  * @returns {(message: ServiceMessage) => Promise<TResult>}
430
490
  */
431
491
  export function sendMessageWithDiscovery({ restateAdminURL }) {
432
- return async ({ restateURL, name, message, key, payload }) => {
492
+ return async ({ restateURL, name, message, key, payload, idempotencyKey }) => {
433
493
  await checkServiceHandler(restateAdminURL, name, message)
434
494
 
435
- return sendMessage({ restateURL, name, message, key, payload })
495
+ return sendMessage({ restateURL, name, message, key, payload, idempotencyKey })
436
496
  }
437
497
  }
438
498
 
@@ -443,10 +503,10 @@ export function sendMessageWithDiscovery({ restateAdminURL }) {
443
503
  * @returns {(message: ServiceMessage) => Promise<InvocationSubmission>}
444
504
  */
445
505
  export function sendMessageAsyncWithDiscovery({ restateAdminURL }) {
446
- return async ({ restateURL, name, message, key, payload }) => {
506
+ return async ({ restateURL, name, message, key, payload, idempotencyKey }) => {
447
507
  await checkServiceHandler(restateAdminURL, name, message)
448
508
 
449
- return sendMessageAsync({ restateURL, name, message, key, payload })
509
+ return sendMessageAsync({ restateURL, name, message, key, payload, idempotencyKey })
450
510
  }
451
511
  }
452
512
 
@@ -772,10 +832,10 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
772
832
  pollInvocation: ({ invocationId, intervalMs }) =>
773
833
  pollInvocation({ restateURL, invocationId, intervalMs }),
774
834
 
775
- sendMessage: ({ name, message, key, payload }) =>
776
- sendMessage({ restateURL, name, message, key, payload }),
777
- sendMessageAsync: ({ name, message, key, payload }) =>
778
- sendMessageAsync({ restateURL, name, message, key, payload }),
835
+ sendMessage: ({ name, message, key, payload, idempotencyKey }) =>
836
+ sendMessage({ restateURL, name, message, key, payload, idempotencyKey }),
837
+ sendMessageAsync: ({ name, message, key, payload, idempotencyKey }) =>
838
+ sendMessageAsync({ restateURL, name, message, key, payload, idempotencyKey }),
779
839
  sendMessageWithDiscovery: () => sendMessageWithDiscovery({ restateAdminURL }),
780
840
  sendMessageAsyncWithDiscovery: () => sendMessageAsyncWithDiscovery({ restateAdminURL }),
781
841
  }
package/lib/Validation.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { TerminalError } from '@restatedev/restate-sdk'
1
2
  import { createValidator } from '@empyria/common'
2
3
 
3
4
  /**
@@ -15,6 +16,14 @@ import { createValidator } from '@empyria/common'
15
16
  * that doesn't match the declared output type, the error is caught before
16
17
  * Restate records the result.
17
18
  *
19
+ * Validation failures are thrown as `TerminalError`, never as the plain
20
+ * `ValidationError` `@empyria/common` raises: Restate retries any non-terminal
21
+ * error indefinitely, and a payload that failed validation once fails it on
22
+ * every retry. Input failures carry errorCode 400 (the caller's fault), output
23
+ * failures 500 (the handler's fault) — Restate propagates the code to an
24
+ * ingress caller as the HTTP status. Errors thrown by `handler` itself are left
25
+ * untouched, so transient failures there are still retried.
26
+ *
18
27
  * Usage:
19
28
  * handlers: {
20
29
  * run: withValidation(InputSchema, OutputSchema, async (ctx, input) => { ... })
@@ -25,15 +34,29 @@ export function withValidation(inputSchema, outputSchema, handler) {
25
34
  const validateOutput = createValidator(outputSchema)
26
35
 
27
36
  return async (ctx, input) => {
28
- // Validate input — throws ValidationError if the payload is malformed.
29
- const validatedInput = validateInput(input)
37
+ const validatedInput = asTerminal(validateInput, input, 'input', 400)
30
38
 
31
39
  // Execute the actual workflow logic.
32
40
  const result = await handler(ctx, validatedInput)
33
41
 
34
- // Validate output — guards against handler implementation drift.
35
- const validatedOutput = validateOutput(result)
42
+ return asTerminal(validateOutput, result, 'output', 500)
43
+ }
44
+ }
36
45
 
37
- return validatedOutput
46
+ /**
47
+ * Runs `validator(value)`, rethrowing any failure as a `TerminalError`.
48
+ * @param {(value: *) => *} validator
49
+ * @param {*} value
50
+ * @param {'input'|'output'} side
51
+ * @param {number} errorCode
52
+ */
53
+ function asTerminal(validator, value, side, errorCode) {
54
+ try {
55
+ return validator(value)
56
+ } catch (e) {
57
+ throw new TerminalError(e.message, {
58
+ errorCode,
59
+ metadata: { validation: side, errorName: String(e.errorName ?? e.name) },
60
+ })
38
61
  }
39
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -3,6 +3,8 @@ import {
3
3
  checkServiceHandler,
4
4
  startSubWorkflow,
5
5
  callSubWorkflow,
6
+ childKey,
7
+ CHILD_KEY_SEPARATOR,
6
8
  listServices,
7
9
  queryRestate,
8
10
  deleteDeployment,
@@ -113,6 +115,32 @@ describe('checkServiceHandler', () => {
113
115
  })
114
116
  })
115
117
 
118
+ describe('childKey', () => {
119
+ test('joins parent key and node path', () => {
120
+ expect(childKey('order-42', 'approval')).toBe('order-42:approval')
121
+ expect(CHILD_KEY_SEPARATOR).toBe(':')
122
+ })
123
+
124
+ test('appends the iteration when given, including 0', () => {
125
+ expect(childKey('order-42', 'shipment', 3)).toBe('order-42:shipment:3')
126
+ expect(childKey('order-42', 'shipment', 0)).toBe('order-42:shipment:0')
127
+ })
128
+
129
+ test('nests: a grandchild key extends the child key', () => {
130
+ expect(childKey(childKey('order-42', 'approval'), 'review', 1)).toBe(
131
+ 'order-42:approval:review:1',
132
+ )
133
+ })
134
+
135
+ test('rejects empty or separator-containing segments and an empty parent', () => {
136
+ expect(() => childKey('order-42', '')).toThrow(TypeError)
137
+ expect(() => childKey('order-42', 'a:b')).toThrow(TypeError)
138
+ expect(() => childKey('order-42', 'a/b')).toThrow(TypeError)
139
+ expect(() => childKey('order-42', 'step', '')).toThrow(TypeError)
140
+ expect(() => childKey('', 'step')).toThrow(TypeError)
141
+ })
142
+ })
143
+
116
144
  describe('startSubWorkflow / callSubWorkflow', () => {
117
145
  test("startSubWorkflow sends via ctx.genericSend to the workflow's run handler", () => {
118
146
  let sentArgs
@@ -389,6 +417,23 @@ describe('sendMessage / sendMessageAsync', () => {
389
417
  ).rejects.toThrow()
390
418
  })
391
419
 
420
+ test('sends the idempotency-key header only when idempotencyKey is given', async () => {
421
+ const seen = []
422
+ globalThis.fetch = mock(async (url, init) => {
423
+ seen.push(init.headers)
424
+ return String(url).endsWith('/send')
425
+ ? jsonResponse({ invocationId: 'inv-3', status: 'PreviouslyAccepted' })
426
+ : { ok: true, text: async () => '' }
427
+ })
428
+ const base = { restateURL: 'http://ingress', name: 'Svc', message: 'do', payload: {} }
429
+ await sendMessage({ ...base, idempotencyKey: 'req-1' })
430
+ await sendMessageAsync({ ...base, idempotencyKey: 'req-2' })
431
+ await sendMessage(base)
432
+ expect(seen[0]['idempotency-key']).toBe('req-1')
433
+ expect(seen[1]['idempotency-key']).toBe('req-2')
434
+ expect(seen[2]).not.toHaveProperty('idempotency-key')
435
+ })
436
+
392
437
  test('sendMessageWithDiscovery / sendMessageAsyncWithDiscovery validate first', async () => {
393
438
  globalThis.fetch = mock(async (url) => {
394
439
  if (String(url).endsWith('/services')) return jsonResponse(servicesPayload)
@@ -522,4 +567,19 @@ describe('createRestateAdmin', () => {
522
567
  })
523
568
  expect(await admin.query('SELECT status FROM sys_invocation')).toEqual(rows)
524
569
  })
570
+ test('sendMessage/sendMessageAsync forward idempotencyKey', async () => {
571
+ const seen = []
572
+ globalThis.fetch = mock(async (url, init) => {
573
+ seen.push(init.headers['idempotency-key'])
574
+ return jsonResponse({ invocationId: 'inv-4', status: 'Accepted' })
575
+ })
576
+ const admin = createRestateAdmin({
577
+ restateAdminURL: 'http://admin',
578
+ restateURL: 'http://ingress',
579
+ })
580
+ const msg = { name: 'Svc', message: 'do', payload: {} }
581
+ await admin.sendMessage({ ...msg, idempotencyKey: 'a' })
582
+ await admin.sendMessageAsync({ ...msg, idempotencyKey: 'b' })
583
+ expect(seen).toEqual(['a', 'b'])
584
+ })
525
585
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
+ import { TerminalError } from '@restatedev/restate-sdk'
2
3
  import { withValidation } from '../lib/Validation.js'
3
- import { EmpyriaError } from '@empyria/common'
4
4
 
5
5
  const inputSchema = {
6
6
  type: 'object',
@@ -13,6 +13,15 @@ const outputSchema = {
13
13
  required: ['greeting'],
14
14
  }
15
15
 
16
+ const rejection = async (promise) => {
17
+ try {
18
+ await promise
19
+ } catch (e) {
20
+ return e
21
+ }
22
+ throw new Error('expected a rejection')
23
+ }
24
+
16
25
  describe('withValidation', () => {
17
26
  test('validates input and output, and returns the validated output', async () => {
18
27
  const handler = withValidation(inputSchema, outputSchema, async (ctx, input) => ({
@@ -22,13 +31,34 @@ describe('withValidation', () => {
22
31
  expect(result).toEqual({ greeting: 'Hello, Bob' })
23
32
  })
24
33
 
25
- test('throws when the input does not match the schema', async () => {
26
- const handler = withValidation(inputSchema, outputSchema, async () => ({ greeting: 'hi' }))
27
- await expect(handler({}, {})).rejects.toThrow(EmpyriaError)
34
+ test('throws a 400 TerminalError when the input does not match the schema', async () => {
35
+ let called = false
36
+ const handler = withValidation(inputSchema, outputSchema, async () => {
37
+ called = true
38
+ return { greeting: 'hi' }
39
+ })
40
+ const e = await rejection(handler({}, {}))
41
+ expect(e).toBeInstanceOf(TerminalError)
42
+ expect(e.code).toBe(400)
43
+ expect(e.metadata.validation).toBe('input')
44
+ expect(called).toBe(false)
28
45
  })
29
46
 
30
- test('throws when the handler output does not match the schema', async () => {
47
+ test('throws a 500 TerminalError when the handler output does not match the schema', async () => {
31
48
  const handler = withValidation(inputSchema, outputSchema, async () => ({ wrong: true }))
32
- await expect(handler({}, { name: 'Bob' })).rejects.toThrow(EmpyriaError)
49
+ const e = await rejection(handler({}, { name: 'Bob' }))
50
+ expect(e).toBeInstanceOf(TerminalError)
51
+ expect(e.code).toBe(500)
52
+ expect(e.metadata.validation).toBe('output')
53
+ })
54
+
55
+ test('leaves errors thrown by the handler itself retryable', async () => {
56
+ const boom = new Error('transient')
57
+ const handler = withValidation(inputSchema, outputSchema, async () => {
58
+ throw boom
59
+ })
60
+ const e = await rejection(handler({}, { name: 'Bob' }))
61
+ expect(e).toBe(boom)
62
+ expect(e).not.toBeInstanceOf(TerminalError)
33
63
  })
34
64
  })