@sekiban/dcb-client 0.1.0

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,515 @@
1
+ import { assertJsonValue as assertCoreJsonValue } from "@sekiban/dcb-core";
2
+ import { executeCommand, normalizeTag, DomainAuthoringError, } from "@sekiban/dcb-domain";
3
+ import { ClientError, } from "./index";
4
+ function isRecord(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ function isHttpResult(value) {
8
+ return isRecord(value) && typeof value.status === "number" && "body" in value;
9
+ }
10
+ function base64Json(value) {
11
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
12
+ let binary = "";
13
+ for (const byte of bytes)
14
+ binary += String.fromCharCode(byte);
15
+ return btoa(binary);
16
+ }
17
+ function decodeJson(value) {
18
+ if (typeof value !== "string")
19
+ return value;
20
+ try {
21
+ const binary = atob(value);
22
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
23
+ return JSON.parse(new TextDecoder().decode(bytes));
24
+ }
25
+ catch {
26
+ return value;
27
+ }
28
+ }
29
+ function bodyOf(value) {
30
+ return isHttpResult(value) ? value.body : value;
31
+ }
32
+ function httpFailure(value) {
33
+ const body = value.body;
34
+ const code = isRecord(body) && typeof body.code === "string" ? body.code : "http_error";
35
+ const message = isRecord(body) && typeof body.error === "string" ? body.error : `HTTP ${value.status}`;
36
+ return new ClientError(code, message, {
37
+ status: value.status,
38
+ partial: isRecord(body) ? body.partial : undefined,
39
+ });
40
+ }
41
+ function successfulBody(value) {
42
+ if (isHttpResult(value)) {
43
+ if (value.status < 200 || value.status >= 300)
44
+ throw httpFailure(value);
45
+ return value.body;
46
+ }
47
+ return value;
48
+ }
49
+ async function responseResult(response) {
50
+ let body;
51
+ try {
52
+ body = await response.json();
53
+ }
54
+ catch {
55
+ body = { code: "transport", error: `HTTP ${response.status}` };
56
+ }
57
+ const headers = {};
58
+ response.headers.forEach((value, key) => { headers[key] = value; });
59
+ return { status: response.status, headers, body };
60
+ }
61
+ function v1Envelope(request) {
62
+ return {
63
+ version: 1,
64
+ eventCandidates: request.candidates.map((candidate) => ({
65
+ payload: base64Json(candidate.payload),
66
+ eventPayloadName: candidate.eventPayloadName,
67
+ tags: [...candidate.tags],
68
+ })),
69
+ consistencyTags: request.consistency.map((entry) => ({
70
+ tag: entry.tag,
71
+ lastSortableUniqueId: entry.lastSortableUniqueId,
72
+ })),
73
+ };
74
+ }
75
+ function fetcherFrom(bindings) {
76
+ const selected = bindings.fetch ?? bindings.RUNTIME?.fetch ?? bindings.runtime?.fetch;
77
+ if (selected === undefined)
78
+ throw new ClientError("transport", "In-process runtime fetch binding is missing");
79
+ const owner = bindings.fetch !== undefined ? bindings : bindings.RUNTIME ?? bindings.runtime;
80
+ return owner === undefined ? selected : selected.bind(owner);
81
+ }
82
+ function makeHttpTransport(options) {
83
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
84
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
85
+ const headers = { "content-type": "application/json", ...(options.headers ?? {}) };
86
+ const call = async (path, body, signal) => {
87
+ const response = await fetchImpl(`${baseUrl}${path}`, {
88
+ method: "POST",
89
+ headers,
90
+ body: JSON.stringify(body),
91
+ signal,
92
+ });
93
+ return responseResult(response);
94
+ };
95
+ const read = async (path, body, signal) => {
96
+ const result = await call(path, body, signal);
97
+ return result.status >= 200 && result.status < 300 ? result.body : result;
98
+ };
99
+ return Object.freeze({
100
+ serviceId: options.serviceId,
101
+ readTagState: (request, signal) => read("/api/sekiban/serialized/tag-state", request, signal),
102
+ readTagLatestSortable: (request, signal) => read("/api/sekiban/serialized/tag-latest-sortable", request, signal),
103
+ commit: (request, signal) => call("/api/sekiban/serialized/commit", v1Envelope(request), signal),
104
+ query: (request, signal) => read("/api/sekiban/serialized/query", request, signal),
105
+ listQuery: (request, signal) => read("/api/sekiban/serialized/list-query", request, signal),
106
+ });
107
+ }
108
+ /** Use the Worker's internal service binding; no public HTTP hop is introduced. */
109
+ export function createInProcessTransport(env, options = {}) {
110
+ return makeHttpTransport({ baseUrl: "https://runtime.internal", fetch: fetcherFrom(env), serviceId: options.serviceId });
111
+ }
112
+ export function createHttpTransport(options) {
113
+ return makeHttpTransport(options);
114
+ }
115
+ function cloudResult(value, credentials = []) {
116
+ if (isHttpResult(value) && (value.status === 401 || value.status === 403)) {
117
+ throw new ClientError("credential.rejected", "SekibanCloud credential was rejected", { status: value.status });
118
+ }
119
+ if (isHttpResult(value) && (value.status < 200 || value.status >= 300)) {
120
+ const body = value.body;
121
+ const candidateCode = isRecord(body) && typeof body.code === "string" ? body.code : undefined;
122
+ const code = candidateCode !== undefined && /^[A-Za-z0-9_.-]{1,64}$/.test(candidateCode) &&
123
+ !credentials.some((credential) => credential.length > 0 && candidateCode.includes(credential))
124
+ ? candidateCode
125
+ : "transport";
126
+ return {
127
+ ...value,
128
+ headers: {},
129
+ body: { code, error: "SekibanCloud request failed" },
130
+ };
131
+ }
132
+ return value;
133
+ }
134
+ export function createSekibanCloudTransport(options) {
135
+ const base = makeHttpTransport({
136
+ baseUrl: options.BaseUrl,
137
+ serviceId: options.ServiceId,
138
+ fetch: options.fetch,
139
+ headers: {
140
+ "X-Sekiban-Service-Id": options.ServiceId,
141
+ "X-Sekiban-Credential-Id": options.CredentialId,
142
+ "X-Sekiban-Credential-Secret": options.CredentialSecret,
143
+ },
144
+ });
145
+ const protect = async (operation) => {
146
+ try {
147
+ return cloudResult(await operation(), [options.CredentialId, options.CredentialSecret]);
148
+ }
149
+ catch (error) {
150
+ if (error instanceof ClientError && error.code === "credential.rejected")
151
+ throw error;
152
+ throw new ClientError("transport", "SekibanCloud request failed");
153
+ }
154
+ };
155
+ return Object.freeze({
156
+ serviceId: options.ServiceId,
157
+ readTagState: (request, signal) => protect(() => base.readTagState(request, signal)),
158
+ readTagLatestSortable: (request, signal) => protect(() => base.readTagLatestSortable(request, signal)),
159
+ commit: (request, signal) => protect(() => base.commit(request, signal)),
160
+ query: (request, signal) => protect(() => base.query(request, signal)),
161
+ listQuery: (request, signal) => protect(() => base.listQuery(request, signal)),
162
+ });
163
+ }
164
+ function snapshotKey(projectorId, tag) {
165
+ return `${projectorId}\u0000${tag.id}`;
166
+ }
167
+ function snapshotReaderFrom(executor, supplied, readMode) {
168
+ const isSnapshotArray = (value) => Array.isArray(value);
169
+ const array = isSnapshotArray(supplied) ? supplied : undefined;
170
+ const reader = supplied !== undefined && !isSnapshotArray(supplied) ? supplied : undefined;
171
+ const byCell = new Map();
172
+ const byTag = new Map();
173
+ for (const snapshot of array ?? []) {
174
+ byCell.set(snapshotKey(snapshot.projectorId, snapshot.tag), snapshot);
175
+ byTag.set(snapshot.tag.id, snapshot);
176
+ }
177
+ const missing = (projectorId, tag) => {
178
+ throw new DomainAuthoringError("executor.snapshot_missing", `Snapshot is missing for ${projectorId ?? "exists"}/${tag.id}`);
179
+ };
180
+ const fallbackRead = async (projector, tag) => {
181
+ if (array !== undefined) {
182
+ const found = byCell.get(snapshotKey(projector.id, tag));
183
+ if (found !== undefined)
184
+ return found;
185
+ if (readMode === "snapshot-only")
186
+ return missing(projector.id, tag);
187
+ }
188
+ if (reader !== undefined) {
189
+ try {
190
+ return await reader.read(projector, tag);
191
+ }
192
+ catch (error) {
193
+ if (readMode === "snapshot-only")
194
+ return missing(projector.id, tag);
195
+ throw error;
196
+ }
197
+ }
198
+ if (readMode === "snapshot-only")
199
+ return missing(projector.id, tag);
200
+ return executor.readState(projector, tag);
201
+ };
202
+ const fallbackExists = async (tag) => {
203
+ const found = byTag.get(tag.id);
204
+ if (found !== undefined)
205
+ return found.exists;
206
+ if (reader !== undefined && reader.exists !== undefined) {
207
+ return reader.exists(tag);
208
+ }
209
+ if (readMode === "snapshot-only")
210
+ return missing(undefined, tag);
211
+ return (await executor.exists(tag)).exists;
212
+ };
213
+ const fallbackHead = async (tag) => {
214
+ const found = byTag.get(tag.id);
215
+ if (found !== undefined)
216
+ return found.head;
217
+ if (reader !== undefined && reader.head !== undefined) {
218
+ return reader.head(tag);
219
+ }
220
+ if (readMode === "snapshot-only")
221
+ return missing(undefined, tag);
222
+ return (await executor.exists(tag)).head;
223
+ };
224
+ return { read: fallbackRead, exists: fallbackExists, head: fallbackHead };
225
+ }
226
+ function normalizedResponse(value, requestedTagStateId) {
227
+ const body = bodyOf(value);
228
+ if (!isRecord(body) || typeof body.payload === "undefined") {
229
+ throw new ClientError("invalid_read_snapshot", "Tag-state response was not an object");
230
+ }
231
+ if (typeof body.version !== "number" || typeof body.lastSortedUniqueId !== "string" ||
232
+ typeof body.tagGroup !== "string" || typeof body.tagContent !== "string" || typeof body.tagProjector !== "string") {
233
+ throw new ClientError("invalid_read_snapshot", "Tag-state response had invalid identity or head fields");
234
+ }
235
+ const stateId = `${body.tagGroup}:${body.tagContent}:${body.tagProjector}`;
236
+ if (stateId !== requestedTagStateId)
237
+ throw new ClientError("incoherent_read_snapshot", "Tag-state identity changed during one read", { status: 500 });
238
+ return body;
239
+ }
240
+ function writtenEvents(value) {
241
+ const body = bodyOf(value);
242
+ return isRecord(body) && Array.isArray(body.writtenEvents)
243
+ ? body.writtenEvents.filter(isRecord)
244
+ : [];
245
+ }
246
+ function tagWriteResults(value) {
247
+ const body = bodyOf(value);
248
+ return isRecord(body) && Array.isArray(body.tagWriteResults)
249
+ ? body.tagWriteResults.filter(isRecord)
250
+ : [];
251
+ }
252
+ function stringField(value, key) {
253
+ return isRecord(value) && typeof value[key] === "string" ? value[key] : undefined;
254
+ }
255
+ function responseHead(value, events) {
256
+ const direct = stringField(bodyOf(value), "head");
257
+ if (direct !== undefined)
258
+ return direct;
259
+ const suids = events.flatMap((event) => [event.sortableUniqueIdValue, event.suid, event.lastSortableUniqueId])
260
+ .filter((candidate) => typeof candidate === "string");
261
+ return suids.sort().at(-1) ?? "";
262
+ }
263
+ function writtenEventHeads(value, candidateEvents) {
264
+ const heads = new Map();
265
+ for (const [index, written] of writtenEvents(value).entries()) {
266
+ const suid = [written.sortableUniqueIdValue, written.suid, written.lastSortableUniqueId]
267
+ .find((candidate) => typeof candidate === "string");
268
+ if (suid === undefined)
269
+ continue;
270
+ const writtenTags = Array.isArray(written.tags)
271
+ ? written.tags.filter((tag) => typeof tag === "string")
272
+ : [];
273
+ const candidateTags = candidateEvents[index]?.tags.map((tag) => tag.id) ?? [];
274
+ for (const tag of writtenTags.length > 0 ? writtenTags : candidateTags) {
275
+ const previous = heads.get(tag);
276
+ if (previous === undefined || suid > previous)
277
+ heads.set(tag, suid);
278
+ }
279
+ }
280
+ return heads;
281
+ }
282
+ function responseHeads(value, claims, fallbackHead, updatedTags, candidateEvents) {
283
+ const responseBody = bodyOf(value);
284
+ const raw = isRecord(responseBody) ? responseBody.heads : undefined;
285
+ const rawHeads = new Map();
286
+ if (Array.isArray(raw)) {
287
+ for (const item of raw) {
288
+ if (!isRecord(item) || typeof item.tag !== "string" || typeof item.head !== "string")
289
+ continue;
290
+ rawHeads.set(normalizeTag(item.tag).id, item.head);
291
+ }
292
+ }
293
+ const writtenHeads = writtenEventHeads(value, candidateEvents);
294
+ if (claims.length === 0 && rawHeads.size > 0) {
295
+ return [...rawHeads].map(([tag, head]) => ({ tag: normalizeTag(tag), head }));
296
+ }
297
+ return claims.map((claim) => {
298
+ const writtenHead = writtenHeads.get(claim.tag.id);
299
+ const rawHead = rawHeads.get(claim.tag.id);
300
+ return {
301
+ tag: claim.tag,
302
+ head: updatedTags.has(claim.tag.id) ? writtenHead ?? rawHead ?? fallbackHead : claim.head ?? "",
303
+ };
304
+ });
305
+ }
306
+ function conflictDetails(value) {
307
+ const body = bodyOf(value);
308
+ const raw = isRecord(body) && Array.isArray(body.conflicts) ? body.conflicts : [];
309
+ return raw.flatMap((item) => {
310
+ if (!isRecord(item) || typeof item.tag !== "string" || typeof item.expectedHead !== "string")
311
+ return [];
312
+ return [{
313
+ tag: normalizeTag(item.tag),
314
+ expectedHead: item.expectedHead,
315
+ ...(typeof item.actualHead === "string" ? { actualHead: item.actualHead } : {}),
316
+ }];
317
+ });
318
+ }
319
+ function envelopeFor(candidate) {
320
+ const eventTags = new Set(candidate.events.flatMap((event) => event.tags.map((tag) => tag.id)));
321
+ const consistency = new Map();
322
+ for (const claim of candidate.readClaims) {
323
+ if (claim.head !== null && eventTags.has(claim.tag.id) && !consistency.has(claim.tag.id)) {
324
+ consistency.set(claim.tag.id, claim.head);
325
+ }
326
+ }
327
+ return {
328
+ candidates: candidate.events.map((event) => ({
329
+ eventId: `authoring:${event.ordinal}`,
330
+ eventPayloadName: event.eventName,
331
+ payload: assertCoreJsonValue(event.payload),
332
+ tags: event.tags.map((tag) => tag.id),
333
+ })),
334
+ consistency: [...consistency].map(([tag, lastSortableUniqueId]) => ({ tag, lastSortableUniqueId })),
335
+ };
336
+ }
337
+ function commitDecision(value) {
338
+ if (!isHttpResult(value))
339
+ return { kind: "accepted" };
340
+ const body = value.body;
341
+ const code = stringField(body, "code");
342
+ if (value.status >= 200 && value.status < 300)
343
+ return { kind: "accepted" };
344
+ if (value.status === 409 || code === "consistency_conflict")
345
+ return { kind: "consistency-conflict", error: body };
346
+ if (value.status >= 500 || code === "unknown_outcome")
347
+ return { kind: "unknown", error: body };
348
+ return { kind: "rejected", error: body };
349
+ }
350
+ function errorText(error) {
351
+ if (error instanceof Error)
352
+ return error.message;
353
+ return isRecord(error) && typeof error.error === "string" ? error.error : String(error);
354
+ }
355
+ export function createSekibanExecutor(transport, options = {}) {
356
+ const scopeMismatch = options.serviceId !== undefined && transport.serviceId !== undefined && options.serviceId !== transport.serviceId;
357
+ const clock = options.clock ?? (() => Date.now());
358
+ const executor = {};
359
+ const readState = async (projector, tag, readOptions = {}) => {
360
+ const tagValue = normalizeTag(tag);
361
+ const stateId = `${tagValue.id}:${projector.id}`;
362
+ const raw = await transport.readTagState({ tagStateId: stateId }, readOptions.signal);
363
+ const response = normalizedResponse(raw, stateId);
364
+ const decoded = decodeJson(response.payload);
365
+ const empty = isRecord(decoded) && decoded.status === "empty";
366
+ const state = empty
367
+ ? (typeof projector.initialState === "function" ? projector.initialState() : projector.initialState)
368
+ : decoded;
369
+ return Object.freeze({
370
+ projectorId: projector.id,
371
+ tag: tagValue,
372
+ head: response.lastSortedUniqueId.length === 0 ? null : response.lastSortedUniqueId,
373
+ state,
374
+ exists: !empty,
375
+ });
376
+ };
377
+ const exists = async (tag, readOptions = {}) => {
378
+ if (transport.readTagLatestSortable === undefined)
379
+ throw new ClientError("transport", "Transport does not implement exists reads");
380
+ const tagValue = normalizeTag(tag);
381
+ const raw = await transport.readTagLatestSortable({ tag: tagValue.id }, readOptions.signal);
382
+ const value = successfulBody(raw);
383
+ if (typeof value.lastSortableUniqueId !== "string" || typeof value.exists !== "boolean") {
384
+ throw new ClientError("invalid_read_snapshot", "Latest-sortable response was invalid");
385
+ }
386
+ return Object.freeze({
387
+ projectorId: "exists",
388
+ tag: tagValue,
389
+ head: value.lastSortableUniqueId.length === 0 ? null : value.lastSortableUniqueId,
390
+ state: undefined,
391
+ exists: value.exists,
392
+ });
393
+ };
394
+ const query = async (request, readOptions = {}) => successfulBody(await transport.query(request, readOptions.signal));
395
+ const listQuery = async (request, readOptions = {}) => successfulBody(await transport.listQuery(request, readOptions.signal));
396
+ const execute = async (command, input, executeOptions = {}) => {
397
+ if (scopeMismatch)
398
+ return { kind: "invalid", attempts: 0, code: "scope.mismatch", error: "Executor service scope does not match its transport" };
399
+ const snapshots = snapshotReaderFrom(executor, executeOptions.snapshots, executeOptions.readMode ?? "read-through");
400
+ const maxConflictRetries = executeOptions.readMode === "snapshot-only" ? 0 : executeOptions.maxConflictRetries ?? 1;
401
+ let commitAttempts = 0;
402
+ let lastResponse;
403
+ try {
404
+ const result = await executeCommand(command, input, {
405
+ timeProvider: { now: clock },
406
+ snapshots,
407
+ maxConflictRetries,
408
+ commit: async (candidate) => {
409
+ commitAttempts += 1;
410
+ const raw = await transport.commit(envelopeFor(candidate), executeOptions.signal);
411
+ lastResponse = raw;
412
+ const decision = commitDecision(raw);
413
+ if (decision.kind === "consistency-conflict") {
414
+ return commitAttempts > maxConflictRetries
415
+ ? { kind: "rejected", error: decision.error }
416
+ : { kind: "consistency-conflict", error: decision.error };
417
+ }
418
+ if (decision.kind === "unknown")
419
+ return { kind: "unknown", error: decision.error };
420
+ if (decision.kind === "rejected")
421
+ return { kind: "rejected", error: decision.error };
422
+ return { kind: "accepted" };
423
+ },
424
+ onPropagation: undefined,
425
+ });
426
+ if (result.status === "accepted") {
427
+ const events = writtenEvents(lastResponse);
428
+ const head = responseHead(lastResponse, events);
429
+ const updatedTags = new Set(result.envelope?.events.flatMap((event) => event.tags.map((tag) => tag.id)) ?? []);
430
+ return {
431
+ kind: "committed",
432
+ attempts: result.attempts,
433
+ status: isHttpResult(lastResponse) ? lastResponse.status : 200,
434
+ response: bodyOf(lastResponse),
435
+ writtenEvents: events,
436
+ tagWriteResults: tagWriteResults(lastResponse),
437
+ head,
438
+ heads: responseHeads(lastResponse, result.envelope?.readClaims.map((claim) => ({ tag: claim.tag, head: claim.head })) ?? [], head, updatedTags, result.envelope?.events ?? []),
439
+ };
440
+ }
441
+ if (result.status === "discarded") {
442
+ if (result.decision.kind === "none")
443
+ return { kind: "noop", attempts: result.attempts, reason: result.decision.reason };
444
+ const details = result.decision.kind === "reject" && typeof result.decision.details === "string"
445
+ ? result.decision.details
446
+ : undefined;
447
+ return {
448
+ kind: "rejected",
449
+ attempts: result.attempts,
450
+ error: result.decision.kind === "reject" ? result.decision.reason : "Command was rejected",
451
+ code: result.decision.kind === "reject" ? details ?? result.decision.code : "command_rejected",
452
+ };
453
+ }
454
+ if (result.status === "rejected") {
455
+ const conflict = result.error !== undefined && lastResponse !== undefined && isHttpResult(lastResponse)
456
+ && (lastResponse.status === 409 || stringField(lastResponse.body, "code") === "consistency_conflict");
457
+ if (conflict) {
458
+ return {
459
+ kind: "conflict",
460
+ attempts: result.attempts,
461
+ status: isHttpResult(lastResponse) ? lastResponse.status : undefined,
462
+ code: "consistency_conflict",
463
+ response: bodyOf(lastResponse),
464
+ conflicts: conflictDetails(lastResponse),
465
+ };
466
+ }
467
+ if (result.error !== undefined) {
468
+ return { kind: "rejected", attempts: result.attempts, error: errorText(result.error), code: stringField(result.error, "code") };
469
+ }
470
+ const details = result.decision.kind === "reject" && typeof result.decision.details === "string"
471
+ ? result.decision.details
472
+ : undefined;
473
+ return {
474
+ kind: "rejected",
475
+ attempts: result.attempts,
476
+ error: result.decision.kind === "reject" ? result.decision.reason : "Command was rejected",
477
+ code: result.decision.kind === "reject" ? details ?? result.decision.code : "command_rejected",
478
+ };
479
+ }
480
+ if (result.status === "unknown")
481
+ return { kind: "timeout", attempts: result.attempts, code: "unknown_outcome", error: errorText(result.error) };
482
+ return { kind: "rejected", attempts: result.attempts, error: `Command ${command.id} was rejected`, code: "command_rejected" };
483
+ }
484
+ catch (error) {
485
+ // The facade and the authored sample can resolve separate package
486
+ // copies in a Worker bundle, so preserve the domain error code across
487
+ // that package boundary instead of relying on instanceof alone.
488
+ const authoringCode = error instanceof DomainAuthoringError
489
+ ? error.code
490
+ : isRecord(error) && typeof error.code === "string" ? error.code : undefined;
491
+ if (authoringCode === "executor.snapshot_missing") {
492
+ return { kind: "invalid", attempts: 0, code: authoringCode, error: errorText(error) };
493
+ }
494
+ // Command input validation is a typed application rejection, not a
495
+ // transport failure. The executor facade must preserve the public
496
+ // invalid-command contract used by the meeting-room API.
497
+ if (authoringCode === "COMMAND_INPUT_INVALID") {
498
+ return { kind: "invalid", attempts: 1, code: "invalid_command_input", error: errorText(error) };
499
+ }
500
+ if (error instanceof ClientError) {
501
+ if (error.code === "timeout" || error.code === "aborted")
502
+ return { kind: "timeout", attempts: 1, code: error.code, error: error.message };
503
+ return { kind: "invalid", attempts: 1, status: error.status, code: error.code, error: error.message };
504
+ }
505
+ return { kind: "transport", attempts: 1, error: errorText(error) };
506
+ }
507
+ };
508
+ executor.readState = readState;
509
+ executor.exists = exists;
510
+ executor.query = query;
511
+ executor.listQuery = listQuery;
512
+ executor.transport = transport;
513
+ executor.execute = execute;
514
+ return Object.freeze(executor);
515
+ }