@xemahq/dsl 0.1.1
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/LICENSE +201 -0
- package/dist/deliverable-spec/index.d.ts +3 -0
- package/dist/deliverable-spec/index.d.ts.map +1 -0
- package/dist/deliverable-spec/index.js +19 -0
- package/dist/deliverable-spec/index.js.map +1 -0
- package/dist/deliverable-spec/lib/schema.d.ts +151 -0
- package/dist/deliverable-spec/lib/schema.d.ts.map +1 -0
- package/dist/deliverable-spec/lib/schema.js +139 -0
- package/dist/deliverable-spec/lib/schema.js.map +1 -0
- package/dist/deliverable-spec/lib/types.d.ts +8 -0
- package/dist/deliverable-spec/lib/types.d.ts.map +1 -0
- package/dist/deliverable-spec/lib/types.js +3 -0
- package/dist/deliverable-spec/lib/types.js.map +1 -0
- package/dist/payload-codec/index.d.ts +8 -0
- package/dist/payload-codec/index.d.ts.map +1 -0
- package/dist/payload-codec/index.js +27 -0
- package/dist/payload-codec/index.js.map +1 -0
- package/dist/payload-codec/lib/blob-store.d.ts +37 -0
- package/dist/payload-codec/lib/blob-store.d.ts.map +1 -0
- package/dist/payload-codec/lib/blob-store.js +0 -0
- package/dist/payload-codec/lib/blob-store.js.map +1 -0
- package/dist/payload-codec/lib/codec-context.d.ts +6 -0
- package/dist/payload-codec/lib/codec-context.d.ts.map +1 -0
- package/dist/payload-codec/lib/codec-context.js +16 -0
- package/dist/payload-codec/lib/codec-context.js.map +1 -0
- package/dist/payload-codec/lib/codec.d.ts +51 -0
- package/dist/payload-codec/lib/codec.d.ts.map +1 -0
- package/dist/payload-codec/lib/codec.js +330 -0
- package/dist/payload-codec/lib/codec.js.map +1 -0
- package/dist/payload-codec/lib/enums.d.ts +18 -0
- package/dist/payload-codec/lib/enums.d.ts.map +1 -0
- package/dist/payload-codec/lib/enums.js +23 -0
- package/dist/payload-codec/lib/enums.js.map +1 -0
- package/dist/payload-codec/lib/errors.d.ts +18 -0
- package/dist/payload-codec/lib/errors.d.ts.map +1 -0
- package/dist/payload-codec/lib/errors.js +39 -0
- package/dist/payload-codec/lib/errors.js.map +1 -0
- package/dist/payload-codec/lib/http-blob-store.d.ts +21 -0
- package/dist/payload-codec/lib/http-blob-store.d.ts.map +1 -0
- package/dist/payload-codec/lib/http-blob-store.js +139 -0
- package/dist/payload-codec/lib/http-blob-store.js.map +1 -0
- package/dist/payload-codec/lib/lru-cache.d.ts +12 -0
- package/dist/payload-codec/lib/lru-cache.d.ts.map +1 -0
- package/dist/payload-codec/lib/lru-cache.js +59 -0
- package/dist/payload-codec/lib/lru-cache.js.map +1 -0
- package/dist/schema/action.schema.json +181 -0
- package/dist/schema/reusable-workflow.schema.json +46 -0
- package/dist/schema/workflow.schema.json +373 -0
- package/dist/workflow/index.d.ts +14 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +49 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/lib/action-input-validator.d.ts +10 -0
- package/dist/workflow/lib/action-input-validator.d.ts.map +1 -0
- package/dist/workflow/lib/action-input-validator.js +69 -0
- package/dist/workflow/lib/action-input-validator.js.map +1 -0
- package/dist/workflow/lib/compiler/action-shape.d.ts +5 -0
- package/dist/workflow/lib/compiler/action-shape.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/action-shape.js +43 -0
- package/dist/workflow/lib/compiler/action-shape.js.map +1 -0
- package/dist/workflow/lib/compiler/canonical-json.d.ts +3 -0
- package/dist/workflow/lib/compiler/canonical-json.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/canonical-json.js +45 -0
- package/dist/workflow/lib/compiler/canonical-json.js.map +1 -0
- package/dist/workflow/lib/compiler/compile.d.ts +4 -0
- package/dist/workflow/lib/compiler/compile.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/compile.js +794 -0
- package/dist/workflow/lib/compiler/compile.js.map +1 -0
- package/dist/workflow/lib/compiler/concurrency.d.ts +5 -0
- package/dist/workflow/lib/compiler/concurrency.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/concurrency.js +104 -0
- package/dist/workflow/lib/compiler/concurrency.js.map +1 -0
- package/dist/workflow/lib/compiler/dag.d.ts +10 -0
- package/dist/workflow/lib/compiler/dag.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/dag.js +74 -0
- package/dist/workflow/lib/compiler/dag.js.map +1 -0
- package/dist/workflow/lib/compiler/index.d.ts +6 -0
- package/dist/workflow/lib/compiler/index.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/index.js +14 -0
- package/dist/workflow/lib/compiler/index.js.map +1 -0
- package/dist/workflow/lib/compiler/inputs.d.ts +4 -0
- package/dist/workflow/lib/compiler/inputs.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/inputs.js +108 -0
- package/dist/workflow/lib/compiler/inputs.js.map +1 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.d.ts +9 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.js +76 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.js.map +1 -0
- package/dist/workflow/lib/compiler/manifest-source.d.ts +4 -0
- package/dist/workflow/lib/compiler/manifest-source.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/manifest-source.js +100 -0
- package/dist/workflow/lib/compiler/manifest-source.js.map +1 -0
- package/dist/workflow/lib/compiler/matrix.d.ts +4 -0
- package/dist/workflow/lib/compiler/matrix.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/matrix.js +76 -0
- package/dist/workflow/lib/compiler/matrix.js.map +1 -0
- package/dist/workflow/lib/compiler/mount-plan.d.ts +4 -0
- package/dist/workflow/lib/compiler/mount-plan.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/mount-plan.js +96 -0
- package/dist/workflow/lib/compiler/mount-plan.js.map +1 -0
- package/dist/workflow/lib/compiler/payload-reach-in.d.ts +14 -0
- package/dist/workflow/lib/compiler/payload-reach-in.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/payload-reach-in.js +273 -0
- package/dist/workflow/lib/compiler/payload-reach-in.js.map +1 -0
- package/dist/workflow/lib/compiler/permissions.d.ts +6 -0
- package/dist/workflow/lib/compiler/permissions.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/permissions.js +43 -0
- package/dist/workflow/lib/compiler/permissions.js.map +1 -0
- package/dist/workflow/lib/compiler/retry-timeout.d.ts +6 -0
- package/dist/workflow/lib/compiler/retry-timeout.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/retry-timeout.js +64 -0
- package/dist/workflow/lib/compiler/retry-timeout.js.map +1 -0
- package/dist/workflow/lib/compiler/review-step.d.ts +18 -0
- package/dist/workflow/lib/compiler/review-step.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/review-step.js +247 -0
- package/dist/workflow/lib/compiler/review-step.js.map +1 -0
- package/dist/workflow/lib/compiler/types.d.ts +42 -0
- package/dist/workflow/lib/compiler/types.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/types.js +3 -0
- package/dist/workflow/lib/compiler/types.js.map +1 -0
- package/dist/workflow/lib/compiler/variable-requirements.d.ts +5 -0
- package/dist/workflow/lib/compiler/variable-requirements.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/variable-requirements.js +119 -0
- package/dist/workflow/lib/compiler/variable-requirements.js.map +1 -0
- package/dist/workflow/lib/deliverable-spec-keys.d.ts +3 -0
- package/dist/workflow/lib/deliverable-spec-keys.d.ts.map +1 -0
- package/dist/workflow/lib/deliverable-spec-keys.js +90 -0
- package/dist/workflow/lib/deliverable-spec-keys.js.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/index.d.ts +23 -0
- package/dist/workflow/lib/dispatch-inputs/index.d.ts.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/index.js +106 -0
- package/dist/workflow/lib/dispatch-inputs/index.js.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.d.ts +3 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.d.ts.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.js +43 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.js.map +1 -0
- package/dist/workflow/lib/duration.d.ts +2 -0
- package/dist/workflow/lib/duration.d.ts.map +1 -0
- package/dist/workflow/lib/duration.js +26 -0
- package/dist/workflow/lib/duration.js.map +1 -0
- package/dist/workflow/lib/errors.d.ts +9 -0
- package/dist/workflow/lib/errors.d.ts.map +1 -0
- package/dist/workflow/lib/errors.js +28 -0
- package/dist/workflow/lib/errors.js.map +1 -0
- package/dist/workflow/lib/expression/ast.d.ts +61 -0
- package/dist/workflow/lib/expression/ast.d.ts.map +1 -0
- package/dist/workflow/lib/expression/ast.js +34 -0
- package/dist/workflow/lib/expression/ast.js.map +1 -0
- package/dist/workflow/lib/expression/context.d.ts +63 -0
- package/dist/workflow/lib/expression/context.d.ts.map +1 -0
- package/dist/workflow/lib/expression/context.js +32 -0
- package/dist/workflow/lib/expression/context.js.map +1 -0
- package/dist/workflow/lib/expression/evaluator.d.ts +5 -0
- package/dist/workflow/lib/expression/evaluator.d.ts.map +1 -0
- package/dist/workflow/lib/expression/evaluator.js +291 -0
- package/dist/workflow/lib/expression/evaluator.js.map +1 -0
- package/dist/workflow/lib/expression/index.d.ts +9 -0
- package/dist/workflow/lib/expression/index.d.ts.map +1 -0
- package/dist/workflow/lib/expression/index.js +26 -0
- package/dist/workflow/lib/expression/index.js.map +1 -0
- package/dist/workflow/lib/expression/interpolation.d.ts +9 -0
- package/dist/workflow/lib/expression/interpolation.d.ts.map +1 -0
- package/dist/workflow/lib/expression/interpolation.js +51 -0
- package/dist/workflow/lib/expression/interpolation.js.map +1 -0
- package/dist/workflow/lib/expression/parser.d.ts +4 -0
- package/dist/workflow/lib/expression/parser.d.ts.map +1 -0
- package/dist/workflow/lib/expression/parser.js +203 -0
- package/dist/workflow/lib/expression/parser.js.map +1 -0
- package/dist/workflow/lib/expression/template.d.ts +18 -0
- package/dist/workflow/lib/expression/template.d.ts.map +1 -0
- package/dist/workflow/lib/expression/template.js +63 -0
- package/dist/workflow/lib/expression/template.js.map +1 -0
- package/dist/workflow/lib/expression/tokenizer.d.ts +3 -0
- package/dist/workflow/lib/expression/tokenizer.d.ts.map +1 -0
- package/dist/workflow/lib/expression/tokenizer.js +153 -0
- package/dist/workflow/lib/expression/tokenizer.js.map +1 -0
- package/dist/workflow/lib/expression/tokens.d.ts +25 -0
- package/dist/workflow/lib/expression/tokens.d.ts.map +1 -0
- package/dist/workflow/lib/expression/tokens.js +24 -0
- package/dist/workflow/lib/expression/tokens.js.map +1 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.d.ts +5 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.d.ts.map +1 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.js +138 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.js.map +1 -0
- package/dist/workflow/lib/installation-resource-kind.d.ts +14 -0
- package/dist/workflow/lib/installation-resource-kind.d.ts.map +1 -0
- package/dist/workflow/lib/installation-resource-kind.js +59 -0
- package/dist/workflow/lib/installation-resource-kind.js.map +1 -0
- package/dist/workflow/lib/schemas-loader.d.ts +4 -0
- package/dist/workflow/lib/schemas-loader.d.ts.map +1 -0
- package/dist/workflow/lib/schemas-loader.js +36 -0
- package/dist/workflow/lib/schemas-loader.js.map +1 -0
- package/dist/workflow/lib/serializer.d.ts +3 -0
- package/dist/workflow/lib/serializer.d.ts.map +1 -0
- package/dist/workflow/lib/serializer.js +15 -0
- package/dist/workflow/lib/serializer.js.map +1 -0
- package/dist/workflow/lib/types.d.ts +179 -0
- package/dist/workflow/lib/types.d.ts.map +1 -0
- package/dist/workflow/lib/types.js +3 -0
- package/dist/workflow/lib/types.js.map +1 -0
- package/dist/workflow/lib/validate.d.ts +8 -0
- package/dist/workflow/lib/validate.d.ts.map +1 -0
- package/dist/workflow/lib/validate.js +119 -0
- package/dist/workflow/lib/validate.js.map +1 -0
- package/dist/workspace-manifest/index.d.ts +6 -0
- package/dist/workspace-manifest/index.d.ts.map +1 -0
- package/dist/workspace-manifest/index.js +22 -0
- package/dist/workspace-manifest/index.js.map +1 -0
- package/dist/workspace-manifest/lib/compile.d.ts +8 -0
- package/dist/workspace-manifest/lib/compile.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/compile.js +439 -0
- package/dist/workspace-manifest/lib/compile.js.map +1 -0
- package/dist/workspace-manifest/lib/interpolate.d.ts +12 -0
- package/dist/workspace-manifest/lib/interpolate.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/interpolate.js +81 -0
- package/dist/workspace-manifest/lib/interpolate.js.map +1 -0
- package/dist/workspace-manifest/lib/resolve-extends.d.ts +10 -0
- package/dist/workspace-manifest/lib/resolve-extends.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/resolve-extends.js +108 -0
- package/dist/workspace-manifest/lib/resolve-extends.js.map +1 -0
- package/dist/workspace-manifest/lib/schema.d.ts +710 -0
- package/dist/workspace-manifest/lib/schema.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/schema.js +355 -0
- package/dist/workspace-manifest/lib/schema.js.map +1 -0
- package/dist/workspace-manifest/lib/types.d.ts +153 -0
- package/dist/workspace-manifest/lib/types.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/types.js +10 -0
- package/dist/workspace-manifest/lib/types.js.map +1 -0
- package/package.json +79 -0
- package/schema/action.schema.json +181 -0
- package/schema/reusable-workflow.schema.json +46 -0
- package/schema/workflow.schema.json +373 -0
- package/src/deliverable-spec/index.ts +19 -0
- package/src/deliverable-spec/lib/schema.ts +248 -0
- package/src/deliverable-spec/lib/types.ts +26 -0
- package/src/payload-codec/index.ts +40 -0
- package/src/payload-codec/lib/blob-store.ts +0 -0
- package/src/payload-codec/lib/codec-context.ts +38 -0
- package/src/payload-codec/lib/codec.ts +593 -0
- package/src/payload-codec/lib/enums.ts +58 -0
- package/src/payload-codec/lib/errors.ts +54 -0
- package/src/payload-codec/lib/http-blob-store.ts +257 -0
- package/src/payload-codec/lib/lru-cache.ts +81 -0
- package/src/workflow/index.ts +98 -0
- package/src/workflow/lib/action-input-validator.ts +160 -0
- package/src/workflow/lib/compiler/action-shape.ts +71 -0
- package/src/workflow/lib/compiler/canonical-json.ts +53 -0
- package/src/workflow/lib/compiler/compile.ts +1518 -0
- package/src/workflow/lib/compiler/concurrency.ts +223 -0
- package/src/workflow/lib/compiler/dag.ts +108 -0
- package/src/workflow/lib/compiler/index.ts +10 -0
- package/src/workflow/lib/compiler/inputs.ts +199 -0
- package/src/workflow/lib/compiler/installation-resource-validator.ts +114 -0
- package/src/workflow/lib/compiler/manifest-source.ts +176 -0
- package/src/workflow/lib/compiler/matrix.ts +135 -0
- package/src/workflow/lib/compiler/mount-plan.ts +202 -0
- package/src/workflow/lib/compiler/payload-reach-in.ts +497 -0
- package/src/workflow/lib/compiler/permissions.ts +64 -0
- package/src/workflow/lib/compiler/retry-timeout.ts +105 -0
- package/src/workflow/lib/compiler/review-step.ts +517 -0
- package/src/workflow/lib/compiler/types.ts +170 -0
- package/src/workflow/lib/compiler/variable-requirements.ts +208 -0
- package/src/workflow/lib/deliverable-spec-keys.ts +109 -0
- package/src/workflow/lib/dispatch-inputs/index.ts +160 -0
- package/src/workflow/lib/dispatch-inputs/to-json-schema.ts +60 -0
- package/src/workflow/lib/duration.ts +48 -0
- package/src/workflow/lib/errors.ts +37 -0
- package/src/workflow/lib/expression/ast.ts +108 -0
- package/src/workflow/lib/expression/context.ts +148 -0
- package/src/workflow/lib/expression/evaluator.ts +492 -0
- package/src/workflow/lib/expression/index.ts +28 -0
- package/src/workflow/lib/expression/interpolation.ts +84 -0
- package/src/workflow/lib/expression/parser.ts +264 -0
- package/src/workflow/lib/expression/template.ts +117 -0
- package/src/workflow/lib/expression/tokenizer.ts +200 -0
- package/src/workflow/lib/expression/tokens.ts +30 -0
- package/src/workflow/lib/expression/walk-artifact-refs.ts +232 -0
- package/src/workflow/lib/installation-resource-kind.ts +107 -0
- package/src/workflow/lib/schemas-loader.ts +64 -0
- package/src/workflow/lib/serializer.ts +30 -0
- package/src/workflow/lib/types.ts +361 -0
- package/src/workflow/lib/validate.ts +199 -0
- package/src/workspace-manifest/index.ts +27 -0
- package/src/workspace-manifest/lib/compile.ts +608 -0
- package/src/workspace-manifest/lib/interpolate.ts +140 -0
- package/src/workspace-manifest/lib/resolve-extends.ts +260 -0
- package/src/workspace-manifest/lib/schema.ts +612 -0
- package/src/workspace-manifest/lib/types.ts +392 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import type { Payload, PayloadCodec } from '@temporalio/common';
|
|
2
|
+
|
|
3
|
+
import { type BlobRef, type BlobStore, sha256Hex } from './blob-store';
|
|
4
|
+
import {
|
|
5
|
+
BlobStoreKind,
|
|
6
|
+
DEFAULT_CACHE_CAPACITY_BYTES,
|
|
7
|
+
DEFAULT_SPILL_THRESHOLD_BYTES,
|
|
8
|
+
SPILL_ENCODING_V1,
|
|
9
|
+
SPILL_ENVELOPE_VERSION,
|
|
10
|
+
SpillMetadataKey,
|
|
11
|
+
} from './enums';
|
|
12
|
+
import { PayloadCodecError, PayloadCodecErrorCode } from './errors';
|
|
13
|
+
import { BytesLruCache } from './lru-cache';
|
|
14
|
+
|
|
15
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
16
|
+
const TEXT_DECODER = new TextDecoder('utf-8', { fatal: true });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Spill envelope written as the Payload.data of a spilled payload. Mirrors
|
|
20
|
+
* the fields the frontend + debugger render; the {@link SpillPayloadCodec}
|
|
21
|
+
* serializes this as JSON.
|
|
22
|
+
*
|
|
23
|
+
* `orgId` is part of the envelope — not just side-band metadata — because
|
|
24
|
+
* the underlying blob store is multi-tenant and the decoder needs the
|
|
25
|
+
* orgId to fetch the same row back without ambient context.
|
|
26
|
+
*/
|
|
27
|
+
interface SpillEnvelope {
|
|
28
|
+
readonly $xemaSpill: typeof SPILL_ENVELOPE_VERSION;
|
|
29
|
+
readonly store: BlobStoreKind;
|
|
30
|
+
readonly uri: string;
|
|
31
|
+
readonly sha256: string;
|
|
32
|
+
readonly orgId: string;
|
|
33
|
+
readonly sizeBytes: number;
|
|
34
|
+
readonly contentType: string;
|
|
35
|
+
readonly createdAt: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Lifecycle event fired by the codec for every payload it inspects.
|
|
40
|
+
* Consumers wire structured logging or metrics by passing an
|
|
41
|
+
* {@link CodecObserver} at construction time.
|
|
42
|
+
*
|
|
43
|
+
* `outcome` lets ops answer the question "is the spill threshold tuned
|
|
44
|
+
* correctly?" empirically — if the bulk of encodes are 'inline' the
|
|
45
|
+
* threshold is too high; if decodes consistently miss the cache the
|
|
46
|
+
* decode-cache capacity is too small.
|
|
47
|
+
*/
|
|
48
|
+
export type CodecLifecycleEvent =
|
|
49
|
+
| { readonly kind: 'encode'; readonly outcome: 'spilled' | 'inline' | 'already-spilled'; readonly sizeBytes: number; readonly orgId?: string }
|
|
50
|
+
| { readonly kind: 'decode'; readonly outcome: 'rehydrated' | 'cache-hit' | 'inline-passthrough'; readonly sizeBytes: number; readonly orgId?: string };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pluggable observer for codec lifecycle events. The codec calls this
|
|
54
|
+
* synchronously after each encode/decode; consumers MUST NOT throw from
|
|
55
|
+
* the observer (the codec catches and ignores observer exceptions to
|
|
56
|
+
* avoid corrupting Temporal payload flow). Use for logging, Prometheus,
|
|
57
|
+
* OpenTelemetry, etc.
|
|
58
|
+
*/
|
|
59
|
+
export type CodecObserver = (event: CodecLifecycleEvent) => void;
|
|
60
|
+
|
|
61
|
+
export interface SpillPayloadCodecOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Map of {@link BlobStoreKind} → backing {@link BlobStore}. The codec
|
|
64
|
+
* uses this as a registry, not a single store:
|
|
65
|
+
*
|
|
66
|
+
* - **Encode** always writes to `stores[primaryStoreKind]`. There is
|
|
67
|
+
* exactly one primary; it is the only store that gets new spills.
|
|
68
|
+
* - **Decode** reads via `stores[envelope.store]`. Any envelope whose
|
|
69
|
+
* store kind is not registered fails fast with `UNKNOWN_STORE_KIND`
|
|
70
|
+
* — that signals a real config error, not a recoverable miss.
|
|
71
|
+
*
|
|
72
|
+
* The registry shape is what makes store migrations safe: keep the old
|
|
73
|
+
* store registered for read-back while the new one becomes primary,
|
|
74
|
+
* drain in-flight workflows, then drop the old store. Without this,
|
|
75
|
+
* flipping `WORKFLOW_PAYLOAD_CODEC_STORE` strands every running
|
|
76
|
+
* workflow whose history references the old store.
|
|
77
|
+
*/
|
|
78
|
+
readonly stores: Partial<Record<BlobStoreKind, BlobStore>>;
|
|
79
|
+
/**
|
|
80
|
+
* Which registered store new spills go to. MUST be a key in `stores`
|
|
81
|
+
* — the constructor validates this. `WORKFLOW_PAYLOAD_CODEC_STORE` in
|
|
82
|
+
* the engine + worker maps directly to this field.
|
|
83
|
+
*/
|
|
84
|
+
readonly primaryStoreKind: BlobStoreKind;
|
|
85
|
+
/**
|
|
86
|
+
* Resolves the tenant orgId for an encode call. The codec calls this
|
|
87
|
+
* once per spilled payload. Throwing or returning empty raises
|
|
88
|
+
* {@link PayloadCodecErrorCode.ORG_ID_REQUIRED} — there is no silent
|
|
89
|
+
* fallback. Decode does NOT consult this provider; it reads orgId from
|
|
90
|
+
* the envelope so historical payloads always rehydrate against the
|
|
91
|
+
* tenant they were written under.
|
|
92
|
+
*/
|
|
93
|
+
readonly orgIdProvider: () => string | Promise<string>;
|
|
94
|
+
/**
|
|
95
|
+
* Threshold (bytes) at or above which payloads spill. Defaults to
|
|
96
|
+
* {@link DEFAULT_SPILL_THRESHOLD_BYTES}.
|
|
97
|
+
*/
|
|
98
|
+
readonly thresholdBytes?: number;
|
|
99
|
+
/**
|
|
100
|
+
* LRU decode cache capacity (bytes). Defaults to
|
|
101
|
+
* {@link DEFAULT_CACHE_CAPACITY_BYTES}. Set to `0` to disable caching.
|
|
102
|
+
*/
|
|
103
|
+
readonly decodeCacheCapacityBytes?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Content-Type stamped on spilled blobs. The codec doesn't need to
|
|
106
|
+
* interpret it; artifact-store-api reflects it in `GET /blobs/:sha256`.
|
|
107
|
+
*/
|
|
108
|
+
readonly contentType?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Optional lifecycle observer. Called synchronously after each
|
|
111
|
+
* encode/decode with a structured event so ops can wire logging,
|
|
112
|
+
* Prometheus, OpenTelemetry, etc. Observer exceptions are swallowed
|
|
113
|
+
* so a faulty metrics emitter cannot break workflow payload flow.
|
|
114
|
+
*/
|
|
115
|
+
readonly observer?: CodecObserver;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Temporal PayloadCodec that transparently spills any payload whose
|
|
120
|
+
* `data.byteLength` exceeds the configured threshold to a BlobStore. The
|
|
121
|
+
* resulting on-wire payload carries a small JSON envelope plus metadata
|
|
122
|
+
* markers; `decode` rehydrates by fetching the blob and restoring the
|
|
123
|
+
* original Payload.
|
|
124
|
+
*
|
|
125
|
+
* Determinism + safety invariants:
|
|
126
|
+
* - Same bytes ⇒ same sha256 ⇒ same uri (content-addressed).
|
|
127
|
+
* - Encode is idempotent at the (bytes, contentType) level.
|
|
128
|
+
* - Decode verifies sha256 on fetch; mismatches raise SHA256_MISMATCH.
|
|
129
|
+
* - Unknown store kinds in an incoming envelope fail fast with
|
|
130
|
+
* UNKNOWN_STORE_KIND; no silent "best-effort" lookup.
|
|
131
|
+
* - Payloads without `data` or below threshold pass through untouched.
|
|
132
|
+
*/
|
|
133
|
+
export class SpillPayloadCodec implements PayloadCodec {
|
|
134
|
+
private readonly stores: ReadonlyMap<BlobStoreKind, BlobStore>;
|
|
135
|
+
private readonly primaryStore: BlobStore;
|
|
136
|
+
private readonly thresholdBytes: number;
|
|
137
|
+
private readonly contentType: string;
|
|
138
|
+
private readonly cache: BytesLruCache;
|
|
139
|
+
private readonly orgIdProvider: () => string | Promise<string>;
|
|
140
|
+
private readonly observer: CodecObserver | null;
|
|
141
|
+
|
|
142
|
+
constructor(options: SpillPayloadCodecOptions) {
|
|
143
|
+
if (typeof options.orgIdProvider !== 'function') {
|
|
144
|
+
throw new Error('SpillPayloadCodec: orgIdProvider is required.');
|
|
145
|
+
}
|
|
146
|
+
if (!options.stores || Object.keys(options.stores).length === 0) {
|
|
147
|
+
throw new Error('SpillPayloadCodec: stores registry must contain at least one entry.');
|
|
148
|
+
}
|
|
149
|
+
const registry = new Map<BlobStoreKind, BlobStore>();
|
|
150
|
+
for (const [kind, store] of Object.entries(options.stores)) {
|
|
151
|
+
if (!store) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (store.kind !== kind) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`SpillPayloadCodec: stores['${kind}'] reports kind '${store.kind}'; the key must match the BlobStore.kind field.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
registry.set(kind as BlobStoreKind, store);
|
|
160
|
+
}
|
|
161
|
+
const primary = registry.get(options.primaryStoreKind);
|
|
162
|
+
if (!primary) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`SpillPayloadCodec: primaryStoreKind='${options.primaryStoreKind}' is not present in stores registry. Register it before naming it primary.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
this.stores = registry;
|
|
168
|
+
this.primaryStore = primary;
|
|
169
|
+
this.orgIdProvider = options.orgIdProvider;
|
|
170
|
+
this.thresholdBytes = options.thresholdBytes ?? DEFAULT_SPILL_THRESHOLD_BYTES;
|
|
171
|
+
if (!Number.isFinite(this.thresholdBytes) || this.thresholdBytes < 0) {
|
|
172
|
+
throw new Error(`SpillPayloadCodec: thresholdBytes must be a non-negative number.`);
|
|
173
|
+
}
|
|
174
|
+
this.contentType = options.contentType ?? 'application/x-temporal-payload';
|
|
175
|
+
const cacheCapacity = options.decodeCacheCapacityBytes ?? DEFAULT_CACHE_CAPACITY_BYTES;
|
|
176
|
+
this.cache = new BytesLruCache(cacheCapacity);
|
|
177
|
+
this.observer = options.observer ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private emit(event: CodecLifecycleEvent): void {
|
|
181
|
+
if (!this.observer) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
this.observer(event);
|
|
186
|
+
} catch {
|
|
187
|
+
// Observer exceptions are swallowed by contract — a broken metrics
|
|
188
|
+
// emitter must not corrupt Temporal payload flow.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Registered store kinds (read-only). Useful for diagnostic logs. */
|
|
193
|
+
get registeredStoreKinds(): readonly BlobStoreKind[] {
|
|
194
|
+
return [...this.stores.keys()];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Primary store kind — where new spills go. */
|
|
198
|
+
get primaryStoreKind(): BlobStoreKind {
|
|
199
|
+
return this.primaryStore.kind;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async encode(payloads: Payload[]): Promise<Payload[]> {
|
|
203
|
+
const result: Payload[] = [];
|
|
204
|
+
for (const payload of payloads) {
|
|
205
|
+
result.push(await this.encodeOne(payload));
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async decode(payloads: Payload[]): Promise<Payload[]> {
|
|
211
|
+
const result: Payload[] = [];
|
|
212
|
+
for (const payload of payloads) {
|
|
213
|
+
result.push(await this.decodeOne(payload));
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async encodeOne(payload: Payload): Promise<Payload> {
|
|
219
|
+
const data = payload.data;
|
|
220
|
+
if (!data || data.byteLength < this.thresholdBytes) {
|
|
221
|
+
this.emit({ kind: 'encode', outcome: 'inline', sizeBytes: data?.byteLength ?? 0 });
|
|
222
|
+
return payload;
|
|
223
|
+
}
|
|
224
|
+
// If the payload is already a spill envelope (rare — means encode was
|
|
225
|
+
// called twice on the same payload), don't double-spill. Pass through.
|
|
226
|
+
if (this.isAlreadySpilled(payload)) {
|
|
227
|
+
this.emit({ kind: 'encode', outcome: 'already-spilled', sizeBytes: data.byteLength });
|
|
228
|
+
return payload;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const orgId = await this.resolveOrgId();
|
|
232
|
+
|
|
233
|
+
let stored;
|
|
234
|
+
try {
|
|
235
|
+
stored = await this.primaryStore.put(data, this.contentType, orgId);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err instanceof PayloadCodecError) {
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
throw new PayloadCodecError(
|
|
241
|
+
PayloadCodecErrorCode.BLOB_PUT_FAILED,
|
|
242
|
+
`BlobStore.put failed: ${(err as Error).message}`,
|
|
243
|
+
{ kind: this.primaryStore.kind, sizeBytes: data.byteLength, orgId },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const envelope: SpillEnvelope = {
|
|
248
|
+
$xemaSpill: SPILL_ENVELOPE_VERSION,
|
|
249
|
+
store: stored.store,
|
|
250
|
+
uri: stored.uri,
|
|
251
|
+
sha256: stored.sha256,
|
|
252
|
+
orgId: stored.orgId,
|
|
253
|
+
sizeBytes: stored.sizeBytes,
|
|
254
|
+
contentType: stored.contentType,
|
|
255
|
+
createdAt: stored.createdAt,
|
|
256
|
+
};
|
|
257
|
+
const envelopeBytes = TEXT_ENCODER.encode(JSON.stringify(envelope));
|
|
258
|
+
const originalMetadata = serializeOriginalMetadata(payload.metadata ?? null);
|
|
259
|
+
|
|
260
|
+
const metadata: Record<string, Uint8Array> = {
|
|
261
|
+
[SpillMetadataKey.ENCODING]: TEXT_ENCODER.encode(SPILL_ENCODING_V1),
|
|
262
|
+
[SpillMetadataKey.VERSION]: TEXT_ENCODER.encode(SPILL_ENVELOPE_VERSION),
|
|
263
|
+
[SpillMetadataKey.SHA256]: TEXT_ENCODER.encode(stored.sha256),
|
|
264
|
+
[SpillMetadataKey.SIZE]: TEXT_ENCODER.encode(String(stored.sizeBytes)),
|
|
265
|
+
[SpillMetadataKey.STORE]: TEXT_ENCODER.encode(stored.store),
|
|
266
|
+
[SpillMetadataKey.ORG_ID]: TEXT_ENCODER.encode(stored.orgId),
|
|
267
|
+
[SpillMetadataKey.ORIGINAL_METADATA]: TEXT_ENCODER.encode(originalMetadata),
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
this.emit({ kind: 'encode', outcome: 'spilled', sizeBytes: data.byteLength, orgId });
|
|
271
|
+
return { metadata, data: envelopeBytes };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async resolveOrgId(): Promise<string> {
|
|
275
|
+
let raw: string;
|
|
276
|
+
try {
|
|
277
|
+
raw = await this.orgIdProvider();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
throw new PayloadCodecError(
|
|
280
|
+
PayloadCodecErrorCode.ORG_ID_REQUIRED,
|
|
281
|
+
`orgIdProvider threw: ${(err as Error).message}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
285
|
+
throw new PayloadCodecError(
|
|
286
|
+
PayloadCodecErrorCode.ORG_ID_REQUIRED,
|
|
287
|
+
'orgIdProvider returned no orgId; cannot spill payload to a multi-tenant blob store.',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return raw;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async decodeOne(payload: Payload): Promise<Payload> {
|
|
294
|
+
if (!this.isAlreadySpilled(payload)) {
|
|
295
|
+
this.emit({
|
|
296
|
+
kind: 'decode',
|
|
297
|
+
outcome: 'inline-passthrough',
|
|
298
|
+
sizeBytes: payload.data?.byteLength ?? 0,
|
|
299
|
+
});
|
|
300
|
+
return payload;
|
|
301
|
+
}
|
|
302
|
+
const metadata = payload.metadata ?? {};
|
|
303
|
+
const { sha256, store } = readSpillMarkers(metadata);
|
|
304
|
+
const resolvedStore = this.resolveStoreOrThrow(store);
|
|
305
|
+
const envelope = this.parseEnvelope(payload.data, sha256);
|
|
306
|
+
|
|
307
|
+
const bytes = await this.rehydrateBlob(
|
|
308
|
+
envelope,
|
|
309
|
+
resolvedStore,
|
|
310
|
+
store,
|
|
311
|
+
sha256,
|
|
312
|
+
envelope.orgId,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const originalMetadataEncoded =
|
|
316
|
+
decodeUtf8(metadata[SpillMetadataKey.ORIGINAL_METADATA]) ?? '{}';
|
|
317
|
+
const restoredMetadata = deserializeOriginalMetadata(originalMetadataEncoded);
|
|
318
|
+
|
|
319
|
+
const out: Payload = { data: bytes };
|
|
320
|
+
if (Object.keys(restoredMetadata).length > 0) {
|
|
321
|
+
out.metadata = restoredMetadata;
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private resolveStoreOrThrow(kind: BlobStoreKind): BlobStore {
|
|
327
|
+
const store = this.stores.get(kind);
|
|
328
|
+
if (!store) {
|
|
329
|
+
const registered = [...this.stores.keys()].join(', ') || '<none>';
|
|
330
|
+
throw new PayloadCodecError(
|
|
331
|
+
PayloadCodecErrorCode.UNKNOWN_STORE_KIND,
|
|
332
|
+
`Spilled payload references store '${kind}' which is not in the codec registry [${registered}]. ` +
|
|
333
|
+
`If this is a store-migration scenario, register the legacy store via WORKFLOW_PAYLOAD_CODEC_READ_STORES until in-flight workflows drain.`,
|
|
334
|
+
{ received: kind, registered: [...this.stores.keys()] },
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return store;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private parseEnvelope(data: Uint8Array | null | undefined, sha256: string): SpillEnvelope {
|
|
341
|
+
if (!data || data.byteLength === 0) {
|
|
342
|
+
throw new PayloadCodecError(
|
|
343
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
344
|
+
'Spilled payload has empty data (envelope JSON expected).',
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
let envelope: SpillEnvelope;
|
|
348
|
+
try {
|
|
349
|
+
const parsed = JSON.parse(decodeUtf8Strict(data)) as unknown;
|
|
350
|
+
envelope = assertEnvelope(parsed);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (err instanceof PayloadCodecError) {
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
throw new PayloadCodecError(
|
|
356
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
357
|
+
`Failed to parse spill envelope: ${(err as Error).message}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (envelope.sha256 !== sha256) {
|
|
361
|
+
throw new PayloadCodecError(
|
|
362
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
363
|
+
'Envelope sha256 does not match metadata sha256.',
|
|
364
|
+
{ metadataSha256: sha256, envelopeSha256: envelope.sha256 },
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return envelope;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async rehydrateBlob(
|
|
371
|
+
envelope: SpillEnvelope,
|
|
372
|
+
store: BlobStore,
|
|
373
|
+
storeKind: BlobStoreKind,
|
|
374
|
+
sha256: string,
|
|
375
|
+
orgId: string,
|
|
376
|
+
): Promise<Uint8Array> {
|
|
377
|
+
// Cache key includes the store kind too — the same sha256 can exist
|
|
378
|
+
// in two registered stores during a migration, and we do not want a
|
|
379
|
+
// hit from one to mask a real read against the other.
|
|
380
|
+
const cacheKey = `${storeKind}:${orgId}:${sha256}`;
|
|
381
|
+
const cached = this.cache.get(cacheKey);
|
|
382
|
+
if (cached) {
|
|
383
|
+
return cached;
|
|
384
|
+
}
|
|
385
|
+
const ref: BlobRef = { store: storeKind, uri: envelope.uri, sha256, orgId };
|
|
386
|
+
let bytes: Uint8Array;
|
|
387
|
+
try {
|
|
388
|
+
bytes = await store.get(ref);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
if (err instanceof PayloadCodecError) {
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
throw new PayloadCodecError(
|
|
394
|
+
PayloadCodecErrorCode.BLOB_GET_FAILED,
|
|
395
|
+
`BlobStore.get failed: ${(err as Error).message}`,
|
|
396
|
+
{ sha256 },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const actualSha = sha256Hex(bytes);
|
|
400
|
+
if (actualSha !== sha256) {
|
|
401
|
+
throw new PayloadCodecError(
|
|
402
|
+
PayloadCodecErrorCode.SHA256_MISMATCH,
|
|
403
|
+
'Fetched blob sha256 does not match the envelope.',
|
|
404
|
+
{ expected: sha256, actual: actualSha },
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
this.cache.put(cacheKey, bytes);
|
|
408
|
+
return bytes;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private isAlreadySpilled(payload: Payload): boolean {
|
|
412
|
+
const encoding = decodeUtf8(payload.metadata?.[SpillMetadataKey.ENCODING]);
|
|
413
|
+
return encoding === SPILL_ENCODING_V1;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Exposed for tests + instrumentation. */
|
|
417
|
+
get cacheStats(): { count: number; bytes: number } {
|
|
418
|
+
return { count: this.cache.count, bytes: this.cache.size };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Keys the serializer strips when capturing the ORIGINAL metadata — the
|
|
424
|
+
* `xema-spill-*` markers are regenerated on every encode and must not
|
|
425
|
+
* round-trip. `SpillMetadataKey.ENCODING` is deliberately NOT in this set:
|
|
426
|
+
* it holds the payload's original encoding (e.g. `binary/plain`) which
|
|
427
|
+
* the consumer needs to decode the rehydrated bytes. The new (wire)
|
|
428
|
+
* payload's encoding is overwritten separately with `xema/spill-v1`.
|
|
429
|
+
*/
|
|
430
|
+
const SPILL_ONLY_METADATA_KEYS: ReadonlySet<string> = new Set([
|
|
431
|
+
SpillMetadataKey.VERSION,
|
|
432
|
+
SpillMetadataKey.SHA256,
|
|
433
|
+
SpillMetadataKey.SIZE,
|
|
434
|
+
SpillMetadataKey.STORE,
|
|
435
|
+
SpillMetadataKey.ORG_ID,
|
|
436
|
+
SpillMetadataKey.ORIGINAL_METADATA,
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Serialize a payload metadata map (`Record<string, Uint8Array>`) as a
|
|
441
|
+
* JSON object of base64-encoded values. Excludes ONLY the spill markers
|
|
442
|
+
* the codec itself writes; the payload's own `encoding` is preserved so
|
|
443
|
+
* the decoder can restore it bit-for-bit.
|
|
444
|
+
*/
|
|
445
|
+
function serializeOriginalMetadata(
|
|
446
|
+
metadata: Readonly<Record<string, Uint8Array>> | null,
|
|
447
|
+
): string {
|
|
448
|
+
if (!metadata) {
|
|
449
|
+
return '{}';
|
|
450
|
+
}
|
|
451
|
+
const object: Record<string, string> = {};
|
|
452
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
453
|
+
if (SPILL_ONLY_METADATA_KEYS.has(key)) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
object[key] = Buffer.from(value).toString('base64');
|
|
457
|
+
}
|
|
458
|
+
return JSON.stringify(object);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function deserializeOriginalMetadata(raw: string): Record<string, Uint8Array> {
|
|
462
|
+
let parsed: unknown;
|
|
463
|
+
try {
|
|
464
|
+
parsed = JSON.parse(raw);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
throw new PayloadCodecError(
|
|
467
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
468
|
+
`Failed to parse original metadata JSON: ${(err as Error).message}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
472
|
+
throw new PayloadCodecError(
|
|
473
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
474
|
+
'Original metadata must be a JSON object.',
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const result: Record<string, Uint8Array> = {};
|
|
478
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
479
|
+
if (typeof value !== 'string') {
|
|
480
|
+
throw new PayloadCodecError(
|
|
481
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
482
|
+
`Original metadata value for '${key}' is not a base64 string.`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
result[key] = new Uint8Array(Buffer.from(value, 'base64'));
|
|
486
|
+
}
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function decodeUtf8(bytes: Uint8Array | undefined | null): string | null {
|
|
491
|
+
if (!bytes) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
return TEXT_DECODER.decode(bytes);
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function decodeUtf8Strict(bytes: Uint8Array): string {
|
|
502
|
+
return TEXT_DECODER.decode(bytes);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function parseBlobStoreKind(raw: string): BlobStoreKind {
|
|
506
|
+
const known: readonly string[] = Object.values(BlobStoreKind);
|
|
507
|
+
if (!known.includes(raw)) {
|
|
508
|
+
throw new PayloadCodecError(
|
|
509
|
+
PayloadCodecErrorCode.UNKNOWN_STORE_KIND,
|
|
510
|
+
`Unknown spill store kind '${raw}'. Allowed: ${known.join(', ')}.`,
|
|
511
|
+
{ raw },
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return raw as BlobStoreKind;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function assertEnvelope(raw: unknown): SpillEnvelope {
|
|
518
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
519
|
+
throw new PayloadCodecError(
|
|
520
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
521
|
+
'Spill envelope must be a JSON object.',
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const o = raw as Record<string, unknown>;
|
|
525
|
+
if (o['$xemaSpill'] !== SPILL_ENVELOPE_VERSION) {
|
|
526
|
+
throw new PayloadCodecError(
|
|
527
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
528
|
+
`Spill envelope $xemaSpill field must equal '${SPILL_ENVELOPE_VERSION}'.`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
// Typed field reads: each helper asserts the JSON value is actually a
|
|
532
|
+
// string/number before we trust it. Without this, `String(o['uri'])`
|
|
533
|
+
// would silently coerce `{...}` or `[...]` to `'[object Object]'` and
|
|
534
|
+
// we'd ship garbage refs to the BlobStore.
|
|
535
|
+
return {
|
|
536
|
+
$xemaSpill: SPILL_ENVELOPE_VERSION,
|
|
537
|
+
store: parseBlobStoreKind(readRequiredString(o, 'store')),
|
|
538
|
+
uri: readRequiredString(o, 'uri'),
|
|
539
|
+
sha256: readRequiredString(o, 'sha256'),
|
|
540
|
+
orgId: readRequiredString(o, 'orgId'),
|
|
541
|
+
sizeBytes: readRequiredInteger(o, 'sizeBytes'),
|
|
542
|
+
contentType: readRequiredString(o, 'contentType'),
|
|
543
|
+
createdAt: readRequiredString(o, 'createdAt'),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function readRequiredString(obj: Record<string, unknown>, key: string): string {
|
|
548
|
+
const value = obj[key];
|
|
549
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
550
|
+
throw new PayloadCodecError(
|
|
551
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
552
|
+
`Spill envelope field '${key}' must be a non-empty string.`,
|
|
553
|
+
{ key, kind: value === null ? 'null' : typeof value },
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function readRequiredInteger(obj: Record<string, unknown>, key: string): number {
|
|
560
|
+
const value = obj[key];
|
|
561
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
562
|
+
throw new PayloadCodecError(
|
|
563
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
564
|
+
`Spill envelope field '${key}' must be a non-negative integer.`,
|
|
565
|
+
{ key, kind: typeof value },
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
return value;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function readSpillMarkers(metadata: Readonly<Record<string, Uint8Array>>): {
|
|
572
|
+
sha256: string;
|
|
573
|
+
store: BlobStoreKind;
|
|
574
|
+
} {
|
|
575
|
+
const version = decodeUtf8(metadata[SpillMetadataKey.VERSION]);
|
|
576
|
+
if (version !== SPILL_ENVELOPE_VERSION) {
|
|
577
|
+
throw new PayloadCodecError(
|
|
578
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
579
|
+
`Unknown spill envelope version: got '${version ?? '<missing>'}', expected '${SPILL_ENVELOPE_VERSION}'.`,
|
|
580
|
+
{ version },
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const sha256 = decodeUtf8(metadata[SpillMetadataKey.SHA256]);
|
|
584
|
+
const storeRaw = decodeUtf8(metadata[SpillMetadataKey.STORE]);
|
|
585
|
+
if (!sha256 || !storeRaw) {
|
|
586
|
+
throw new PayloadCodecError(
|
|
587
|
+
PayloadCodecErrorCode.MALFORMED_ENVELOPE,
|
|
588
|
+
'Spilled payload is missing required metadata (sha256 or store).',
|
|
589
|
+
{ hasSha256: Boolean(sha256), hasStore: Boolean(storeRaw) },
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
return { sha256, store: parseBlobStoreKind(storeRaw) };
|
|
593
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata keys the codec writes/reads on Temporal payloads. All keys are
|
|
3
|
+
* prefixed `xema-spill-` so they don't collide with Temporal's own
|
|
4
|
+
* encoding/messageType metadata.
|
|
5
|
+
*/
|
|
6
|
+
export enum SpillMetadataKey {
|
|
7
|
+
/** Signals that this payload was produced by the Xema spill codec. */
|
|
8
|
+
ENCODING = 'encoding',
|
|
9
|
+
/** Codec version — bumped if the envelope shape changes. */
|
|
10
|
+
VERSION = 'xema-spill-version',
|
|
11
|
+
/** Content-addressed id of the spilled blob in the backing store. */
|
|
12
|
+
SHA256 = 'xema-spill-sha256',
|
|
13
|
+
/** Size in bytes of the original (un-spilled) payload.data. */
|
|
14
|
+
SIZE = 'xema-spill-size',
|
|
15
|
+
/** Identifier of the backing store (e.g. `artifact-store`, `in-memory`). */
|
|
16
|
+
STORE = 'xema-spill-store',
|
|
17
|
+
/**
|
|
18
|
+
* Tenant id (orgId) the blob was written under. Required because the
|
|
19
|
+
* artifact-store-api scopes blobs by `(orgId, sha256)`; decoders read
|
|
20
|
+
* this back to fetch the same blob without ambient context.
|
|
21
|
+
*/
|
|
22
|
+
ORG_ID = 'xema-spill-org-id',
|
|
23
|
+
/** Base64-encoded JSON of the original payload.metadata (sans the spill keys themselves). */
|
|
24
|
+
ORIGINAL_METADATA = 'xema-spill-original-metadata',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Value written to `metadata.encoding` when a payload is spilled. */
|
|
28
|
+
export const SPILL_ENCODING_V1 = 'xema/spill-v1';
|
|
29
|
+
|
|
30
|
+
/** Spill envelope version. Incoming payloads with a higher version fail fast. */
|
|
31
|
+
export const SPILL_ENVELOPE_VERSION = '1';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default threshold in bytes — payloads smaller than this stay inline.
|
|
35
|
+
*
|
|
36
|
+
* Set to 64 KiB (was 128 KiB) so virtually every production CompiledRun
|
|
37
|
+
* dispatch exercises the spill path. Reasoning: typical CompiledRun JSON
|
|
38
|
+
* sits in the 40–110 KiB band; matrix expansions and reusable workflows
|
|
39
|
+
* push higher. A threshold straddling that band means some dispatches
|
|
40
|
+
* spill and others don't, which forces every consumer to handle both
|
|
41
|
+
* shapes. Lowering the threshold makes the spill path the only path,
|
|
42
|
+
* eliminates "passes locally, fails in prod near the boundary" bugs,
|
|
43
|
+
* and keeps Temporal payload size bounded regardless of workflow shape.
|
|
44
|
+
*
|
|
45
|
+
* Override via WORKFLOW_PAYLOAD_CODEC_THRESHOLD_BYTES on engine + worker.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_SPILL_THRESHOLD_BYTES = 64 * 1024;
|
|
48
|
+
|
|
49
|
+
/** Default LRU cache capacity in bytes. */
|
|
50
|
+
export const DEFAULT_CACHE_CAPACITY_BYTES = 16 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** Known blob-store kinds. The codec refuses to read a blob from an unknown store. */
|
|
53
|
+
export enum BlobStoreKind {
|
|
54
|
+
/** Production backing: artifact-store-api blob endpoints. */
|
|
55
|
+
ARTIFACT_STORE = 'artifact-store',
|
|
56
|
+
/** In-process map used by tests and dev harnesses. */
|
|
57
|
+
IN_MEMORY = 'in-memory',
|
|
58
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error codes emitted by the spill codec. Callers MUST branch on
|
|
3
|
+
* `.code` — free-form message matching is forbidden, because these errors
|
|
4
|
+
* travel across process + network boundaries (Temporal workflow ↔ engine)
|
|
5
|
+
* and need a stable identity.
|
|
6
|
+
*/
|
|
7
|
+
export enum PayloadCodecErrorCode {
|
|
8
|
+
/** Input payload shape is invalid (e.g. missing data). */
|
|
9
|
+
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
|
|
10
|
+
/** BlobStore.put failed during encode. */
|
|
11
|
+
BLOB_PUT_FAILED = 'BLOB_PUT_FAILED',
|
|
12
|
+
/** BlobStore.get failed during decode. */
|
|
13
|
+
BLOB_GET_FAILED = 'BLOB_GET_FAILED',
|
|
14
|
+
/** Received a spilled payload but no BlobStore is configured. */
|
|
15
|
+
NO_BLOB_STORE_CONFIGURED = 'NO_BLOB_STORE_CONFIGURED',
|
|
16
|
+
/** Spill envelope is malformed or uses an unknown version. */
|
|
17
|
+
MALFORMED_ENVELOPE = 'MALFORMED_ENVELOPE',
|
|
18
|
+
/** Envelope declares a store kind this codec doesn't know. */
|
|
19
|
+
UNKNOWN_STORE_KIND = 'UNKNOWN_STORE_KIND',
|
|
20
|
+
/** sha256 mismatch between envelope declaration and fetched blob. */
|
|
21
|
+
SHA256_MISMATCH = 'SHA256_MISMATCH',
|
|
22
|
+
/** orgIdProvider returned no orgId at encode time (fail-fast — no silent fallback). */
|
|
23
|
+
ORG_ID_REQUIRED = 'ORG_ID_REQUIRED',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class PayloadCodecError extends Error {
|
|
27
|
+
readonly code: PayloadCodecErrorCode;
|
|
28
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
code: PayloadCodecErrorCode,
|
|
32
|
+
message: string,
|
|
33
|
+
details: Readonly<Record<string, unknown>> = {},
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'PayloadCodecError';
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.details = details;
|
|
39
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
toJSON(): Readonly<Record<string, unknown>> {
|
|
43
|
+
return {
|
|
44
|
+
name: this.name,
|
|
45
|
+
code: this.code,
|
|
46
|
+
message: this.message,
|
|
47
|
+
details: this.details,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isPayloadCodecError(err: unknown): err is PayloadCodecError {
|
|
53
|
+
return err instanceof PayloadCodecError;
|
|
54
|
+
}
|