ankka 0.15.0 → 0.16.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.
Files changed (60) hide show
  1. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +365 -0
  2. package/dist/_proto/ankka/protocol/v1/client_pb.js +61 -5
  3. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +7 -0
  4. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +1 -1
  5. package/dist/_proto/ankka/protocol/v1/erasure_pb.d.ts +72 -0
  6. package/dist/_proto/ankka/protocol/v1/erasure_pb.js +24 -0
  7. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +14 -0
  8. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +1 -1
  9. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +136 -1
  10. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +21 -1
  11. package/dist/autonomous.d.ts +12 -2
  12. package/dist/autonomous.js +17 -3
  13. package/dist/client.d.ts +21 -3
  14. package/dist/client.js +46 -13
  15. package/dist/codec.d.ts +2 -0
  16. package/dist/codec.js +8 -1
  17. package/dist/consumer.d.ts +5 -0
  18. package/dist/consumer.js +10 -0
  19. package/dist/endpoint.d.ts +5 -0
  20. package/dist/endpoint.js +10 -0
  21. package/dist/erasure.d.ts +35 -0
  22. package/dist/erasure.js +10 -0
  23. package/dist/erasures.d.ts +32 -0
  24. package/dist/erasures.js +65 -0
  25. package/dist/graph.d.ts +5 -0
  26. package/dist/graph.js +10 -0
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.js +5 -1
  29. package/dist/json.js +110 -0
  30. package/dist/keyring.d.ts +119 -0
  31. package/dist/keyring.js +417 -0
  32. package/dist/personal.d.ts +97 -0
  33. package/dist/personal.js +209 -0
  34. package/dist/schema.d.ts +13 -0
  35. package/dist/schema.js +43 -0
  36. package/dist/server/agent.js +2 -1
  37. package/dist/server/discovery.d.ts +13 -1
  38. package/dist/server/discovery.js +14 -2
  39. package/dist/server/erasure.d.ts +7 -0
  40. package/dist/server/erasure.js +40 -0
  41. package/dist/server/eventSourced.js +25 -8
  42. package/dist/server/keyValue.js +6 -2
  43. package/dist/server/payloads.d.ts +10 -2
  44. package/dist/server/payloads.js +36 -4
  45. package/dist/server/server.d.ts +2 -0
  46. package/dist/server/server.js +59 -2
  47. package/dist/server/stateless.js +10 -1
  48. package/dist/server/workflow.js +8 -2
  49. package/dist/service.d.ts +12 -1
  50. package/dist/service.js +60 -3
  51. package/dist/spec.d.ts +1 -1
  52. package/dist/spec.js +2 -1
  53. package/dist/storage.d.ts +48 -2
  54. package/dist/storage.js +138 -7
  55. package/dist/testkit/index.d.ts +1 -1
  56. package/dist/testkit/integration.d.ts +35 -0
  57. package/dist/testkit/integration.js +106 -1
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
@@ -0,0 +1,209 @@
1
+ // Personal fields (feature 042): a value about one data subject, never held in the clear.
2
+ //
3
+ // Marked in a shape with `s.personal(inner)` and built with `personal(subject("player/8c1f"), value)`.
4
+ // Written by a serializer (a payload sent to the sidecar) it is the personal envelope of
5
+ // protocol/ENCODING.md, sealed with AES-256-GCM under the subject's key; read back it is present, or
6
+ // erased once the key is destroyed. Where no serializer is writing (an endpoint's reply or a caller's
7
+ // body) it is the value with its subject beside it. Its textual form names the subject, never the value.
8
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9
+ import { CommandError, ErrorCode } from "./effects/common.js";
10
+ /** The rule, in the words every SDK refuses with. */
11
+ export const DATA_SUBJECT_RULE = "a data subject is 1 to 253 characters from letters, digits, '.', '_', '-', '/' and ':'";
12
+ const SUBJECT = /^[A-Za-z0-9._/:-]{1,253}$/;
13
+ /**
14
+ * Why `value` is not a data subject, or `undefined` when it is one. The reason names the rule and never
15
+ * the value, which may be the personal data a caller meant to protect (FR-029).
16
+ */
17
+ export function subjectProblem(value) {
18
+ if (typeof value === "string" && SUBJECT.test(value))
19
+ return undefined;
20
+ return `the value given is not a data subject: ${DATA_SUBJECT_RULE}`;
21
+ }
22
+ /** `value` as a data subject; throws a `TypeError` naming the rule when it is not one. */
23
+ export function subject(value) {
24
+ const problem = subjectProblem(value);
25
+ if (problem !== undefined)
26
+ throw new TypeError(problem);
27
+ return value;
28
+ }
29
+ // ── the field ────────────────────────────────────────────────────────────────
30
+ const inspect = Symbol.for("nodejs.util.inspect.custom");
31
+ /** A personal field holding its value. */
32
+ export class Present {
33
+ kind = "present";
34
+ subject;
35
+ value;
36
+ constructor(subject, value) {
37
+ this.subject = subject;
38
+ this.value = value;
39
+ Object.freeze(this);
40
+ }
41
+ /** The value; a present field ignores `fallback`. */
42
+ orElse(_fallback) {
43
+ return this.value;
44
+ }
45
+ get isErased() {
46
+ return false;
47
+ }
48
+ toString() {
49
+ return `Personal(${this.subject})`;
50
+ }
51
+ [inspect]() {
52
+ return this.toString();
53
+ }
54
+ /**
55
+ * The subject alone, never the value: `JSON.stringify` is how an object is logged, and a personal
56
+ * field's textual form holds no value (FR-029). A payload's and an endpoint's forms come from the
57
+ * field's schema, which never calls this.
58
+ */
59
+ toJSON() {
60
+ return { subject: this.subject };
61
+ }
62
+ }
63
+ /** A personal field whose data subject was erased: only decoding makes one. */
64
+ export class Erased {
65
+ kind = "erased";
66
+ subject;
67
+ constructor(subject) {
68
+ this.subject = subject;
69
+ Object.freeze(this);
70
+ }
71
+ orElse(fallback) {
72
+ return fallback;
73
+ }
74
+ get isErased() {
75
+ return true;
76
+ }
77
+ toString() {
78
+ return `Personal(${this.subject})`;
79
+ }
80
+ [inspect]() {
81
+ return this.toString();
82
+ }
83
+ toJSON() {
84
+ return { subject: this.subject };
85
+ }
86
+ }
87
+ /** A personal field about `about` holding `value`. */
88
+ export function personal(about, value) {
89
+ const problem = subjectProblem(about);
90
+ if (problem !== undefined)
91
+ throw new TypeError(problem);
92
+ return new Present(about, value);
93
+ }
94
+ /** @internal An erased field, as decoding makes one. */
95
+ export function erased(about) {
96
+ return new Erased(about);
97
+ }
98
+ // The project of each field read from an envelope, by the field's identity and held weakly (FR-027):
99
+ // a field a handler makes, even with the same value, is a different object and the service's own.
100
+ const origins = new WeakMap();
101
+ /** @internal Remembers that `field` was read from an envelope of `project`; answers it. */
102
+ export function readFrom(field, project) {
103
+ origins.set(field, project);
104
+ return field;
105
+ }
106
+ /** @internal The project whose envelope `field` was read from, or `undefined` when a handler made it. */
107
+ export function originOf(field) {
108
+ return origins.get(field);
109
+ }
110
+ export function isPersonal(value) {
111
+ return value instanceof Present || value instanceof Erased;
112
+ }
113
+ /**
114
+ * Why a personal field could not be written or read. A `CommandError` whose `details.personal` is the
115
+ * reason: `unavailable` (no keyring, or it did not answer), `erased` (a write for an erased data
116
+ * subject), `corrupt` (an envelope that fails authentication). Reading an erased subject is never one.
117
+ */
118
+ export class PersonalError extends CommandError {
119
+ reason;
120
+ constructor(reason, message) {
121
+ const code = reason === "unavailable" ? ErrorCode.Unavailable : reason === "erased" ? ErrorCode.Conflict : ErrorCode.Internal;
122
+ super({ message, code, details: { personal: reason } });
123
+ this.name = "PersonalError";
124
+ this.reason = reason;
125
+ }
126
+ static unavailable(why) {
127
+ return new PersonalError("unavailable", `the keyring is unavailable: ${why}`);
128
+ }
129
+ static erased(about) {
130
+ return new PersonalError("erased", `the data subject '${about}' is erased; nothing was written`);
131
+ }
132
+ static corrupt(about, why) {
133
+ return new PersonalError("corrupt", `the personal envelope of '${about}' is corrupt: ${why}`);
134
+ }
135
+ }
136
+ // ── the envelope's cipher ────────────────────────────────────────────────────
137
+ /** The envelope's version; a reader refuses one it does not know. */
138
+ export const ENVELOPE_VERSION = 1;
139
+ const NONCE_BYTES = 12;
140
+ const TAG_BYTES = 16;
141
+ const utf8 = new TextEncoder();
142
+ /** Subject, project and manifest, each as a 4-byte big-endian length and its UTF-8 bytes. */
143
+ export function associatedData(about, project, manifest) {
144
+ const parts = [about, project, manifest].map((p) => utf8.encode(p));
145
+ const out = new Uint8Array(parts.reduce((n, p) => n + 4 + p.length, 0));
146
+ const view = new DataView(out.buffer);
147
+ let at = 0;
148
+ for (const p of parts) {
149
+ view.setUint32(at, p.length, false);
150
+ out.set(p, at + 4);
151
+ at += 4 + p.length;
152
+ }
153
+ return out;
154
+ }
155
+ /** `nonce ‖ ciphertext ‖ tag`. A nonce is random unless given, which only the fixtures' tests do. */
156
+ export function seal(key, plaintext, ad, nonce) {
157
+ const iv = nonce ?? randomBytes(NONCE_BYTES);
158
+ if (iv.length !== NONCE_BYTES)
159
+ throw new RangeError(`a nonce is ${NONCE_BYTES} bytes, was ${iv.length}`);
160
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
161
+ cipher.setAAD(ad);
162
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
163
+ return new Uint8Array(Buffer.concat([iv, body, cipher.getAuthTag()]));
164
+ }
165
+ /** The plaintext, or `undefined` when the tag does not verify under this key and associated data. */
166
+ export function open(key, boxed, ad) {
167
+ if (boxed.length < NONCE_BYTES + TAG_BYTES)
168
+ return undefined;
169
+ try {
170
+ const decipher = createDecipheriv("aes-256-gcm", key, boxed.subarray(0, NONCE_BYTES));
171
+ decipher.setAAD(ad);
172
+ decipher.setAuthTag(boxed.subarray(boxed.length - TAG_BYTES));
173
+ return new Uint8Array(Buffer.concat([decipher.update(boxed.subarray(NONCE_BYTES, boxed.length - TAG_BYTES)), decipher.final()]));
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ }
179
+ let bound;
180
+ /** @internal The scope bound now, if any. */
181
+ export function currentScope() {
182
+ return bound;
183
+ }
184
+ /**
185
+ * @internal Runs `body` (synchronous: a codec's encode or decode) as a serializer writing under
186
+ * `manifest`. `nonce` fixes the nonce every envelope in `body` is sealed with, which only the fixtures'
187
+ * tests may do: a nonce used twice under one key breaks GCM.
188
+ */
189
+ export function withinScope(manifest, body, nonce) {
190
+ const before = bound;
191
+ bound = nonce === undefined ? { manifest } : { manifest, nonce };
192
+ try {
193
+ return body();
194
+ }
195
+ finally {
196
+ bound = before;
197
+ }
198
+ }
199
+ /** @internal Runs `body` at the edge, with no scope bound. */
200
+ export function outsideScope(body) {
201
+ const before = bound;
202
+ bound = undefined;
203
+ try {
204
+ return body();
205
+ }
206
+ finally {
207
+ bound = before;
208
+ }
209
+ }
package/dist/schema.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Duration, Instant, LocalDate, LocalDateTime } from "./time.ts";
2
+ import type { Personal } from "./personal.ts";
2
3
  /** The one value of type `Done`: a handler that has nothing to say. */
