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
package/dist/storage.js CHANGED
@@ -26,8 +26,35 @@ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
26
26
  import { isAbsolute, join, resolve } from "node:path";
27
27
  import { Readable } from "node:stream";
28
28
  import { CommandError, ErrorCode } from "./effects/common.js";
29
+ import { subjectProblem } from "./personal.js";
29
30
  import { Credentials, EMPTY_PAYLOAD, UNSIGNED_PAYLOAD, encode, hmac, presign, sha256Hex, sign, SignedRequest } from "./sigv4.js";
30
- import { Instant } from "./time.js";
31
+ import { Duration, Instant } from "./time.js";
32
+ const IMMEDIATE = Object.freeze({ kind: "immediate" });
33
+ /**
34
+ * Where a service keeps the objects of `subject` (feature 042): `subjects/`, the data subject with every
35
+ * `/` written `%2F`, and `/`. A data subject cannot hold `%`, so no subject prefix holds another's:
36
+ * `player`'s is `subjects/player/` and `player/8c1f`'s `subjects/player%2F8c1f/`. A string that is no
37
+ * data subject is refused (`BAD_REQUEST`).
38
+ */
39
+ export function subjectPrefix(subject) {
40
+ const p = subjectProblem(subject);
41
+ if (p !== undefined)
42
+ throw badRequest(p);
43
+ return "subjects/" + subject.replaceAll("/", "%2F") + "/";
44
+ }
45
+ /** `erase` for a store that keeps no versions: list the subject's prefix and delete each object. */
46
+ async function eraseListed(bucket, subject, finality = IMMEDIATE) {
47
+ const names = [];
48
+ for await (const e of bucket.list(subjectPrefix(subject)))
49
+ names.push(e.name);
50
+ for (const n of names)
51
+ await bucket.delete(n);
52
+ return Object.freeze({ count: names.length, finality });
53
+ }
54
+ /** A bucket made of its other calls, with `erase` by listing. */
55
+ function listedBucket(bucket) {
56
+ return Object.freeze({ ...bucket, erase: (subject) => eraseListed(bucket, subject) });
57
+ }
31
58
  // ── The rules and the words ────────────────────────────────────────────────────
32
59
  const utf8 = new TextEncoder();
33
60
  function shown(text) {
@@ -107,6 +134,10 @@ export const ObjectRules = Object.freeze({
107
134
  if (p !== undefined)
108
135
  throw badRequest(p);
109
136
  },
137
+ /** Where a service keeps the objects of `subject`: `subjectPrefix`. */
138
+ subjectPrefix(subject) {
139
+ return subjectPrefix(subject);
140
+ },
110
141
  });
111
142
  /** What the storage client says when it refuses: the runtime's words (`StorageRefusals` in the Scala SDK). */
