@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.
- package/README.md +126 -2
- package/docs/API.md +572 -0
- package/docs/guides/routing.md +202 -0
- package/docs/preview-scope.md +142 -0
- package/package.json +38 -5
- package/src/host.d.ts +420 -0
- package/src/host.js +115 -0
- package/src/index.d.ts +215 -0
- package/src/index.js +11 -0
- package/src/internal/bindings.js +281 -0
- package/src/internal/body.js +254 -0
- package/src/internal/conditional-kv.js +199 -0
- package/src/internal/context.js +275 -0
- package/src/internal/effect-adapter.js +877 -0
- package/src/internal/errors.js +27 -0
- package/src/internal/event-emission.js +182 -0
- package/src/internal/event-execution.js +496 -0
- package/src/internal/fetch.js +221 -0
- package/src/internal/index.js +24 -0
- package/src/internal/logging.js +65 -0
- package/src/internal/package-runtime.js +604 -0
- package/src/internal/path.js +130 -0
- package/src/internal/redaction.js +231 -0
- package/src/internal/response.js +415 -0
- package/src/internal/router.js +487 -0
- package/src/internal/schema.js +340 -0
- package/src/package.d.ts +127 -0
- package/src/package.js +19 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PulseRuntimeContractError } = require('./errors.js');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_STRUCTURED_BODY_BYTES = 65_536;
|
|
6
|
+
|
|
7
|
+
function normalizeBodyLimit(value, fallback = DEFAULT_STRUCTURED_BODY_BYTES) {
|
|
8
|
+
const limit = value === undefined ? fallback : Number(value);
|
|
9
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
10
|
+
throw new TypeError('Pulse structured body limits must be positive safe integers.');
|
|
11
|
+
}
|
|
12
|
+
return limit;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeContentType(value) {
|
|
16
|
+
return String(value || '').split(';')[0].trim().toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function classifyContentType(value) {
|
|
20
|
+
const normalized = normalizeContentType(value);
|
|
21
|
+
if (!normalized) return 'unknown';
|
|
22
|
+
if (normalized === 'application/json' || normalized.endsWith('+json')) return 'json';
|
|
23
|
+
if (normalized.startsWith('text/')) return 'text';
|
|
24
|
+
if ([
|
|
25
|
+
'application/javascript',
|
|
26
|
+
'application/xml',
|
|
27
|
+
'application/xhtml+xml',
|
|
28
|
+
'application/x-www-form-urlencoded'
|
|
29
|
+
].includes(normalized) || normalized.endsWith('+xml')) return 'text';
|
|
30
|
+
return 'opaque';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlainRecord(value) {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
35
|
+
const prototype = Object.getPrototypeOf(value);
|
|
36
|
+
return prototype === Object.prototype || prototype === null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clonePlain(value) {
|
|
40
|
+
if (Array.isArray(value)) return value.map(clonePlain);
|
|
41
|
+
if (isPlainRecord(value)) {
|
|
42
|
+
const output = {};
|
|
43
|
+
for (const key of Object.keys(value)) {
|
|
44
|
+
Object.defineProperty(output, key, {
|
|
45
|
+
enumerable: true,
|
|
46
|
+
configurable: true,
|
|
47
|
+
writable: true,
|
|
48
|
+
value: clonePlain(value[key])
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return output;
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deepFreeze(value) {
|
|
57
|
+
if ((Array.isArray(value) || isPlainRecord(value)) && !Object.isFrozen(value)) {
|
|
58
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
59
|
+
Object.freeze(value);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function immutableClone(value) {
|
|
65
|
+
return deepFreeze(clonePlain(value));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contentLength(headers) {
|
|
69
|
+
if (!headers || typeof headers.get !== 'function') return undefined;
|
|
70
|
+
const raw = headers.get('content-length');
|
|
71
|
+
if (raw == null || raw === '') return undefined;
|
|
72
|
+
const value = Number(raw);
|
|
73
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bodyError(code, message, detail, cause) {
|
|
77
|
+
return new PulseRuntimeContractError(code, message, {
|
|
78
|
+
detail: Object.freeze({ ...(detail || {}) }),
|
|
79
|
+
cause
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bodyUnavailable(label, detail, cause) {
|
|
84
|
+
return bodyError(
|
|
85
|
+
'PULSE_BODY_UNAVAILABLE',
|
|
86
|
+
`Pulse ${label} body is unavailable or was consumed outside its request-owned body projection.`,
|
|
87
|
+
detail,
|
|
88
|
+
cause
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function bodyTooLarge(label, bytes, maxBytes) {
|
|
93
|
+
return bodyError(
|
|
94
|
+
'PULSE_BODY_TOO_LARGE',
|
|
95
|
+
`Pulse ${label} body exceeds the ${maxBytes} byte structured-body limit.`,
|
|
96
|
+
{ bytes, maxBytes }
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function opaqueInspection(label, contentType) {
|
|
101
|
+
return bodyError(
|
|
102
|
+
'PULSE_OPAQUE_BODY_INSPECTION',
|
|
103
|
+
`Pulse ${label} body is opaque and may only be returned as a host-owned response.`,
|
|
104
|
+
{ contentType: String(contentType || ''), contentClass: 'opaque' }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bodyAborted(label, reason) {
|
|
109
|
+
if (reason instanceof PulseRuntimeContractError) return reason;
|
|
110
|
+
return bodyError(
|
|
111
|
+
'PULSE_RUNTIME_EFFECT_ABORTED',
|
|
112
|
+
`Pulse ${label} body read was aborted with its request.`,
|
|
113
|
+
{ reason: reason && reason.name ? reason.name : typeof reason },
|
|
114
|
+
reason
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readBodyBytes(owner, maxBytes, label, signal) {
|
|
119
|
+
if (!owner || typeof owner !== 'object') throw bodyUnavailable(label, { reason: 'missing-owner' });
|
|
120
|
+
const declared = contentLength(owner.headers);
|
|
121
|
+
if (declared !== undefined && declared > maxBytes) throw bodyTooLarge(label, declared, maxBytes);
|
|
122
|
+
if (owner.bodyUsed) throw bodyUnavailable(label, { reason: 'already-consumed' });
|
|
123
|
+
if (signal && signal.aborted) throw bodyAborted(label, signal.reason);
|
|
124
|
+
if (owner.body == null) return new Uint8Array(0);
|
|
125
|
+
|
|
126
|
+
if (typeof owner.body.getReader !== 'function') {
|
|
127
|
+
if (typeof owner.arrayBuffer !== 'function') throw bodyUnavailable(label, { reason: 'reader-unavailable' });
|
|
128
|
+
let buffer;
|
|
129
|
+
try {
|
|
130
|
+
buffer = new Uint8Array(await owner.arrayBuffer());
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (signal && signal.aborted) throw bodyAborted(label, signal.reason);
|
|
133
|
+
throw bodyUnavailable(label, { reason: 'read-failed' }, error);
|
|
134
|
+
}
|
|
135
|
+
if (buffer.byteLength > maxBytes) throw bodyTooLarge(label, buffer.byteLength, maxBytes);
|
|
136
|
+
return buffer;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reader = owner.body.getReader();
|
|
140
|
+
const chunks = [];
|
|
141
|
+
let bytes = 0;
|
|
142
|
+
let removeAbortListener;
|
|
143
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
144
|
+
const onAbort = () => {
|
|
145
|
+
Promise.resolve(reader.cancel(bodyAborted(label, signal.reason))).catch(() => undefined);
|
|
146
|
+
};
|
|
147
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
148
|
+
removeAbortListener = () => signal.removeEventListener('abort', onAbort);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
while (true) {
|
|
153
|
+
if (signal && signal.aborted) throw bodyAborted(label, signal.reason);
|
|
154
|
+
const result = await reader.read();
|
|
155
|
+
if (signal && signal.aborted) throw bodyAborted(label, signal.reason);
|
|
156
|
+
if (result.done) break;
|
|
157
|
+
const chunk = result.value instanceof Uint8Array ? result.value : new Uint8Array(result.value || 0);
|
|
158
|
+
bytes += chunk.byteLength;
|
|
159
|
+
if (bytes > maxBytes) {
|
|
160
|
+
const error = bodyTooLarge(label, bytes, maxBytes);
|
|
161
|
+
// Stop the request-owned structured projection as soon as the bound
|
|
162
|
+
// is crossed. Cancellation is best effort and must not mask the size error.
|
|
163
|
+
try { Promise.resolve(reader.cancel(error)).catch(() => undefined); } catch (_) { /* best effort */ }
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
chunks.push(chunk);
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof PulseRuntimeContractError) throw error;
|
|
170
|
+
if (signal && signal.aborted) throw bodyAborted(label, signal.reason);
|
|
171
|
+
throw bodyUnavailable(label, { reason: 'read-failed', bytes }, error);
|
|
172
|
+
} finally {
|
|
173
|
+
if (removeAbortListener) removeAbortListener();
|
|
174
|
+
try { reader.releaseLock(); } catch (_) { /* already released */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const output = new Uint8Array(bytes);
|
|
178
|
+
let offset = 0;
|
|
179
|
+
for (const chunk of chunks) {
|
|
180
|
+
output.set(chunk, offset);
|
|
181
|
+
offset += chunk.byteLength;
|
|
182
|
+
}
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function createStructuredBodyReader(owner, options = {}) {
|
|
187
|
+
const label = String(options.label || 'structured');
|
|
188
|
+
const maxBytes = normalizeBodyLimit(options.maxBytes);
|
|
189
|
+
const contentType = String(options.contentType || owner?.headers?.get?.('content-type') || '');
|
|
190
|
+
const contentClass = classifyContentType(contentType);
|
|
191
|
+
const forceStructured = options.forceStructured === true;
|
|
192
|
+
const signal = options.signal;
|
|
193
|
+
let bytesPromise;
|
|
194
|
+
let textPromise;
|
|
195
|
+
let jsonPromise;
|
|
196
|
+
|
|
197
|
+
function assertInspectable() {
|
|
198
|
+
if (!forceStructured && contentClass === 'opaque') throw opaqueInspection(label, contentType);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function bytes() {
|
|
202
|
+
assertInspectable();
|
|
203
|
+
if (!bytesPromise) bytesPromise = readBodyBytes(owner, maxBytes, label, signal);
|
|
204
|
+
return bytesPromise;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function text() {
|
|
208
|
+
assertInspectable();
|
|
209
|
+
if (!textPromise) textPromise = bytes().then((value) => new TextDecoder().decode(value));
|
|
210
|
+
return textPromise;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function json() {
|
|
214
|
+
assertInspectable();
|
|
215
|
+
if (!jsonPromise) {
|
|
216
|
+
jsonPromise = text().then((value) => {
|
|
217
|
+
try {
|
|
218
|
+
return immutableClone(JSON.parse(value));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw bodyError(
|
|
221
|
+
'PULSE_BODY_DECODE',
|
|
222
|
+
`Pulse ${label} body could not be decoded as JSON.`,
|
|
223
|
+
{ contentType, contentClass },
|
|
224
|
+
error
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return jsonPromise;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return Object.freeze({
|
|
233
|
+
label,
|
|
234
|
+
maxBytes,
|
|
235
|
+
contentType,
|
|
236
|
+
contentClass,
|
|
237
|
+
bodyClass: !forceStructured && contentClass === 'opaque' ? 'opaque' : 'structured',
|
|
238
|
+
bytes,
|
|
239
|
+
text,
|
|
240
|
+
json,
|
|
241
|
+
hasSnapshot() { return Boolean(bytesPromise); }
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = Object.freeze({
|
|
246
|
+
DEFAULT_STRUCTURED_BODY_BYTES,
|
|
247
|
+
classifyContentType,
|
|
248
|
+
createStructuredBodyReader,
|
|
249
|
+
deepFreeze,
|
|
250
|
+
immutableClone,
|
|
251
|
+
normalizeBodyLimit,
|
|
252
|
+
normalizeContentType,
|
|
253
|
+
readBodyBytes
|
|
254
|
+
});
|
|
@@ -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 });
|