@pulse-compute/runtime 0.0.0 → 1.0.0-beta.2

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.
@@ -0,0 +1,877 @@
1
+ 'use strict';
2
+
3
+ const { PulseRuntimeContractError } = require('./errors.js');
4
+ const {
5
+ cloneKvValue,
6
+ normalizeBindingName,
7
+ normalizeBindingValue,
8
+ normalizeKvKey,
9
+ normalizeKvNamespace,
10
+ normalizeKvPutResult
11
+ } = require('./bindings.js');
12
+ const conditionalKv = require('./conditional-kv.js');
13
+ const { fetchRequestInitForHost } = require('./fetch.js');
14
+ const { createRedactionState } = require('./redaction.js');
15
+ const { validateSchemaValue } = require('./schema.js');
16
+ const { EVENT_EMIT_CODES } = require('./event-emission.js');
17
+
18
+ const JAVASCRIPT_EFFECT_PROTOCOL_VERSION = 'pulse.javascript-effect.v1';
19
+ const JAVASCRIPT_EFFECT_ADAPTER_VERSION = 'pulse.javascript-effect-adapter.v1';
20
+ const JAVASCRIPT_EFFECT_OBSERVATION_VERSION = 'pulse.javascript-effect-observation.v1';
21
+ const DEFAULT_MAX_EFFECTS = 1024;
22
+
23
+ const EFFECT_DATA = new WeakMap();
24
+
25
+ function requireObject(label, value) {
26
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
27
+ throw new TypeError(`Pulse ${label} must be an object.`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function requireFunction(label, value) {
33
+ if (typeof value !== 'function') throw new TypeError(`Pulse ${label} must be a function.`);
34
+ return value;
35
+ }
36
+
37
+ function normalizeMaxEffects(value) {
38
+ const max = value === undefined ? DEFAULT_MAX_EFFECTS : Number(value);
39
+ if (!Number.isSafeInteger(max) || max <= 0) {
40
+ throw new TypeError('Pulse maxEffects must be a positive safe integer.');
41
+ }
42
+ return max;
43
+ }
44
+
45
+ function normalizeExecutionKind(value) {
46
+ const kind = value === undefined ? 'request' : String(value);
47
+ if (kind !== 'request' && kind !== 'event') {
48
+ throw new TypeError('Pulse JavaScript executionKind must be request or event.');
49
+ }
50
+ return kind;
51
+ }
52
+
53
+ function errorIdentity(error, redaction) {
54
+ const redactString = redaction && typeof redaction.redactString === 'function'
55
+ ? (value) => redaction.redactString(value)
56
+ : (value) => String(value);
57
+ return Object.freeze({
58
+ name: redactString(error && typeof error.name === 'string' ? error.name : typeof error),
59
+ code: error && typeof error.code === 'string' ? redactString(error.code) : undefined
60
+ });
61
+ }
62
+
63
+ function abortedEffectError(reason) {
64
+ if (reason instanceof PulseRuntimeContractError) return reason;
65
+ return new PulseRuntimeContractError(
66
+ 'PULSE_RUNTIME_EFFECT_ABORTED',
67
+ 'Pulse JavaScript effect execution was aborted.',
68
+ { cause: reason }
69
+ );
70
+ }
71
+
72
+ function createEffectSignal(lifecycleSignal, descriptor) {
73
+ const controller = new AbortController();
74
+ let timer;
75
+ const forwardAbort = () => {
76
+ if (!controller.signal.aborted) controller.abort(lifecycleSignal.reason);
77
+ };
78
+ if (lifecycleSignal.aborted) forwardAbort();
79
+ else lifecycleSignal.addEventListener('abort', forwardAbort, { once: true });
80
+
81
+ const timeoutMs = descriptor.kind === 'fetch' && descriptor.init
82
+ ? descriptor.init.timeoutMs
83
+ : undefined;
84
+ if (timeoutMs !== undefined && !controller.signal.aborted) {
85
+ timer = setTimeout(() => {
86
+ if (!controller.signal.aborted) {
87
+ controller.abort(new PulseRuntimeContractError(
88
+ 'PULSE_FETCH_TIMEOUT',
89
+ `Pulse fetch ${descriptor.id} exceeded its ${timeoutMs}ms timeout.`
90
+ ));
91
+ }
92
+ }, timeoutMs);
93
+ }
94
+
95
+ return Object.freeze({
96
+ signal: controller.signal,
97
+ dispose() {
98
+ lifecycleSignal.removeEventListener('abort', forwardAbort);
99
+ if (timer !== undefined) clearTimeout(timer);
100
+ }
101
+ });
102
+ }
103
+
104
+ function raceWithSignal(promise, signal) {
105
+ if (!signal) return Promise.resolve(promise);
106
+ return new Promise((resolve, reject) => {
107
+ let settled = false;
108
+ const finish = (callback, value) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ signal.removeEventListener('abort', onAbort);
112
+ callback(value);
113
+ };
114
+ const onAbort = () => finish(reject, abortedEffectError(signal.reason));
115
+ signal.addEventListener('abort', onAbort, { once: true });
116
+ Promise.resolve(promise).then(
117
+ (value) => finish(resolve, value),
118
+ (error) => finish(reject, error)
119
+ );
120
+ // Observe the owned promise even when cancellation preceded the race.
121
+ if (signal.aborted) onAbort();
122
+ });
123
+ }
124
+
125
+ function headerNames(input) {
126
+ if (!input) return [];
127
+ if (Array.isArray(input)) {
128
+ return input
129
+ .map((entry) => Array.isArray(entry) ? String(entry[0]).toLowerCase() : '')
130
+ .filter(Boolean);
131
+ }
132
+ if (input instanceof Headers) return Array.from(input.keys(), (name) => String(name).toLowerCase());
133
+ if (typeof input === 'object') return Object.keys(input).map((name) => String(name).toLowerCase());
134
+ return [];
135
+ }
136
+
137
+ /**
138
+ * Produce bounded, value-redacted evidence. The trusted adapter receives the complete
139
+ * descriptor; observations intentionally omit bodies, binding values, and package payloads.
140
+ * Field-level and known-secret redaction is applied before evidence leaves the request.
141
+ */
142
+ function publicEffectDescriptor(effect, redaction) {
143
+ const redactString = redaction && typeof redaction.redactString === 'function'
144
+ ? (value) => redaction.redactString(value)
145
+ : (value) => String(value);
146
+ const base = {
147
+ version: JAVASCRIPT_EFFECT_PROTOCOL_VERSION,
148
+ id: redactString(effect.id),
149
+ kind: redactString(effect.kind),
150
+ providerKind: redactString(effect.providerKind),
151
+ operation: redactString(effect.operation),
152
+ capability: redactString(effect.capability)
153
+ };
154
+ if (effect.kind === 'fetch') {
155
+ const init = effect.init && typeof effect.init === 'object' ? effect.init : {};
156
+ return Object.freeze({
157
+ ...base,
158
+ url: redactString(effect.url),
159
+ init: Object.freeze({
160
+ method: init.method === undefined ? 'GET' : String(init.method).toUpperCase(),
161
+ headerNames: Object.freeze(headerNames(init.headers).map(redactString)),
162
+ bodyMode: init.bodyMode === undefined ? 'none' : String(init.bodyMode),
163
+ hasBody: init.bodyMode === 'text',
164
+ hasJson: init.bodyMode === 'json',
165
+ timeoutMs: init.timeoutMs === undefined ? undefined : Number(init.timeoutMs)
166
+ })
167
+ });
168
+ }
169
+ if (effect.kind === 'config.get' || effect.kind === 'secret.get') {
170
+ return Object.freeze({ ...base, name: redactString(effect.name) });
171
+ }
172
+ if (conditionalKv.isConditionalKv(effect.kind)) return Object.freeze({ ...base, namespace: redactString(effect.namespace), key: '<redacted>', generation: '<redacted>', value: '<redacted>' });
173
+ if (effect.kind === 'kv.get' || effect.kind === 'kv.put') {
174
+ return Object.freeze({
175
+ ...base,
176
+ namespace: redactString(effect.namespace),
177
+ key: redactString(effect.key),
178
+ value: effect.kind === 'kv.put' ? '<redacted>' : undefined
179
+ });
180
+ }
181
+ if (effect.kind === 'event.emit') {
182
+ const frame = effect.frame && typeof effect.frame === 'object' ? effect.frame : {};
183
+ return Object.freeze({
184
+ ...base,
185
+ frame: Object.freeze({
186
+ version: 'pulse.event-frame.v1',
187
+ type: redactString(frame.type),
188
+ schemaId: frame.schemaId === null ? null : redactString(frame.schemaId),
189
+ hasPayload: Object.prototype.hasOwnProperty.call(frame, 'payload'),
190
+ payload: Object.prototype.hasOwnProperty.call(frame, 'payload') ? '<redacted>' : undefined
191
+ })
192
+ });
193
+ }
194
+ if (effect.package) {
195
+ return Object.freeze({
196
+ ...base,
197
+ package: redactString(effect.package),
198
+ contractId: effect.contractId === undefined ? undefined : redactString(effect.contractId)
199
+ });
200
+ }
201
+ return Object.freeze(base);
202
+ }
203
+
204
+ function createJavascriptEffectAdapter(input) {
205
+ requireObject('JavaScript effect adapter', input);
206
+ const dispatch = requireFunction('JavaScript effect adapter dispatch', input.dispatch);
207
+ const dispose = input.dispose === undefined
208
+ ? undefined
209
+ : requireFunction('JavaScript effect adapter dispose', input.dispose);
210
+ return Object.freeze({
211
+ version: JAVASCRIPT_EFFECT_ADAPTER_VERSION,
212
+ id: String(input.id || 'pulse.javascript-effect-adapter.custom'),
213
+ dispatch,
214
+ prepareConditionalKv: typeof input.prepareConditionalKv === 'function' ? input.prepareConditionalKv : undefined,
215
+ dispose
216
+ });
217
+ }
218
+
219
+ function unavailableCapability(effect) {
220
+ throw new PulseRuntimeContractError(
221
+ 'PULSE_RUNTIME_CAPABILITY_UNAVAILABLE',
222
+ `Pulse capability ${effect.capability} is unavailable in the current JavaScript realization.`
223
+ );
224
+ }
225
+
226
+ /** Compatibility adapter for the per-capability injection shape. */
227
+ function createCapabilityEffectAdapter(capabilities = {}) {
228
+ const value = capabilities && typeof capabilities === 'object' ? capabilities : {};
229
+ const namespaces = new Map();
230
+ return createJavascriptEffectAdapter({
231
+ id: 'pulse.javascript-effect-adapter.capabilities',
232
+ async prepareConditionalKv(effect, execution) {
233
+ if (typeof value.prepareConditionalKv === 'function') return value.prepareConditionalKv(effect, execution);
234
+ if (typeof value.kv !== 'function') return undefined;
235
+ if (!namespaces.has(effect.namespace)) namespaces.set(effect.namespace, await value.kv(effect.namespace, execution));
236
+ const namespace = namespaces.get(effect.namespace), method = effect.kind.slice(3);
237
+ if (!namespace || typeof namespace[method] !== 'function') return undefined;
238
+ return () => method === 'getVersioned' ? namespace[method](effect.key, execution)
239
+ : method === 'insertIfAbsent' ? namespace[method](effect.key, effect.value, execution)
240
+ : namespace[method](effect.key, effect.generation, effect.value, execution);
241
+ },
242
+ async dispatch(effect, execution) {
243
+ if (effect.kind === 'fetch') {
244
+ if (typeof value.fetch !== 'function') return unavailableCapability(effect);
245
+ return value.fetch(effect.url, fetchRequestInitForHost(effect.init, execution.signal), execution);
246
+ }
247
+ if (effect.kind === 'config.get') {
248
+ if (typeof value.config !== 'function') return unavailableCapability(effect);
249
+ return value.config(effect.name, execution);
250
+ }
251
+ if (effect.kind === 'secret.get') {
252
+ if (typeof value.secret !== 'function') return unavailableCapability(effect);
253
+ return value.secret(effect.name, execution);
254
+ }
255
+ if (effect.kind === 'kv.get' || effect.kind === 'kv.put') {
256
+ if (typeof value.kv !== 'function') return unavailableCapability(effect);
257
+ if (!namespaces.has(effect.namespace)) namespaces.set(effect.namespace, value.kv(effect.namespace, execution));
258
+ const namespace = namespaces.get(effect.namespace);
259
+ const method = effect.kind === 'kv.get' ? 'get' : 'put';
260
+ if (!namespace || typeof namespace[method] !== 'function') return unavailableCapability(effect);
261
+ return method === 'get'
262
+ ? namespace.get(effect.key)
263
+ : namespace.put(effect.key, effect.value);
264
+ }
265
+ if (effect.kind === 'event.emit') {
266
+ if (typeof value.emit !== 'function') return unavailableCapability(effect);
267
+ return value.emit(effect.frame, execution);
268
+ }
269
+ if (typeof value.effect === 'function') return value.effect(effect, execution);
270
+ return unavailableCapability(effect);
271
+ }
272
+ });
273
+ }
274
+
275
+ function bindingLimits(options = {}) {
276
+ return Object.freeze({
277
+ kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
278
+ maxBindingNameBytes: options.maxBindingNameBytes,
279
+ maxBindingValueBytes: options.maxBindingValueBytes,
280
+ maxKvNamespaceBytes: options.maxKvNamespaceBytes,
281
+ maxKvKeyBytes: options.maxKvKeyBytes,
282
+ maxKvValueBytes: options.maxKvValueBytes,
283
+ maxKvValueDepth: options.maxKvValueDepth,
284
+ maxKvValueEntries: options.maxKvValueEntries
285
+ });
286
+ }
287
+
288
+ function normalizeEffectDescriptor(input, limits) {
289
+ const kind = String(input.kind || 'effect');
290
+ if (conditionalKv.isConditionalKv(kind)) return { ...conditionalKv.admitConditionalKv(input, limits), id: input.id, parallelEligible: input.parallelEligible };
291
+ if (kind === 'config.get' || kind === 'secret.get') {
292
+ const bindingKind = kind === 'secret.get' ? 'secret' : 'config';
293
+ return { ...input, name: normalizeBindingName(bindingKind, input.name, limits) };
294
+ }
295
+ if (kind === 'kv.get' || kind === 'kv.put') {
296
+ const normalized = {
297
+ ...input,
298
+ namespace: normalizeKvNamespace(input.namespace, limits),
299
+ key: normalizeKvKey(input.key, limits)
300
+ };
301
+ if (kind === 'kv.put') normalized.value = cloneKvValue(input.value, limits);
302
+ return normalized;
303
+ }
304
+ return input;
305
+ }
306
+
307
+ function normalizeEffectResult(descriptor, value, limits) {
308
+ if (descriptor.kind === 'config.get' || descriptor.kind === 'secret.get') {
309
+ return normalizeBindingValue(descriptor.kind, descriptor.name, value, limits);
310
+ }
311
+ if (descriptor.kind === 'kv.get') {
312
+ return cloneKvValue(value, { ...limits, allowUndefined: true });
313
+ }
314
+ if (descriptor.kind === 'kv.put') return normalizeKvPutResult(value);
315
+ if (descriptor.kind === 'event.emit') {
316
+ if (value !== undefined) {
317
+ throw new PulseRuntimeContractError(
318
+ EVENT_EMIT_CODES.ACCEPTANCE_INVALID,
319
+ 'Pulse event emit adapters must acknowledge host acceptance with undefined.'
320
+ );
321
+ }
322
+ return undefined;
323
+ }
324
+ return value;
325
+ }
326
+
327
+ function normalizeEffectAdapter(options = {}) {
328
+ if (options.effectAdapter !== undefined && options.capabilities !== undefined) {
329
+ throw new PulseRuntimeContractError(
330
+ 'PULSE_RUNTIME_EFFECT_ADAPTER_AMBIGUOUS',
331
+ 'Pulse JavaScript execution accepts either effectAdapter or legacy capabilities, not both.'
332
+ );
333
+ }
334
+ if (options.effectAdapter === undefined) return createCapabilityEffectAdapter(options.capabilities);
335
+ return createJavascriptEffectAdapter(options.effectAdapter);
336
+ }
337
+
338
+ function defineEffect(promise, data, methods = {}) {
339
+ const effect = Promise.resolve(promise);
340
+ EFFECT_DATA.set(effect, Object.freeze(data));
341
+ for (const [name, method] of Object.entries(methods)) {
342
+ Object.defineProperty(effect, name, {
343
+ enumerable: false,
344
+ configurable: false,
345
+ writable: false,
346
+ value: method
347
+ });
348
+ }
349
+ return effect;
350
+ }
351
+
352
+ function dataForEffect(value) {
353
+ return value && (typeof value === 'object' || typeof value === 'function')
354
+ ? EFFECT_DATA.get(value)
355
+ : undefined;
356
+ }
357
+
358
+ function isPulseJavascriptEffect(value) {
359
+ return Boolean(dataForEffect(value));
360
+ }
361
+
362
+ function isArrayIndexKey(key) {
363
+ if (!/^(0|[1-9]\d*)$/.test(key)) return false;
364
+ const value = Number(key);
365
+ return Number.isSafeInteger(value) && value >= 0 && value < 0xffffffff && String(value) === key;
366
+ }
367
+
368
+ function validateParallelRecord(record, execution, claimedRootIds) {
369
+ requireObject('parallel effect record', record);
370
+ const prototype = Object.getPrototypeOf(record);
371
+ if (prototype !== Object.prototype) {
372
+ throw new PulseRuntimeContractError(
373
+ 'PULSE_RUNTIME_PARALLEL_OBJECT_REQUIRED',
374
+ 'ctx.parallel requires an ordinary object literal-shaped record of Pulse effects.'
375
+ );
376
+ }
377
+
378
+ const keys = Reflect.ownKeys(record);
379
+ if (keys.length === 0) {
380
+ throw new PulseRuntimeContractError(
381
+ 'PULSE_RUNTIME_PARALLEL_EMPTY',
382
+ 'ctx.parallel requires at least one keyed Pulse effect.'
383
+ );
384
+ }
385
+
386
+ const entries = [];
387
+ const seenRootIds = new Set();
388
+ for (const key of keys) {
389
+ if (typeof key !== 'string') {
390
+ throw new PulseRuntimeContractError(
391
+ 'PULSE_RUNTIME_PARALLEL_KEY_STATIC_REQUIRED',
392
+ 'ctx.parallel does not accept symbol keys.'
393
+ );
394
+ }
395
+ if (key === '__proto__' || isArrayIndexKey(key)) {
396
+ throw new PulseRuntimeContractError(
397
+ 'PULSE_RUNTIME_PARALLEL_KEY_UNSUPPORTED',
398
+ `ctx.parallel key ${JSON.stringify(key)} is not portable; use a non-index string key.`
399
+ );
400
+ }
401
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
402
+ if (!descriptor || !descriptor.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
403
+ throw new PulseRuntimeContractError(
404
+ 'PULSE_RUNTIME_PARALLEL_DATA_PROPERTY_REQUIRED',
405
+ `ctx.parallel property ${JSON.stringify(key)} must be an enumerable data property.`
406
+ );
407
+ }
408
+ const effect = descriptor.value;
409
+ const data = dataForEffect(effect);
410
+ if (!data || data.parallelEligible !== true) {
411
+ throw new PulseRuntimeContractError(
412
+ 'PULSE_RUNTIME_PARALLEL_EFFECT_REQUIRED',
413
+ `ctx.parallel property ${JSON.stringify(key)} must contain a parallel-eligible Pulse effect.`
414
+ );
415
+ }
416
+ if (data.execution !== execution) {
417
+ throw new PulseRuntimeContractError(
418
+ 'PULSE_RUNTIME_PARALLEL_EFFECT_OWNERSHIP',
419
+ `ctx.parallel property ${JSON.stringify(key)} belongs to a different managed execution.`
420
+ );
421
+ }
422
+ if (seenRootIds.has(data.rootId) || claimedRootIds.has(data.rootId)) {
423
+ throw new PulseRuntimeContractError(
424
+ 'PULSE_RUNTIME_PARALLEL_EFFECT_DUPLICATE',
425
+ `ctx.parallel property ${JSON.stringify(key)} reuses an effect root already claimed by a parallel group.`
426
+ );
427
+ }
428
+ seenRootIds.add(data.rootId);
429
+ entries.push(Object.freeze({ key, effect, data }));
430
+ }
431
+ return Object.freeze(entries);
432
+ }
433
+
434
+ function attachGroupFailure(primaryReason, failures, redaction) {
435
+ const primary = primaryReason instanceof Error ? primaryReason : new Error(String(primaryReason));
436
+ const evidence = Object.freeze(failures.map((entry) => Object.freeze({
437
+ key: redaction.redactString(entry.key),
438
+ index: entry.index,
439
+ effectId: redaction.redactString(entry.effectId),
440
+ kind: redaction.redactString(entry.kind),
441
+ ...errorIdentity(entry.error, redaction)
442
+ })));
443
+ try {
444
+ Object.defineProperty(primary, 'effectFailures', {
445
+ configurable: true,
446
+ enumerable: false,
447
+ writable: false,
448
+ value: evidence
449
+ });
450
+ return primary;
451
+ } catch (_) {
452
+ const wrapped = new PulseRuntimeContractError(
453
+ 'PULSE_RUNTIME_PARALLEL_FAILED',
454
+ 'One or more ctx.parallel effects failed.',
455
+ { cause: primary }
456
+ );
457
+ Object.defineProperty(wrapped, 'effectFailures', { enumerable: false, value: evidence });
458
+ return wrapped;
459
+ }
460
+ }
461
+
462
+ function createJavascriptEffectExecution(options = {}) {
463
+ const adapter = normalizeEffectAdapter(options);
464
+ const redaction = createRedactionState(options.redactionValues);
465
+ const maxEffects = normalizeMaxEffects(options.maxEffects);
466
+ const executionKind = normalizeExecutionKind(options.executionKind);
467
+ const limitMessage = executionKind === 'event'
468
+ ? `Pulse event invocation exceeded the maximum of ${maxEffects} execution-owned effects.`
469
+ : `Pulse request exceeded the maximum of ${maxEffects} request-owned effects.`;
470
+ const limits = bindingLimits(options);
471
+ const onObservation = options.onEffectObservation === undefined
472
+ ? undefined
473
+ : requireFunction('effect observation callback', options.onEffectObservation);
474
+ const lifecycleController = new AbortController();
475
+ const sourceSignal = options.signal;
476
+ let removeSourceAbortListener;
477
+ if (sourceSignal && typeof sourceSignal.addEventListener === 'function') {
478
+ const forwardAbort = () => {
479
+ if (!lifecycleController.signal.aborted) lifecycleController.abort(sourceSignal.reason);
480
+ };
481
+ if (sourceSignal.aborted) forwardAbort();
482
+ else {
483
+ sourceSignal.addEventListener('abort', forwardAbort, { once: true });
484
+ removeSourceAbortListener = () => sourceSignal.removeEventListener('abort', forwardAbort);
485
+ }
486
+ }
487
+ const externalExecution = Object.freeze({
488
+ kind: executionKind,
489
+ request: options.request,
490
+ event: options.event,
491
+ application: options.application,
492
+ signal: lifecycleController.signal,
493
+ registerRedactionValue(value) {
494
+ if (typeof value === 'string' && value.length > 0) redaction.add(value);
495
+ },
496
+ validateSchemaValue(schemaId, value, context = {}) {
497
+ return validateSchemaValue(schemaId, value, {
498
+ schemaCodecs: options.schemaCodecs,
499
+ strict: options.strict,
500
+ target: options.target,
501
+ provider: options.provider
502
+ }, context);
503
+ }
504
+ });
505
+ const counters = new Map();
506
+ const dispatchedIds = new Set();
507
+ const pendingEffects = new Map();
508
+ const claimedParallelRootIds = new Set();
509
+ const observations = [];
510
+ const resolutionOrder = [];
511
+ const groups = [];
512
+ let totalEffects = 0;
513
+ let localEffects = 0;
514
+ let parallelCount = 0;
515
+ let resolutionIndex = 0;
516
+ let closed = false;
517
+
518
+ function observe(event) {
519
+ const observation = Object.freeze(redaction.redactValue({
520
+ version: JAVASCRIPT_EFFECT_OBSERVATION_VERSION,
521
+ ...event
522
+ }));
523
+ observations.push(observation);
524
+ if (onObservation) onObservation(observation);
525
+ }
526
+
527
+ function nextId(kind) {
528
+ const prefix = String(kind || 'effect')
529
+ .replace(/[^a-z0-9]+/gi, '-')
530
+ .replace(/^-+|-+$/g, '')
531
+ .toLowerCase() || 'effect';
532
+ const next = (counters.get(prefix) || 0) + 1;
533
+ counters.set(prefix, next);
534
+ return `${prefix}-${next}`;
535
+ }
536
+
537
+ function assertOpen() {
538
+ if (closed) {
539
+ throw new PulseRuntimeContractError(
540
+ 'PULSE_RUNTIME_EFFECT_EXECUTION_CLOSED',
541
+ 'Pulse effect execution is already closed.'
542
+ );
543
+ }
544
+ }
545
+
546
+ function trackEffect(effect, data) {
547
+ pendingEffects.set(effect, data);
548
+ effect.then(
549
+ () => pendingEffects.delete(effect),
550
+ () => pendingEffects.delete(effect)
551
+ );
552
+ // Keep an execution-owned observer attached so a rejected effect does not become
553
+ // an ambient unhandled rejection before Router completion checks run.
554
+ effect.catch(() => undefined);
555
+ return effect;
556
+ }
557
+
558
+ const execution = {
559
+ dispatch(input, projector) {
560
+ assertOpen();
561
+ requireObject('effect descriptor', input);
562
+ if (totalEffects + localEffects >= maxEffects) {
563
+ throw new PulseRuntimeContractError(
564
+ 'PULSE_RUNTIME_EFFECT_LIMIT_EXCEEDED',
565
+ limitMessage
566
+ );
567
+ }
568
+ const normalizedInput = normalizeEffectDescriptor(input, limits);
569
+ const kind = String(normalizedInput.kind || 'effect');
570
+ const descriptor = Object.freeze({
571
+ ...normalizedInput,
572
+ version: JAVASCRIPT_EFFECT_PROTOCOL_VERSION,
573
+ id: input.id === undefined ? nextId(kind) : String(input.id),
574
+ kind,
575
+ providerKind: String(input.providerKind || kind),
576
+ operation: String(input.operation || 'dispatch'),
577
+ capability: String(input.capability || kind)
578
+ });
579
+ if (!descriptor.id) {
580
+ throw new PulseRuntimeContractError(
581
+ 'PULSE_RUNTIME_EFFECT_ID_INVALID',
582
+ 'Pulse effect ids must be non-empty strings.'
583
+ );
584
+ }
585
+ if (dispatchedIds.has(descriptor.id)) {
586
+ throw new PulseRuntimeContractError(
587
+ 'PULSE_RUNTIME_EFFECT_ID_DUPLICATE',
588
+ `Pulse effect id ${JSON.stringify(descriptor.id)} is already in use for this request.`
589
+ );
590
+ }
591
+ dispatchedIds.add(descriptor.id);
592
+ totalEffects += 1;
593
+ if (conditionalKv.isConditionalKv(kind)) conditionalKv.registerKvRedactions(descriptor, (value) => redaction.add(value));
594
+ const publicDescriptor = publicEffectDescriptor(descriptor, redaction);
595
+ observe({ type: 'effect-dispatched', effect: publicDescriptor });
596
+
597
+ const operationSignal = createEffectSignal(lifecycleController.signal, descriptor);
598
+ const operationExecution = Object.freeze({ ...externalExecution, signal: operationSignal.signal });
599
+ const raw = raceWithSignal(Promise.resolve().then(() => {
600
+ if (operationSignal.signal.aborted) throw abortedEffectError(operationSignal.signal.reason);
601
+ if (conditionalKv.isConditionalKv(kind)) return conditionalKv.executeConditionalKv(descriptor,
602
+ adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatch(descriptor, kvExecution)),
603
+ { ...operationExecution, onKvObservation: observe }, limits);
604
+ return adapter.dispatch(descriptor, operationExecution);
605
+ }), operationSignal.signal).then(
606
+ (value) => {
607
+ const normalized = normalizeEffectResult(descriptor, value, limits);
608
+ if (descriptor.kind === 'secret.get' && typeof normalized === 'string') redaction.add(normalized);
609
+ return normalized;
610
+ },
611
+ (error) => { throw redaction.redactError(error); }
612
+ ).finally(operationSignal.dispose);
613
+ const projected = (typeof projector === 'function' ? raw.then(projector) : raw).catch((error) => {
614
+ throw redaction.redactError(error);
615
+ });
616
+ const effectData = {
617
+ execution,
618
+ id: descriptor.id,
619
+ rootId: descriptor.id,
620
+ kind: descriptor.kind,
621
+ descriptor,
622
+ projection: null,
623
+ parallelEligible: normalizedInput.parallelEligible !== false
624
+ };
625
+ const effect = trackEffect(defineEffect(projected, effectData), effectData);
626
+ effect.then(
627
+ () => {
628
+ resolutionIndex += 1;
629
+ resolutionOrder.push(redaction.redactString(descriptor.id));
630
+ observe({
631
+ type: 'effect-settled',
632
+ effect: publicDescriptor,
633
+ resolutionIndex,
634
+ status: 'fulfilled'
635
+ });
636
+ },
637
+ (error) => {
638
+ resolutionIndex += 1;
639
+ resolutionOrder.push(redaction.redactString(descriptor.id));
640
+ observe({
641
+ type: 'effect-settled',
642
+ effect: publicDescriptor,
643
+ resolutionIndex,
644
+ status: 'rejected',
645
+ error: errorIdentity(error, redaction)
646
+ });
647
+ }
648
+ );
649
+ return effect;
650
+ },
651
+
652
+ local(kind, producer) {
653
+ assertOpen();
654
+ requireFunction('local effect producer', producer);
655
+ if (totalEffects + localEffects >= maxEffects) {
656
+ throw new PulseRuntimeContractError(
657
+ 'PULSE_RUNTIME_EFFECT_LIMIT_EXCEEDED',
658
+ limitMessage
659
+ );
660
+ }
661
+ const normalizedKind = String(kind || 'local');
662
+ const id = nextId(normalizedKind);
663
+ localEffects += 1;
664
+ const descriptor = Object.freeze({
665
+ version: JAVASCRIPT_EFFECT_PROTOCOL_VERSION,
666
+ id,
667
+ kind: normalizedKind,
668
+ providerKind: 'runtime',
669
+ operation: normalizedKind,
670
+ capability: normalizedKind
671
+ });
672
+ const publicDescriptor = publicEffectDescriptor(descriptor, redaction);
673
+ observe({ type: 'effect-dispatched', effect: publicDescriptor });
674
+ const promise = Promise.resolve().then(() => {
675
+ if (lifecycleController.signal.aborted) throw abortedEffectError(lifecycleController.signal.reason);
676
+ return producer(externalExecution);
677
+ }).catch((error) => { throw redaction.redactError(error); });
678
+ const effectData = {
679
+ execution,
680
+ id,
681
+ rootId: id,
682
+ kind: normalizedKind,
683
+ descriptor,
684
+ projection: null,
685
+ parallelEligible: false
686
+ };
687
+ const effect = trackEffect(defineEffect(raceWithSignal(promise, lifecycleController.signal), effectData), effectData);
688
+ effect.then(
689
+ () => {
690
+ resolutionIndex += 1;
691
+ resolutionOrder.push(redaction.redactString(id));
692
+ observe({ type: 'effect-settled', effect: publicDescriptor, resolutionIndex, status: 'fulfilled' });
693
+ },
694
+ (error) => {
695
+ resolutionIndex += 1;
696
+ resolutionOrder.push(redaction.redactString(id));
697
+ observe({ type: 'effect-settled', effect: publicDescriptor, resolutionIndex, status: 'rejected', error: errorIdentity(error, redaction) });
698
+ }
699
+ );
700
+ return effect;
701
+ },
702
+
703
+ project(parent, projection, projector) {
704
+ assertOpen();
705
+ const parentData = dataForEffect(parent);
706
+ if (!parentData || parentData.execution !== execution) {
707
+ throw new PulseRuntimeContractError(
708
+ 'PULSE_RUNTIME_EFFECT_PROJECTION_INVALID',
709
+ executionKind === 'event'
710
+ ? 'Pulse effect projections require an event-owned Pulse effect.'
711
+ : 'Pulse effect projections require a request-owned Pulse effect.'
712
+ );
713
+ }
714
+ const projected = raceWithSignal(parent.then(projector), lifecycleController.signal).catch((error) => {
715
+ throw redaction.redactError(error);
716
+ });
717
+ const effectData = {
718
+ ...parentData,
719
+ projection: String(projection)
720
+ };
721
+ return trackEffect(defineEffect(projected, effectData), effectData);
722
+ },
723
+
724
+ parallel(record) {
725
+ assertOpen();
726
+ const entries = validateParallelRecord(record, execution, claimedParallelRootIds);
727
+ for (const entry of entries) claimedParallelRootIds.add(entry.data.rootId);
728
+ parallelCount += 1;
729
+ const id = `parallel-${parallelCount}`;
730
+ const group = Object.freeze({
731
+ id,
732
+ keys: Object.freeze(entries.map((entry) => entry.key)),
733
+ effectIds: Object.freeze(entries.map((entry) => entry.data.id))
734
+ });
735
+ const publicGroup = Object.freeze(redaction.redactValue(group));
736
+ groups.push(publicGroup);
737
+ observe({ type: 'parallel-dispatched', group: publicGroup });
738
+
739
+ const promise = Promise.allSettled(entries.map((entry) => entry.effect)).then((settled) => {
740
+ const failures = [];
741
+ // Native lowering reconstructs an ordinary object literal. Keep the direct
742
+ // JavaScript realization observably aligned rather than returning a map.
743
+ const output = {};
744
+ for (let index = 0; index < settled.length; index += 1) {
745
+ const result = settled[index];
746
+ const entry = entries[index];
747
+ if (result.status === 'fulfilled') {
748
+ Object.defineProperty(output, entry.key, {
749
+ enumerable: true,
750
+ configurable: true,
751
+ writable: true,
752
+ value: result.value
753
+ });
754
+ } else {
755
+ failures.push({
756
+ key: entry.key,
757
+ index,
758
+ effectId: entry.data.id,
759
+ kind: entry.data.kind,
760
+ error: result.reason
761
+ });
762
+ }
763
+ }
764
+ if (failures.length > 0) {
765
+ // Re-apply redaction after every group member has settled. A secret resolved
766
+ // by another member may not have been registered when an earlier failure
767
+ // first crossed the adapter boundary.
768
+ throw redaction.redactError(attachGroupFailure(failures[0].error, failures, redaction));
769
+ }
770
+ return output;
771
+ });
772
+ const parallelEffectData = {
773
+ execution,
774
+ id,
775
+ rootId: id,
776
+ kind: 'parallel',
777
+ descriptor: group,
778
+ projection: null,
779
+ parallelEligible: false
780
+ };
781
+ const parallelEffect = trackEffect(defineEffect(promise, parallelEffectData), parallelEffectData);
782
+ parallelEffect.then(
783
+ () => observe({ type: 'parallel-settled', group: publicGroup, status: 'fulfilled' }),
784
+ (error) => observe({
785
+ type: 'parallel-settled',
786
+ group: publicGroup,
787
+ status: 'rejected',
788
+ error: errorIdentity(error, redaction)
789
+ })
790
+ );
791
+ return parallelEffect;
792
+ },
793
+
794
+ async assertIdle() {
795
+ if (pendingEffects.size === 0) return;
796
+ const snapshot = Array.from(pendingEffects.entries());
797
+ const active = Object.freeze(snapshot.map(([, data]) => Object.freeze({
798
+ id: data.id,
799
+ rootId: data.rootId,
800
+ kind: data.kind,
801
+ projection: data.projection
802
+ })));
803
+ const error = new PulseRuntimeContractError(
804
+ 'PULSE_RUNTIME_EFFECT_PENDING_AT_HANDLER_RETURN',
805
+ executionKind === 'event'
806
+ ? 'Pulse event handlers must await event-owned effects before completion.'
807
+ : 'Pulse handlers must await request-owned effects before returning or transferring control.',
808
+ { cause: Object.freeze(redaction.redactValue({ effects: active })) }
809
+ );
810
+ if (!lifecycleController.signal.aborted) lifecycleController.abort(error);
811
+ await Promise.allSettled(snapshot.map(([effect]) => effect));
812
+ throw error;
813
+ },
814
+
815
+ redactError(error) {
816
+ return redaction.redactError(error);
817
+ },
818
+
819
+ redactValue(value) {
820
+ return redaction.redactValue(value);
821
+ },
822
+
823
+ summary() {
824
+ return Object.freeze({
825
+ version: JAVASCRIPT_EFFECT_OBSERVATION_VERSION,
826
+ adapter: Object.freeze({ version: adapter.version, id: redaction.redactString(adapter.id) }),
827
+ effectCount: totalEffects,
828
+ localEffectCount: localEffects,
829
+ ownedEffectCount: totalEffects + localEffects,
830
+ parallelCount,
831
+ resolutionOrder: Object.freeze([...resolutionOrder]),
832
+ groups: Object.freeze([...groups]),
833
+ observations: Object.freeze([...observations]),
834
+ redactedSecretCount: redaction.count(),
835
+ aborted: lifecycleController.signal.aborted,
836
+ abortCode: lifecycleController.signal.aborted && lifecycleController.signal.reason && lifecycleController.signal.reason.code
837
+ ? redaction.redactString(lifecycleController.signal.reason.code)
838
+ : undefined,
839
+ closed
840
+ });
841
+ },
842
+
843
+ async close() {
844
+ if (closed) return;
845
+ closed = true;
846
+ if (pendingEffects.size > 0 && !lifecycleController.signal.aborted) {
847
+ lifecycleController.abort(new PulseRuntimeContractError(
848
+ 'PULSE_RUNTIME_EFFECT_EXECUTION_CLOSED',
849
+ executionKind === 'event'
850
+ ? 'Pulse JavaScript event execution closed with active event-owned effects.'
851
+ : 'Pulse JavaScript effect execution closed with active request-owned effects.'
852
+ ));
853
+ }
854
+ if (pendingEffects.size > 0) await Promise.allSettled(Array.from(pendingEffects.keys()));
855
+ if (removeSourceAbortListener) removeSourceAbortListener();
856
+ if (adapter.dispose) {
857
+ try { await adapter.dispose(externalExecution); }
858
+ catch (error) { throw redaction.redactError(error); }
859
+ }
860
+ }
861
+ };
862
+
863
+ return Object.freeze(execution);
864
+ }
865
+
866
+ module.exports = Object.freeze({
867
+ JAVASCRIPT_EFFECT_PROTOCOL_VERSION,
868
+ JAVASCRIPT_EFFECT_ADAPTER_VERSION,
869
+ JAVASCRIPT_EFFECT_OBSERVATION_VERSION,
870
+ DEFAULT_MAX_EFFECTS,
871
+ createJavascriptEffectAdapter,
872
+ createCapabilityEffectAdapter,
873
+ createJavascriptEffectExecution,
874
+ dataForEffect,
875
+ isPulseJavascriptEffect,
876
+ publicEffectDescriptor
877
+ });