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
@@ -1,11 +1,43 @@
1
1
  // The bridge between a codec and the protocol's `Payload`: `{ content_type, manifest, data }`.
2
+ //
3
+ // A payload is what a serializer writes, so a personal field in it is the personal envelope, bound to
4
+ // the payload's manifest (feature 042): both directions run under that scope. The codec is synchronous,
5
+ // so the keys it needs are fetched first: `prefetchPayload` before a decode, `prefetchValue` before an
6
+ // encode. Each is a no-op when the process has no keyring or nothing personal is in sight.
7
+ //
8
+ // A replicated entity's keys are its home's (FR-032): the `…At` forms name the installation whose
9
+ // keyring keeps them, "" for this one, for both the fetch and the codec call that reads it.
2
10
  import { create } from "@bufbuild/protobuf";
3
11
  import { PayloadSchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
4
- export function encodePayload(codec, value) {
5
- return create(PayloadSchema, { contentType: codec.contentType, manifest: codec.manifest, data: codec.encode(value) });
12
+ import { withinScope } from "../personal.js";
13
+ import { prefetchForDecode, prefetchForEncode, withKeysAt } from "../keyring.js";
14
+ export function encodePayload(codec, value, home = "") {
15
+ return create(PayloadSchema, {
16
+ contentType: codec.contentType,
17
+ manifest: codec.manifest,
18
+ data: withKeysAt(home, () => withinScope(codec.manifest, () => codec.encode(value))),
19
+ });
6
20
  }
7
21
  /** Decodes a payload with `codec`. An absent payload is the empty payload, which is what `unit` and `done` are. */
8
- export function decodePayload(codec, payload) {
9
- return codec.decode(payload?.data ?? new Uint8Array());
22
+ export function decodePayload(codec, payload, home = "") {
23
+ return withKeysAt(home, () => withinScope(codec.manifest, () => codec.decode(payload?.data ?? new Uint8Array())));
24
+ }
25
+ /** Fetches the keys every personal envelope in these payloads needs. Never throws. */
26
+ export async function prefetchPayload(...payloads) {
27
+ await prefetchPayloadAt("", ...payloads);
28
+ }
29
+ /** Fetches, from `home`'s keyring, the keys every personal envelope in these payloads needs. Never throws. */
30
+ export async function prefetchPayloadAt(home, ...payloads) {
31
+ for (const p of payloads)
32
+ await prefetchForDecode(p?.data, home);
33
+ }
34
+ /** Fetches the keys every personal field in these values needs to be written. Never throws. */
35
+ export async function prefetchValue(...values) {
36
+ await prefetchValueAt("", ...values);
37
+ }
38
+ /** Fetches, from `home`'s keyring, the keys every personal field in these values needs to be written. Never throws. */
39
+ export async function prefetchValueAt(home, ...values) {
40
+ for (const v of values)
41
+ await prefetchForEncode(v, home);
10
42
  }
11
43
  export const EMPTY_PAYLOAD = create(PayloadSchema, { contentType: "application/octet-stream", manifest: "unit", data: new Uint8Array() });
@@ -16,6 +16,8 @@ export interface ServerOptions {
16
16
  readonly storage?: Buckets;
17
17
  /** Where `service.json` is read from for the buckets it declares; the working directory by default. */
18
18
  readonly serviceDirectory?: string;
19
+ /** How long a subject key is waited for from the sidecar; 10 seconds by default. */
20
+ readonly keyTimeoutMillis?: number;
19
21
  }
20
22
  /** What every servicer needs. */
21
23
  export interface ServerContext {
@@ -16,11 +16,17 @@ import { statelessRoutes } from "./stateless.js";
16
16
  import { agentRoutes } from "./agent.js";
17
17
  import { httpRoutes } from "./http.js";
18
18
  import { mcpRoutes } from "./mcp.js";
19
+ import { erasureRoutes } from "./erasure.js";
20
+ import { randomUUID } from "node:crypto";
21
+ import { channelOf } from "../client.js";
22
+ import { currentKeyring, followNotices, installKeyring, Keyring } from "../keyring.js";
23
+ import { olderThan } from "../startFrom.js";
24
+ import { PERSONAL_SINCE } from "./discovery.js";
19
25
  /** The registry with the directory store's endpoint, which serves the URLs that store signs. */
20
26
  function withStorageEndpoint(registry) {
21
27
  const cls = DirectoryStorageEndpoint;
22
28
  const endpoint = Object.freeze({ id: cls.name, cls, prefix: cls.prefix, acl: cls.acl, routes: new Map(Object.entries(cls.routes)) });
23
- return new Registry(registry.components, new Map([...registry.endpoints, [endpoint.id, endpoint]]));
29
+ return new Registry(registry.components, new Map([...registry.endpoints, [endpoint.id, endpoint]]), registry.mcpEndpoints, registry.erasureHandler);
24
30
  }
25
31
  const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0", "::"]);
26
32
  export class Server {
@@ -30,6 +36,8 @@ export class Server {
30
36
  #sessions = new Set();
31
37
  #http2;
32
38
  #address;
39
+ #keyring;
40
+ #following = false;
33
41
  #resolveClosed;
34
42
  closed = new Promise((resolve) => {
35
43
  this.#resolveClosed = resolve;
@@ -56,10 +64,14 @@ export class Server {
56
64
  _setProcessStorage(chosen.buckets);
57
65
  const registry = chosen.buckets instanceof DirectoryBuckets ? withStorageEndpoint(this.#context.registry) : this.#context.registry;
58
66
  this.#context = { ...this.#context, registry, storage: chosen.buckets };
67
+ this.#installKeyring();
59
68
  this.#context.log(`ankka: object storage is ${chosen.backend}${chosen.buckets instanceof DirectoryBuckets ? ` at ${chosen.buckets.root}` : ""}`);
60
69
  const handler = connectNodeAdapter({
61
70
  routes: (router) => {
62
- discoveryRoutes(router, () => renderSpec(this.#context.registry), this.#context.log);
71
+ discoveryRoutes(router, () => renderSpec(this.#context.registry), this.#context.log, {
72
+ users: () => this.#context.registry.personalUsers,
73
+ discovered: (protocol) => this.#followNotices(protocol),
74
+ });
63
75
  eventSourcedRoutes(router, this.#context);
64
76
  keyValueRoutes(router, this.#context);
65
77
  workflowRoutes(router, this.#context);
@@ -67,6 +79,7 @@ export class Server {
67
79
  agentRoutes(router, this.#context);
68
80
  httpRoutes(router, this.#context);
69
81
  mcpRoutes(router, this.#context);
82
+ erasureRoutes(router, this.#context);
70
83
  },
71
84
  shutdownSignal: this.#shutdown.signal,
72
85
  readMaxBytes: this.#options.readMaxBytes ?? 64 * 1024 * 1024,
@@ -93,6 +106,48 @@ export class Server {
93
106
  server.on("close", () => this.#resolveClosed());
94
107
  return this.#address;
95
108
  }
109
+ /** The process's keyring: subject keys asked of the sidecar this server's client dials (feature 042). */
110
+ #installKeyring() {
111
+ const channel = channelOf(this.#context.client);
112
+ if (!channel)
113
+ return;
114
+ const timeoutMs = this.#options.keyTimeoutMillis ?? 10_000;
115
+ // The project is learned from the sidecar's answers (`SubjectKeyReply.project`), never configured.
116
+ this.#keyring = new Keyring({
117
+ fetch: async (project, subject, create, home = "") => {
118
+ const reply = await channel.stub().getSubjectKey({ subject, create, project, home }, { timeoutMs });
119
+ switch (reply.reply.case) {
120
+ case "key":
121
+ return { project: reply.project, answer: { kind: "key", key: reply.reply.value } };
122
+ case "erased":
123
+ return { project: reply.project, answer: { kind: "erased" } };
124
+ case "error":
125
+ throw new Error(reply.reply.value.message);
126
+ default:
127
+ throw new Error("the sidecar answered a subject key request with nothing");
128
+ }
129
+ },
130
+ });
131
+ installKeyring(this.#keyring);
132
+ }
133
+ /** Holds the sidecar's subject notices once a runtime speaking 1.23 has discovered a service with personal fields. */
134
+ #followNotices(protocol) {
135
+ const keyring = this.#keyring;
136
+ // Discovered, by a sidecar that may be new: one that stopped before it handed on a notice hands
137
+ // it never again, and this process may not yet have seen the old notice stream end. Every key
138
+ // held is forgotten, so an erased subject is asked for again and reads as erased.
139
+ keyring?.dropAll();
140
+ const channel = channelOf(this.#context.client);
141
+ if (this.#following || !keyring || !channel || olderThan(protocol, PERSONAL_SINCE) || this.#context.registry.personalUsers.length === 0)
142
+ return;
143
+ this.#following = true;
144
+ const instance = randomUUID();
145
+ void followNotices(keyring, (signal) => channel.stub().subjectNotices({ instance }, { signal }), async (seq) => {
146
+ const reply = await channel.stub().ackSubjectNotice({ seq, instance });
147
+ if (reply.error)
148
+ this.#context.log(`ankka: the sidecar refused the ack of subject notice ${seq}: ${reply.error.message}`);
149
+ }, this.#shutdown.signal, this.#context.log);
150
+ }
96
151
  /** The bound address; throws before `start`. */
97
152
  get address() {
98
153
  if (!this.#address)
@@ -105,6 +160,8 @@ export class Server {
105
160
  if (!server)
106
161
  return;
107
162
  this.#shutdown.abort();
163
+ if (this.#keyring && currentKeyring() === this.#keyring)
164
+ installKeyring(undefined);
108
165
  const closing = new Promise((resolve) => server.close(() => resolve()));
109
166
  for (const session of this.#sessions)
110
167
  session.close();
@@ -13,7 +13,7 @@ import { ProtocolVersionError, requireSeveralMessages } from "../consumer.js";
13
13
  import { GRAPH_DELTA_SCHEMA, deltaRecords } from "../graph.js";
14
14
  import { JSON_CONTENT } from "../codec.js";
15
15
  import { errorCodeToProto } from "../kinds.js";
16
- import { decodePayload, encodePayload } from "./payloads.js";
16
+ import { decodePayload, encodePayload, prefetchPayload, prefetchValue } from "./payloads.js";
17
17
  import { PayloadSchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
18
18
  import { standingFromProto } from "../standing.js";
19
19
  function messageOf(e) {
@@ -34,12 +34,14 @@ async function handleKeyedView(req, sourceId, ctx) {
34
34
  const view = new registered.cls();
35
35
  try {
36
36
  const client = ctx.client.withMetadata(metadata);
37
+ await prefetchPayload(req.event);
37
38
  view._bind(metadata, client, clientRows(client, registered.id, registered.rowCodec), standingFromProto(req.standing));
38
39
  const effect = (req.deleted
39
40
  ? source.deleted
40
41
  ? await source.deleted(view)
41
42
  : undefined
42
43
  : await source.onChange(view, decodePayload(source.eventCodec, req.event)));
44
+ await prefetchValue(effect);
43
45
  if (effect !== undefined && effect?.kind !== "rows")
44
46
  throw new TypeError(`${registered.id}'s handler for ${sourceId} returned something that is not a keyed view effect`);
45
47
  const changes = (effect?.changes ?? []).map((c) => "deleted" in c
@@ -63,9 +65,11 @@ export async function handleView(req, ctx) {
63
65
  const metadata = metadataFromProto(req.metadata);
64
66
  const view = new registered.cls();
65
67
  try {
68
+ await prefetchPayload(req.row, req.event);
66
69
  const row = req.row ? decodePayload(registered.rowCodec, req.row) : null;
67
70
  view._bind(row, metadata, ctx.client.withMetadata(metadata), standingFromProto(req.standing));
68
71
  const effect = req.deleted ? await view.onDelete() : await view.onChange(decodePayload(registered.eventCodec, req.event));
72
+ await prefetchValue(effect);
69
73
  switch (effect.kind) {
70
74
  case "update-row":
71
75
  return create(ViewEffectSchema, { effect: { case: "updateRow", value: encodePayload(registered.rowCodec, effect.row) } });
@@ -104,7 +108,9 @@ function produceAll(request, messages) {
104
108
  async function graphEffect(registered, req, metadata, ctx) {
105
109
  const consumer = new registered.cls();
106
110
  consumer._bind(metadata, ctx.client.withMetadata(metadata), standingFromProto(req.standing));
111
+ await prefetchPayload(req.message);
107
112
  const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
113
+ await prefetchValue(effect);
108
114
  switch (effect.kind) {
109
115
  case "publish":
110
116
  return produceAll(metadata, deltaRecords(effect, metadata).map((r) => ({
@@ -123,7 +129,9 @@ async function graphEffect(registered, req, metadata, ctx) {
123
129
  async function consumerEffect(registered, req, metadata, ctx) {
124
130
  const consumer = new registered.cls();
125
131
  consumer._bind(metadata, ctx.client.withMetadata(metadata), standingFromProto(req.standing));
132
+ await prefetchPayload(req.message);
126
133
  const effect = req.deleted ? await consumer.onDelete() : await consumer.onMessage(decodePayload(registered.messageCodec, req.message));
134
+ await prefetchValue(effect);
127
135
  const outCodec = () => {
128
136
  if (!registered.outCodec)
129
137
  throw new Error(`${registered.id} produced a message but declares no out shape`);
@@ -171,6 +179,7 @@ export async function handleTimedAction(req, ctx) {
171
179
  const metadata = metadataFromProto(req.metadata);
172
180
  const instance = new registered.cls();
173
181
  try {
182
+ await prefetchPayload(req.payload);
174
183
  const input = action.input ? decodePayload(codecFor(action.input), req.payload) : undefined;
175
184
  instance._bind(metadata, ctx.client.withMetadata(metadata));
176
185
  const effect = await action.run(instance, input);
@@ -10,7 +10,8 @@ import { commandContext, metadataFromProto } from "../context.js";
10
10
  import { ErrorCode } from "../effects/common.js";
11
11
  import { errorCodeToProto } from "../kinds.js";
12
12
  import { materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
13
- import { decodePayload, encodePayload } from "./payloads.js";
13
+ import { PersonalError } from "../personal.js";
14
+ import { decodePayload, encodePayload, prefetchPayload, prefetchValue } from "./payloads.js";
14
15
  import { AsyncQueue } from "./queue.js";
15
16
  function failure(commandId, error) {
16
17
  return create(WorkflowOutSchema, { message: { case: "failure", value: { commandId, error: { message: error.message, code: errorCodeToProto(error.code) } } } });
@@ -48,6 +49,7 @@ class WorkflowStream {
48
49
  if (!handler)
49
50
  return failure(cmd.id, { message: `no handler ${JSON.stringify(cmd.name)} on ${registered.id}`, code: ErrorCode.NotFound });
50
51
  let input;
52
+ await prefetchPayload(cmd.payload);
51
53
  try {
52
54
  input = handler.input ? decodePayload(codecFor(handler.input), cmd.payload) : undefined;
53
55
  }
@@ -72,6 +74,7 @@ class WorkflowStream {
72
74
  }
73
75
  try {
74
76
  const m = materialiseWorkflowCommand(effect, this.state);
77
+ await prefetchValue(m);
75
78
  const replyCodec = handler.reply ? codecFor(handler.reply) : binaryCodecs.done;
76
79
  const outcome = m.error
77
80
  ? { outcome: { case: "error", value: { message: m.error.message, code: errorCodeToProto(m.error.code) } } }
@@ -95,7 +98,7 @@ class WorkflowStream {
95
98
  }
96
99
  catch (e) {
97
100
  ctx.log(`ankka: ${registered.id}/${this.entityId}/${cmd.name}: the effect could not be applied: ${messageOf(e)}`);
98
- return failure(cmd.id, { message: messageOf(e), code: ErrorCode.Internal });
101
+ return failure(cmd.id, { message: messageOf(e), code: e instanceof PersonalError ? e.code : ErrorCode.Internal });
99
102
  }
100
103
  }
101
104
  /** Runs a step on a fresh instance, as the sidecar's engine does: commands keep arriving on the stream's instance meanwhile. */
@@ -107,6 +110,7 @@ class WorkflowStream {
107
110
  const fresh = new registered.cls();
108
111
  fresh._bindInstance(this.entityId);
109
112
  let input;
113
+ await prefetchPayload(run.input);
110
114
  try {
111
115
  input = step.input ? decodePayload(codecFor(step.input), run.input) : undefined;
112
116
  }
@@ -132,6 +136,7 @@ class WorkflowStream {
132
136
  }
133
137
  try {
134
138
  const m = materialiseStep(effect, this.state);
139
+ await prefetchValue(m);
135
140
  let next;
136
141
  switch (m.next.kind) {
137
142
  case "transition":
@@ -181,6 +186,7 @@ export async function* handleWorkflow(requests, ctx) {
181
186
  out.push(failure(0n, { message: `no workflow ${JSON.stringify(init.componentId)} is registered`, code: ErrorCode.NotFound }));
182
187
  return;
183
188
  }
189
+ await prefetchPayload(init.state);
184
190
  try {
185
191
  const instance = new found.cls();
186
192
  instance._bindInstance(init.entityId);
package/dist/service.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type StartFrom } from "./startFrom.ts";
2
2
  import { Contract, type Publication } from "./contract.ts";
3
3
  import { type Codec } from "./codec.ts";
4
+ import type { ErasureHandler } from "./erasure.ts";
4
5
  import { EventSourcedEntity, type EventSourcedEntityClass } from "./eventSourcedEntity.ts";
5
6
  import { KeyValueEntity, type KeyValueEntityClass } from "./keyValueEntity.ts";
6
7
  import { Workflow, type WorkflowClass } from "./workflow.ts";
@@ -153,7 +154,11 @@ export declare class Registry {
153
154
  readonly endpoints: ReadonlyMap<string, RegisteredEndpoint>;
154
155
  /** The MCP endpoints (1.20), by id, their references resolved. */
155
156
  readonly mcpEndpoints: ReadonlyMap<string, RegisteredMcpEndpoint>;
156
- constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>, mcpEndpoints?: ReadonlyMap<string, RegisteredMcpEndpoint>);
157
+ /** The service's erasure handler (1.23), if it declares one. */
158
+ readonly erasureHandler: ErasureHandler | undefined;
159
+ /** The components and endpoints whose shapes hold a personal field (1.23). */
160
+ readonly personalUsers: readonly string[];
161
+ constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>, mcpEndpoints?: ReadonlyMap<string, RegisteredMcpEndpoint>, erasureHandler?: ErasureHandler);
157
162
  mcpEndpoint(id: string): RegisteredMcpEndpoint | undefined;
158
163
  component(id: string): RegisteredComponent | undefined;
159
164
  /** The component `id` when it is of `kind`. */
@@ -191,6 +196,12 @@ export declare class ServiceBuilder {
191
196
  register<C extends AutonomousAgent>(cls: AutonomousAgentClass<C>): this;
192
197
  register<C extends McpEndpoint>(cls: McpEndpointClass<C>): this;
193
198
  register<C extends Endpoint>(cls: EndpointClass<C>): this;
199
+ /**
200
+ * The service's erasure handler (feature 042, protocol 1.23): run for each erased data subject, and again
201
+ * on each re-run, so it must be idempotent. One per service. A handler that throws is recorded as failed
202
+ * for that run and run again on the next.
203
+ */
204
+ erasureHandler(handler: ErasureHandler): this;
194
205
  /** Validates every registration and returns the registry, or throws a `RegistrationError` naming every problem. */
195
206
  validate(): Registry;
196
207
  /** The discovery `Spec` for what is registered, without listening. */
package/dist/service.js CHANGED
@@ -5,7 +5,8 @@
5
5
  // await Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint).listen()
6
6
  import { isStartFrom } from "./startFrom.js";
7
7
  import { Contract } from "./contract.js";
8
- import { codecFor, isCodec } from "./codec.js";
8
+ import { codecFor, isCodec, schemaOf } from "./codec.js";
9
+ import { holdsPersonal } from "./schema.js";
9
10
  import { EventSourcedEntity } from "./eventSourcedEntity.js";
10
11
  import { KeyValueEntity } from "./keyValueEntity.js";
11
12
  import { Workflow } from "./workflow.js";
@@ -29,10 +30,19 @@ export class Registry {
29
30
  endpoints;
30
31
  /** The MCP endpoints (1.20), by id, their references resolved. */
31
32
  mcpEndpoints;
32
- constructor(components, endpoints, mcpEndpoints = new Map()) {
33
+ /** The service's erasure handler (1.23), if it declares one. */
34
+ erasureHandler;
35
+ /** The components and endpoints whose shapes hold a personal field (1.23). */
36
+ personalUsers;
37
+ constructor(components, endpoints, mcpEndpoints = new Map(), erasureHandler) {
33
38
  this.components = components;
34
39
  this.endpoints = endpoints;
35
40
  this.mcpEndpoints = mcpEndpoints;
41
+ this.erasureHandler = erasureHandler;
42
+ this.personalUsers = Object.freeze([
43
+ ...[...components.values()].filter((c) => mentionsPersonal(c)).map((c) => c.id),
44
+ ...[...endpoints.values()].filter((e) => mentionsPersonal(e.routes)).map((e) => e.id),
45
+ ]);
36
46
  Object.freeze(this);
37
47
  }
38
48
  mcpEndpoint(id) {
@@ -50,6 +60,39 @@ export class Registry {
50
60
  return this.endpoints.get(id);
51
61
  }
52
62
  }
63
+ /**
64
+ * Whether a registered component or endpoint holds a schema with a personal field anywhere among its
65
+ * codecs and handler shapes. Its class is not searched: everything it declares was resolved into the entry.
66
+ */
67
+ function mentionsPersonal(entry) {
68
+ const seen = new Set();
69
+ const go = (v, depth) => {
70
+ if (depth > 8 || v === null || (typeof v !== "object" && typeof v !== "function") || seen.has(v))
71
+ return false;
72
+ seen.add(v);
73
+ if (typeof v === "function")
74
+ return false;
75
+ if (isCodec(v)) {
76
+ const schema = schemaOf(v);
77
+ return schema !== undefined && holdsPersonal(schema);
78
+ }
79
+ if (typeof v.kind === "string" && isSchemaKind(v.kind))
80
+ return holdsPersonal(v);
81
+ if (v instanceof Map)
82
+ return [...v.values()].some((x) => go(x, depth + 1));
83
+ if (Array.isArray(v))
84
+ return v.some((x) => go(x, depth + 1));
85
+ return Object.entries(v).some(([k, x]) => k !== "cls" && go(x, depth + 1));
86
+ };
87
+ return go(entry, 0);
88
+ }
89
+ const SCHEMA_KINDS = new Set([
90
+ "string", "int", "long", "double", "boolean", "instant", "duration", "localDate", "localDateTime", "bytes", "unit", "done",
91
+ "option", "list", "stringMap", "record", "sumType", "enumeration", "lazy", "personal",
92
+ ]);
93
+ function isSchemaKind(kind) {
94
+ return SCHEMA_KINDS.has(kind);
95
+ }
53
96
  /** Thrown by `validate`, `spec`, `server` and `listen` with every problem the registration has. */
54
97
  export class RegistrationError extends Error {
55
98
  problems;
@@ -71,6 +114,7 @@ function nameOf(cls) {
71
114
  export class ServiceBuilder {
72
115
  #classes = [];
73
116
  #client;
117
+ #erasureHandlers = [];
74
118
  #log;
75
119
  constructor(options = {}) {
76
120
  this.#client = options.client ?? new ComponentClient();
@@ -84,6 +128,15 @@ export class ServiceBuilder {
84
128
  this.#classes.push(cls);
85
129
  return this;
86
130
  }
131
+ /**
132
+ * The service's erasure handler (feature 042, protocol 1.23): run for each erased data subject, and again
133
+ * on each re-run, so it must be idempotent. One per service. A handler that throws is recorded as failed
134
+ * for that run and run again on the next.
135
+ */
136
+ erasureHandler(handler) {
137
+ this.#erasureHandlers.push(handler);
138
+ return this;
139
+ }
87
140
  /** Validates every registration and returns the registry, or throws a `RegistrationError` naming every problem. */
88
141
  validate() {
89
142
  const problems = [];
@@ -166,9 +219,13 @@ export class ServiceBuilder {
166
219
  else
167
220
  mcpEndpoints.set(r.id, r);
168
221
  }
222
+ if (this.#erasureHandlers.some((h) => typeof h !== "function"))
223
+ problems.push("the erasure handler is not a function");
224
+ if (this.#erasureHandlers.length > 1)
225
+ problems.push(`a service has one erasure handler, and ${this.#erasureHandlers.length} were given`);
169
226
  if (problems.length > 0)
170
227
  throw new RegistrationError(problems);
171
- return new Registry(components, endpoints, mcpEndpoints);
228
+ return new Registry(components, endpoints, mcpEndpoints, this.#erasureHandlers[0]);
172
229
  }
173
230
  /** The discovery `Spec` for what is registered, without listening. */
174
231
  spec() {
package/dist/spec.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type MessageInitShape } from "@bufbuild/protobuf";
2
2
  import { Endpoint_Acl, type McpAclSchema, type CallerMatcherSchema, type Spec } from "./_proto/ankka/protocol/v1/discovery_pb.ts";
3
3
  import type { Registry } from "./service.ts";
4
4
  import type { Acl } from "./routes.ts";
5
- export declare const PROTOCOL_VERSION = "1.22";
5
+ export declare const PROTOCOL_VERSION = "1.23";
6
6
  export declare const SDK_NAME = "ankka-typescript";
7
7
  /** An HTTP endpoint's rule; client keys and `anyOf` are refused for one at registration, so they deny here. */
8
8
  export declare function aclToProto(acl: Acl): Endpoint_Acl;
package/dist/spec.js CHANGED
@@ -17,7 +17,7 @@ function toolInit(t) {
17
17
  ...(approval !== undefined ? { approval } : {}),
18
18
  };
19
19
  }
20
- export const PROTOCOL_VERSION = "1.22";
20
+ export const PROTOCOL_VERSION = "1.23";
21
21
  export const SDK_NAME = "ankka-typescript";
22
22
  /** An HTTP endpoint's rule; client keys and `anyOf` are refused for one at registration, so they deny here. */
23
23
  export function aclToProto(acl) {
@@ -287,5 +287,6 @@ export function renderSpec(registry) {
287
287
  components,
288
288
  endpoints,
289
289
  mcpEndpoints,
290
+ erasureHandler: registry.erasureHandler !== undefined,
290
291
  });
291
292
  }
package/dist/storage.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { CommandError } from "./effects/common.ts";
2
+ import type { Finality } from "./erasure.ts";
3
+ import { type DataSubject } from "./personal.ts";
2
4
  import { Credentials } from "./sigv4.ts";
3
- import { Instant } from "./time.ts";
5
+ import { Duration, Instant } from "./time.ts";
4
6
  /** How a bucket is reached from outside the installation. */
5
7
  export type BucketExposure = "private" | "signed" | "public" | "supplied";
6
8
  /** What a signed URL lets its holder do with one object. */
@@ -61,7 +63,25 @@ export interface Bucket {
61
63
  signedUrl(name: string, access: SignedAccess, spanSeconds?: number): string;
62
64
  /** The address a browser reads the object under `name` at, with no signature: a public bucket's only. */
63
65
  url(name: string): string;
66
+ /**
67
+ * Removes every object of `subject` (feature 042): those under its prefix (`subjectPrefix`), and on a
68
+ * store that keeps versions every version of each. Objects outside it are kept, those of another data
69
+ * subject included. Run again, it erases what was kept since, and answers 0 when nothing was.
70
+ */
71
+ erase(subject: DataSubject | string): Promise<ErasedObjects>;
72
+ }
73
+ /** What `Bucket.erase` removed: how many objects, and when they are gone for good (an `ErasureReport`'s `finality`). */
74
+ export interface ErasedObjects {
75
+ readonly count: number;
76
+ readonly finality: Finality;
64
77
  }
78
+ /**
79
+ * Where a service keeps the objects of `subject` (feature 042): `subjects/`, the data subject with every
80
+ * `/` written `%2F`, and `/`. A data subject cannot hold `%`, so no subject prefix holds another's:
81
+ * `player`'s is `subjects/player/` and `player/8c1f`'s `subjects/player%2F8c1f/`. A string that is no
82
+ * data subject is refused (`BAD_REQUEST`).
83
+ */
84
+ export declare function subjectPrefix(subject: DataSubject | string): string;
65
85
  /** A service's buckets: `default`, the unnamed one, and the named ones by name. */
66
86
  export interface Buckets {
67
87
  /** The unnamed bucket. Refuses, naming `provisionObjectStorage`, when the service has none. */
@@ -94,6 +114,8 @@ export declare const ObjectRules: Readonly<{
94
114
  checkName(name: string): void;
95
115
  checkPrefix(prefix: string): void;
96
116
  checkSpan(spanSeconds: number): void;
117
+ /** Where a service keeps the objects of `subject`: `subjectPrefix`. */
118
+ subjectPrefix(subject: DataSubject | string): string;
97
119
  }>;
98
120
  /** What the storage client says when it refuses: the runtime's words (`StorageRefusals` in the Scala SDK). */
99
121
  export declare const StorageRefusals: Readonly<{
@@ -220,6 +242,11 @@ export interface S3Settings {
220
242
  readonly named: readonly NamedTarget[];
221
243
  /** How long one request waits for an answer: 30 seconds. */
222
244
  readonly timeoutMs: number;
245
+ /**
246
+ * How long the store keeps a deleted object (feature 042), from `ANKKA_S3_SOFT_DELETE_DAYS`: a whole
247
+ * number of days from 1, or `undefined` for at once.
248
+ */
249
+ readonly softDelete?: Duration | undefined;
223
250
  }
224
251
  type Env = Readonly<Record<string, string | undefined>>;
225
252
  /** The bucket a variable's `<NAME>` names: lower case, `_` as `-`. */
@@ -239,17 +266,36 @@ export declare function parseListing(xml: string): {
239
266
  readonly entries: ObjectEntry[];
240
267
  readonly next: string | undefined;
241
268
  };
269
+ /** One page of a `ListObjectVersions` answer: each version and delete marker as its key and version id. */
270
+ export interface VersionsPage {
271
+ /** `[key, versionId]`; the id is `null` for a version written before versioning was on. */
272
+ readonly versions: readonly (readonly [string, string])[];
273
+ readonly truncated: boolean;
274
+ readonly nextKey: string | undefined;
275
+ readonly nextVersion: string | undefined;
276
+ /** The answer named versions as Google Cloud Storage does (`Generation`, `NextGenerationMarker`); its next page is asked for by `generation-marker`. */
277
+ readonly generations: boolean;
278
+ }
279
+ /** A `ListObjectVersions` answer: its versions and delete markers, and where the next page starts while it is truncated. */
280
+ export declare function parseVersions(xml: string): VersionsPage;
242
281
  /** One bucket in an S3 store: path-style requests, a put streamed once with an unsigned payload, a listing paged as it is walked. */
243
282
  export declare class S3Bucket implements Bucket {
244
283
  #private;
245
284
  readonly name: string | undefined;
246
285
  readonly exposure: BucketExposure;
247
- constructor(target: S3Target, name: string | undefined, exposure: BucketExposure, publicUrl: string | undefined, timeoutMs?: number);
286
+ constructor(target: S3Target, name: string | undefined, exposure: BucketExposure, publicUrl: string | undefined, timeoutMs?: number, softDelete?: Duration);
248
287
  put(objectName: string, body: ReadableStream<Uint8Array> | Uint8Array, options: PutOptions): Promise<void>;
249
288
  get(objectName: string): Promise<StoredObject | undefined>;
250
289
  stat(objectName: string): Promise<ObjectInfo | undefined>;
251
290
  delete(objectName: string): Promise<void>;
252
291
  list(prefix: string): AsyncIterable<ObjectEntry>;
292
+ /**
293
+ * Lists every version and delete marker under the subject's prefix (`ListObjectVersions`) and deletes
294
+ * each by its version id; the count is the objects with a version. A store that lists no versions
295
+ * (Garage answers `NotImplemented`) is listed plainly and each object deleted. The finality is
296
+ * `ANKKA_S3_SOFT_DELETE_DAYS`'s, or immediate.
297
+ */
298
+ erase(subject: DataSubject | string): Promise<ErasedObjects>;
253
299
  signedUrl(objectName: string, access: SignedAccess, spanSeconds?: number): string;
254
300
  url(objectName: string): string;
255
301
  }