@empyria/restate 0.1.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.
package/lib/Caller.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @typedef {Object} CallerParams
3
+ * @property {string} name - Target service name as registered in Restate.
4
+ * @property {string} handler - Handler name to invoke on the target service.
5
+ * @property {object} payload - JSON payload forwarded to the handler.
6
+ */
7
+
8
+ /**
9
+ * CallerServiceDef — a generic Restate service that dispatches a fire-and-
10
+ * forget call to any handler on any other service by name.
11
+ *
12
+ * Why does this exist?
13
+ * Inside a Restate workflow or virtual object you cannot call another service
14
+ * handler dynamically by string name using the typed SDK clients — the client
15
+ * is bound to a specific service definition at compile time. CallerService
16
+ * breaks that constraint: a workflow sends a {@link CallerParams} message to
17
+ * this service, which then uses `ctx.serviceSendClient` to fan the call out
18
+ * to the target, and returns the resulting invocation ID.
19
+ *
20
+ * The retry policy is intentionally set to 1 attempt. The caller is
21
+ * responsible for deciding whether to retry; this service is a thin proxy.
22
+ */
23
+ export const CallerServiceDef = {
24
+ name: 'CallerService',
25
+ handlers: {
26
+ /**
27
+ * @param {import('@restatedev/restate-sdk').Context} ctx
28
+ * @param {CallerParams} params
29
+ * @returns {Promise<string>} The invocation ID of the dispatched call.
30
+ * @throws {Error} If `handler` isn't a function on the target service's client.
31
+ */
32
+ dispatch: async (ctx, { name, payload, handler }) => {
33
+ const client = ctx.serviceSendClient({ name })
34
+
35
+ const candidate = client[handler]
36
+ if (typeof candidate !== 'function') {
37
+ throw new Error(`Handler "${handler}" is not a function on service "${name}"`)
38
+ }
39
+
40
+ const { invocationId } = await candidate(payload)
41
+
42
+ return await invocationId
43
+ },
44
+ },
45
+ options: {
46
+ retryPolicy: {
47
+ maxAttempts: 1,
48
+ },
49
+ },
50
+ }
package/lib/Cron.js ADDED
@@ -0,0 +1,109 @@
1
+ import * as restate from '@restatedev/restate-sdk'
2
+ import { TerminalError, serde } from '@restatedev/restate-sdk'
3
+ import { Cron } from 'croner'
4
+
5
+ let JOB_STATE_NAME = 'principia-job-state'
6
+
7
+ export const setJobStateName = (jobStateName) => {
8
+ JOB_STATE_NAME = jobStateName
9
+ return JOB_STATE_NAME
10
+ }
11
+
12
+ export const cronJobInitiator = restate.service({
13
+ name: 'CronJobInitiator',
14
+ handlers: {
15
+ create: async (ctx, request) => {
16
+ // Create a new job ID and initiate the cron job object for that ID
17
+ // We can then address this job object by its ID
18
+ const jobId = ctx.rand.uuidv4()
19
+ const job = await ctx.objectClient(cronJob, jobId).initiate(request)
20
+ return `Job created with ID ${jobId} and next execution time ${job.next_execution_time}`
21
+ },
22
+ },
23
+ })
24
+
25
+ export const cronJob = restate.object({
26
+ name: 'CronJob',
27
+ handlers: {
28
+ initiate: async (ctx, request) => {
29
+ if (await ctx.get(JOB_STATE_NAME)) {
30
+ throw new TerminalError('Job already exists for this ID.')
31
+ }
32
+
33
+ return await scheduleNextExecution(ctx, request)
34
+ },
35
+ execute: async (ctx) => {
36
+ const jobState = await ctx.get(JOB_STATE_NAME)
37
+ if (!jobState) {
38
+ throw new TerminalError('Job not found.')
39
+ }
40
+
41
+ // execute the task
42
+ const { service, method, key, payload } = jobState.request
43
+ if (payload) {
44
+ ctx.genericSend({
45
+ service,
46
+ method,
47
+ parameter: payload,
48
+ key,
49
+ inputSerde: serde.json,
50
+ })
51
+ } else {
52
+ ctx.genericSend({
53
+ service,
54
+ method,
55
+ parameter: undefined,
56
+ key,
57
+ inputSerde: serde.empty,
58
+ })
59
+ }
60
+
61
+ await scheduleNextExecution(ctx, jobState.request)
62
+ },
63
+ cancel: async (ctx) => {
64
+ // Cancel the next execution
65
+ const jobState = await ctx.get(JOB_STATE_NAME)
66
+ if (jobState) {
67
+ ctx.cancel(jobState.next_execution_id)
68
+ }
69
+
70
+ // Clear the job state
71
+ ctx.clearAll()
72
+ },
73
+ getInfo: restate.handlers.object.shared(async (ctx) => {
74
+ return ctx.get(JOB_STATE_NAME)
75
+ }),
76
+ },
77
+ })
78
+
79
+ const scheduleNextExecution = async (ctx, request) => {
80
+ // Parse cron expression
81
+ // Persist current date in Restate for deterministic replay
82
+ const currentDate = await ctx.date.now()
83
+ let next
84
+ try {
85
+ next = new Cron(request.cronExpression).nextRun(new Date(currentDate))
86
+ } catch (e) {
87
+ throw new TerminalError(`Invalid cron expression: ${e.message}`)
88
+ }
89
+ if (!next) {
90
+ throw new TerminalError(`Cron expression '${request.cronExpression}' has no future run`)
91
+ }
92
+
93
+ const delay = next.getTime() - currentDate
94
+
95
+ // Schedule next execution for this job
96
+ const thisJobId = ctx.key // This got generated by the CronJobInitiator
97
+ const handle = ctx.objectSendClient(cronJob, thisJobId).execute(restate.rpc.sendOpts({ delay }))
98
+
99
+ // Store the job information
100
+ const jobState = {
101
+ request,
102
+ next_execution_time: next.toString(),
103
+ next_execution_id: await handle.invocationId,
104
+ }
105
+ ctx.set(JOB_STATE_NAME, jobState)
106
+ return jobState
107
+ }
108
+
109
+ export { JOB_STATE_NAME }
@@ -0,0 +1,39 @@
1
+ import { createValidator } from '@empyria/common'
2
+
3
+ /**
4
+ * withValidation — higher-order function that wraps a Restate workflow handler
5
+ * with AJV schema validation on both the input and the output.
6
+ *
7
+ * Why validate at this layer?
8
+ * Restate deserialises the workflow payload from JSON and passes it directly to
9
+ * the handler. Without explicit validation there is no guarantee the shape
10
+ * matches what the handler expects — especially across service versions. By
11
+ * validating here we get a clear, early error rather than a confusing runtime
12
+ * failure deep inside business logic.
13
+ *
14
+ * The output schema acts as a contract check: if the handler returns something
15
+ * that doesn't match the declared output type, the error is caught before
16
+ * Restate records the result.
17
+ *
18
+ * Usage:
19
+ * handlers: {
20
+ * run: withValidation(InputSchema, OutputSchema, async (ctx, input) => { ... })
21
+ * }
22
+ */
23
+ export function withValidation(inputSchema, outputSchema, handler) {
24
+ const validateInput = createValidator(inputSchema)
25
+ const validateOutput = createValidator(outputSchema)
26
+
27
+ return async (ctx, input) => {
28
+ // Validate input — throws ValidationError if the payload is malformed.
29
+ const validatedInput = validateInput(input)
30
+
31
+ // Execute the actual workflow logic.
32
+ const result = await handler(ctx, validatedInput)
33
+
34
+ // Validate output — guards against handler implementation drift.
35
+ const validatedOutput = validateOutput(result)
36
+
37
+ return validatedOutput
38
+ }
39
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@empyria/restate",
3
+ "version": "0.1.0",
4
+ "description": "Restate.dev helpers for the Principia nanoservice framework",
5
+ "license": "MIT",
6
+ "author": "Imre Fazekas <imre.fazekas@icloud.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/imrefazekas/empyria-guard-restate.git"
10
+ },
11
+ "type": "module",
12
+ "main": "./index.js",
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./lib/*": "./lib/*.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "scripts": {
19
+ "format": "bun oxfmt --check",
20
+ "format:fix": "bun oxfmt",
21
+ "lint": "bun oxlint",
22
+ "lint:fix": "bun oxlint --fix",
23
+ "test": "bun test --coverage",
24
+ "test:watch": "bun test --watch"
25
+ },
26
+ "dependencies": {
27
+ "@empyria/classification": "0.1.0",
28
+ "@empyria/common": "0.1.0",
29
+ "@restatedev/restate-sdk": "1.16.6",
30
+ "@restatedev/restate-sdk-clients": "1.16.6",
31
+ "@restatedev/restate-sdk-core": "1.16.6",
32
+ "croner": "10.0.1"
33
+ },
34
+ "devDependencies": {
35
+ "@restatedev/restate-server": "1.7.3",
36
+ "oxfmt": "0.63.0",
37
+ "oxlint": "1.78.0"
38
+ },
39
+ "engines": {
40
+ "bun": ">=1.4.0",
41
+ "node": ">=26"
42
+ },
43
+ "packageManager": "bun@1.4.0"
44
+ }