@jskit-ai/kernel 0.1.97 → 0.1.99
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/package.json +2 -1
- package/server/registries/serviceRegistrationRegistry.js +59 -0
- package/server/runtime/entityChangeEvents.js +125 -0
- package/server/runtime/entityChangeEvents.test.js +64 -1
- package/server/runtime/serviceRegistration.test.js +58 -0
- package/test/exportsContract.test.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/kernel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.99",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"json-rest-schema": "1.x.x"
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./server/platform": "./server/platform/index.js",
|
|
30
30
|
"./server/registries": "./server/registries/index.js",
|
|
31
31
|
"./server/runtime": "./server/runtime/index.js",
|
|
32
|
+
"./server/runtime/entityChangeEvents": "./server/runtime/entityChangeEvents.js",
|
|
32
33
|
"./server/runtime/errors": "./server/runtime/errors.js",
|
|
33
34
|
"./server/runtime/requestUrl": "./server/runtime/requestUrl.js",
|
|
34
35
|
"./server/support": "./server/support/index.js",
|
|
@@ -60,6 +60,25 @@ function normalizeServiceEventEntityId(value) {
|
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function normalizeServiceEventMetaField(value, { context = "service event meta field" } = {}) {
|
|
64
|
+
if (value == null) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof value === "function") {
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
if (typeof value !== "string") {
|
|
72
|
+
throw new TypeError(`${context} must be a non-empty string or function.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const normalized = normalizeText(value);
|
|
76
|
+
if (!normalized) {
|
|
77
|
+
throw new TypeError(`${context} must be a non-empty string or function.`);
|
|
78
|
+
}
|
|
79
|
+
return normalized;
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
function normalizeRealtimeDispatch(value, { context = "service event.realtime" } = {}) {
|
|
64
83
|
const source = normalizeObject(value);
|
|
65
84
|
if (Object.keys(source).length < 1) {
|
|
@@ -118,6 +137,8 @@ function normalizeServiceEventSpec(entry, { context = "service event" } = {}) {
|
|
|
118
137
|
entity: normalizeText(source.entity),
|
|
119
138
|
operation: normalizeServiceEventOperation(source.operation, { context }),
|
|
120
139
|
entityId: normalizeServiceEventEntityId(source.entityId),
|
|
140
|
+
action: normalizeServiceEventMetaField(source.action, { context: `${context}.action` }),
|
|
141
|
+
reason: normalizeServiceEventMetaField(source.reason, { context: `${context}.reason` }),
|
|
121
142
|
realtime: normalizeRealtimeDispatch(source.realtime, { context: `${context}.realtime` })
|
|
122
143
|
});
|
|
123
144
|
}
|
|
@@ -230,6 +251,28 @@ function resolveEventEntityId(spec, state) {
|
|
|
230
251
|
return state?.result?.id;
|
|
231
252
|
}
|
|
232
253
|
|
|
254
|
+
function resolveEventMetaField(value, state) {
|
|
255
|
+
const context = `service metadata.events.${state.methodName}.meta`;
|
|
256
|
+
if (typeof value === "function") {
|
|
257
|
+
const resolved = value({
|
|
258
|
+
result: state.result,
|
|
259
|
+
args: state.args,
|
|
260
|
+
options: state.options,
|
|
261
|
+
methodName: state.methodName,
|
|
262
|
+
serviceToken: state.serviceToken,
|
|
263
|
+
event: state.event
|
|
264
|
+
});
|
|
265
|
+
if (resolved == null) {
|
|
266
|
+
return "";
|
|
267
|
+
}
|
|
268
|
+
if (typeof resolved !== "string") {
|
|
269
|
+
throw new TypeError(`${context} field resolver must return a string.`);
|
|
270
|
+
}
|
|
271
|
+
return normalizeText(resolved);
|
|
272
|
+
}
|
|
273
|
+
return normalizeText(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
233
276
|
function resolveEventMeta(spec, state) {
|
|
234
277
|
const meta = {
|
|
235
278
|
service: Object.freeze({
|
|
@@ -238,6 +281,22 @@ function resolveEventMeta(spec, state) {
|
|
|
238
281
|
})
|
|
239
282
|
};
|
|
240
283
|
|
|
284
|
+
const action = resolveEventMetaField(spec.action, {
|
|
285
|
+
...state,
|
|
286
|
+
event: spec
|
|
287
|
+
});
|
|
288
|
+
if (action) {
|
|
289
|
+
meta.action = action;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const reason = resolveEventMetaField(spec.reason, {
|
|
293
|
+
...state,
|
|
294
|
+
event: spec
|
|
295
|
+
});
|
|
296
|
+
if (reason) {
|
|
297
|
+
meta.reason = reason;
|
|
298
|
+
}
|
|
299
|
+
|
|
241
300
|
if (spec.realtime) {
|
|
242
301
|
meta.realtime = spec.realtime.payload
|
|
243
302
|
? Object.freeze({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isContainerToken } from "../../shared/support/containerToken.js";
|
|
1
2
|
import { normalizeObject, normalizeOpaqueId, normalizeText } from "../../shared/support/normalize.js";
|
|
2
3
|
import { resolveServiceContext } from "./serviceAuthorization.js";
|
|
3
4
|
|
|
@@ -169,6 +170,129 @@ function createEntityChangePublisher({
|
|
|
169
170
|
};
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
function normalizeMetaTextField(source = {}, fieldName = "", { context = "realtime entity change" } = {}) {
|
|
174
|
+
if (!Object.hasOwn(source, fieldName)) {
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const value = source[fieldName];
|
|
179
|
+
if (value == null) {
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
if (typeof value !== "string") {
|
|
183
|
+
throw new TypeError(`${context}.${fieldName} must be a non-empty string.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const normalized = normalizeText(value);
|
|
187
|
+
if (!normalized) {
|
|
188
|
+
throw new TypeError(`${context}.${fieldName} must be a non-empty string.`);
|
|
189
|
+
}
|
|
190
|
+
return normalized;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeRealtimePayload(value, { context = "realtime entity change.payload" } = {}) {
|
|
194
|
+
if (value == null) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
198
|
+
throw new TypeError(`${context} must be an object.`);
|
|
199
|
+
}
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
...value
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createRealtimeEntityChangeMeta({
|
|
206
|
+
serviceToken,
|
|
207
|
+
methodName,
|
|
208
|
+
event,
|
|
209
|
+
change = {}
|
|
210
|
+
} = {}) {
|
|
211
|
+
const source = normalizeObject(change);
|
|
212
|
+
const baseMeta = normalizeObject(source.meta);
|
|
213
|
+
const meta = {
|
|
214
|
+
...baseMeta,
|
|
215
|
+
service: Object.freeze({
|
|
216
|
+
token: serviceToken,
|
|
217
|
+
method: methodName
|
|
218
|
+
})
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const action = normalizeMetaTextField(source, "action");
|
|
222
|
+
if (action) {
|
|
223
|
+
meta.action = action;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const reason = normalizeMetaTextField(source, "reason");
|
|
227
|
+
if (reason) {
|
|
228
|
+
meta.reason = reason;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const realtimeSource = normalizeObject(source.realtime);
|
|
232
|
+
const payloadSource = Object.hasOwn(source, "payload") ? source.payload : realtimeSource.payload;
|
|
233
|
+
const payload = normalizeRealtimePayload(payloadSource);
|
|
234
|
+
meta.realtime = payload
|
|
235
|
+
? Object.freeze({
|
|
236
|
+
event,
|
|
237
|
+
payload
|
|
238
|
+
})
|
|
239
|
+
: Object.freeze({
|
|
240
|
+
event
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return Object.freeze(meta);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveRealtimeEntityChangeOptions(change = {}, options = {}) {
|
|
247
|
+
const source = normalizeObject(change);
|
|
248
|
+
if (Object.hasOwn(source, "options")) {
|
|
249
|
+
return normalizeObject(source.options);
|
|
250
|
+
}
|
|
251
|
+
return normalizeObject(options);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createRealtimeEntityChangePublisher({
|
|
255
|
+
domainEvents,
|
|
256
|
+
source,
|
|
257
|
+
entity,
|
|
258
|
+
event,
|
|
259
|
+
serviceToken,
|
|
260
|
+
methodName,
|
|
261
|
+
scopeResolver = resolveDefaultScope
|
|
262
|
+
} = {}) {
|
|
263
|
+
const normalizedEvent = normalizeText(event);
|
|
264
|
+
if (!normalizedEvent) {
|
|
265
|
+
throw new TypeError("createRealtimeEntityChangePublisher requires event.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!isContainerToken(serviceToken)) {
|
|
269
|
+
throw new TypeError("createRealtimeEntityChangePublisher requires a valid serviceToken.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const normalizedMethodName = normalizeText(methodName);
|
|
273
|
+
if (!normalizedMethodName) {
|
|
274
|
+
throw new TypeError("createRealtimeEntityChangePublisher requires methodName.");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const publishEntityChange = createEntityChangePublisher({
|
|
278
|
+
domainEvents,
|
|
279
|
+
source,
|
|
280
|
+
entity,
|
|
281
|
+
scopeResolver
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return async function publishRealtimeEntityChange(operation, entityId, change = {}, options = {}) {
|
|
285
|
+
const runtimeOptions = resolveRealtimeEntityChangeOptions(change, options);
|
|
286
|
+
const meta = createRealtimeEntityChangeMeta({
|
|
287
|
+
serviceToken,
|
|
288
|
+
methodName: normalizedMethodName,
|
|
289
|
+
event: normalizedEvent,
|
|
290
|
+
change
|
|
291
|
+
});
|
|
292
|
+
return publishEntityChange(operation, entityId, runtimeOptions, meta);
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
172
296
|
function createNoopEntityChangePublisher() {
|
|
173
297
|
return async function publishEntityChange() {
|
|
174
298
|
return null;
|
|
@@ -178,5 +302,6 @@ function createNoopEntityChangePublisher() {
|
|
|
178
302
|
export {
|
|
179
303
|
resolveDefaultScope,
|
|
180
304
|
createEntityChangePublisher,
|
|
305
|
+
createRealtimeEntityChangePublisher,
|
|
181
306
|
createNoopEntityChangePublisher
|
|
182
307
|
};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createEntityChangePublisher,
|
|
5
|
+
createRealtimeEntityChangePublisher
|
|
6
|
+
} from "./entityChangeEvents.js";
|
|
4
7
|
|
|
5
8
|
test("entity change publisher emits normalized event payload", async () => {
|
|
6
9
|
const published = [];
|
|
@@ -209,3 +212,63 @@ test("entity change publisher does not infer actor scope from scope kind suffix"
|
|
|
209
212
|
assert.equal(payload?.actorId, null);
|
|
210
213
|
assert.equal(published.length, 1);
|
|
211
214
|
});
|
|
215
|
+
|
|
216
|
+
test("realtime entity change publisher adds service, semantic, and realtime metadata", async () => {
|
|
217
|
+
const published = [];
|
|
218
|
+
const publishProjectChange = createRealtimeEntityChangePublisher({
|
|
219
|
+
domainEvents: {
|
|
220
|
+
async publish(payload) {
|
|
221
|
+
published.push(payload);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
source: "vibe64",
|
|
225
|
+
entity: "project",
|
|
226
|
+
event: "vibe64.project.changed",
|
|
227
|
+
serviceToken: "vibe64.terminals.service",
|
|
228
|
+
methodName: "projectRuntime"
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const payload = await publishProjectChange("updated", "project_acme", {
|
|
232
|
+
action: "runtime-closed",
|
|
233
|
+
reason: "user-request",
|
|
234
|
+
payload: {
|
|
235
|
+
message: "Project is closed.",
|
|
236
|
+
runtime: {
|
|
237
|
+
open: false
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
options: {
|
|
241
|
+
context: {
|
|
242
|
+
actor: {
|
|
243
|
+
id: "user_17"
|
|
244
|
+
},
|
|
245
|
+
visibilityContext: {
|
|
246
|
+
scopeKind: "workspace",
|
|
247
|
+
scopeOwnerId: "workspace_23"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
assert.equal(payload?.source, "vibe64");
|
|
254
|
+
assert.equal(payload?.entity, "project");
|
|
255
|
+
assert.equal(payload?.operation, "updated");
|
|
256
|
+
assert.equal(payload?.entityId, "project_acme");
|
|
257
|
+
assert.deepEqual(payload?.scope, {
|
|
258
|
+
kind: "workspace",
|
|
259
|
+
id: "workspace_23"
|
|
260
|
+
});
|
|
261
|
+
assert.equal(payload?.actorId, "user_17");
|
|
262
|
+
assert.equal(payload?.meta?.service?.token, "vibe64.terminals.service");
|
|
263
|
+
assert.equal(payload?.meta?.service?.method, "projectRuntime");
|
|
264
|
+
assert.equal(payload?.meta?.action, "runtime-closed");
|
|
265
|
+
assert.equal(payload?.meta?.reason, "user-request");
|
|
266
|
+
assert.equal(payload?.meta?.realtime?.event, "vibe64.project.changed");
|
|
267
|
+
assert.deepEqual(payload?.meta?.realtime?.payload, {
|
|
268
|
+
message: "Project is closed.",
|
|
269
|
+
runtime: {
|
|
270
|
+
open: false
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
assert.equal(published.length, 1);
|
|
274
|
+
});
|
|
@@ -93,6 +93,64 @@ test("app.service rejects deprecated permissions metadata", () => {
|
|
|
93
93
|
);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test("app.service publishes lifecycle action, reason, and realtime payload metadata", async () => {
|
|
97
|
+
const app = createContainer();
|
|
98
|
+
const published = [];
|
|
99
|
+
app.singleton("domainEvents", () => ({
|
|
100
|
+
async publish(payload) {
|
|
101
|
+
published.push(payload);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
installServiceRegistrationApi(app);
|
|
106
|
+
|
|
107
|
+
app.service(
|
|
108
|
+
"test.projects.service",
|
|
109
|
+
() => ({
|
|
110
|
+
async closeRuntime() {
|
|
111
|
+
return {
|
|
112
|
+
id: "project_acme",
|
|
113
|
+
action: "runtime-closed",
|
|
114
|
+
message: "Project is closed."
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
{
|
|
119
|
+
events: {
|
|
120
|
+
closeRuntime: [
|
|
121
|
+
{
|
|
122
|
+
type: "entity.changed",
|
|
123
|
+
source: "vibe64",
|
|
124
|
+
entity: "project",
|
|
125
|
+
operation: "updated",
|
|
126
|
+
action: ({ result }) => result?.action,
|
|
127
|
+
reason: "user-request",
|
|
128
|
+
realtime: {
|
|
129
|
+
event: "vibe64.project.changed",
|
|
130
|
+
payload: ({ result }) => ({
|
|
131
|
+
message: result?.message || ""
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const service = app.make("test.projects.service");
|
|
141
|
+
await service.closeRuntime();
|
|
142
|
+
|
|
143
|
+
assert.equal(service.serviceEvents.closeRuntime[0].reason, "user-request");
|
|
144
|
+
assert.equal(typeof service.serviceEvents.closeRuntime[0].action, "function");
|
|
145
|
+
assert.equal(published.length, 1);
|
|
146
|
+
assert.equal(published[0].meta?.action, "runtime-closed");
|
|
147
|
+
assert.equal(published[0].meta?.reason, "user-request");
|
|
148
|
+
assert.equal(published[0].meta?.realtime?.event, "vibe64.project.changed");
|
|
149
|
+
assert.deepEqual(published[0].meta?.realtime?.payload, {
|
|
150
|
+
message: "Project is closed."
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
96
154
|
test("resolveServiceRegistrations returns declared service metadata", () => {
|
|
97
155
|
const app = createContainer();
|
|
98
156
|
app.singleton("domainEvents", () => ({
|
|
@@ -25,7 +25,9 @@ const EXPORTED_UNUSED_ALLOWLIST = new Set([
|
|
|
25
25
|
// Used by generated app bootstrap code emitted from kernel client vite plugin.
|
|
26
26
|
"./client/moduleBootstrap",
|
|
27
27
|
// Intentionally retained as an explicit low-level kernel API subpath.
|
|
28
|
-
"./shared/support/tokens"
|
|
28
|
+
"./shared/support/tokens",
|
|
29
|
+
// Public helper API for packages that publish entity-change realtime events.
|
|
30
|
+
"./server/runtime/entityChangeEvents"
|
|
29
31
|
]);
|
|
30
32
|
|
|
31
33
|
function normalizeSlash(value) {
|