@prisma-next/extension-cipherstash 0.0.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.
Files changed (68) hide show
  1. package/README.md +153 -0
  2. package/dist/call-classes-CSvD7w8U.mjs +206 -0
  3. package/dist/call-classes-CSvD7w8U.mjs.map +1 -0
  4. package/dist/column-types.d.mts +33 -0
  5. package/dist/column-types.d.mts.map +1 -0
  6. package/dist/column-types.mjs +42 -0
  7. package/dist/column-types.mjs.map +1 -0
  8. package/dist/constants-BDxL9Pe3.d.mts +22 -0
  9. package/dist/constants-BDxL9Pe3.d.mts.map +1 -0
  10. package/dist/constants-B_2TNvUi.mjs +46 -0
  11. package/dist/constants-B_2TNvUi.mjs.map +1 -0
  12. package/dist/control.d.mts +7 -0
  13. package/dist/control.d.mts.map +1 -0
  14. package/dist/control.mjs +430 -0
  15. package/dist/control.mjs.map +1 -0
  16. package/dist/descriptor-meta-BgQfZTAF.mjs +129 -0
  17. package/dist/descriptor-meta-BgQfZTAF.mjs.map +1 -0
  18. package/dist/envelope-P9BxfJNr.mjs +271 -0
  19. package/dist/envelope-P9BxfJNr.mjs.map +1 -0
  20. package/dist/middleware.d.mts +13 -0
  21. package/dist/middleware.d.mts.map +1 -0
  22. package/dist/middleware.mjs +129 -0
  23. package/dist/middleware.mjs.map +1 -0
  24. package/dist/migration.d.mts +141 -0
  25. package/dist/migration.d.mts.map +1 -0
  26. package/dist/migration.mjs +2 -0
  27. package/dist/operation-types.d.mts +49 -0
  28. package/dist/operation-types.d.mts.map +1 -0
  29. package/dist/operation-types.mjs +1 -0
  30. package/dist/pack.d.mts +86 -0
  31. package/dist/pack.d.mts.map +1 -0
  32. package/dist/pack.mjs +2 -0
  33. package/dist/runtime.d.mts +207 -0
  34. package/dist/runtime.d.mts.map +1 -0
  35. package/dist/runtime.mjs +429 -0
  36. package/dist/runtime.mjs.map +1 -0
  37. package/dist/sdk-D5FTGyzp.d.mts +67 -0
  38. package/dist/sdk-D5FTGyzp.d.mts.map +1 -0
  39. package/package.json +69 -0
  40. package/src/contract/authoring.ts +62 -0
  41. package/src/contract/contract.d.ts +149 -0
  42. package/src/contract/contract.json +104 -0
  43. package/src/contract/contract.prisma +46 -0
  44. package/src/execution/abort.ts +143 -0
  45. package/src/execution/codec-runtime.ts +209 -0
  46. package/src/execution/decrypt-all.ts +217 -0
  47. package/src/execution/envelope.ts +263 -0
  48. package/src/execution/operators.ts +211 -0
  49. package/src/execution/parameterized.ts +71 -0
  50. package/src/execution/routing.ts +93 -0
  51. package/src/execution/sdk.ts +68 -0
  52. package/src/exports/column-types.ts +62 -0
  53. package/src/exports/contract-space-typing.ts +86 -0
  54. package/src/exports/control.ts +120 -0
  55. package/src/exports/middleware.ts +24 -0
  56. package/src/exports/migration.ts +43 -0
  57. package/src/exports/operation-types.ts +16 -0
  58. package/src/exports/pack.ts +13 -0
  59. package/src/exports/runtime.ts +110 -0
  60. package/src/extension-metadata/codec-metadata.ts +81 -0
  61. package/src/extension-metadata/constants.ts +70 -0
  62. package/src/extension-metadata/descriptor-meta.ts +76 -0
  63. package/src/middleware/bulk-encrypt.ts +192 -0
  64. package/src/migration/call-classes.ts +350 -0
  65. package/src/migration/cipherstash-codec.ts +157 -0
  66. package/src/migration/eql-bundle.ts +29 -0
  67. package/src/migration/eql-install.generated.ts +5751 -0
  68. package/src/types/operation-types.ts +81 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Authoring contributions for the cipherstash extension.