3
4
  export declare const done: Readonly<{
4
5
  readonly done: true;
@@ -32,6 +33,9 @@ export type Shape = {
32
33
  } | {
33
34
  readonly kind: "lazy";
34
35
  readonly thunk: () => Schema;
36
+ } | {
37
+ readonly kind: "personal";
38
+ readonly inner: Schema;
35
39
  };
36
40
  interface Typed<T> {
37
41
  /** Phantom: carries the TypeScript type. Never set. */
@@ -90,6 +94,13 @@ export declare const s: Readonly<{
90
94
  sumType<C extends Readonly<Record<string, Readonly<Record<string, Schema>>>>>(name: string, cases: C): Schema<InferCases<C>>;
91
95
  /** A fieldless enumeration used as a field: `{"type":"Ready"}` on the wire, `"Ready"` in TypeScript. */
92
96
  enumeration<const V extends readonly [string, ...string[]]>(name: string, ...values: V): Schema<V[number]>;
97
+ /**
98
+ * A personal field (feature 042): `s.personal(s.string)` is a `Personal<string>`, built with
99
+ * `personal(subject("player/8c1f"), value)`. A payload holds it as the personal envelope, sealed under
100
+ * the data subject's key; an endpoint's reply or body as `{"subject": s, "value": v}`, or
101
+ * `{"subject": s}` once the subject is erased.
102
+ */
103
+ personal<T>(inner: Schema<T>): Schema<Personal<T>>;
93
104
  /** A reference to a schema declared later, for recursive shapes: `s.list(s.lazy(() => Tree))`. */
94
105
  lazy<T>(thunk: () => Schema<T>): Schema<T>;
95
106
  }>;
@@ -109,4 +120,6 @@ export declare function defaultManifest(schema: Schema): string;
109
120
  * unknown properties; integers are `integer`; instants are `string` with `format: date-time`.
110
121
  */
111
122
  export declare function toJsonSchema(schema: Schema): Record<string, unknown>;
123
+ /** Whether `schema` holds a personal field anywhere in it. */
124
+ export declare function holdsPersonal(schema: Schema, seen?: Set<Schema>): boolean;
112
125
  export {};
package/dist/schema.js CHANGED
@@ -82,6 +82,15 @@ export const s = Object.freeze({
82
82
  throw new SchemaError(`enumeration ${name} repeats a value`);
83
83
  return Object.freeze({ kind: "enumeration", name, values: Object.freeze([...values]) });
84
84
  },
85
+ /**
86
+ * A personal field (feature 042): `s.personal(s.string)` is a `Personal<string>`, built with
87
+ * `personal(subject("player/8c1f"), value)`. A payload holds it as the personal envelope, sealed under
88
+ * the data subject's key; an endpoint's reply or body as `{"subject": s, "value": v}`, or
89
+ * `{"subject": s}` once the subject is erased.
90
+ */
91
+ personal(inner) {
92
+ return Object.freeze({ kind: "personal", inner });
93
+ },
85
94
  /** A reference to a schema declared later, for recursive shapes: `s.list(s.lazy(() => Tree))`. */
86
95
  lazy(thunk) {
87
96
  return Object.freeze({ kind: "lazy", thunk });
@@ -114,6 +123,8 @@ export function describe(schema) {
114
123
  return `list<${describe(r.inner)}>`;
115
124
  case "stringMap":
116
125
  return `map<string, ${describe(r.inner)}>`;
126
+ case "personal":
127
+ return `personal<${describe(r.inner)}>`;
117
128
  default:
118
129
  return r.kind;
119
130
  }
@@ -132,6 +143,8 @@ export function defaultManifest(schema) {
132
143
  return `list[${defaultManifest(r.inner)}]`;
133
144
  case "stringMap":
134
145
  return `map[${defaultManifest(r.inner)}]`;
146
+ case "personal":
147
+ return `personal[${defaultManifest(r.inner)}]`;
135
148
  default:
136
149
  return r.kind;
137
150
  }
@@ -186,6 +199,36 @@ export function toJsonSchema(schema) {
186
199
  };
187
200
  case "enumeration":
188
201
  return { type: "object", properties: { type: { enum: [...r.values] } }, required: ["type"], additionalProperties: false };
202
+ case "personal":
203
+ // The edge form: the value with its subject beside it, or the subject alone once erased.
204
+ return {
205
+ type: "object",
206
+ properties: { subject: { type: "string", pattern: "^[A-Za-z0-9._/:-]{1,253}$" }, value: toJsonSchema(r.inner) },
207
+ required: ["subject"],
208
+ additionalProperties: false,
209
+ };
210
+ }
211
+ }
212
+ /** Whether `schema` holds a personal field anywhere in it. */
213
+ export function holdsPersonal(schema, seen = new Set()) {
214
+ if (seen.has(schema))
215
+ return false;
216
+ seen.add(schema);
217
+ switch (schema.kind) {
218
+ case "personal":
219
+ return true;
220
+ case "lazy":
221
+ return holdsPersonal(schema.thunk(), seen);
222
+ case "option":
223
+ case "list":
224
+ case "stringMap":
225
+ return holdsPersonal(schema.inner, seen);
226
+ case "record":
227
+ return Object.values(schema.fields).some((f) => holdsPersonal(f, seen));
228
+ case "sumType":
229
+ return Object.values(schema.cases).some((fields) => Object.values(fields).some((f) => holdsPersonal(f, seen)));
230
+ default:
231
+ return false;
189
232
  }
190
233
  }
191
234
  function objectSchema(fields) {
@@ -10,7 +10,7 @@ import { ErrorCode } from "../effects/common.js";
10
10
  import { decodeJsonValue, reviver } from "../json.js";
11
11
  import { errorCodeToProto } from "../kinds.js";
12
12
  import { decodeResult } from "../autonomous.js";
13
- import { decodePayload } from "./payloads.js";
13
+ import { decodePayload, prefetchPayload } from "./payloads.js";
14
14
  function messageOf(e) {
15
15
  return e instanceof Error ? e.message : String(e);
16
16
  }
@@ -33,6 +33,7 @@ export async function handlePlan(req, ctx) {
33
33
  const agent = new registered.cls();
34
34
  agent._bind(req.sessionId, metadata, ctx.client.withMetadata(metadata));
35
35
  let effect;
36
+ await prefetchPayload(req.payload);
36
37
  try {
37
38
  const input = handler.input ? decodePayload(codecFor(handler.input), req.payload) : undefined;
38
39
  effect = (await handler.run(agent, input));
@@ -10,7 +10,14 @@ export declare function refusal(spec: Spec, sidecarProtocol: string): string | u
10
10
  export declare const STORAGE_SINCE: readonly [number, number];
11
11
  /** Every problem the sidecar has reported to this process, in order. */
12
12
  export declare const problems: string[];
13
- export declare function discoveryRoutes(router: ConnectRouter, spec: () => Spec, log?: (message: string) => void): void;
13
+ /** What discovery needs beside the spec to judge personal data (1.23), and to start following its notices. */
14
+ export interface PersonalDiscovery {
15
+ /** The components and endpoints whose shapes hold a personal field. */
16
+ readonly users: () => readonly string[];
17
+ /** Told the runtime's protocol once discovery has been answered. */
18
+ readonly discovered?: (protocolVersion: string) => void;
19
+ }
20
+ export declare function discoveryRoutes(router: ConnectRouter, spec: () => Spec, log?: (message: string) => void, personal?: PersonalDiscovery): void;
14
21
  /** The protocol version whose runtimes serve socket routes. */
15
22
  export declare const SOCKETS_SINCE: readonly [number, number];
16
23
  /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.9
@@ -21,6 +28,11 @@ export declare const GRANTS_SINCE: readonly [number, number];
21
28
  /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
22
29
  * does not know `Callers.granted`, and would admit nobody by it. */
23
30
  export declare function grantsRefusal(spec: Spec, runtime: string): string | undefined;
31
+ /** The protocol version whose runtimes serve a subject's key, its notices and the erasure handler. */
32
+ export declare const PERSONAL_SINCE: readonly [number, number];
33
+ /** Why a service with personal fields (`users`) or an erasure handler cannot run on a runtime speaking
34
+ * `runtime`, or undefined. A runtime before 1.23 has no keyring to ask and would never call the handler. */
35
+ export declare function personalRefusal(spec: Spec, users: readonly string[], runtime: string): string | undefined;
24
36
  /** The protocol version whose runtimes serve a process's MCP endpoint. */
25
37
  export declare const MCP_SINCE: readonly [number, number];
26
38
  /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
@@ -142,7 +142,7 @@ function startPositionRefusal(spec, sidecarProtocol) {
142
142
  export const STORAGE_SINCE = [1, 21];
143
143
  /** Every problem the sidecar has reported to this process, in order. */
144
144
  export const problems = [];
145
- export function discoveryRoutes(router, spec, log = console.error) {
145
+ export function discoveryRoutes(router, spec, log = console.error, personal) {
146
146
  router.service(Discovery, {
147
147
  async discover(info) {
148
148
  log(`ankka: discovery from sidecar (protocol ${info.protocolVersion}, runtime ${info.runtimeVersion})`);
@@ -157,12 +157,14 @@ export function discoveryRoutes(router, spec, log = console.error) {
157
157
  socketRefusal(answer, info.protocolVersion) ??
158
158
  grantsRefusal(answer, info.protocolVersion) ??
159
159
  // Last: an older runtime is told the oldest thing it lacks first.
160
- mcpRefusal(answer, info.protocolVersion);
160
+ mcpRefusal(answer, info.protocolVersion) ??
161
+ personalRefusal(answer, personal?.users() ?? [], info.protocolVersion);
161
162
  if (why !== undefined) {
162
163
  log(`ankka: ${why}`);
163
164
  problems.push(why);
164
165
  throw new ConnectError(why, Code.FailedPrecondition);
165
166
  }
167
+ personal?.discovered?.(info.protocolVersion);
166
168
  return answer;
167
169
  },
168
170
  async reportError(problem) {
@@ -211,6 +213,16 @@ export function grantsRefusal(spec, runtime) {
211
213
  return undefined;
212
214
  return `this runtime speaks protocol ${runtime || "unknown"}; granted callers and another project's topics need 1.15 (${endpoints.join(", ")})`;
213
215
  }
216
+ /** The protocol version whose runtimes serve a subject's key, its notices and the erasure handler. */
217
+ export const PERSONAL_SINCE = [1, 23];
218
+ /** Why a service with personal fields (`users`) or an erasure handler cannot run on a runtime speaking
219
+ * `runtime`, or undefined. A runtime before 1.23 has no keyring to ask and would never call the handler. */
220
+ export function personalRefusal(spec, users, runtime) {
221
+ const what = [...users, ...(spec.erasureHandler ? ["the erasure handler"] : [])];
222
+ if (what.length === 0 || !olderThan(runtime, PERSONAL_SINCE))
223
+ return undefined;
224
+ return `personal fields need protocol 1.23: this runtime speaks ${runtime || "unknown"}, and this SDK ${PROTOCOL_VERSION} (${what.join(", ")})`;
225
+ }
214
226
  /** The protocol version whose runtimes serve a process's MCP endpoint. */
215
227
  export const MCP_SINCE = [1, 20];
216
228
  /** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
@@ -0,0 +1,7 @@
1
+ import type { ConnectRouter } from "@connectrpc/connect";
2
+ import { type ErasureCall, type ErasureReply } from "../_proto/ankka/protocol/v1/erasure_pb.ts";
3
+ import { type ErasureHandler } from "../erasure.ts";
4
+ import type { ServerContext } from "./server.ts";
5
+ /** Runs the service's handler for one call; the Connect servicer and the unit tests both call it. */
6
+ export declare function invokeErasure(handler: ErasureHandler | undefined, call: ErasureCall, ctx: ServerContext): Promise<ErasureReply>;
7
+ export declare function erasureRoutes(router: ConnectRouter, ctx: ServerContext): void;
@@ -0,0 +1,40 @@
1
+ // The `Erasure` service (protocol 1.23, feature 042): the sidecar asks this process to run its erasure
2
+ // handler for one erased data subject. A handler that throws answers an `Error`, which the platform
3
+ // records as a failed run and runs again on the next pass; it is never a gRPC status.
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { Erasure, ErasureReplySchema } from "../_proto/ankka/protocol/v1/erasure_pb.js";
6
+ import { secretsFor } from "../client.js";
7
+ import { metadataFromProto } from "../context.js";
8
+ import { CommandError, ErrorCode } from "../effects/common.js";
9
+ import { erasuresFor } from "../erasures.js";
10
+ import { finalityToProto } from "../erasure.js";
11
+ import { errorCodeToProto } from "../kinds.js";
12
+ import { subjectProblem } from "../personal.js";
13
+ import { processStorage } from "../storage.js";
14
+ function failed(message, code = ErrorCode.Internal) {
15
+ return create(ErasureReplySchema, { objectsErased: 0n, finality: "", error: { message, code: errorCodeToProto(code) } });
16
+ }
17
+ /** Runs the service's handler for one call; the Connect servicer and the unit tests both call it. */
18
+ export async function invokeErasure(handler, call, ctx) {
19
+ if (!handler)
20
+ return failed("this service declares no erasure handler", ErrorCode.NotFound);
21
+ const problem = subjectProblem(call.subject);
22
+ if (problem !== undefined)
23
+ return failed(problem, ErrorCode.BadRequest);
24
+ const metadata = metadataFromProto(call.metadata);
25
+ const client = ctx.client.withMetadata(metadata);
26
+ try {
27
+ const report = (await handler({ client, secrets: secretsFor(client), storage: ctx.storage ?? processStorage(), erasures: erasuresFor(client), erasureSeq: call.erasureSeq, rerun: call.rerun, metadata }, call.subject)) ?? {};
28
+ const count = report.objectsErased ?? 0;
29
+ return create(ErasureReplySchema, { objectsErased: BigInt(count), finality: finalityToProto(report.finality) });
30
+ }
31
+ catch (e) {
32
+ ctx.log(`ankka: the erasure handler failed for '${call.subject}': ${e instanceof Error ? e.message : String(e)}`);
33
+ return failed(e instanceof Error ? e.message : String(e), e instanceof CommandError ? e.code : ErrorCode.Internal);
34
+ }
35
+ }
36
+ export function erasureRoutes(router, ctx) {
37
+ router.service(Erasure, {
38
+ invoke: (call) => invokeErasure(ctx.registry.erasureHandler, call, ctx),
39
+ });
40
+ }
@@ -14,7 +14,8 @@ import { ErrorCode } from "../effects/common.js";
14
14
  import { binaryCodecs } from "../codec.js";
15
15
  import { errorCodeToProto } from "../kinds.js";
16
16
  import { materialiseEventSourced } from "../materialise.js";
17
- import { decodePayload, encodePayload } from "./payloads.js";
17
+ import { PersonalError } from "../personal.js";
18
+ import { decodePayload, encodePayload, prefetchPayload, prefetchPayloadAt, prefetchValue, prefetchValueAt } from "./payloads.js";
18
19
  /** A replicated entity's handover or change to its replication set, as the reply carries it (1.19). */
19
20
  export function replicationToProto(change) {
20
21
  if (!change)
@@ -53,11 +54,16 @@ export async function* handleEventSourced(requests, ctx) {
53
54
  let sequence = 0n;
54
55
  // This installation, for a replicated entity (1.19): null on a service that names none.
55
56
  let here = null;
57
+ // The entity's home as its records have named it so far (FR-032): Init.home, then each folded
58
+ // event's installation, then Command.home. Its personal fields' keys are that installation's.
59
+ let home = null;
60
+ const keysAt = () => (home === null || home === here ? "" : home);
56
61
  for await (const m of requests) {
57
62
  switch (m.message.case) {
58
63
  case "init": {
59
64
  const init = m.message.value;
60
65
  here = init.installation || null;
66
+ home = init.home || null;
61
67
  const found = ctx.registry.component(init.componentId);
62
68
  if (!found || found.kind !== "event-sourced") {
63
69
  yield failure(0n, { message: `no event sourced entity ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound });
@@ -65,11 +71,12 @@ export async function* handleEventSourced(requests, ctx) {
65
71
  }
66
72
  registered = found;
67
73
  entityId = init.entityId;
74
+ await prefetchPayloadAt(keysAt(), init.snapshot?.payload);
68
75
  try {
69
76
  entity = new registered.cls();
70
77
  entity._bindInstance(entityId);
71
78
  if (init.snapshot) {
72
- state = decodePayload(registered.stateCodec, init.snapshot.payload);
79
+ state = decodePayload(registered.stateCodec, init.snapshot.payload, keysAt());
73
80
  sequence = init.snapshot.sequence;
74
81
  }
75
82
  else {
@@ -90,9 +97,13 @@ export async function* handleEventSourced(requests, ctx) {
90
97
  return;
91
98
  }
92
99
  const ev = m.message.value;
100
+ // A record from before replication names no installation, and reads as written at the home.
101
+ if (ev.installation)
102
+ home = ev.installation;
103
+ await prefetchPayloadAt(keysAt(), ev.payload);
93
104
  try {
94
105
  const written = ev.installation || here;
95
- state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload), eventContext(written, written === here, ev.sequence, ev.attribution, ev.forwardedFrom));
106
+ state = entity.applyEvent(state, decodePayload(registered.eventCodec, ev.payload, keysAt()), eventContext(written, written === here, ev.sequence, ev.attribution, ev.forwardedFrom));
96
107
  sequence = ev.sequence;
97
108
  }
98
109
  catch (e) {
@@ -107,7 +118,9 @@ export async function* handleEventSourced(requests, ctx) {
107
118
  yield failure(m.message.value.id, { message: "a command arrived before Init", code: ErrorCode.Internal });
108
119
  return;
109
120
  }
110
- const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx, here);
121
+ if (m.message.value.home)
122
+ home = m.message.value.home;
123
+ const result = await runCommand(registered, entity, entityId, state, sequence, m.message.value, ctx, here, keysAt());
111
124
  yield result.out;
112
125
  if (result.newState !== undefined) {
113
126
  state = result.newState.state;
@@ -120,11 +133,12 @@ export async function* handleEventSourced(requests, ctx) {
120
133
  }
121
134
  }
122
135
  }
123
- async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx, here = null) {
136
+ async function runCommand(registered, entity, entityId, state, sequence, cmd, ctx, here = null, keys = "") {
124
137
  const handler = registered.handlers.get(cmd.name);
125
138
  if (!handler)
126
139
  return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
127
140
  let input;
141
+ await prefetchPayload(cmd.payload);
128
142
  try {
129
143
  input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
130
144
  }
@@ -154,8 +168,11 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
154
168
  applied += 1n;
155
169
  return entity.applyEvent(s, e, eventContext(here, true, applied, context.attribution, context.forwardedFrom));
156
170
  });
171
+ // What is persisted, and the snapshot, are the record's: sealed with its home's keys (FR-032).
172
+ await prefetchValueAt(keys, m.events, cmd.snapshotRequested ? m.newState : undefined);
173
+ await prefetchValue(m.reply);
157
174
  const replication = !m.error && effect.kind === "persist" ? replicationToProto(effect.replication) : undefined;
158
- const events = m.events.map((e) => encodePayload(registered.eventCodec, e));
175
+ const events = m.events.map((e) => encodePayload(registered.eventCodec, e, keys));
159
176
  const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
160
177
  const outcome = m.error
161
178
  ? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
@@ -170,7 +187,7 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
170
187
  events,
171
188
  retention: retentionToProto(m.retention),
172
189
  outcome,
173
- snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState) : undefined,
190
+ snapshot: cmd.snapshotRequested ? encodePayload(registered.stateCodec, m.newState, keys) : undefined,
174
191
  replication,
175
192
  },
176
193
  },
@@ -181,6 +198,6 @@ async function runCommand(registered, entity, entityId, state, sequence, cmd, ct
181
198
  }
182
199
  catch (e) {
183
200
  ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
184
- return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
201
+ return { out: failure(cmd.id, { message: messageOf(e), code: e instanceof PersonalError ? e.code : ErrorCode.Internal }) };
185
202
  }
186
203
  }
@@ -8,7 +8,8 @@ import { ErrorCode } from "../effects/common.js";
8
8
  import { errorCodeToProto } from "../kinds.js";
9
9
  import { materialiseKeyValue } from "../materialise.js";
10
10
  import { retentionToProto } from "./eventSourced.js";
11
- import { decodePayload, encodePayload } from "./payloads.js";
11
+ import { PersonalError } from "../personal.js";
12
+ import { decodePayload, encodePayload, prefetchPayload, prefetchValue } from "./payloads.js";
12
13
  function failure(commandId, error) {
13
14
  return create(KeyValueOutSchema, { message: { case: "failure", value: { commandId, error: { message: error.message, code: errorCodeToProto(error.code) } } } });
14
15
  }
@@ -36,6 +37,7 @@ export async function* handleKeyValue(requests, ctx) {
36
37
  }
37
38
  registered = found;
38
39
  entityId = init.entityId;
40
+ await prefetchPayload(init.state);
39
41
  try {
40
42
  entity = new registered.cls();
41
43
  entity._bindInstance(entityId);
@@ -69,6 +71,7 @@ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
69
71
  if (!handler)
70
72
  return { out: failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound }) };
71
73
  let input;
74
+ await prefetchPayload(cmd.payload);
72
75
  try {
73
76
  input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
74
77
  }
@@ -94,6 +97,7 @@ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
94
97
  }
95
98
  try {
96
99
  const m = materialiseKeyValue(effect, state);
100
+ await prefetchValue(m.reply, m.changed ? m.newState : undefined);
97
101
  const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
98
102
  const outcome = m.error
99
103
  ? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
@@ -117,6 +121,6 @@ async function runCommand(registered, entity, entityId, state, cmd, ctx) {
117
121
  }
118
122
  catch (e) {
119
123
  ctx.log(`ankka: ${registered.id}/${entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
120
- return { out: failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal }) };
124
+ return { out: failure(cmd.id, { message: messageOf(e), code: e instanceof PersonalError ? e.code : ErrorCode.Internal }) };
121
125
  }
122
126
  }
@@ -1,6 +1,14 @@
1
1
  import { type Payload } from "../_proto/ankka/protocol/v1/payload_pb.ts";
2
2
  import type { Codec } from "../codec.ts";
3
- export declare function encodePayload<T>(codec: Codec<T>, value: T): Payload;
3
+ export declare function encodePayload<T>(codec: Codec<T>, value: T, home?: string): Payload;
4
4
  /** Decodes a payload with `codec`. An absent payload is the empty payload, which is what `unit` and `done` are. */
5
- export declare function decodePayload<T>(codec: Codec<T>, payload: Payload | undefined): T;
5
+ export declare function decodePayload<T>(codec: Codec<T>, payload: Payload | undefined, home?: string): T;
6
+ /** Fetches the keys every personal envelope in these payloads needs. Never throws. */
7
+ export declare function prefetchPayload(...payloads: (Payload | undefined)[]): Promise<void>;
8
+ /** Fetches, from `home`'s keyring, the keys every personal envelope in these payloads needs. Never throws. */
9
+ export declare function prefetchPayloadAt(home: string, ...payloads: (Payload | undefined)[]): Promise<void>;
10
+ /** Fetches the keys every personal field in these values needs to be written. Never throws. */
11
+ export declare function prefetchValue(...values: unknown[]): Promise<void>;
12
+ /** Fetches, from `home`'s keyring, the keys every personal field in these values needs to be written. Never throws. */
13
+ export declare function prefetchValueAt(home: string, ...values: unknown[]): Promise<void>;
6
14
  export declare const EMPTY_PAYLOAD: Payload;