@empyria/restate 0.4.27 → 0.4.29

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/README.md CHANGED
@@ -81,6 +81,9 @@ endpoint, then create jobs with either:
81
81
  starts with the application.** The job has the stable `id` you give it, so calling it on every start never adds a second
82
82
  job: the same request changes nothing, and a changed request (another schedule, target or payload) replaces the pending
83
83
  run under the same ID. Call it once the endpoint has registered, e.g. from `setupRestate`'s `registrationCallbackFn`.
84
+ - `getCronJob({restateURL, id})`: the job's request and next run, or `null` when it does not exist;
85
+ - `cancelCronJob({restateURL, id})`: cancels the pending run, clears the job and **reads it back**: it resolves only when
86
+ the job is really gone, and throws otherwise. Safe to repeat, and for a job that never existed.
84
87
 
85
88
  Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
86
89
  `payload` as its input, or no input when there is none.
package/lib/Cron.js CHANGED
@@ -156,6 +156,8 @@ const scheduleNextExecution = async (ctx, request) => {
156
156
  const jobState = {
157
157
  request,
158
158
  next_execution_time: next.toString(),
159
+ // The same instant in ms since 1970, for callers that store or compare it.
160
+ next_execution_at: next.getTime(),
159
161
  next_execution_id: await handle.invocationId,
160
162
  }
161
163
  ctx.set(JOB_STATE_NAME, jobState)
@@ -188,4 +190,34 @@ export async function ensureCronJob({ restateURL, id, ...request }) {
188
190
  })
189
191
  }
190
192
 
193
+ const checkJobId = (fn, id) => {
194
+ if (typeof id !== 'string' || !/^[A-Za-z0-9._:-]+$/.test(id)) {
195
+ throw new TypeError(`${fn} needs an id made of letters, digits, ".", "_", ":" and "-"`)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * What a recurring job is doing now, from outside Restate: its request and next run, or `null` when no such job
201
+ * exists (never created, or cancelled).
202
+ * @param {{restateURL: string, id: string}} params
203
+ */
204
+ export async function getCronJob({ restateURL, id }) {
205
+ checkJobId('getCronJob', id)
206
+ return (await sendMessage({ restateURL, name: 'CronJob', message: 'getInfo', key: id })) ?? null
207
+ }
208
+
209
+ /**
210
+ * Cancels a recurring job and checks that it is gone: the pending run is cancelled and the job's state cleared, then
211
+ * the job is read back. Resolves only when the job really does not exist any more, so a caller may act on it (for
212
+ * example move a card) without a second look. Safe to repeat, and safe for a job that never existed.
213
+ * @param {{restateURL: string, id: string}} params
214
+ * @returns {Promise<void>}
215
+ */
216
+ export async function cancelCronJob({ restateURL, id }) {
217
+ checkJobId('cancelCronJob', id)
218
+ await sendMessage({ restateURL, name: 'CronJob', message: 'cancel', key: id })
219
+ const left = await getCronJob({ restateURL, id })
220
+ if (left) throw new Error(`Cron job '${id}' is still scheduled after it was cancelled`)
221
+ }
222
+
191
223
  export { JOB_STATE_NAME }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -26,8 +26,8 @@
26
26
  "test:watch": "bun test --watch"
27
27
  },
28
28
  "dependencies": {
29
- "@empyria/classification": "0.1.12",
30
- "@empyria/common": "0.3.15",
29
+ "@empyria/classification": "0.1.13",
30
+ "@empyria/common": "0.3.16",
31
31
  "@restatedev/pubsub": "0.3.2",
32
32
  "@restatedev/pubsub-client": "0.3.2",
33
33
  "@restatedev/restate-sdk": "1.17.4",
@@ -36,10 +36,10 @@
36
36
  "croner": "10.0.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@ai-sdk/openai-compatible": "3.0.75",
40
- "@ai-sdk/provider": "4.0.31",
39
+ "@ai-sdk/openai-compatible": "3.0.78",
40
+ "@ai-sdk/provider": "4.0.34",
41
41
  "@restatedev/restate-server": "1.8.1",
42
- "ai": "7.0.148",
42
+ "ai": "7.0.153",
43
43
  "oxfmt": "0.74.0",
44
44
  "oxlint": "1.89.0"
45
45
  },
package/test/Cron.test.js CHANGED
@@ -3,6 +3,8 @@ import {
3
3
  cronJobInitiator,
4
4
  cronJob,
5
5
  ensureCronJob,
6
+ getCronJob,
7
+ cancelCronJob,
6
8
  setJobStateName,
7
9
  JOB_STATE_NAME,
8
10
  } from '../lib/Cron.js'
@@ -300,3 +302,46 @@ describe('retry policy', () => {
300
302
  expect(cronJob.options?.retryPolicy ?? cronJob.retryPolicy).toBeDefined()
301
303
  })
302
304
  })
305
+
306
+ describe('getCronJob and cancelCronJob', () => {
307
+ const realFetch = globalThis.fetch
308
+ afterEach(() => {
309
+ globalThis.fetch = realFetch
310
+ })
311
+ const respond = (script) => {
312
+ const calls = []
313
+ globalThis.fetch = async (url) => {
314
+ calls.push(url)
315
+ const body = script.shift()
316
+ return new Response(body === undefined ? '' : JSON.stringify(body), { status: 200 })
317
+ }
318
+ return calls
319
+ }
320
+
321
+ test('getCronJob answers null when the job does not exist', async () => {
322
+ respond([undefined])
323
+ expect(await getCronJob({ restateURL: 'http://r', id: 'a:1' })).toBeNull()
324
+ })
325
+
326
+ test('cancelCronJob cancels, then reads the job back before it resolves', async () => {
327
+ const calls = respond([undefined, undefined])
328
+ await cancelCronJob({ restateURL: 'http://r', id: 'a:1' })
329
+ expect(calls).toEqual(['http://r/CronJob/a:1/cancel', 'http://r/CronJob/a:1/getInfo'])
330
+ })
331
+
332
+ test('cancelCronJob fails when the job is still there afterwards', async () => {
333
+ respond([undefined, { request: {} }])
334
+ expect(cancelCronJob({ restateURL: 'http://r', id: 'a:1' })).rejects.toThrow(
335
+ 'still scheduled',
336
+ )
337
+ })
338
+
339
+ test('cancelCronJob fails when the cancel call fails', async () => {
340
+ globalThis.fetch = async () => new Response('', { status: 503, statusText: 'down' })
341
+ expect(cancelCronJob({ restateURL: 'http://r', id: 'a:1' })).rejects.toThrow('503')
342
+ })
343
+
344
+ test('cancelCronJob refuses an id that cannot be a job id', () => {
345
+ expect(cancelCronJob({ restateURL: 'http://r', id: 'a b' })).rejects.toThrow(TypeError)
346
+ })
347
+ })