@pulse-compute/runtime 1.0.0-beta.1 → 1.0.0-beta.3
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/README.md +3 -2
- package/docs/API.md +50 -17
- package/docs/guides/routing.md +20 -2
- package/docs/preview-scope.md +4 -4
- package/package.json +2 -2
- package/src/host.d.ts +45 -1
- package/src/host.js +1 -0
- package/src/index.d.ts +27 -1
- package/src/internal/bindings.js +1 -1
- package/src/internal/conditional-kv.js +199 -0
- package/src/internal/context.js +4 -0
- package/src/internal/effect-adapter.js +21 -1
- package/src/internal/event-execution.js +1 -0
- package/src/internal/package-runtime.js +7 -0
- package/src/internal/router.js +28 -0
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
> **Install directly:** Yes. Install it in every Pulse application.<br>
|
|
7
7
|
> **Supported entry points:** `@pulse-compute/runtime`<br>
|
|
8
8
|
> **Stability:** Supported application authoring and execution contract.<br>
|
|
9
|
-
> **
|
|
9
|
+
> **npm:** [`@pulse-compute/runtime`](https://www.npmjs.com/package/@pulse-compute/runtime)<br>
|
|
10
|
+
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.3/packages/runtime/)
|
|
10
11
|
>
|
|
11
|
-
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.
|
|
12
|
+
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.3` package policy.
|
|
12
13
|
<!-- pulse-package-status:end -->
|
|
13
14
|
|
|
14
15
|
`@pulse-compute/runtime` is the low-level, provider-neutral Pulse application contract.
|
package/docs/API.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Pulse runtime contract
|
|
2
2
|
|
|
3
3
|
For source eligibility, use [Managed handler TypeScript and
|
|
4
|
-
JavaScript](https://pulsecompute.io/v1.0.0-beta.
|
|
4
|
+
JavaScript](https://pulsecompute.io/v1.0.0-beta.3/reference/handler-authoring/). For provider and target
|
|
5
5
|
differences, use the [compatibility
|
|
6
|
-
matrix](https://pulsecompute.io/v1.0.0-beta.
|
|
6
|
+
matrix](https://pulsecompute.io/v1.0.0-beta.3/reference/compatibility-matrix/). For CLI and runtime failures,
|
|
7
7
|
use the stable codes in the [diagnostics
|
|
8
|
-
reference](https://pulsecompute.io/v1.0.0-beta.
|
|
8
|
+
reference](https://pulsecompute.io/v1.0.0-beta.3/reference/diagnostics/).
|
|
9
9
|
|
|
10
10
|
This document describes the provider-neutral TypeScript application contract compiled by Pulse. Low-level authoring types and the static `Router` come from `@pulse-compute/runtime`. The public `@pulse-compute/pulse` package owns the conventional `Pulse` application root, deferred project configuration, and schema declarations, while `@pulse-compute/cli` owns workspace orchestration.
|
|
11
11
|
|
|
@@ -30,7 +30,7 @@ process, provider SDK, or global network surface behind it.
|
|
|
30
30
|
| [`ctx.emit`](#ctxemit) | HTTP and event handlers | One-way, schema-bound event acceptance effect. |
|
|
31
31
|
| [`ctx.log`](#ctxlog) | HTTP and event handlers | Synchronous thresholded logging. |
|
|
32
32
|
| [`ctx.config`, `ctx.secret`](#config-and-secrets) | HTTP and event handlers | Explicit configured binding reads. |
|
|
33
|
-
| [`ctx.kv(name)`](#kv) | HTTP and event handlers | Bound
|
|
33
|
+
| [`ctx.kv(name)`](#kv) | HTTP and event handlers | Bound reads, writes, and conditional KV effects (Node reference). |
|
|
34
34
|
| [`ctx.json`, `ctx.text`, `ctx.response`](#response-builders) | HTTP handlers and middleware | Synchronous response construction. |
|
|
35
35
|
| `ctx.event` | Event handlers only | Exact event type and immutable validated payload. |
|
|
36
36
|
|
|
@@ -49,7 +49,7 @@ Every managed handler is async-shaped:
|
|
|
49
49
|
type Handler = (ctx: PulseContext) => Promise<PulseResult | PulseFetchResponse>
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
For native targets the compiler erases the async wrapper. Awaited Pulse effects lower into the existing explicit effect and continuation state machine; no Promise runtime or Asyncify transform is linked. Awaiting a proven synchronous `ctx` expression is redundant and may warn, while arbitrary non-Pulse awaits mark the native eligibility boundary. The canonical [handler authoring reference](https://pulsecompute.io/v1.0.0-beta.
|
|
52
|
+
For native targets the compiler erases the async wrapper. Awaited Pulse effects lower into the existing explicit effect and continuation state machine; no Promise runtime or Asyncify transform is linked. Awaiting a proven synchronous `ctx` expression is redundant and may warn, while arbitrary non-Pulse awaits mark the native eligibility boundary. The canonical [handler authoring reference](https://pulsecompute.io/v1.0.0-beta.3/reference/handler-authoring/) defines the static language subset; the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.3/reference/compatibility-matrix/) owns the tested four-mode claims.
|
|
53
53
|
|
|
54
54
|
## Static `Router`
|
|
55
55
|
|
|
@@ -83,7 +83,7 @@ app.mount('/api', api)
|
|
|
83
83
|
export default app
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
`Router` is a compile-time marker. Canonical v2 supports `get`, `head`, `post`, exact paths, named parameters, a trailing wildcard, static mounts, global/path-scoped/mounted middleware, route fallthrough, and error middleware. `ctx.param(name)` returns the matched named parameter inside route handlers.
|
|
86
|
+
`Router` is a compile-time marker. Canonical v2 supports `get`, `head`, `post`, `put`, `patch`, `delete`, exact paths, named parameters, a trailing wildcard, static mounts, global/path-scoped/mounted middleware, route fallthrough, and error middleware. `Pulse` inherits these registrations. `ctx.param(name)` returns the matched named parameter inside route handlers.
|
|
87
87
|
|
|
88
88
|
`next()` is a terminal control transfer. `return next()` advances the normal Router cursor and permanently ends the current handler scope. `return next(error)` enters or advances the error lane. There is no onion-style downstream return or post-`next()` resume. Normal and error exhaustion produce compiler-owned 404 and 500 responses respectively.
|
|
89
89
|
|
|
@@ -137,9 +137,9 @@ eligibility. Fastly targets fail closed because no event ingress/emit adapter is
|
|
|
137
137
|
claimed. There is no public event injection command, no event-aware development
|
|
138
138
|
listener, no HTTP/GRIP translation, and no automatic target fallback.
|
|
139
139
|
|
|
140
|
-
The [static events guide](https://pulsecompute.io/v1.0.0-beta.
|
|
140
|
+
The [static events guide](https://pulsecompute.io/v1.0.0-beta.3/guides/events/) owns the complete frame,
|
|
141
141
|
queue, target-eligibility, diagnostic, and Native-extension contract. The
|
|
142
|
-
source-bound [event example](https://pulsecompute.io/v1.0.0-beta.
|
|
142
|
+
source-bound [event example](https://pulsecompute.io/v1.0.0-beta.3/examples/11-events/) runs the same mixed project
|
|
143
143
|
on Node JavaScript and Node Native.
|
|
144
144
|
|
|
145
145
|
## `ctx.req`
|
|
@@ -400,7 +400,7 @@ const value = await sessions.get('current')
|
|
|
400
400
|
if (value !== undefined) await sessions.put('last', value)
|
|
401
401
|
```
|
|
402
402
|
|
|
403
|
-
The
|
|
403
|
+
The existing `get` and `put` operations remain available. Store names and keys are
|
|
404
404
|
provider-neutral logical bindings. Values are bounded, detached, deeply frozen
|
|
405
405
|
JSON-compatible trees; accessors, symbols, sparse arrays, repeated references,
|
|
406
406
|
cycles, class instances, nonfinite numbers, and nested `undefined` are rejected.
|
|
@@ -408,6 +408,39 @@ cycles, class instances, nonfinite numbers, and nested `undefined` are rejected.
|
|
|
408
408
|
and cross-request lifetime remain provider capabilities rather than properties
|
|
409
409
|
of the common API.
|
|
410
410
|
|
|
411
|
+
The Node reference on JavaScript and Native, and the Fastly Native adapter,
|
|
412
|
+
realize these operations through direct await or keyed `ctx.parallel`:
|
|
413
|
+
|
|
414
|
+
| Method | Result |
|
|
415
|
+
| --- | --- |
|
|
416
|
+
| `getVersioned(key)` | `found` with one observed `value` and opaque `generation`, `not-found`, or `failed` with a reason. |
|
|
417
|
+
| `insertIfAbsent(key, value)` | Atomically creates an absent key. |
|
|
418
|
+
| `compareAndSwap(key, generation, value)` | Atomically replaces the value only when its current generation matches. |
|
|
419
|
+
|
|
420
|
+
Conditional writes return `stored`, `conflict`, `not-stored` with a reason, or
|
|
421
|
+
`unknown` with a reason. `unknown` may have committed; Pulse never automatically
|
|
422
|
+
retries or rebases. A write acknowledgement carries no new generation. An absent
|
|
423
|
+
CAS conflicts. A read may be stale, but its value and token describe the same
|
|
424
|
+
observation. Tokens are bounded opaque strings, never JavaScript numbers or
|
|
425
|
+
application revision counters.
|
|
426
|
+
|
|
427
|
+
Namespaces must be literal bindings; keys, generations, and candidates are runtime
|
|
428
|
+
data. Candidates are snapshotted at admission. Keys are exact Unicode scalar
|
|
429
|
+
strings of 1–1,024 UTF-8 bytes without C0/C1 controls; tokens are 1–256 visible
|
|
430
|
+
ASCII bytes. Values retain the 65,536-byte JSON, depth-64 and 10,000-entry bounds.
|
|
431
|
+
The host owns the ten-second operation deadline, shortened by a request deadline.
|
|
432
|
+
Timeouts before dispatch are `not-stored`; unconfirmed writes after dispatch are
|
|
433
|
+
`unknown`. Request cancellation follows the existing managed lifecycle and
|
|
434
|
+
never implies rollback.
|
|
435
|
+
|
|
436
|
+
The Node realization is an explicit in-memory reference instance, not a durable
|
|
437
|
+
storage guarantee. Fastly Native uses lossless 64-bit generation metadata and
|
|
438
|
+
conditional host operations with bounded readiness and body acquisition. Deployed
|
|
439
|
+
cross-location acceptance remains a separate gate. The Fastly JavaScript SDK is
|
|
440
|
+
incomplete capability mapping and does not define or block
|
|
441
|
+
Pulse's contract. Conditional wire values use the strict
|
|
442
|
+
`{"__pulseKv":1,"value":...}` envelope; legacy raw JSON requires explicit migration.
|
|
443
|
+
|
|
411
444
|
## Explicit JSON schemas
|
|
412
445
|
|
|
413
446
|
<!-- pulse-doc-source: examples/02-request-schema/.pulse/config.ts -->
|
|
@@ -449,12 +482,12 @@ forms lower into canonical package operations; JavaScript targets execute the
|
|
|
449
482
|
real package implementation.
|
|
450
483
|
|
|
451
484
|
Older `/pulsewasm` imports are compatibility-only and are isolated in the
|
|
452
|
-
[migration guide](https://pulsecompute.io/v1.0.0-beta.
|
|
453
|
-
[GRIP package guide](https://pulsecompute.io/v1.0.0-beta.
|
|
485
|
+
[migration guide](https://pulsecompute.io/v1.0.0-beta.3/guides/compatibility-imports/). See the
|
|
486
|
+
[GRIP package guide](https://pulsecompute.io/v1.0.0-beta.3/packages/grip/) for the complete current surface.
|
|
454
487
|
|
|
455
488
|
## Entities API
|
|
456
489
|
|
|
457
|
-
`@pulse-compute/entities` is part of the synchronized `1.0.0-beta.
|
|
490
|
+
`@pulse-compute/entities` is part of the synchronized `1.0.0-beta.3` package
|
|
458
491
|
set. The application surface has two runtime values:
|
|
459
492
|
|
|
460
493
|
```ts
|
|
@@ -516,9 +549,9 @@ Registrations and the terminal binding must use the supported static form. The
|
|
|
516
549
|
first-party JSON-RPC adapter accepts bounded JSON-RPC 2.0 request objects and
|
|
517
550
|
named params, validates declared schemas, uses stable error framing, and
|
|
518
551
|
acknowledges notifications with HTTP `204`. See the [package
|
|
519
|
-
guide](https://pulsecompute.io/v1.0.0-beta.
|
|
520
|
-
model](https://pulsecompute.io/v1.0.0-beta.
|
|
521
|
-
example](https://pulsecompute.io/v1.0.0-beta.
|
|
552
|
+
guide](https://pulsecompute.io/v1.0.0-beta.3/packages/entities/), [entity/adapter
|
|
553
|
+
model](https://pulsecompute.io/v1.0.0-beta.3/concepts/entities-and-adapters/), and [executable
|
|
554
|
+
example](https://pulsecompute.io/v1.0.0-beta.3/examples/10-entities-tools/).
|
|
522
555
|
|
|
523
556
|
## Project workflow
|
|
524
557
|
|
|
@@ -535,5 +568,5 @@ selected profile, handler entry, schema declarations, provider bindings, and
|
|
|
535
568
|
output directory through `.pulse/config.ts`. `pulse inspect` is optional
|
|
536
569
|
observability, and `pulse compile` is the advanced provider-neutral Native
|
|
537
570
|
artifact command; neither is required before `pulse build`. See the [project
|
|
538
|
-
lifecycle guide](https://pulsecompute.io/v1.0.0-beta.
|
|
539
|
-
reference](https://pulsecompute.io/v1.0.0-beta.
|
|
571
|
+
lifecycle guide](https://pulsecompute.io/v1.0.0-beta.3/guides/project-lifecycle/) and [CLI
|
|
572
|
+
reference](https://pulsecompute.io/v1.0.0-beta.3/reference/cli/).
|
package/docs/guides/routing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Static Router authoring
|
|
2
2
|
|
|
3
|
-
Use `Router` when an application has several method/path entry points or needs compile-time middleware. It is a static authoring marker from `@pulse-compute/runtime`, not a JavaScript runtime dispatcher. The shared async/static restrictions are defined in [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.
|
|
3
|
+
Use `Router` when an application has several method/path entry points or needs compile-time middleware. It is a static authoring marker from `@pulse-compute/runtime`, not a JavaScript runtime dispatcher. The shared async/static restrictions are defined in [Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.3/reference/handler-authoring/); target claims live in the [compatibility matrix](https://pulsecompute.io/v1.0.0-beta.3/reference/compatibility-matrix/).
|
|
4
4
|
|
|
5
5
|
<!-- pulse-doc-source: examples/09-router-lowering/src/index.ts -->
|
|
6
6
|
```ts
|
|
@@ -76,7 +76,7 @@ export default app
|
|
|
76
76
|
|
|
77
77
|
- `new Router()` with a default-exported root router;
|
|
78
78
|
- `use(handler)` and `use(path, handler)` middleware;
|
|
79
|
-
- `get`, `head`, and `
|
|
79
|
+
- `get`, `head`, `post`, `put`, `patch`, and `delete` registrations;
|
|
80
80
|
- exact paths, named `:parameters`, and a trailing `*` wildcard;
|
|
81
81
|
- static acyclic `mount` composition;
|
|
82
82
|
- named or inline async-shaped route handlers using `(ctx)` or `(ctx, next)`;
|
|
@@ -87,6 +87,24 @@ export default app
|
|
|
87
87
|
|
|
88
88
|
Every Router handler uses the normal canonical context. Fetches, schemas, config, secrets, KV, opaque responses, and trusted package effects lower into the same native execution plan as single-handler authoring.
|
|
89
89
|
|
|
90
|
+
`Pulse` inherits the same route registration methods. Each registration matches
|
|
91
|
+
its exact HTTP method; a method mismatch advances the route cursor and normal
|
|
92
|
+
exhaustion remains `404`. There is no automatic `OPTIONS` or `405` response.
|
|
93
|
+
Ingress route methods do not widen the separate outbound `ctx.fetch` contract.
|
|
94
|
+
|
|
95
|
+
The `catalog-router-parity` conformance task exercises the original Catalog
|
|
96
|
+
consumer probe and all 14 of its PUT/PATCH/DELETE operations with their existing
|
|
97
|
+
methods and paths. Its shared cases check mounted parameters, request text,
|
|
98
|
+
middleware state, exact method misses, registration order, terminal fallthrough,
|
|
99
|
+
404 exhaustion and handled errors on Node/Fastly JavaScript and Native. Native
|
|
100
|
+
lanes execute compiled Wasm; Fastly evidence uses local provider emulation and
|
|
101
|
+
the target ABI mock host, not a deployed service. The fixtures establish routing
|
|
102
|
+
acceptance only; they do not implement Catalog persistence or authorization.
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
node wasm/scripts/run-wasm-tests.cjs --task catalog-router-parity --no-report
|
|
106
|
+
```
|
|
107
|
+
|
|
90
108
|
## `next()` is a terminal transfer
|
|
91
109
|
|
|
92
110
|
`next()` is not an onion-style callback. It is a compiler-visible control transfer:
|
package/docs/preview-scope.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Beta scope
|
|
2
2
|
|
|
3
|
-
Pulse `1.0.0-beta.
|
|
3
|
+
Pulse `1.0.0-beta.3` is a Beta of one provider-neutral application contract
|
|
4
4
|
with explicit Native and JavaScript execution targets. The intended 1.0
|
|
5
5
|
surface is present, but deliberate corrections may still occur before the
|
|
6
6
|
stable `1.0.0` release.
|
|
@@ -15,7 +15,7 @@ targets.
|
|
|
15
15
|
- Async-shaped provider-neutral TypeScript handlers; trusted Pulse awaits lower
|
|
16
16
|
into explicit effects and continuations without a Promise runtime.
|
|
17
17
|
- Static Router v2 with terminal middleware, route fallthrough, error
|
|
18
|
-
middleware, GET/HEAD/POST routes, exact paths, named parameters, trailing
|
|
18
|
+
middleware, GET/HEAD/POST/PUT/PATCH/DELETE routes, exact paths, named parameters, trailing
|
|
19
19
|
wildcards, and acyclic mounts.
|
|
20
20
|
- Basic branching and structured object, array, and scalar manipulation.
|
|
21
21
|
- Request method, URL, path, headers, text, and JSON access.
|
|
@@ -106,13 +106,13 @@ JavaScript-selected project into a Native one.
|
|
|
106
106
|
## Compatibility authority
|
|
107
107
|
|
|
108
108
|
The single source-form, provider-binding, artifact, and deployment-boundary
|
|
109
|
-
table is [Provider and target compatibility](https://pulsecompute.io/v1.0.0-beta.
|
|
109
|
+
table is [Provider and target compatibility](https://pulsecompute.io/v1.0.0-beta.3/reference/compatibility-matrix/).
|
|
110
110
|
It uses one public target order—Node JavaScript, Fastly JavaScript, Node Native,
|
|
111
111
|
and Fastly Native—and links every row to a focused proof or canonical contract.
|
|
112
112
|
|
|
113
113
|
The exact portable language subset and the JavaScript-only Native eligibility
|
|
114
114
|
boundaries are defined in
|
|
115
|
-
[Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.
|
|
115
|
+
[Managed handler TypeScript and JavaScript](https://pulsecompute.io/v1.0.0-beta.3/reference/handler-authoring/).
|
|
116
116
|
|
|
117
117
|
## Body model
|
|
118
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulse-compute/runtime",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.3",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^22.14.0 || ^24.0.0"
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"url": "git+https://github.com/pulse-compute/pulse.git",
|
|
42
42
|
"directory": "packages/runtime"
|
|
43
43
|
},
|
|
44
|
-
"homepage": "https://pulsecompute.io/v1.0.0-beta.
|
|
44
|
+
"homepage": "https://pulsecompute.io/v1.0.0-beta.3/packages/runtime/",
|
|
45
45
|
"bugs": {
|
|
46
46
|
"url": "https://github.com/pulse-compute/pulse/issues"
|
|
47
47
|
}
|
package/src/host.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Handler,
|
|
3
3
|
PulseEffect,
|
|
4
|
+
PulseKvGeneration, PulseKvVersionedResult, PulseKvConditionalResult,
|
|
4
5
|
PulseFetchResponse,
|
|
5
6
|
PulseParallelEffect,
|
|
6
7
|
PulseParallelResult,
|
|
@@ -14,6 +15,7 @@ export interface PulseJavascriptEffectHostExecution {
|
|
|
14
15
|
readonly event?: PulseEventFrame;
|
|
15
16
|
readonly application?: unknown;
|
|
16
17
|
readonly signal?: AbortSignal;
|
|
18
|
+
readonly deadlineMonotonicMs?: number;
|
|
17
19
|
/** Adds execution-owned sensitive text to runtime redaction before provider work continues. */
|
|
18
20
|
registerRedactionValue(value: string | Uint8Array): void;
|
|
19
21
|
/** Applies one project-owned schema codec directly to an in-memory semantic value. */
|
|
@@ -35,7 +37,26 @@ export interface PulseJavascriptEffectDescriptor {
|
|
|
35
37
|
readonly [field: string]: unknown;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Host-only preparation hook. Resolving/staging must not dispatch; invoking
|
|
41
|
+
* the returned primitive is the conservative storage dispatch boundary. */
|
|
42
|
+
export interface PulseConditionalKvExecution {
|
|
43
|
+
readonly signal?: AbortSignal;
|
|
44
|
+
readonly deadlineMonotonicMs?: number;
|
|
45
|
+
registerRedactionValue?(value: string): void;
|
|
46
|
+
onKvObservation?(value: Readonly<Record<string, unknown>>): void;
|
|
47
|
+
}
|
|
48
|
+
export interface PulseKvClock {
|
|
49
|
+
now(): number;
|
|
50
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
51
|
+
clearTimeout(handle: unknown): void;
|
|
52
|
+
}
|
|
53
|
+
export type PulseConditionalKvPreparation = (
|
|
54
|
+
effect: Readonly<Record<string, unknown>>,
|
|
55
|
+
execution: PulseConditionalKvExecution
|
|
56
|
+
) => Promise<(() => unknown) | undefined> | (() => unknown) | undefined;
|
|
57
|
+
|
|
38
58
|
export interface PulseJavascriptEffectAdapterInput {
|
|
59
|
+
prepareConditionalKv?: PulseConditionalKvPreparation;
|
|
39
60
|
readonly id?: string;
|
|
40
61
|
dispatch(
|
|
41
62
|
effect: PulseJavascriptEffectDescriptor,
|
|
@@ -46,6 +67,7 @@ export interface PulseJavascriptEffectAdapterInput {
|
|
|
46
67
|
|
|
47
68
|
export interface PulseJavascriptEffectAdapter {
|
|
48
69
|
readonly version: 'pulse.javascript-effect-adapter.v1';
|
|
70
|
+
readonly prepareConditionalKv?: PulseConditionalKvPreparation;
|
|
49
71
|
readonly id: string;
|
|
50
72
|
dispatch(
|
|
51
73
|
effect: PulseJavascriptEffectDescriptor,
|
|
@@ -80,12 +102,16 @@ export interface PulseEventRecordingAdapter extends PulseJavascriptEffectAdapter
|
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
export interface PulseRuntimeHostKvNamespace<T = unknown> {
|
|
105
|
+
getVersioned?(key: string): Promise<PulseKvVersionedResult<T>> | PulseKvVersionedResult<T>;
|
|
106
|
+
insertIfAbsent?(key: string, value: T): Promise<PulseKvConditionalResult> | PulseKvConditionalResult;
|
|
107
|
+
compareAndSwap?(key: string, generation: PulseKvGeneration, value: T): Promise<PulseKvConditionalResult> | PulseKvConditionalResult;
|
|
83
108
|
get(key: string): Promise<T | undefined> | T | undefined;
|
|
84
109
|
put(key: string, value: T): Promise<boolean> | boolean;
|
|
85
110
|
}
|
|
86
111
|
|
|
87
112
|
/** Compatibility injection shape retained while providers move to one effect adapter. */
|
|
88
113
|
export interface PulseRuntimeHostCapabilities {
|
|
114
|
+
prepareConditionalKv?: PulseConditionalKvPreparation;
|
|
89
115
|
fetch?(
|
|
90
116
|
url: string,
|
|
91
117
|
init?: unknown,
|
|
@@ -115,7 +141,7 @@ export interface PulseRuntimeHostCapabilities {
|
|
|
115
141
|
|
|
116
142
|
export interface PulseJavascriptEffectObservation {
|
|
117
143
|
readonly version: 'pulse.javascript-effect-observation.v1';
|
|
118
|
-
readonly type: 'effect-dispatched' | 'effect-settled' | 'parallel-dispatched' | 'parallel-settled';
|
|
144
|
+
readonly type: 'effect-dispatched' | 'effect-settled' | 'parallel-dispatched' | 'parallel-settled' | 'kv-lifecycle';
|
|
119
145
|
readonly [field: string]: unknown;
|
|
120
146
|
}
|
|
121
147
|
|
|
@@ -129,6 +155,7 @@ export interface PulseJavascriptEffectSummary {
|
|
|
129
155
|
readonly version: 'pulse.javascript-effect-observation.v1';
|
|
130
156
|
readonly adapter: {
|
|
131
157
|
readonly version: 'pulse.javascript-effect-adapter.v1';
|
|
158
|
+
readonly prepareConditionalKv?: PulseConditionalKvPreparation;
|
|
132
159
|
readonly id: string;
|
|
133
160
|
};
|
|
134
161
|
readonly effectCount: number;
|
|
@@ -173,6 +200,10 @@ export interface PulseJavascriptEffectExecution {
|
|
|
173
200
|
}
|
|
174
201
|
|
|
175
202
|
export interface PulseRuntimeExecutionOptions {
|
|
203
|
+
/** Host-only monotonic clock/scheduler injection for deterministic lifecycle evidence. */
|
|
204
|
+
readonly kvClock?: PulseKvClock;
|
|
205
|
+
/** Host monotonic deadline; conditional KV uses the earlier of this and 10 seconds. */
|
|
206
|
+
readonly deadlineMonotonicMs?: number;
|
|
176
207
|
/** Compatibility bridge. New providers should inject one shared effectAdapter instead. */
|
|
177
208
|
readonly capabilities?: PulseRuntimeHostCapabilities;
|
|
178
209
|
readonly effectAdapter?: PulseJavascriptEffectAdapter | PulseJavascriptEffectAdapterInput;
|
|
@@ -374,3 +405,16 @@ export declare const EVENT_EMIT_CODES: Readonly<{
|
|
|
374
405
|
QUEUE_FULL: 'PULSE_RUNTIME_EVENT_EMIT_QUEUE_FULL';
|
|
375
406
|
ADAPTER_INVALID: 'PULSE_RUNTIME_EVENT_ADAPTER_INVALID';
|
|
376
407
|
}>;
|
|
408
|
+
|
|
409
|
+
/** Shared host-only conditional KV boundary; no provider SDK tokens enter as numbers. */
|
|
410
|
+
export declare const KV_CONDITIONAL_KINDS: readonly string[];
|
|
411
|
+
export declare const KV_CONDITIONAL_LIMITS: Readonly<Record<string, number>>;
|
|
412
|
+
export declare function isConditionalKv(kind: string): boolean;
|
|
413
|
+
export declare function normalizeKvGeneration(value: unknown): PulseKvGeneration;
|
|
414
|
+
export declare function normalizeConditionalKvKey(value: unknown, options?: PulseBindingValueLimits): string;
|
|
415
|
+
export declare function encodeConditionalKvValue(value: unknown, options?: PulseBindingValueLimits): Uint8Array;
|
|
416
|
+
export declare function decodeConditionalKvValue(bytes: Uint8Array, options?: PulseBindingValueLimits): unknown;
|
|
417
|
+
export declare function admitConditionalKv(input: Readonly<Record<string, unknown>>, options?: PulseRuntimeExecutionOptions): Readonly<Record<string, unknown>>;
|
|
418
|
+
export declare function normalizeConditionalKvResult(effect: Readonly<{ kind: string }>, value: unknown, options?: PulseBindingValueLimits): PulseKvVersionedResult<unknown> | PulseKvConditionalResult;
|
|
419
|
+
export declare function executeConditionalKv(effect: Readonly<Record<string, unknown>>, prepare: PulseConditionalKvPreparation, execution?: PulseConditionalKvExecution, options?: PulseRuntimeExecutionOptions): Promise<PulseKvVersionedResult<unknown> | PulseKvConditionalResult>;
|
|
420
|
+
export declare function registerKvRedactions(effect: Readonly<Record<string, unknown>>, register?: (value: string) => void): void;
|
package/src/host.js
CHANGED
|
@@ -78,6 +78,7 @@ async function executeApplication(application, request, options = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
module.exports = Object.freeze({
|
|
81
|
+
...require('./internal/conditional-kv.js'),
|
|
81
82
|
RUNTIME_HOST_API_VERSION,
|
|
82
83
|
JAVASCRIPT_EFFECT_PROTOCOL_VERSION,
|
|
83
84
|
JAVASCRIPT_EFFECT_ADAPTER_VERSION,
|
package/src/index.d.ts
CHANGED
|
@@ -101,7 +101,30 @@ export interface PulseLogger {
|
|
|
101
101
|
debug(message: string): void;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// Opaque, bounded string. It is neither an application version nor a number.
|
|
105
|
+
export type PulseKvGeneration = string;
|
|
106
|
+
export type PulseKvReadFailureReason =
|
|
107
|
+
| 'invalid-key' | 'configuration' | 'not-authorized' | 'throttled'
|
|
108
|
+
| 'unavailable' | 'transport' | 'timeout' | 'protocol' | 'too-large' | 'invalid-value';
|
|
109
|
+
export type PulseKvVersionedResult<T> =
|
|
110
|
+
| { readonly status: 'found'; readonly value: T; readonly generation: PulseKvGeneration }
|
|
111
|
+
| { readonly status: 'not-found' }
|
|
112
|
+
| { readonly status: 'failed'; readonly reason: PulseKvReadFailureReason };
|
|
113
|
+
|
|
114
|
+
export type PulseKvNotStoredReason =
|
|
115
|
+
| 'invalid-key' | 'invalid-value' | 'invalid-generation' | 'too-large'
|
|
116
|
+
| 'configuration' | 'not-authorized' | 'throttled' | 'rejected'
|
|
117
|
+
| 'unavailable' | 'transport' | 'timeout' | 'protocol';
|
|
118
|
+
export type PulseKvConditionalResult =
|
|
119
|
+
| { readonly status: 'stored' }
|
|
120
|
+
| { readonly status: 'conflict' }
|
|
121
|
+
| { readonly status: 'not-stored'; readonly reason: PulseKvNotStoredReason }
|
|
122
|
+
| { readonly status: 'unknown'; readonly reason: 'transport' | 'timeout' | 'unavailable' | 'protocol' };
|
|
123
|
+
|
|
104
124
|
export interface PulseKvNamespace<T = unknown> {
|
|
125
|
+
getVersioned(key: string): PulseParallelEffect<PulseKvVersionedResult<T>>;
|
|
126
|
+
insertIfAbsent(key: string, value: T): PulseParallelEffect<PulseKvConditionalResult>;
|
|
127
|
+
compareAndSwap(key: string, generation: PulseKvGeneration, value: T): PulseParallelEffect<PulseKvConditionalResult>;
|
|
105
128
|
get(key: string): PulseParallelEffect<T | undefined>;
|
|
106
129
|
put(key: string, value: T): PulseParallelEffect<boolean>;
|
|
107
130
|
}
|
|
@@ -172,7 +195,7 @@ export type PulseErrorHandler = RouterErrorHandler;
|
|
|
172
195
|
|
|
173
196
|
/**
|
|
174
197
|
* Static authoring marker compiled by Pulse. Router instances are not runtime dispatchers.
|
|
175
|
-
* Canonical v2 supports terminal middleware/error transfer, get/head/post routes,
|
|
198
|
+
* Canonical v2 supports terminal middleware/error transfer, get/head/post/put/patch/delete routes,
|
|
176
199
|
* fallthrough, and acyclic static mounts.
|
|
177
200
|
*/
|
|
178
201
|
export declare class Router {
|
|
@@ -181,6 +204,9 @@ export declare class Router {
|
|
|
181
204
|
get(path: string, handler: RouteHandler): this;
|
|
182
205
|
head(path: string, handler: RouteHandler): this;
|
|
183
206
|
post(path: string, handler: RouteHandler): this;
|
|
207
|
+
put(path: string, handler: RouteHandler): this;
|
|
208
|
+
patch(path: string, handler: RouteHandler): this;
|
|
209
|
+
delete(path: string, handler: RouteHandler): this;
|
|
184
210
|
mount(path: string, router: Router): this;
|
|
185
211
|
error(handler: RouterErrorHandler): this;
|
|
186
212
|
}
|
package/src/internal/bindings.js
CHANGED
|
@@ -171,7 +171,7 @@ function clonePortableKvValue(value, state, path, depth) {
|
|
|
171
171
|
}
|
|
172
172
|
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
173
173
|
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
174
|
-
throw kvValueError('Pulse KV arrays may not contain accessors.', `${path}[${index}]`,
|
|
174
|
+
throw kvValueError('Pulse KV arrays may not contain accessors.', `${path}[${index}]`, undefined);
|
|
175
175
|
}
|
|
176
176
|
definePortableProperty(output, String(index), clonePortableKvValue(descriptor.value, state, `${path}[${index}]`, depth + 1));
|
|
177
177
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared host contract. Provider preparation must not send a storage operation;
|
|
4
|
+
// the returned primitive is the conservative dispatch boundary (including throws).
|
|
5
|
+
const { cloneKvValue, normalizeKvNamespace } = require('./bindings.js');
|
|
6
|
+
const { PulseRuntimeContractError } = require('./errors.js');
|
|
7
|
+
const KV_CONDITIONAL_KINDS = Object.freeze(['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap']);
|
|
8
|
+
const KV_CONDITIONAL_LIMITS = Object.freeze({ namespaceBytes: 256, keyBytes: 1024, generationBytes: 256,
|
|
9
|
+
valueBytes: 65536, wireBytes: 65560, depth: 64, entries: 10000, envelopeBytes: 262144, timeoutMs: 10000 });
|
|
10
|
+
const readReasons = new Set(['invalid-key', 'configuration', 'not-authorized', 'throttled', 'unavailable', 'transport', 'timeout', 'protocol', 'too-large', 'invalid-value']);
|
|
11
|
+
const writeReasons = new Set([...readReasons, 'invalid-generation', 'rejected']);
|
|
12
|
+
const unknownReasons = new Set(['transport', 'timeout', 'unavailable', 'protocol']);
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const admissions = new WeakSet();
|
|
15
|
+
// Descriptor copies retain the original admission/deadline. Symbols never enter
|
|
16
|
+
// the serialized effect envelope; only admissions minted by this owner are trusted.
|
|
17
|
+
const admissionSymbol = Symbol('pulse.conditional-kv-admission');
|
|
18
|
+
const isConditionalKv = (kind) => KV_CONDITIONAL_KINDS.includes(kind);
|
|
19
|
+
const isRead = (effect) => effect.kind === 'kv.getVersioned';
|
|
20
|
+
const frozen = (value) => Object.freeze(value);
|
|
21
|
+
function fail(reason) { const error = new Error('Pulse conditional KV boundary rejected data.'); error.kvReason = reason; throw error; }
|
|
22
|
+
function byteLength(value) { return encoder.encode(value).byteLength; }
|
|
23
|
+
function kvClock(options = {}) { return options.kvClock || { now: () => performance.now(), setTimeout, clearTimeout }; }
|
|
24
|
+
function boundedLimits(options = {}) {
|
|
25
|
+
const limit = (name, max) => options[name] === undefined ? max : Math.min(max, options[name]);
|
|
26
|
+
return { maxKvValueBytes: limit('maxKvValueBytes', 65536), maxKvValueDepth: limit('maxKvValueDepth', 64), maxKvValueEntries: limit('maxKvValueEntries', 10000) };
|
|
27
|
+
}
|
|
28
|
+
function normalizeKvGeneration(value) {
|
|
29
|
+
if (typeof value !== 'string' || !/^[\x21-\x7e]{1,256}$/.test(value)) fail('invalid-generation');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function normalizeConditionalKvKey(value, options = {}) {
|
|
33
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 1024 || byteLength(value) > Math.min(1024, options.maxKvKeyBytes ?? 1024)
|
|
34
|
+
|| /[\u0000-\u001f\u007f-\u009f]/u.test(value) || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value)) fail('invalid-key');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function validationReason(error) {
|
|
38
|
+
if (error && error.kvReason) return error.kvReason;
|
|
39
|
+
return /TOO_LARGE|DEPTH_EXCEEDED|ENTRIES_EXCEEDED/.test(error && error.code || '') ? 'too-large' : 'invalid-value';
|
|
40
|
+
}
|
|
41
|
+
function encodeConditionalKvValue(value, options = {}) {
|
|
42
|
+
let detached;
|
|
43
|
+
try { detached = cloneKvValue(value, boundedLimits(options)); } catch (error) { fail(validationReason(error)); }
|
|
44
|
+
const wire = encoder.encode('{"__pulseKv":1,"value":' + JSON.stringify(detached) + '}');
|
|
45
|
+
if (wire.byteLength > KV_CONDITIONAL_LIMITS.wireBytes) fail('too-large');
|
|
46
|
+
return wire;
|
|
47
|
+
}
|
|
48
|
+
function decodeConditionalKvValue(bytes, options = {}) {
|
|
49
|
+
if (!(bytes instanceof Uint8Array)) fail('protocol');
|
|
50
|
+
if (bytes.byteLength > KV_CONDITIONAL_LIMITS.wireBytes) fail('too-large');
|
|
51
|
+
let text, parsed;
|
|
52
|
+
try {
|
|
53
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
54
|
+
parsed = JSON.parse(text);
|
|
55
|
+
// JSON.parse validates syntax; this bounded scan rejects last-wins duplicate
|
|
56
|
+
// members, including escaped aliases, before exposing any parsed value.
|
|
57
|
+
const tokens = text.match(/"(?:[^"\\]|\\.)*"|[{}\[\],:]|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g);
|
|
58
|
+
let cursor = 0, entries = 0;
|
|
59
|
+
function visit(depth) {
|
|
60
|
+
if (depth > 65 || ++entries > 10002) fail('too-large');
|
|
61
|
+
const first = tokens[cursor++];
|
|
62
|
+
if (first !== '{' && first !== '[') return;
|
|
63
|
+
const end = first === '{' ? '}' : ']', names = new Set();
|
|
64
|
+
if (tokens[cursor] === end) { cursor++; return; }
|
|
65
|
+
while (true) {
|
|
66
|
+
if (first === '{') {
|
|
67
|
+
const name = JSON.parse(tokens[cursor++]);
|
|
68
|
+
if (names.has(name)) fail('protocol'); names.add(name);
|
|
69
|
+
if (tokens[cursor++] !== ':') fail('protocol');
|
|
70
|
+
}
|
|
71
|
+
visit(depth + 1);
|
|
72
|
+
const separator = tokens[cursor++];
|
|
73
|
+
if (separator === end) return;
|
|
74
|
+
if (separator !== ',') fail('protocol');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
visit(0);
|
|
78
|
+
if (cursor !== tokens.length || !parsed || Array.isArray(parsed) || parsed.__pulseKv !== 1
|
|
79
|
+
|| Object.keys(parsed).sort().join(',') !== '__pulseKv,value') fail('protocol');
|
|
80
|
+
} catch (error) { fail(error.kvReason || 'protocol'); }
|
|
81
|
+
try { return cloneKvValue(parsed.value, boundedLimits(options)); } catch (error) { fail(validationReason(error)); }
|
|
82
|
+
}
|
|
83
|
+
function kvFailure(effect, reason, dispatched = false) {
|
|
84
|
+
return frozen(isRead(effect) ? { status: 'failed', reason: readReasons.has(reason) ? reason : 'protocol' }
|
|
85
|
+
: { status: dispatched ? 'unknown' : 'not-stored', reason: (dispatched ? unknownReasons : writeReasons).has(reason) ? reason : 'protocol' });
|
|
86
|
+
}
|
|
87
|
+
function admitConditionalKv(input, options = {}) {
|
|
88
|
+
if (admissions.has(input)) return input;
|
|
89
|
+
if (input && admissions.has(input[admissionSymbol])) return input[admissionSymbol];
|
|
90
|
+
const clock = kvClock(options);
|
|
91
|
+
const deadline = Math.min(clock.now() + KV_CONDITIONAL_LIMITS.timeoutMs, options.deadlineMonotonicMs ?? Infinity);
|
|
92
|
+
const result = { kind: input.kind, deadline };
|
|
93
|
+
try {
|
|
94
|
+
try { result.namespace = normalizeKvNamespace(input.namespace ?? input.store, { maxKvNamespaceBytes: Math.min(256, options.maxKvNamespaceBytes ?? 256) }); }
|
|
95
|
+
catch (_) { fail('configuration'); }
|
|
96
|
+
result.key = normalizeConditionalKvKey(input.key, options);
|
|
97
|
+
if (input.kind === 'kv.compareAndSwap') result.generation = normalizeKvGeneration(input.generation);
|
|
98
|
+
if (!isRead(input)) result.value = decodeConditionalKvValue(encodeConditionalKvValue(input.value, options), options);
|
|
99
|
+
if (byteLength(JSON.stringify(result)) > KV_CONDITIONAL_LIMITS.envelopeBytes) fail('too-large');
|
|
100
|
+
} catch (error) { result.validationResult = kvFailure(result, validationReason(error)); }
|
|
101
|
+
result[admissionSymbol] = result;
|
|
102
|
+
const admitted = frozen(result); admissions.add(admitted); return admitted;
|
|
103
|
+
}
|
|
104
|
+
function exactRecord(value, fields) {
|
|
105
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('protocol');
|
|
106
|
+
const prototype = Object.getPrototypeOf(value);
|
|
107
|
+
if (prototype !== Object.prototype && prototype !== null) fail('protocol');
|
|
108
|
+
const keys = Reflect.ownKeys(value);
|
|
109
|
+
if (keys.length !== fields.length || !fields.every((key) => keys.includes(key))) fail('protocol');
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
112
|
+
if (!descriptor.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) fail('protocol');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function normalizeConditionalKvResult(effect, value, options = {}) {
|
|
116
|
+
try {
|
|
117
|
+
// Read status without invoking an accessor from a malformed provider result.
|
|
118
|
+
const status = value && Object.getOwnPropertyDescriptor(value, 'status');
|
|
119
|
+
const tag = status && status.value;
|
|
120
|
+
if (isRead(effect)) {
|
|
121
|
+
if (tag === 'found') {
|
|
122
|
+
exactRecord(value, ['status', 'value', 'generation']);
|
|
123
|
+
const generation = normalizeKvGeneration(value.generation);
|
|
124
|
+
const detached = decodeConditionalKvValue(encodeConditionalKvValue(value.value, options), options);
|
|
125
|
+
return frozen({ status: 'found', value: detached, generation });
|
|
126
|
+
}
|
|
127
|
+
if (tag === 'not-found') { exactRecord(value, ['status']); return frozen({ status: tag }); }
|
|
128
|
+
exactRecord(value, ['status', 'reason']);
|
|
129
|
+
if (tag !== 'failed' || !readReasons.has(value.reason)) fail('protocol');
|
|
130
|
+
} else {
|
|
131
|
+
if (tag === 'stored' || tag === 'conflict') { exactRecord(value, ['status']); return frozen({ status: tag }); }
|
|
132
|
+
exactRecord(value, ['status', 'reason']);
|
|
133
|
+
if (!((tag === 'not-stored' && writeReasons.has(value.reason)) || (tag === 'unknown' && unknownReasons.has(value.reason)))) fail('protocol');
|
|
134
|
+
}
|
|
135
|
+
return frozen({ status: tag, reason: value.reason });
|
|
136
|
+
} catch (error) { return kvFailure(effect, validationReason(error) === 'invalid-generation' ? 'protocol' : validationReason(error), !isRead(effect)); }
|
|
137
|
+
}
|
|
138
|
+
function registerKvRedactions(effect, register) {
|
|
139
|
+
if (typeof register !== 'function') return;
|
|
140
|
+
const visit = (value) => {
|
|
141
|
+
if (typeof value === 'string' && value.length) register(value);
|
|
142
|
+
else if (value && typeof value === 'object') for (const key of Object.keys(value)) { register(key); visit(value[key]); }
|
|
143
|
+
};
|
|
144
|
+
visit(effect.key); visit(effect.generation); visit(effect.value);
|
|
145
|
+
}
|
|
146
|
+
function cancelled() { return new PulseRuntimeContractError('PULSE_RUNTIME_EFFECT_ABORTED', 'Pulse conditional KV execution was cancelled.'); }
|
|
147
|
+
async function executeConditionalKv(effect, prepare, execution = {}, options = {}) {
|
|
148
|
+
const admitted = admissions.has(effect) ? effect : admitConditionalKv(effect, options);
|
|
149
|
+
const clock = kvClock(options), controller = new AbortController();
|
|
150
|
+
let dispatched = false, settled = false, timer, rejectCancellation, resolveTimeout;
|
|
151
|
+
registerKvRedactions(admitted, execution.registerRedactionValue);
|
|
152
|
+
const observe = (outcome) => {
|
|
153
|
+
if (typeof execution.onKvObservation === 'function') execution.onKvObservation(frozen({
|
|
154
|
+
type: 'kv-lifecycle', kind: admitted.kind, dispatched, mayHaveCommitted: !isRead(admitted) && dispatched && (outcome === 'cancelled' || outcome === 'unknown'), outcome
|
|
155
|
+
}));
|
|
156
|
+
};
|
|
157
|
+
const cancellation = new Promise((_, reject) => { rejectCancellation = reject; });
|
|
158
|
+
const timeout = new Promise((resolve) => { resolveTimeout = resolve; });
|
|
159
|
+
const abort = () => { if (!settled) { controller.abort(); rejectCancellation(cancelled()); } };
|
|
160
|
+
const expire = () => { if (!settled) { controller.abort(); resolveTimeout(kvFailure(admitted, 'timeout', dispatched)); } };
|
|
161
|
+
const signal = execution.signal;
|
|
162
|
+
if (signal) signal.addEventListener('abort', abort, { once: true });
|
|
163
|
+
try {
|
|
164
|
+
if (signal && signal.aborted) throw cancelled();
|
|
165
|
+
if (admitted.validationResult) return admitted.validationResult;
|
|
166
|
+
if (clock.now() >= admitted.deadline) return kvFailure(admitted, 'timeout');
|
|
167
|
+
timer = clock.setTimeout(expire, admitted.deadline - clock.now());
|
|
168
|
+
const operationExecution = frozen({ ...execution, signal: controller.signal, deadlineMonotonicMs: admitted.deadline });
|
|
169
|
+
const work = Promise.resolve().then(async () => {
|
|
170
|
+
if (controller.signal.aborted) return kvFailure(admitted, 'timeout');
|
|
171
|
+
try {
|
|
172
|
+
const primitive = await prepare(admitted, operationExecution);
|
|
173
|
+
if (controller.signal.aborted || clock.now() >= admitted.deadline) {
|
|
174
|
+
controller.abort();
|
|
175
|
+
return kvFailure(admitted, 'timeout');
|
|
176
|
+
}
|
|
177
|
+
if (typeof primitive !== 'function') return kvFailure(admitted, 'configuration');
|
|
178
|
+
dispatched = true;
|
|
179
|
+
const value = await primitive();
|
|
180
|
+
if (clock.now() >= admitted.deadline) {
|
|
181
|
+
controller.abort();
|
|
182
|
+
return kvFailure(admitted, 'timeout', true);
|
|
183
|
+
}
|
|
184
|
+
const normalized = normalizeConditionalKvResult(admitted, value, options);
|
|
185
|
+
if (normalized.status === 'found') registerKvRedactions(normalized, execution.registerRedactionValue);
|
|
186
|
+
return normalized;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return kvFailure(admitted, dispatched ? 'transport' : error && writeReasons.has(error.kvReason) ? error.kvReason : 'unavailable', dispatched);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
const result = await Promise.race([work, timeout, cancellation]);
|
|
192
|
+
if (signal && signal.aborted) throw cancelled();
|
|
193
|
+
observe(result.status);
|
|
194
|
+
return result;
|
|
195
|
+
} catch (error) { if (signal && signal.aborted) observe('cancelled'); throw error; }
|
|
196
|
+
finally { settled = true; if (timer !== undefined) clock.clearTimeout(timer); if (signal) signal.removeEventListener('abort', abort); }
|
|
197
|
+
}
|
|
198
|
+
module.exports = Object.freeze({ KV_CONDITIONAL_KINDS, KV_CONDITIONAL_LIMITS, isConditionalKv, normalizeKvGeneration, normalizeConditionalKvKey,
|
|
199
|
+
admitConditionalKv, encodeConditionalKvValue, decodeConditionalKvValue, normalizeConditionalKvResult, executeConditionalKv, registerKvRedactions });
|
package/src/internal/context.js
CHANGED
|
@@ -207,6 +207,10 @@ function createContext(frame) {
|
|
|
207
207
|
kv(name) {
|
|
208
208
|
const namespace = normalizeKvNamespace(name, options);
|
|
209
209
|
return Object.freeze({
|
|
210
|
+
...Object.fromEntries(['getVersioned', 'insertIfAbsent', 'compareAndSwap'].map((operation) => [operation, (key, generationOrValue, value) => effects.dispatch({
|
|
211
|
+
kind: `kv.${operation}`, providerKind: 'kv', operation, capability: `kv.${operation}`, namespace, key,
|
|
212
|
+
...(operation === 'compareAndSwap' ? { generation: generationOrValue, value } : operation === 'insertIfAbsent' ? { value: generationOrValue } : {})
|
|
213
|
+
})])),
|
|
210
214
|
get(key) {
|
|
211
215
|
const normalizedKey = normalizeKvKey(key, options);
|
|
212
216
|
return effects.dispatch({
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
normalizeKvNamespace,
|
|
10
10
|
normalizeKvPutResult
|
|
11
11
|
} = require('./bindings.js');
|
|
12
|
+
const conditionalKv = require('./conditional-kv.js');
|
|
12
13
|
const { fetchRequestInitForHost } = require('./fetch.js');
|
|
13
14
|
const { createRedactionState } = require('./redaction.js');
|
|
14
15
|
const { validateSchemaValue } = require('./schema.js');
|
|
@@ -102,7 +103,6 @@ function createEffectSignal(lifecycleSignal, descriptor) {
|
|
|
102
103
|
|
|
103
104
|
function raceWithSignal(promise, signal) {
|
|
104
105
|
if (!signal) return Promise.resolve(promise);
|
|
105
|
-
if (signal.aborted) return Promise.reject(abortedEffectError(signal.reason));
|
|
106
106
|
return new Promise((resolve, reject) => {
|
|
107
107
|
let settled = false;
|
|
108
108
|
const finish = (callback, value) => {
|
|
@@ -117,6 +117,8 @@ function raceWithSignal(promise, signal) {
|
|
|
117
117
|
(value) => finish(resolve, value),
|
|
118
118
|
(error) => finish(reject, error)
|
|
119
119
|
);
|
|
120
|
+
// Observe the owned promise even when cancellation preceded the race.
|
|
121
|
+
if (signal.aborted) onAbort();
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
|
|
@@ -167,6 +169,7 @@ function publicEffectDescriptor(effect, redaction) {
|
|
|
167
169
|
if (effect.kind === 'config.get' || effect.kind === 'secret.get') {
|
|
168
170
|
return Object.freeze({ ...base, name: redactString(effect.name) });
|
|
169
171
|
}
|
|
172
|
+
if (conditionalKv.isConditionalKv(effect.kind)) return Object.freeze({ ...base, namespace: redactString(effect.namespace), key: '<redacted>', generation: '<redacted>', value: '<redacted>' });
|
|
170
173
|
if (effect.kind === 'kv.get' || effect.kind === 'kv.put') {
|
|
171
174
|
return Object.freeze({
|
|
172
175
|
...base,
|
|
@@ -208,6 +211,7 @@ function createJavascriptEffectAdapter(input) {
|
|
|
208
211
|
version: JAVASCRIPT_EFFECT_ADAPTER_VERSION,
|
|
209
212
|
id: String(input.id || 'pulse.javascript-effect-adapter.custom'),
|
|
210
213
|
dispatch,
|
|
214
|
+
prepareConditionalKv: typeof input.prepareConditionalKv === 'function' ? input.prepareConditionalKv : undefined,
|
|
211
215
|
dispose
|
|
212
216
|
});
|
|
213
217
|
}
|
|
@@ -225,6 +229,16 @@ function createCapabilityEffectAdapter(capabilities = {}) {
|
|
|
225
229
|
const namespaces = new Map();
|
|
226
230
|
return createJavascriptEffectAdapter({
|
|
227
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
|
+
},
|
|
228
242
|
async dispatch(effect, execution) {
|
|
229
243
|
if (effect.kind === 'fetch') {
|
|
230
244
|
if (typeof value.fetch !== 'function') return unavailableCapability(effect);
|
|
@@ -260,6 +274,7 @@ function createCapabilityEffectAdapter(capabilities = {}) {
|
|
|
260
274
|
|
|
261
275
|
function bindingLimits(options = {}) {
|
|
262
276
|
return Object.freeze({
|
|
277
|
+
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
263
278
|
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
264
279
|
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
265
280
|
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|
|
@@ -272,6 +287,7 @@ function bindingLimits(options = {}) {
|
|
|
272
287
|
|
|
273
288
|
function normalizeEffectDescriptor(input, limits) {
|
|
274
289
|
const kind = String(input.kind || 'effect');
|
|
290
|
+
if (conditionalKv.isConditionalKv(kind)) return { ...conditionalKv.admitConditionalKv(input, limits), id: input.id, parallelEligible: input.parallelEligible };
|
|
275
291
|
if (kind === 'config.get' || kind === 'secret.get') {
|
|
276
292
|
const bindingKind = kind === 'secret.get' ? 'secret' : 'config';
|
|
277
293
|
return { ...input, name: normalizeBindingName(bindingKind, input.name, limits) };
|
|
@@ -574,6 +590,7 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
574
590
|
}
|
|
575
591
|
dispatchedIds.add(descriptor.id);
|
|
576
592
|
totalEffects += 1;
|
|
593
|
+
if (conditionalKv.isConditionalKv(kind)) conditionalKv.registerKvRedactions(descriptor, (value) => redaction.add(value));
|
|
577
594
|
const publicDescriptor = publicEffectDescriptor(descriptor, redaction);
|
|
578
595
|
observe({ type: 'effect-dispatched', effect: publicDescriptor });
|
|
579
596
|
|
|
@@ -581,6 +598,9 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
581
598
|
const operationExecution = Object.freeze({ ...externalExecution, signal: operationSignal.signal });
|
|
582
599
|
const raw = raceWithSignal(Promise.resolve().then(() => {
|
|
583
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);
|
|
584
604
|
return adapter.dispatch(descriptor, operationExecution);
|
|
585
605
|
}), operationSignal.signal).then(
|
|
586
606
|
(value) => {
|
|
@@ -317,6 +317,7 @@ function effectExecutionOptions(options, application, frame) {
|
|
|
317
317
|
executionKind: 'event',
|
|
318
318
|
signal: options.signal,
|
|
319
319
|
maxEffects: options.maxEffects,
|
|
320
|
+
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
320
321
|
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
321
322
|
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
322
323
|
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|
|
@@ -16,6 +16,13 @@ const DEFAULT_MAX_PAYLOAD_ENTRIES = 4096;
|
|
|
16
16
|
const PACKAGE_CONTEXTS = new WeakMap();
|
|
17
17
|
|
|
18
18
|
const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
|
|
19
|
+
'@pulse-compute/s3': Object.freeze({
|
|
20
|
+
contractId: 'pulse.s3', providerKind: 's3', operations: Object.freeze({
|
|
21
|
+
head: Object.freeze({ kind: 's3.head', capability: 's3.head', result: 's3-head-result' }),
|
|
22
|
+
getText: Object.freeze({ kind: 's3.getText', capability: 's3.getText', result: 's3-get-text-result' }),
|
|
23
|
+
putText: Object.freeze({ kind: 's3.putText', capability: 's3.putText', result: 's3-put-text-result' })
|
|
24
|
+
})
|
|
25
|
+
}),
|
|
19
26
|
'@pulse-compute/assets': Object.freeze({
|
|
20
27
|
contractId: 'pulse.assets',
|
|
21
28
|
providerKind: 'assets',
|
package/src/internal/router.js
CHANGED
|
@@ -84,6 +84,33 @@ class Router {
|
|
|
84
84
|
return this;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
put(path, handler) {
|
|
88
|
+
ROUTER_STATE.get(this).entries.push(entry('route', {
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
path: compileRoutePath(path, { allowWildcard: true }),
|
|
91
|
+
handler: requireHandler(handler, 'put(path, handler)')
|
|
92
|
+
}));
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
patch(path, handler) {
|
|
97
|
+
ROUTER_STATE.get(this).entries.push(entry('route', {
|
|
98
|
+
method: 'PATCH',
|
|
99
|
+
path: compileRoutePath(path, { allowWildcard: true }),
|
|
100
|
+
handler: requireHandler(handler, 'patch(path, handler)')
|
|
101
|
+
}));
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
delete(path, handler) {
|
|
106
|
+
ROUTER_STATE.get(this).entries.push(entry('route', {
|
|
107
|
+
method: 'DELETE',
|
|
108
|
+
path: compileRoutePath(path, { allowWildcard: true }),
|
|
109
|
+
handler: requireHandler(handler, 'delete(path, handler)')
|
|
110
|
+
}));
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
mount(path, router) {
|
|
88
115
|
ROUTER_STATE.get(this).entries.push(entry('mount', {
|
|
89
116
|
path: compileRoutePath(path, { scoped: true, allowWildcard: true }),
|
|
@@ -391,6 +418,7 @@ async function executeRouter(router, request, options = {}) {
|
|
|
391
418
|
executionKind: 'request',
|
|
392
419
|
signal: executionSignal,
|
|
393
420
|
maxEffects: options.maxEffects,
|
|
421
|
+
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
394
422
|
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
395
423
|
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
396
424
|
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|