@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/.oxfmtrc.json +9 -0
- package/AGENTS.md +64 -0
- package/README.md +78 -0
- package/index.js +7 -0
- package/lib/Admin.js +737 -0
- package/lib/Caller.js +50 -0
- package/lib/Cron.js +109 -0
- package/lib/Validation.js +39 -0
- package/package.json +44 -0
- package/test/Admin.test.js +470 -0
- package/test/Caller.test.js +42 -0
- package/test/Cron.test.js +151 -0
- package/test/Validation.test.js +34 -0
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
|
+
}
|