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.
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +365 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.js +61 -5
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +7 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/erasure_pb.d.ts +72 -0
- package/dist/_proto/ankka/protocol/v1/erasure_pb.js +24 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +14 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +136 -1
- package/dist/_proto/ankka/protocol/v1/wasm_pb.js +21 -1
- package/dist/autonomous.d.ts +12 -2
- package/dist/autonomous.js +17 -3
- package/dist/client.d.ts +21 -3
- package/dist/client.js +46 -13
- package/dist/codec.d.ts +2 -0
- package/dist/codec.js +8 -1
- package/dist/consumer.d.ts +5 -0
- package/dist/consumer.js +10 -0
- package/dist/endpoint.d.ts +5 -0
- package/dist/endpoint.js +10 -0
- package/dist/erasure.d.ts +35 -0
- package/dist/erasure.js +10 -0
- package/dist/erasures.d.ts +32 -0
- package/dist/erasures.js +65 -0
- package/dist/graph.d.ts +5 -0
- package/dist/graph.js +10 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/json.js +110 -0
- package/dist/keyring.d.ts +119 -0
- package/dist/keyring.js +417 -0
- package/dist/personal.d.ts +97 -0
- package/dist/personal.js +209 -0
- package/dist/schema.d.ts +13 -0
- package/dist/schema.js +43 -0
- package/dist/server/agent.js +2 -1
- package/dist/server/discovery.d.ts +13 -1
- package/dist/server/discovery.js +14 -2
- package/dist/server/erasure.d.ts +7 -0
- package/dist/server/erasure.js +40 -0
- package/dist/server/eventSourced.js +25 -8
- package/dist/server/keyValue.js +6 -2
- package/dist/server/payloads.d.ts +10 -2
- package/dist/server/payloads.js +36 -4
- package/dist/server/server.d.ts +2 -0
- package/dist/server/server.js +59 -2
- package/dist/server/stateless.js +10 -1
- package/dist/server/workflow.js +8 -2
- package/dist/service.d.ts +12 -1
- package/dist/service.js +60 -3
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +2 -1
- package/dist/storage.d.ts +48 -2
- package/dist/storage.js +138 -7
- package/dist/testkit/index.d.ts +1 -1
- package/dist/testkit/integration.d.ts +35 -0
- package/dist/testkit/integration.js +106 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/server/payloads.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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() });
|
package/dist/server/server.d.ts
CHANGED
|
@@ -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 {
|
package/dist/server/server.js
CHANGED
|
@@ -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();
|
package/dist/server/stateless.js
CHANGED
|
@@ -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);
|
package/dist/server/workflow.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|