@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.97",
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 { createEntityChangePublisher } from "./entityChangeEvents.js";
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) {