3
+ *
4
+ * Registers `cipherstash.EncryptedString({ equality?, freeTextSearch? })`
5
+ * as a namespaced PSL type constructor. The same descriptor lowers a
6
+ * PSL field-type expression like `cipherstash.EncryptedString({ equality:
7
+ * true })` and a TS factory call like `encryptedString({ equality: true })`
8
+ * (see `../exports/column-types`) to an identical `ColumnTypeDescriptor`
9
+ * so PSL- and TS-authored contracts emit byte-identical `contract.json`.
10
+ *
11
+ * Mirrors `packages/3-extensions/pgvector/src/contract/authoring.ts`. The
12
+ * cipherstash variant differs in three respects:
13
+ * (a) `cipherstash` is the namespace,
14
+ * (b) the constructor takes a single OPTIONAL object argument with two
15
+ * optional booleans (so `cipherstash.EncryptedString()`,
16
+ * `cipherstash.EncryptedString({})`, and the fully-spelled
17
+ * `cipherstash.EncryptedString({ equality: true, freeTextSearch: true })`
18
+ * all parse), and
19
+ * (c) both flags default to `true` — searchable encryption is the
20
+ * legitimate default for an extension whose entire reason for
21
+ * existing is to make encrypted columns queryable. Users who want
22
+ * storage-only encryption opt out explicitly:
23
+ * `cipherstash.EncryptedString({ equality: false, freeTextSearch: false })`.
24
+ */
25
+
26
+ import type { AuthoringTypeNamespace } from '@prisma-next/framework-components/authoring';
27
+ import {
28
+ CIPHERSTASH_STRING_CODEC_ID,
29
+ EQL_V2_ENCRYPTED_TYPE,
30
+ } from '../extension-metadata/constants';
31
+
32
+ export const cipherstashAuthoringTypes = {
33
+ cipherstash: {
34
+ EncryptedString: {
35
+ kind: 'typeConstructor',
36
+ args: [
37
+ {
38
+ kind: 'object',
39
+ name: 'options',
40
+ optional: true,
41
+ properties: {
42
+ equality: { kind: 'boolean', optional: true },
43
+ freeTextSearch: { kind: 'boolean', optional: true },
44
+ },
45
+ },
46
+ ],
47
+ output: {
48
+ codecId: CIPHERSTASH_STRING_CODEC_ID,
49
+ nativeType: EQL_V2_ENCRYPTED_TYPE,
50
+ typeParams: {
51
+ equality: { kind: 'arg', index: 0, path: ['equality'], default: true },
52
+ freeTextSearch: {
53
+ kind: 'arg',
54
+ index: 0,
55
+ path: ['freeTextSearch'],
56
+ default: true,
57
+ },
58
+ },
59
+ },
60
+ },
61
+ },
62
+ } as const satisfies AuthoringTypeNamespace;
@@ -0,0 +1,149 @@
1
+ // ⚠️ GENERATED FILE - DO NOT EDIT
2
+ // This file is automatically generated by 'prisma-next contract emit'.
3
+ // To regenerate, run: prisma-next contract emit
4
+ import type { CodecTypes as PgTypes } from '@prisma-next/target-postgres/codec-types';
5
+ import type { JsonValue } from '@prisma-next/target-postgres/codec-types';
6
+ import type { Char } from '@prisma-next/target-postgres/codec-types';
7
+ import type { Varchar } from '@prisma-next/target-postgres/codec-types';
8
+ import type { Numeric } from '@prisma-next/target-postgres/codec-types';
9
+ import type { Bit } from '@prisma-next/target-postgres/codec-types';
10
+ import type { VarBit } from '@prisma-next/target-postgres/codec-types';
11
+ import type { Timestamp } from '@prisma-next/target-postgres/codec-types';
12
+ import type { Timestamptz } from '@prisma-next/target-postgres/codec-types';
13
+ import type { Time } from '@prisma-next/target-postgres/codec-types';
14
+ import type { Timetz } from '@prisma-next/target-postgres/codec-types';
15
+ import type { Interval } from '@prisma-next/target-postgres/codec-types';
16
+ import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types';
17
+
18
+ import type {
19
+ ContractWithTypeMaps,
20
+ TypeMaps as TypeMapsType,
21
+ } from '@prisma-next/sql-contract/types';
22
+ import type {
23
+ Contract as ContractType,
24
+ ExecutionHashBase,
25
+ ProfileHashBase,
26
+ StorageHashBase,
27
+ } from '@prisma-next/contract/types';
28
+
29
+ export type StorageHash =
30
+ StorageHashBase<'sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4'>;
31
+ export type ExecutionHash = ExecutionHashBase<string>;
32
+ export type ProfileHash =
33
+ ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'>;
34
+
35
+ export type CodecTypes = PgTypes;
36
+ export type OperationTypes = Record<string, never>;
37
+ export type LaneCodecTypes = CodecTypes;
38
+ export type QueryOperationTypes = PgAdapterQueryOps<CodecTypes>;
39
+ type DefaultLiteralValue<CodecId extends string, _Encoded> = CodecId extends keyof CodecTypes
40
+ ? CodecTypes[CodecId]['output']
41
+ : _Encoded;
42
+
43
+ export type FieldOutputTypes = {
44
+ readonly EqlV2Configuration: {
45
+ readonly id: CodecTypes['pg/text@1']['output'];
46
+ readonly state: CodecTypes['pg/text@1']['output'];
47
+ readonly data: CodecTypes['pg/jsonb@1']['output'];
48
+ };
49
+ };
50
+ export type FieldInputTypes = {
51
+ readonly EqlV2Configuration: {
52
+ readonly id: CodecTypes['pg/text@1']['input'];
53
+ readonly state: CodecTypes['pg/text@1']['input'];
54
+ readonly data: CodecTypes['pg/jsonb@1']['input'];
55
+ };
56
+ };
57
+ export type TypeMaps = TypeMapsType<
58
+ CodecTypes,
59
+ OperationTypes,
60
+ QueryOperationTypes,
61
+ FieldOutputTypes,
62
+ FieldInputTypes
63
+ >;
64
+
65
+ type ContractBase = ContractType<
66
+ {
67
+ readonly tables: {
68
+ readonly eql_v2_configuration: {
69
+ columns: {
70
+ readonly id: {
71
+ readonly nativeType: 'text';
72
+ readonly codecId: 'pg/text@1';
73
+ readonly nullable: false;
74
+ };
75
+ readonly state: {
76
+ readonly nativeType: 'text';
77
+ readonly codecId: 'pg/text@1';
78
+ readonly nullable: false;
79
+ };
80
+ readonly data: {
81
+ readonly nativeType: 'jsonb';
82
+ readonly codecId: 'pg/jsonb@1';
83
+ readonly nullable: false;
84
+ };
85
+ };
86
+ primaryKey: { readonly columns: readonly ['id'] };
87
+ uniques: readonly [];
88
+ indexes: readonly [];
89
+ foreignKeys: readonly [];
90
+ };
91
+ };
92
+ readonly types: Record<string, never>;
93
+ readonly storageHash: StorageHash;
94
+ },
95
+ {
96
+ readonly EqlV2Configuration: {
97
+ readonly fields: {
98
+ readonly id: {
99
+ readonly nullable: false;
100
+ readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' };
101
+ };
102
+ readonly state: {
103
+ readonly nullable: false;
104
+ readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' };
105
+ };
106
+ readonly data: {
107
+ readonly nullable: false;
108
+ readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' };
109
+ };
110
+ };
111
+ readonly relations: Record<string, never>;
112
+ readonly storage: {
113
+ readonly table: 'eql_v2_configuration';
114
+ readonly fields: {
115
+ readonly id: { readonly column: 'id' };
116
+ readonly state: { readonly column: 'state' };
117
+ readonly data: { readonly column: 'data' };
118
+ };
119
+ };
120
+ };
121
+ }
122
+ > & {
123
+ readonly target: 'postgres';
124
+ readonly targetFamily: 'sql';
125
+ readonly roots: { readonly eql_v2_configuration: 'EqlV2Configuration' };
126
+ readonly capabilities: {
127
+ readonly postgres: {
128
+ readonly jsonAgg: true;
129
+ readonly lateral: true;
130
+ readonly limit: true;
131
+ readonly orderBy: true;
132
+ readonly returning: true;
133
+ };
134
+ readonly sql: {
135
+ readonly defaultInInsert: true;
136
+ readonly enums: true;
137
+ readonly returning: true;
138
+ };
139
+ };
140
+ readonly extensionPacks: {};
141
+ readonly meta: {};
142
+
143
+ readonly profileHash: ProfileHash;
144
+ };
145
+
146
+ export type Contract = ContractWithTypeMaps<ContractBase, TypeMaps>;
147
+
148
+ export type Tables = Contract['storage']['tables'];
149
+ export type Models = Contract['models'];
@@ -0,0 +1,104 @@
1
+ {
2
+ "schemaVersion": "1",
3
+ "targetFamily": "sql",
4
+ "target": "postgres",
5
+ "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e",
6
+ "roots": {
7
+ "eql_v2_configuration": "EqlV2Configuration"
8
+ },
9
+ "models": {
10
+ "EqlV2Configuration": {
11
+ "fields": {
12
+ "data": {
13
+ "nullable": false,
14
+ "type": {
15
+ "codecId": "pg/jsonb@1",
16
+ "kind": "scalar"
17
+ }
18
+ },
19
+ "id": {
20
+ "nullable": false,
21
+ "type": {
22
+ "codecId": "pg/text@1",
23
+ "kind": "scalar"
24
+ }
25
+ },
26
+ "state": {
27
+ "nullable": false,
28
+ "type": {
29
+ "codecId": "pg/text@1",
30
+ "kind": "scalar"
31
+ }
32
+ }
33
+ },
34
+ "relations": {},
35
+ "storage": {
36
+ "fields": {
37
+ "data": {
38
+ "column": "data"
39
+ },
40
+ "id": {
41
+ "column": "id"
42
+ },
43
+ "state": {
44
+ "column": "state"
45
+ }
46
+ },
47
+ "table": "eql_v2_configuration"
48
+ }
49
+ }
50
+ },
51
+ "storage": {
52
+ "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4",
53
+ "tables": {
54
+ "eql_v2_configuration": {
55
+ "columns": {
56
+ "data": {
57
+ "codecId": "pg/jsonb@1",
58
+ "nativeType": "jsonb",
59
+ "nullable": false
60
+ },
61
+ "id": {
62
+ "codecId": "pg/text@1",
63
+ "nativeType": "text",
64
+ "nullable": false
65
+ },
66
+ "state": {
67
+ "codecId": "pg/text@1",
68
+ "nativeType": "text",
69
+ "nullable": false
70
+ }
71
+ },
72
+ "foreignKeys": [],
73
+ "indexes": [],
74
+ "primaryKey": {
75
+ "columns": [
76
+ "id"
77
+ ]
78
+ },
79
+ "uniques": []
80
+ }
81
+ }
82
+ },
83
+ "capabilities": {
84
+ "postgres": {
85
+ "jsonAgg": true,
86
+ "lateral": true,
87
+ "limit": true,
88
+ "orderBy": true,
89
+ "returning": true
90
+ },
91
+ "sql": {
92
+ "defaultInInsert": true,
93
+ "enums": true,
94
+ "returning": true
95
+ }
96
+ },
97
+ "extensionPacks": {},
98
+ "meta": {},
99
+ "_generated": {
100
+ "warning": "⚠️ GENERATED FILE - DO NOT EDIT",
101
+ "message": "This file is automatically generated by \"prisma-next contract emit\".",
102
+ "regenerate": "To regenerate, run: prisma-next contract emit"
103
+ }
104
+ }
@@ -0,0 +1,46 @@
1
+ // PSL contract source for the `extension-cipherstash` package.
2
+ //
3
+ // Authored against the on-disk-in-package convention. The same emit
4
+ // pipeline application authors use is applied here:
5
+ //
6
+ // `prisma-next contract emit` → `<package>/src/contract/contract.{json,d.ts}`
7
+ // `prisma-next migration plan` → `<package>/migrations/cipherstash/<dirName>/`
8
+ //
9
+ // The descriptor at `src/exports/control.ts` then wires the emitted JSON
10
+ // artefacts via JSON-import declarations.
11
+ //
12
+ // ## IR coverage and explicit deferral
13
+ //
14
+ // CipherStash should declare four kinds of typed objects in its
15
+ // contract IR: tables, enums, composite types, and domains. Of these,
16
+ // today's `SqlStorage` IR (`@prisma-next/sql-contract/types`) only
17
+ // models tables and parameterised type instances (a fit for things
18
+ // like pgvector's `vector(N)`, but not yet codec-less composite types,
19
+ // standalone enums, or domains).
20
+ //
21
+ // The contract therefore declares the only IR-representable object
22
+ // today (the `eql_v2_configuration` table) using portable scalar
23
+ // types (`String` / `Json`). The actual database state — the `eql_v2`
24
+ // schema, the typed `eql_v2_configuration_state` enum, the
25
+ // `eql_v2_encrypted` composite, the `eql_v2.bloom_filter` /
26
+ // `hmac_256` / `blake3` domains, and the various `ore_*` composites —
27
+ // is created by the `installEqlBundle` migration op (which carries
28
+ // the vendored bundle SQL byte-for-byte; see
29
+ // `./src/migration/eql-bundle.ts`). The structural
30
+ // `cipherstash:create-*-v1` no-op ops register the invariantIds the
31
+ // verifier needs so its `applied_invariants` gate passes.
32
+ //
33
+ // Once the IR vocabulary expands to first-class composite types,
34
+ // standalone enums, and domains, those typed objects shift up into
35
+ // `storage.types` and the structural ops gain real verification work
36
+ // (precheck SQL probing `pg_type` / `information_schema`).
37
+ //
38
+ // @see docs/architecture docs/adrs/ADR 211 - Contract spaces.md
39
+
40
+ model EqlV2Configuration {
41
+ id String @id
42
+ state String
43
+ data Json
44
+
45
+ @@map("eql_v2_configuration")
46
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Cipherstash-internal `RUNTIME.ABORTED` phase wrapping.
3
+ *
4
+ * The framework`s `runtimeAborted(phase)` (`@prisma-next/framework-
5
+ * components/runtime`) constructs the canonical `RUNTIME.ABORTED`
6
+ * envelope (`code === 'RUNTIME.ABORTED'`, `category === 'RUNTIME'`,
7
+ * `details.phase`, `cause`) but its `phase` parameter is typed as
8
+ * the framework`s closed `RuntimeAbortedPhase` union — `encode`,
9
+ * `decode`, `stream`, `beforeExecute`, `afterExecute`, `onRow`. Those
10
+ * tags describe phases of `runtime.execute()` itself (see ADR 207`s
11
+ * "Where the runtime observes abort" table); cipherstash`s async
12
+ * observation points sit one layer outside the framework runtime:
13
+ *
14
+ * - `bulk-encrypt` — the bulk-encrypt middleware`s SDK round-trip
15
+ * inside `beforeExecute`. Conceptually a sub-phase of the
16
+ * framework`s `beforeExecute`, but tag-wise distinct so callers
17
+ * can attribute the abort to the cipherstash SDK call rather
18
+ * than to a generic middleware step.
19
+ * - `decrypt` — the single-cell `EncryptedString#decrypt()`
20
+ * SDK call, invoked by the application after the framework
21
+ * returns the row. Not inside any framework phase.
22
+ * - `decrypt-all` — the `decryptAll` walker`s `bulkDecrypt` calls,
23
+ * invoked by the application after the framework returns the
24
+ * row set. Not inside any framework phase.
25
+ *
26
+ * Rather than widen the framework union (which would conflate
27
+ * extension-specific tags with the framework`s own attribution
28
+ * sites), this module reuses the framework`s `runtimeError(...)`
29
+ * envelope builder directly — the *envelope shape* (the
30
+ * `RuntimeErrorEnvelope` interface, the `code` slot, the `category`
31
+ * slot, the `details.phase` slot, the `cause` field) is unchanged;
32
+ * only the set of legal `phase` string values grows. ADR 027`s
33
+ * envelope contract is preserved bit-for-bit.
34
+ *
35
+ * The `raceCipherstashAbort` helper mirrors framework
36
+ * `raceAgainstAbort` so cipherstash`s SDK-call sites get the same
37
+ * "return promptly even when the SDK ignores the signal" behaviour
38
+ * (the cooperative-cancellation model from ADR 207). Identity-
39
+ * checked sentinel rejection distinguishes abort-source from a
40
+ * codec-thrown envelope, matching the framework`s pattern. We
41
+ * duplicate the logic (rather than passing a cast tag to the
42
+ * framework helper) to keep the cipherstash `phase` strings
43
+ * cipherstash-internal — no widening of the framework union.
44
+ */
45
+
46
+ import type { RuntimeErrorEnvelope } from '@prisma-next/framework-components/runtime';
47
+ import { RUNTIME_ABORTED, runtimeError } from '@prisma-next/framework-components/runtime';
48
+
49
+ /** Discriminator placed in `details.phase` of cipherstash-issued aborts. */
50
+ export type CipherstashAbortPhase = 'bulk-encrypt' | 'decrypt' | 'decrypt-all';
51
+
52
+ /**
53
+ * Construct a `RUNTIME.ABORTED` envelope tagged with a cipherstash
54
+ * phase. Reuses the framework`s `runtimeError(RUNTIME_ABORTED, ...)`
55
+ * envelope builder so the structural shape (`code`, `category`,
56
+ * `severity`, `message`, `details.phase`, `cause`) matches everything
57
+ * else the framework emits. Only the `phase` string set is
58
+ * cipherstash-specific.
59
+ */
60
+ export function cipherstashAborted(
61
+ phase: CipherstashAbortPhase,
62
+ cause?: unknown,
63
+ ): RuntimeErrorEnvelope {
64
+ const envelope = runtimeError(RUNTIME_ABORTED, `Operation aborted during ${phase}`, { phase });
65
+ return Object.assign(envelope, { cause });
66
+ }
67
+
68
+ /**
69
+ * Pre-check helper: throw a cipherstash-tagged `RUNTIME.ABORTED`
70
+ * envelope if the supplied signal is already aborted at the call
71
+ * site. Mirrors framework `checkAborted` (which is typed against the
72
+ * framework`s phase union) — used to short-circuit the bulk-encrypt
73
+ * middleware`s pre-flight, the single-cell `decrypt()` pre-flight,
74
+ * and the `decryptAll` walker`s pre-flight before any SDK round-trip
75
+ * is scheduled.
76
+ */
77
+ export function checkCipherstashAborted(
78
+ signal: AbortSignal | undefined,
79
+ phase: CipherstashAbortPhase,
80
+ ): void {
81
+ if (signal?.aborted) {
82
+ throw cipherstashAborted(phase, signal.reason);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Race a cipherstash SDK promise against the supplied `AbortSignal`
88
+ * so the awaiting caller is rejected promptly with a
89
+ * `RUNTIME.ABORTED` envelope as soon as the signal aborts — even
90
+ * when the SDK body itself ignores the signal. Cooperative
91
+ * cancellation: in-flight SDK calls that ignore the signal continue
92
+ * running in the background and complete; the abort-attributed
93
+ * rejection is what the cipherstash caller sees (the SDK`s eventual
94
+ * resolution is silently abandoned per ADR 207`s "cooperative
95
+ * cancellation, not termination" contract).
96
+ *
97
+ * Mirrors framework `raceAgainstAbort` line-for-line aside from the
98
+ * cipherstash-typed phase parameter and the cipherstash-tagged
99
+ * envelope construction. The sentinel-identity attribution is
100
+ * load-bearing for the same reason ADR 207 spells out: a codec /
101
+ * SDK that itself throws a `RUNTIME.ENCODE_FAILED` /
102
+ * `RUNTIME.DECODE_FAILED` (or any other named envelope) must pass
103
+ * through unchanged — only the cipherstash-installed listener ever
104
+ * rejects with the local sentinel reference, so an `error ===
105
+ * sentinel` identity check after the race is unambiguous.
106
+ */
107
+ export async function raceCipherstashAbort<T>(
108
+ work: Promise<T>,
109
+ signal: AbortSignal | undefined,
110
+ phase: CipherstashAbortPhase,
111
+ ): Promise<T> {
112
+ if (signal === undefined) {
113
+ return await work;
114
+ }
115
+ const sentinel: { reason: unknown } = { reason: undefined };
116
+ let onAbort: (() => void) | undefined;
117
+
118
+ const abortPromise = new Promise<never>((_, reject) => {
119
+ if (signal.aborted) {
120
+ sentinel.reason = signal.reason;
121
+ reject(sentinel);
122
+ return;
123
+ }
124
+ onAbort = () => {
125
+ sentinel.reason = signal.reason;
126
+ reject(sentinel);
127
+ };
128
+ signal.addEventListener('abort', onAbort, { once: true });
129
+ });
130
+
131
+ try {
132
+ return await Promise.race([work, abortPromise]);
133
+ } catch (error) {
134
+ if (error === sentinel) {
135
+ throw cipherstashAborted(phase, sentinel.reason);
136
+ }
137
+ throw error;
138
+ } finally {
139
+ if (onAbort) {
140
+ signal.removeEventListener('abort', onAbort);
141
+ }
142
+ }
143
+ }