112
143
  export const StorageRefusals = Object.freeze({
@@ -321,7 +352,7 @@ export class InMemoryBuckets {
321
352
  return m;
322
353
  };
323
354
  const publicBase = this.#publicBase;
324
- return Object.freeze({
355
+ return listedBucket({
325
356
  name,
326
357
  exposure,
327
358
  async put(objectName, body, options) {
@@ -538,7 +569,7 @@ export class DirectoryBuckets {
538
569
  }
539
570
  };
540
571
  const address = (objectName) => `${store.#addressBase().replace(/\/$/, "")}${STORAGE_PATH}/${segment}/${addressOf(objectName)}`;
541
- return Object.freeze({
572
+ return listedBucket({
542
573
  name,
543
574
  exposure,
544
575
  async put(objectName, body, options) {
@@ -663,6 +694,16 @@ export const MAX_ADDRESS_SEGMENTS = 16;
663
694
  export function addressOf(objectName) {
664
695
  return encode(objectName, objectName.split("/").length > MAX_ADDRESS_SEGMENTS);
665
696
  }
697
+ /** `ANKKA_S3_SOFT_DELETE_DAYS`: a whole number of days from 1, or unset. */
698
+ function softDeleteOf(env) {
699
+ const variable = PREFIX + "SOFT_DELETE_DAYS";
700
+ const text = env[variable]?.trim() ?? "";
701
+ if (text === "")
702
+ return undefined;
703
+ if (!/^[0-9]+$/.test(text) || Number(text) < 1)
704
+ throw new Error(`${variable} is '${text}'; it is a whole number of days from 1`);
705
+ return Duration.ofSeconds(Number(text) * 86_400);
706
+ }
666
707
  const PREFIX = "ANKKA_S3_";
667
708
  function nonEmpty(env, name) {
668
709
  const v = env[name];
@@ -754,7 +795,8 @@ export function s3Settings(env = process.env, timeoutMs = 30_000) {
754
795
  throw new Error(`bucket '${name}' is public and ${PREFIX}PUBLIC_URL_${suffix} is not set`);
755
796
  named.push(Object.freeze({ name, exposure: word, target: Object.freeze({ ...unnamed, bucket }), publicUrl }));
756
797
  }
757
- return Object.freeze({ unnamed: unnamed && unnamed.bucket !== "" ? unnamed : undefined, named: Object.freeze(named), timeoutMs });
798
+ const softDelete = softDeleteOf(env);
799
+ return Object.freeze({ unnamed: unnamed && unnamed.bucket !== "" ? unnamed : undefined, named: Object.freeze(named), timeoutMs, softDelete });
758
800
  }
759
801
  const httpAgent = new HttpAgent({ keepAlive: true });
760
802
  const httpsAgent = new HttpsAgent({ keepAlive: true });
@@ -857,6 +899,30 @@ export function parseListing(xml) {
857
899
  const truncated = element(xml, "IsTruncated")?.trim() === "true";
858
900
  return { entries, next: truncated ? element(xml, "NextContinuationToken") : undefined };
859
901
  }
902
+ /** A `ListObjectVersions` answer: its versions and delete markers, and where the next page starts while it is truncated. */
903
+ export function parseVersions(xml) {
904
+ const versions = [];
905
+ let generations = false;
906
+ for (const m of xml.matchAll(/<(Version|DeleteMarker)>([\s\S]*?)<\/\1>/g)) {
907
+ const block = m[2];
908
+ const key = element(block, "Key");
909
+ if (key === undefined)
910
+ throw new Error(`a version listing's ${m[1]} has no Key`);
911
+ const versionId = element(block, "VersionId");
912
+ const generation = versionId === undefined ? element(block, "Generation") : undefined;
913
+ if (generation !== undefined)
914
+ generations = true;
915
+ const id = (versionId ?? generation)?.trim() ?? "";
916
+ versions.push([key, id]);
917
+ }
918
+ return {
919
+ versions,
920
+ truncated: element(xml, "IsTruncated")?.trim() === "true",
921
+ nextKey: element(xml, "NextKeyMarker"),
922
+ nextVersion: element(xml, "NextVersionIdMarker") ?? element(xml, "NextGenerationMarker"),
923
+ generations: generations || element(xml, "NextGenerationMarker") !== undefined,
924
+ };
925
+ }
860
926
  function lastModifiedOf(response) {
861
927
  const v = response.headers["last-modified"];
862
928
  const d = v ? new Date(v) : undefined;
@@ -870,7 +936,9 @@ export class S3Bucket {
870
936
  #publicUrl;
871
937
  #timeoutMs;
872
938
  #endpoint;
873
- constructor(target, name, exposure, publicUrl, timeoutMs = 30_000) {
939
+ #finality;
940
+ constructor(target, name, exposure, publicUrl, timeoutMs = 30_000, softDelete) {
941
+ this.#finality = softDelete === undefined ? IMMEDIATE : Object.freeze({ kind: "after", after: softDelete });
874
942
  this.#target = target;
875
943
  this.name = name;
876
944
  this.exposure = exposure;
@@ -952,6 +1020,69 @@ export class S3Bucket {
952
1020
  } while (token !== undefined);
953
1021
  })();
954
1022
  }
1023
+ /**
1024
+ * Lists every version and delete marker under the subject's prefix (`ListObjectVersions`) and deletes
1025
+ * each by its version id; the count is the objects with a version. A store that lists no versions
1026
+ * (Garage answers `NotImplemented`) is listed plainly and each object deleted. The finality is
1027
+ * `ANKKA_S3_SOFT_DELETE_DAYS`'s, or immediate.
1028
+ */
1029
+ async erase(subject) {
1030
+ const prefix = subjectPrefix(subject);
1031
+ const versions = await this.#versionsUnder(prefix);
1032
+ if (versions === undefined)
1033
+ return eraseListed(this, subject, this.#finality);
1034
+ for (const [key, version] of versions)
1035
+ await this.#deleteVersion(key, version);
1036
+ const objects = new Set(versions.filter(([, v]) => v !== "").map(([k]) => k));
1037
+ return Object.freeze({ count: objects.size, finality: this.#finality });
1038
+ }
1039
+ /** Every version and delete marker under `prefix`, or `undefined` when the store lists none. */
1040
+ async #versionsUnder(prefix) {
1041
+ const found = [];
1042
+ let keyMarker;
1043
+ let versionMarker;
1044
+ let generations = false;
1045
+ for (;;) {
1046
+ const query = [
1047
+ ["prefix", prefix],
1048
+ ["versions", ""],
1049
+ ];
1050
+ if (keyMarker !== undefined)
1051
+ query.push(["key-marker", keyMarker]);
1052
+ // Google pages by generation and names the next one `generation-marker`.
1053
+ if (versionMarker !== undefined)
1054
+ query.push([generations ? "generation-marker" : "version-id-marker", versionMarker]);
1055
+ const { status, response } = await this.#send("GET", `/${this.#target.bucket}`, query);
1056
+ const text = await textOf(response, 64 << 20);
1057
+ if (status === 501 || text.includes("<Code>NotImplemented</Code>"))
1058
+ return undefined;
1059
+ if (status !== 200)
1060
+ throw this.#refused(status, text);
1061
+ let page;
1062
+ try {
1063
+ page = parseVersions(text);
1064
+ }
1065
+ catch (e) {
1066
+ throw StorageRefusals.unreadable(this.#endpoint, e instanceof Error ? e.message : String(e));
1067
+ }
1068
+ found.push(...page.versions);
1069
+ keyMarker = page.nextKey;
1070
+ versionMarker = page.nextVersion;
1071
+ generations = page.generations;
1072
+ if (!page.truncated)
1073
+ return found;
1074
+ }
1075
+ }
1076
+ /**
1077
+ * One version, or a delete marker; `version` empty is the plain delete. The store's `null` version is
1078
+ * deleted as `versionId=null`: a plain delete would only add a marker above it.
1079
+ */
1080
+ async #deleteVersion(objectName, version) {
1081
+ const { status, response } = await this.#send("DELETE", this.#objectPath(objectName), version === "" ? [] : [["versionId", version]]);
1082
+ const text = await textOf(response);
1083
+ if (status !== 200 && status !== 204 && status !== 404)
1084
+ throw this.#refused(status, text);
1085
+ }
955
1086
  signedUrl(objectName, access, spanSeconds = ObjectRules.DEFAULT_SIGNED_URL_SECONDS) {
956
1087
  ObjectRules.checkName(objectName);
957
1088
  let base;
@@ -1005,8 +1136,8 @@ export class S3Buckets {
1005
1136
  #named;
1006
1137
  constructor(settings = s3Settings()) {
1007
1138
  const unnamedExposure = settings.unnamed?.publicEndpoint ? "signed" : "private";
1008
- this.#unnamed = settings.unnamed && new S3Bucket(settings.unnamed, undefined, unnamedExposure, undefined, settings.timeoutMs);
1009
- this.#named = new Map(settings.named.map((n) => [n.name, new S3Bucket(n.target, n.name, n.exposure, n.publicUrl, settings.timeoutMs)]));
1139
+ this.#unnamed = settings.unnamed && new S3Bucket(settings.unnamed, undefined, unnamedExposure, undefined, settings.timeoutMs, settings.softDelete);
1140
+ this.#named = new Map(settings.named.map((n) => [n.name, new S3Bucket(n.target, n.name, n.exposure, n.publicUrl, settings.timeoutMs, settings.softDelete)]));
1010
1141
  }
1011
1142
  get default() {
1012
1143
  if (!this.#unnamed)
@@ -1,3 +1,3 @@
1
1
  export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised, type SocketRun, type ReplicationMode } from "./unit.ts";
2
2
  export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, KeyedViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, type GraphChange, } from "./kinds.ts";
3
- export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside } from "./integration.ts";
3
+ export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside, type KitKeyring } from "./integration.ts";
@@ -1,6 +1,32 @@
1
1
  import type { ComponentClient } from "../client.ts";
2
2
  import type { ServiceBuilder } from "../service.ts";
3
3
  import { type Buckets } from "../storage.ts";
4
+ /**
5
+ * One statement that searches every column of every table of the service's database, cast to text,
6
+ * for each value as written, as base64 (standard and URL-safe, unpadded) and as lower-case hex in the
7
+ * lower-cased text: the rules of the JVM kit's `PersonalAssertions`, the same forms. Each row is
8
+ * `<index of the value> <form> <schema>.<table>.<column>`.
9
+ */
10
+ export declare function personalValueQuery(values: readonly string[]): string;
11
+ /** The JVM kit's failure message for what `personalValueQuery` found, or `undefined` for nothing. */
12
+ export declare function personalValueFailure(values: readonly string[], output: string): string | undefined;
13
+ /**
14
+ * The keyring of the kit's sidecar (feature 042): the runtime's local keyring, kept in the kit's
15
+ * database, reached through the sidecar's local console endpoint. `erase` destroys a data subject's key
16
+ * at once and sends the notice, as an applied erasure request does; `outage` makes the keyring refuse
17
+ * every request as unavailable, erasures included, until it ends or `recover` ends it. An outage is the
18
+ * sidecar's: `kit.restart()` ends it.
19
+ */
20
+ export declare class KitKeyring {
21
+ #private;
22
+ constructor(url: () => string);
23
+ /** Erases `subject` in the service's project now; the notice's sequence, once the sidecar has carried it out (or after ten seconds). Throws in an outage. */
24
+ erase(subject: string, rerun?: boolean): Promise<number>;
25
+ /** Refuses every request as unavailable for `seconds`: a subject whose key the sidecar holds is still read and written; one it does not hold is refused, `ErrorCode.Unavailable`. */
26
+ outage(seconds: number): Promise<void>;
27
+ /** Ends an outage now. */
28
+ recover(): Promise<void>;
29
+ }
4
30
  export interface AnkkaTestKitOptions {
5
31
  /** The sidecar image; `sidecarImage()` by default. */
6
32
  readonly image?: string;
@@ -52,6 +78,8 @@ export declare const PUBLISHED_SIDECAR = "ghcr.io/thinkmorestupidless/ankka-side
52
78
  export declare function sidecarImage(version?: string): string;
53
79
  export declare class AnkkaTestKit {
54
80
  #private;
81
+ /** The sidecar's keyring: erase a data subject, start or end an outage (feature 042). */
82
+ readonly keyring: KitKeyring;
55
83
  /** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
56
84
  readonly client: ComponentClient;
57
85
  /** The buckets the service holds: what a handler kept, a test reads here, and what a test puts here a handler reads. */
@@ -64,6 +92,13 @@ export declare class AnkkaTestKit {
64
92
  get baseUrl(): string;
65
93
  /** The JDBC URL of the throwaway database. */
66
94
  get jdbcUrl(): string;
95
+ /**
96
+ * Fails, naming each table and column, when any of `values` can be read back from the service's
97
+ * database as written, as base64 (standard and URL-safe) or as hex: what a personal field must never
98
+ * be (feature 042, FR-030). Every column of every table, cast to text, searched by the kit's Postgres
99
+ * itself with the JVM kit's rules (`personalValueQuery`).
100
+ */
101
+ assertNoPersonalValue(...values: string[]): Promise<void>;
67
102
  /** Replaces the sidecar against the same database: every instance is gone from memory, so the next read rebuilds from the journal. */
68
103
  restart(): Promise<void>;
69
104
  /** Starts another image against this kit's Postgres — the Scala cart, say, to prove the journal is shared. */
@@ -21,6 +21,82 @@ import { VERSION } from "../version.js";
21
21
  const POSTGRES_IMAGE = "postgres:17-alpine";
22
22
  const HTTP_PORT = 9000;
23
23
  const CALLBACK_PORT = 9011;
24
+ // The sidecar's local console endpoint, where `kit.keyring` files erasures and outages: named with
25
+ // ANKKA_OBSERVABILITY_ADDRESS so Docker can publish it, where a service run on a machine binds it to
26
+ // loopback on an ephemeral port.
27
+ const LOCAL_ENDPOINT_PORT = 9012;
28
+ /**
29
+ * One statement that searches every column of every table of the service's database, cast to text,
30
+ * for each value as written, as base64 (standard and URL-safe, unpadded) and as lower-case hex in the
31
+ * lower-cased text: the rules of the JVM kit's `PersonalAssertions`, the same forms. Each row is
32
+ * `<index of the value> <form> <schema>.<table>.<column>`.
33
+ */
34
+ export function personalValueQuery(values) {
35
+ const quote = (text) => `'${text.replaceAll("'", "''")}'`;
36
+ const rows = values.flatMap((value, i) => {
37
+ const data = Buffer.from(value, "utf8");
38
+ const forms = {
39
+ plain: value,
40
+ base64: data.toString("base64").replace(/=+$/, ""),
41
+ base64url: data.toString("base64url"),
42
+ hex: data.toString("hex"),
43
+ };
44
+ return Object.entries(forms).map(([name, form]) => `(${i}, ${quote(name)}, ${quote(form)})`);
45
+ });
46
+ return ("SELECT f.i || ' ' || f.form_name || ' ' || c.table_schema || '.' || c.table_name || '.' || c.column_name " +
47
+ `FROM information_schema.columns c CROSS JOIN (VALUES ${rows.join(", ")}) AS f(i, form_name, form) ` +
48
+ "WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') " +
49
+ "AND (xpath('/row/n/text()', query_to_xml(format('SELECT count(*) AS n FROM %I.%I WHERE strpos(%s, %L) > 0', " +
50
+ "c.table_schema, c.table_name, " +
51
+ "CASE WHEN f.form_name = 'hex' THEN format('lower(%I::text)', c.column_name) ELSE format('%I::text', c.column_name) END, " +
52
+ "f.form), false, true, '')))[1]::text::int > 0 " +
53
+ "ORDER BY c.table_schema, c.table_name, c.ordinal_position, f.i");
54
+ }
55
+ /** The JVM kit's failure message for what `personalValueQuery` found, or `undefined` for nothing. */
56
+ export function personalValueFailure(values, output) {
57
+ const found = output
58
+ .split("\n")
59
+ .filter((line) => line.trim() !== "")
60
+ .map((line) => {
61
+ const [i, form, ...where] = line.split(" ");
62
+ return ` '${values[Number(i)]}' as ${form} in ${where.join(" ")}`;
63
+ });
64
+ return found.length === 0 ? undefined : `a personal value can be read back from what the service kept:\n${found.join("\n")}`;
65
+ }
66
+ /**
67
+ * The keyring of the kit's sidecar (feature 042): the runtime's local keyring, kept in the kit's
68
+ * database, reached through the sidecar's local console endpoint. `erase` destroys a data subject's key
69
+ * at once and sends the notice, as an applied erasure request does; `outage` makes the keyring refuse
70
+ * every request as unavailable, erasures included, until it ends or `recover` ends it. An outage is the
71
+ * sidecar's: `kit.restart()` ends it.
72
+ */
73
+ export class KitKeyring {
74
+ #url;
75
+ constructor(url) {
76
+ this.#url = url;
77
+ }
78
+ async #post(path, body) {
79
+ const r = await fetch(this.#url() + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
80
+ const text = await r.text();
81
+ if (r.status !== 200)
82
+ throw new Error(`${path} answered ${r.status}: ${text}`);
83
+ return JSON.parse(text);
84
+ }
85
+ /** Erases `subject` in the service's project now; the notice's sequence, once the sidecar has carried it out (or after ten seconds). Throws in an outage. */
86
+ async erase(subject, rerun = false) {
87
+ return Number((await this.#post("/_ankka/erasures", { subject, rerun })).seq);
88
+ }
89
+ /** Refuses every request as unavailable for `seconds`: a subject whose key the sidecar holds is still read and written; one it does not hold is refused, `ErrorCode.Unavailable`. */
90
+ async outage(seconds) {
91
+ if (!(seconds > 0))
92
+ throw new Error("an outage lasts more than 0 seconds; recover() ends one");
93
+ await this.#post("/_ankka/keyring/outage", { seconds });
94
+ }
95
+ /** Ends an outage now. */
96
+ async recover() {
97
+ await this.#post("/_ankka/keyring/outage", { seconds: 0 });
98
+ }
99
+ }
24
100
  /** What `http` answers with. */
25
101
  export class HttpResponse {
26
102
  status;
@@ -148,6 +224,13 @@ export class AnkkaTestKit {
148
224
  #server;
149
225
  #processPort = 0;
150
226
  #http;
227
+ #localUrl;
228
+ /** The sidecar's keyring: erase a data subject, start or end an outage (feature 042). */
229
+ keyring = new KitKeyring(() => {
230
+ if (!this.#localUrl)
231
+ throw new Error("the testkit is not started");
232
+ return this.#localUrl;
233
+ });
151
234
  /** The component client every handler in `service` uses, pointed at the sidecar's mapped callback port. */
152
235
  client;
153
236
  /** The buckets the service holds: what a handler kept, a test reads here, and what a test puts here a handler reads. */
@@ -228,9 +311,10 @@ export class AnkkaTestKit {
228
311
  // A fresh secret key per kit, so the secret store works with no setup; the caller's `env`
229
312
  // replaces it, and `ANKKA_SECRET_KEY: ""` starts a service with none.
230
313
  ANKKA_SECRET_KEY: randomBytes(32).toString("base64"),
314
+ ANKKA_OBSERVABILITY_ADDRESS: `0.0.0.0:${LOCAL_ENDPOINT_PORT}`,
231
315
  ...this.#env,
232
316
  })
233
- .withExposedPorts(HTTP_PORT, CALLBACK_PORT)
317
+ .withExposedPorts(HTTP_PORT, CALLBACK_PORT, LOCAL_ENDPOINT_PORT)
234
318
  // Both ports, not only the health route: it answers as soon as HTTP is bound, which can be
235
319
  // before the callback server listens, and Docker's port proxy accepts then resets a
236
320
  // connection to a port nothing in the container listens on yet — so a handler's first call
@@ -241,6 +325,27 @@ export class AnkkaTestKit {
241
325
  this.#sidecar = container;
242
326
  this.client.reconnect(`${container.getHost()}:${container.getMappedPort(CALLBACK_PORT)}`);
243
327
  this.#http = new Http(`http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`);
328
+ this.#localUrl = `http://${container.getHost()}:${container.getMappedPort(LOCAL_ENDPOINT_PORT)}`;
329
+ }
330
+ /**
331
+ * Fails, naming each table and column, when any of `values` can be read back from the service's
332
+ * database as written, as base64 (standard and URL-safe) or as hex: what a personal field must never
333
+ * be (feature 042, FR-030). Every column of every table, cast to text, searched by the kit's Postgres
334
+ * itself with the JVM kit's rules (`personalValueQuery`).
335
+ */
336
+ async assertNoPersonalValue(...values) {
337
+ if (values.length === 0)
338
+ return;
339
+ if (!this.#postgres)
340
+ throw new Error("the testkit is not started");
341
+ const r = await this.#postgres.exec(["psql", "-U", "ankka", "-d", "ankka", "-v", "ON_ERROR_STOP=1", "-At", "-c", personalValueQuery(values)]);
342
+ if (r.exitCode !== 0)
343
+ throw new Error(`the database could not be searched: ${r.output}`);
344
+ const failure = personalValueFailure(values, r.stdout ?? r.output);
345
+ if (failure !== undefined) {
346
+ const { AssertionError } = await import("node:assert");
347
+ throw new AssertionError({ message: failure });
348
+ }
244
349
  }
245
350
  /** Replaces the sidecar against the same database: every instance is gone from memory, so the next read rebuilds from the journal. */
246
351
  async restart() {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.15.0";
1
+ export declare const VERSION = "0.16.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Written by scripts/proto.ts from package.json; do not edit. 0.0.0 in the tree, the tag's version in a release.
2
- export const VERSION = "0.15.0";
2
+ export const VERSION = "0.16.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ankka",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Build services on ankka in TypeScript: entities, views, workflows, agents and endpoints hosted by the ankka sidecar.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",