@astrale-os/sdk 0.6.0-beta.35 → 0.6.0-beta.36

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/README.md CHANGED
@@ -73,6 +73,16 @@ on the Kernel's workflow engine: a call returns its `K.Execution` at once, and t
73
73
  restarts with journaled Steps, durable timers and signals. Serve it with `serve({ durable })`. See
74
74
  [the durable Workflow guide](docs/durable-workflows.md).
75
75
 
76
+ ## Admission age
77
+
78
+ A callable declared with `admission: { maxAge: <seconds> }` (1 to 300) lets its deployment reuse an
79
+ admission the source Kernel granted, for the same Kernel, caller, target and Policy-read input fields,
80
+ while it is younger than that age: such a call skips its `Function.admit` round trip. Revocation then
81
+ reaches it within that age. Credential verification, the release pin and input validation still run
82
+ on every call. Admissions are kept in the deployment cache, `serve({ cache })`: a memory cache by
83
+ default, the Cache API on Cloudflare, and none with `cache: false`. See
84
+ [the admission age guide](docs/admission-age.md).
85
+
76
86
  ## Node Class icons
77
87
 
78
88
  Every Node Class authored through the SDK must explicitly declare a self-contained SVG icon.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Disposable entries one deployment keeps across its requests, and across its isolates where the
3
+ * platform shares them. An entry may vanish at any time and never outlives its expiry. A reader
4
+ * treats a failed or late read as a miss, so nothing that must persist or be coordinated belongs
5
+ * here: every consumer stays correct when the cache keeps nothing.
6
+ */
7
+ export interface DeploymentCache {
8
+ /** The value kept under `key`, or undefined when it expired, vanished or was never kept. */
9
+ get(key: string): Promise<string | undefined>;
10
+ /**
11
+ * Keep `value` under `key` until `expiresAt` (Unix milliseconds) at the latest. The cache may
12
+ * drop it sooner, or not keep it at all.
13
+ */
14
+ put(key: string, value: string, expiresAt: number): Promise<void>;
15
+ }
16
+ /** Capture a caller-supplied cache, receiver-bound, after checking its shape. */
17
+ export declare function deploymentCache(input: DeploymentCache): DeploymentCache;
@@ -0,0 +1,11 @@
1
+ /** Capture a caller-supplied cache, receiver-bound, after checking its shape. */
2
+ export function deploymentCache(input) {
3
+ if (input === null ||
4
+ typeof input !== 'object' ||
5
+ Array.isArray(input) ||
6
+ typeof input.get !== 'function' ||
7
+ typeof input.put !== 'function') {
8
+ throw new TypeError('Deployment cache is invalid.');
9
+ }
10
+ return Object.freeze({ get: input.get.bind(input), put: input.put.bind(input) });
11
+ }
@@ -0,0 +1,2 @@
1
+ export { deploymentCache, type DeploymentCache } from './cache.js';
2
+ export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, type MemoryCacheOptions } from './memory.js';
@@ -0,0 +1,2 @@
1
+ export { deploymentCache } from './cache.js';
2
+ export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache } from './memory.js';
@@ -0,0 +1,13 @@
1
+ import type { DeploymentCache } from './cache.js';
2
+ /** Entries one memory cache keeps by default before it evicts the least recently used. */
3
+ export declare const DEFAULT_MEMORY_CACHE_ENTRIES = 1024;
4
+ export interface MemoryCacheOptions {
5
+ /** Most entries kept at once; the least recently used is evicted first. */
6
+ readonly maximumEntries?: number;
7
+ }
8
+ /**
9
+ * A deployment cache held in this process or isolate: bounded, least recently used first out, and
10
+ * an entry is gone at its expiry. It keeps values only, never a pending operation, so no request
11
+ * waits on another's I/O.
12
+ */
13
+ export declare function memoryCache(options?: MemoryCacheOptions, now?: () => number): DeploymentCache;
@@ -0,0 +1,48 @@
1
+ /** Entries one memory cache keeps by default before it evicts the least recently used. */
2
+ export const DEFAULT_MEMORY_CACHE_ENTRIES = 1_024;
3
+ /**
4
+ * A deployment cache held in this process or isolate: bounded, least recently used first out, and
5
+ * an entry is gone at its expiry. It keeps values only, never a pending operation, so no request
6
+ * waits on another's I/O.
7
+ */
8
+ export function memoryCache(options = {}, now = Date.now) {
9
+ if (options === null || typeof options !== 'object' || Array.isArray(options))
10
+ invalid();
11
+ const maximum = options.maximumEntries ?? DEFAULT_MEMORY_CACHE_ENTRIES;
12
+ if (!Number.isSafeInteger(maximum) || maximum < 1) {
13
+ throw new TypeError('Memory cache maximumEntries must be a positive integer.');
14
+ }
15
+ const entries = new Map();
16
+ return Object.freeze({
17
+ async get(key) {
18
+ if (typeof key !== 'string')
19
+ invalid();
20
+ const entry = entries.get(key);
21
+ if (entry === undefined)
22
+ return undefined;
23
+ entries.delete(key);
24
+ if (entry.expiresAt <= now())
25
+ return undefined;
26
+ entries.set(key, entry);
27
+ return entry.value;
28
+ },
29
+ async put(key, value, expiresAt) {
30
+ if (typeof key !== 'string' || typeof value !== 'string' || !Number.isFinite(expiresAt)) {
31
+ invalid();
32
+ }
33
+ entries.delete(key);
34
+ if (expiresAt <= now())
35
+ return;
36
+ while (entries.size >= maximum) {
37
+ const oldest = entries.keys().next().value;
38
+ if (oldest === undefined)
39
+ break;
40
+ entries.delete(oldest);
41
+ }
42
+ entries.set(key, Object.freeze({ value, expiresAt }));
43
+ },
44
+ });
45
+ }
46
+ function invalid() {
47
+ throw new TypeError('Memory cache input is invalid.');
48
+ }
@@ -1,5 +1,6 @@
1
1
  export { serve } from './serve.js';
2
2
  export type { ExecutionContext, ServedDomain, ServeInput } from './serve.js';
3
+ export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, type DeploymentCache, type MemoryCacheOptions, } from './cache/index.js';
3
4
  export { createIdentity, maximumExchangeTtl, type ExchangeOptions, type Identity, type IdentityInput, } from './identity/index.js';
4
5
  export type { DurableEngine, DurableServing, DurableServingProvider } from './workflows/index.js';
5
6
  export { isCanonicalDurableRequest } from './workflows/route.js';
@@ -1,3 +1,4 @@
1
1
  export { serve } from './serve.js';
2
+ export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, } from './cache/index.js';
2
3
  export { createIdentity, maximumExchangeTtl, } from './identity/index.js';
3
4
  export { isCanonicalDurableRequest } from './workflows/route.js';
@@ -0,0 +1,57 @@
1
+ import type * as invocation from '@astrale-os/kernel-protocol/invocation';
2
+ import type { RuntimeRegistry } from '../../../application/runtime/index.js';
3
+ import type { DeploymentCache } from '../../cache/index.js';
4
+ import type { Admission, InvocationTarget } from './target.js';
5
+ /**
6
+ * How long reading a kept admission may take before the call asks its Kernel instead. Reads of a
7
+ * shared platform cache answer in single-digit milliseconds; `Function.admit` costs tens.
8
+ */
9
+ export declare const ADMISSION_RECALL_TIMEOUT_MS = 30;
10
+ /** The callables a call can be admitted to, when one of them declares an admission age. */
11
+ export interface AdmissionScope {
12
+ /** Keys of the implementations the call target names: an admission to any other is not kept. */
13
+ readonly callables: ReadonlySet<string>;
14
+ /** The input fields their Policies read, the only part of the input an admission depends on. */
15
+ readonly fields: readonly string[];
16
+ }
17
+ /**
18
+ * Whose admission one call asks for: the source Kernel, the release this deployment serves, and
19
+ * the caller its credential proved, verified here before any kept admission is read.
20
+ */
21
+ export interface AdmissionPartition {
22
+ readonly kernel: string;
23
+ readonly release: string;
24
+ /** Absent for a call that carries no credential. */
25
+ readonly caller?: {
26
+ readonly principal: string;
27
+ readonly identity: string;
28
+ };
29
+ }
30
+ /**
31
+ * The scope of a call whose target names, before the graph is read, implementations of which one
32
+ * declares an admission age. Undefined otherwise: a target that names no local implementation (a
33
+ * callable Node id, a path ending in a traversal), only callables without an age, or a durable
34
+ * Workflow, which runs only as a Kernel Execution. The Path language dispatches exactly, so a named
35
+ * target selects one declaration; the fields are still the union over every implementation named.
36
+ */
37
+ export declare function admissionScope(registry: RuntimeRegistry, call: invocation.Call): AdmissionScope | undefined;
38
+ /** Whether any implementation of this registry declares an admission age. */
39
+ export declare function declaresAdmissionAge(registry: RuntimeRegistry): boolean;
40
+ /**
41
+ * The cache key of one call's admission: a SHA-256 over the source Kernel, the served release, the
42
+ * verified principal and caller, the canonical call target (receiver included) and the input
43
+ * projected on the fields its Policy reads. A missing field differs from `null`, object keys are
44
+ * sorted at every depth, and no other part of the input enters the key.
45
+ */
46
+ export declare function admissionKey(scope: AdmissionScope, partition: AdmissionPartition, call: invocation.Call): Promise<string>;
47
+ /**
48
+ * The target of an admission this deployment kept under `key`, while it is younger than the age it
49
+ * carried. A missing, expired, malformed, out-of-scope, failed or late read is a miss.
50
+ */
51
+ export declare function recallAdmission(cache: DeploymentCache, key: string, scope: AdmissionScope): Promise<InvocationTarget | undefined>;
52
+ /**
53
+ * Keep a successful admission that carries an age, until `sentAt` plus that age, `sentAt` being when
54
+ * `Function.admit` was asked. Written in the background: the call never waits on the cache, and a
55
+ * cache that fails to keep it only costs a later call its admission round trip.
56
+ */
57
+ export declare function keepAdmission(cache: DeploymentCache, key: string, scope: AdmissionScope, admitted: Admission, sentAt: number, background: (task: () => PromiseLike<unknown>) => void): void;
@@ -0,0 +1,208 @@
1
+ import { callableAddress, Key } from '@astrale-os/kernel-dsl/v1/addressing';
2
+ import { admissionInputFields, MAXIMUM_ADMISSION_AGE_SECONDS, } from '@astrale-os/kernel-dsl/v1/language';
3
+ import { Path } from '../../../platform/graph/path/index.js';
4
+ import { target as acceptTarget } from './target.js';
5
+ /**
6
+ * How long reading a kept admission may take before the call asks its Kernel instead. Reads of a
7
+ * shared platform cache answer in single-digit milliseconds; `Function.admit` costs tens.
8
+ */
9
+ export const ADMISSION_RECALL_TIMEOUT_MS = 30;
10
+ /** Version of the key and entry encodings below; a change makes every kept admission a miss. */
11
+ const ENCODING = 'astrale.admission/1';
12
+ const ENTRY_FIELDS = new Set(['v', 'callable', 'receiver', 'expiresAt']);
13
+ /**
14
+ * The scope of a call whose target names, before the graph is read, implementations of which one
15
+ * declares an admission age. Undefined otherwise: a target that names no local implementation (a
16
+ * callable Node id, a path ending in a traversal), only callables without an age, or a durable
17
+ * Workflow, which runs only as a Kernel Execution. The Path language dispatches exactly, so a named
18
+ * target selects one declaration; the fields are still the union over every implementation named.
19
+ */
20
+ export function admissionScope(registry, call) {
21
+ const names = declarationsNamedBy(call.target);
22
+ const named = registry.implementations.filter(({ callable }) => names(callable.key));
23
+ if (named.every(({ callable }) => callable.admission === undefined))
24
+ return undefined;
25
+ if (named.some((implementation) => implementation.kind === 'workflow' && implementation.durable)) {
26
+ return undefined;
27
+ }
28
+ const fields = new Set();
29
+ for (const { callable } of named) {
30
+ for (const field of admissionInputFields(callable.policy))
31
+ fields.add(field);
32
+ }
33
+ return Object.freeze({
34
+ callables: new Set(named.map(({ callable }) => callable.key)),
35
+ fields: Object.freeze([...fields].sort()),
36
+ });
37
+ }
38
+ /** Whether any implementation of this registry declares an admission age. */
39
+ export function declaresAdmissionAge(registry) {
40
+ return registry.implementations.some(({ callable }) => callable.admission !== undefined);
41
+ }
42
+ /**
43
+ * The cache key of one call's admission: a SHA-256 over the source Kernel, the served release, the
44
+ * verified principal and caller, the canonical call target (receiver included) and the input
45
+ * projected on the fields its Policy reads. A missing field differs from `null`, object keys are
46
+ * sorted at every depth, and no other part of the input enters the key.
47
+ */
48
+ export async function admissionKey(scope, partition, call) {
49
+ const encoded = JSON.stringify([
50
+ ENCODING,
51
+ partition.kernel,
52
+ partition.release,
53
+ partition.caller?.principal ?? null,
54
+ partition.caller?.identity ?? null,
55
+ canonical(call.target),
56
+ projection(call.input, scope.fields),
57
+ ]);
58
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(encoded));
59
+ return `admission/${hex(new Uint8Array(digest))}`;
60
+ }
61
+ /**
62
+ * The target of an admission this deployment kept under `key`, while it is younger than the age it
63
+ * carried. A missing, expired, malformed, out-of-scope, failed or late read is a miss.
64
+ */
65
+ export async function recallAdmission(cache, key, scope) {
66
+ const value = await boundedRead(cache, key);
67
+ return value === undefined ? undefined : keptTarget(value, scope, Date.now());
68
+ }
69
+ /**
70
+ * Keep a successful admission that carries an age, until `sentAt` plus that age, `sentAt` being when
71
+ * `Function.admit` was asked. Written in the background: the call never waits on the cache, and a
72
+ * cache that fails to keep it only costs a later call its admission round trip.
73
+ */
74
+ export function keepAdmission(cache, key, scope, admitted, sentAt, background) {
75
+ if (admitted.maxAge === undefined || !scope.callables.has(admitted.target.callable))
76
+ return;
77
+ const expiresAt = sentAt + admitted.maxAge * 1000;
78
+ const value = JSON.stringify({
79
+ v: 1,
80
+ callable: admitted.target.callable,
81
+ ...(admitted.target.receiver === undefined ? {} : { receiver: admitted.target.receiver }),
82
+ expiresAt,
83
+ });
84
+ try {
85
+ background(async () => {
86
+ try {
87
+ await cache.put(key, value, expiresAt);
88
+ }
89
+ catch {
90
+ // A cache that cannot keep this admission leaves the next call to its Kernel.
91
+ }
92
+ });
93
+ }
94
+ catch {
95
+ // A Server that no longer accepts background work keeps nothing; the call is unaffected.
96
+ }
97
+ }
98
+ function keptTarget(value, scope, now) {
99
+ let entry;
100
+ try {
101
+ entry = JSON.parse(value);
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ if (!isRecord(entry) || Reflect.ownKeys(entry).some((key) => !ENTRY_FIELDS.has(key))) {
107
+ return undefined;
108
+ }
109
+ const { v, callable, receiver, expiresAt } = entry;
110
+ if (v !== 1 ||
111
+ typeof callable !== 'string' ||
112
+ !scope.callables.has(callable) ||
113
+ typeof expiresAt !== 'number' ||
114
+ !Number.isSafeInteger(expiresAt) ||
115
+ expiresAt <= now ||
116
+ expiresAt > now + MAXIMUM_ADMISSION_AGE_SECONDS * 1000) {
117
+ return undefined;
118
+ }
119
+ try {
120
+ return acceptTarget({ callable, ...(receiver === undefined ? {} : { receiver }) });
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
126
+ /** One read bounded in time; a cache error is a miss. No pending read is shared across calls. */
127
+ async function boundedRead(cache, key) {
128
+ let timer;
129
+ const read = Promise.resolve()
130
+ .then(() => cache.get(key))
131
+ .then((value) => (typeof value === 'string' ? value : undefined), () => undefined);
132
+ const late = new Promise((resolve) => {
133
+ timer = setTimeout(() => resolve(undefined), ADMISSION_RECALL_TIMEOUT_MS);
134
+ });
135
+ try {
136
+ return await Promise.race([read, late]);
137
+ }
138
+ finally {
139
+ if (timer !== undefined)
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+ /** The declarations `target` names before the graph is read, as a test on a callable key. */
144
+ function declarationsNamedBy(target) {
145
+ if (typeof target !== 'string')
146
+ return (key) => target.callable === key;
147
+ let path;
148
+ try {
149
+ path = Path.parse(target);
150
+ }
151
+ catch {
152
+ return () => false;
153
+ }
154
+ const { anchor, steps } = path.ast;
155
+ const last = steps.at(-1);
156
+ const first = steps[0];
157
+ return (key) => {
158
+ const coordinate = callableAddress(key);
159
+ if (coordinate.kind === 'method') {
160
+ if (last?.kind !== 'method' || last.name !== coordinate.name)
161
+ return false;
162
+ if (last.dispatch === 'instance')
163
+ return last.class === Key.of(coordinate.owner);
164
+ return (anchor.kind === 'domain' &&
165
+ anchor.origin === coordinate.owner.origin &&
166
+ steps.length === 2 &&
167
+ first?.kind === 'projection' &&
168
+ first.projection.kind === 'class' &&
169
+ first.projection.name === coordinate.owner.name);
170
+ }
171
+ return (anchor.kind === 'domain' &&
172
+ anchor.origin === coordinate.origin &&
173
+ steps.length === 1 &&
174
+ first?.kind === 'projection' &&
175
+ first.projection.kind === 'function' &&
176
+ first.projection.name === coordinate.name);
177
+ };
178
+ }
179
+ /** The input on the fields a Policy reads: each present field with its value, or its name alone. */
180
+ function projection(input, fields) {
181
+ if (fields.length === 0)
182
+ return [];
183
+ if (!isRecord(input))
184
+ return ['other'];
185
+ return [
186
+ 'object',
187
+ ...fields.map((field) => Object.hasOwn(input, field) ? [field, canonical(input[field])] : [field]),
188
+ ];
189
+ }
190
+ /** Canonical JSON text of a portable value: object keys sorted at every depth. */
191
+ function canonical(value) {
192
+ if (Array.isArray(value))
193
+ return `[${value.map(canonical).join(',')}]`;
194
+ if (value !== null && typeof value === 'object') {
195
+ const object = value;
196
+ return `{${Object.keys(object)
197
+ .sort()
198
+ .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`)
199
+ .join(',')}}`;
200
+ }
201
+ return JSON.stringify(value);
202
+ }
203
+ function isRecord(input) {
204
+ return input !== null && typeof input === 'object' && !Array.isArray(input);
205
+ }
206
+ function hex(bytes) {
207
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
208
+ }
@@ -13,3 +13,5 @@ export type InvocationAuthentication = AnonymousInvocation | AuthenticatedInvoca
13
13
  */
14
14
  export type InvocationSessions = InvocationAuthentication['graph'];
15
15
  export declare function authentication(callable: ResolvedCallable, request: HandlerRequest, domain: Domain, authority: InvocationAuthority): Promise<InvocationAuthentication>;
16
+ /** Verify the credential a call carries and admit the caller it proves, whatever the callable. */
17
+ export declare function credentialAuthentication(request: HandlerRequest, domain: Domain, authority: InvocationAuthority): Promise<AuthenticatedInvocation>;
@@ -5,10 +5,14 @@ import { protocolError } from '../result/index.js';
5
5
  import { requireVia } from './authority.js';
6
6
  const ANONYMOUS = Object.freeze({ kind: 'anonymous' });
7
7
  export async function authentication(callable, request, domain, authority) {
8
- const credential = request.credential;
9
- if (callable.auth === 'anonymous' && credential === undefined) {
8
+ if (callable.auth === 'anonymous' && request.credential === undefined) {
10
9
  return anonymous(await authority.self({ via: requireVia(request), domain, context: request.context }));
11
10
  }
11
+ return credentialAuthentication(request, domain, authority);
12
+ }
13
+ /** Verify the credential a call carries and admit the caller it proves, whatever the callable. */
14
+ export async function credentialAuthentication(request, domain, authority) {
15
+ const credential = request.credential;
12
16
  if (credential === undefined) {
13
17
  throw protocolError('AUTH_MISSING', 'Authentication is required.');
14
18
  }
@@ -1,5 +1,6 @@
1
+ export { ADMISSION_RECALL_TIMEOUT_MS, admissionKey, admissionScope, declaresAdmissionAge, keepAdmission, recallAdmission, type AdmissionPartition, type AdmissionScope, } from './age.js';
1
2
  export { authority, requireVia } from './authority.js';
2
3
  export type { AuthenticatedInvocation, InvocationAdmissionInput, InvocationAuthenticationInput, InvocationAuthority, InvocationSelfInput, SelfInvocation, } from './authority.js';
3
- export { authentication, type AnonymousInvocation, type InvocationAuthentication, type InvocationSessions, } from './authentication.js';
4
+ export { authentication, credentialAuthentication, type AnonymousInvocation, type InvocationAuthentication, type InvocationSessions, } from './authentication.js';
4
5
  export { input } from './input.js';
5
- export { target, type InvocationTarget } from './target.js';
6
+ export { admission, target, type Admission, type InvocationTarget } from './target.js';
@@ -1,4 +1,5 @@
1
+ export { ADMISSION_RECALL_TIMEOUT_MS, admissionKey, admissionScope, declaresAdmissionAge, keepAdmission, recallAdmission, } from './age.js';
1
2
  export { authority, requireVia } from './authority.js';
2
- export { authentication, } from './authentication.js';
3
+ export { authentication, credentialAuthentication, } from './authentication.js';
3
4
  export { input } from './input.js';
4
- export { target } from './target.js';
5
+ export { admission, target } from './target.js';
@@ -4,5 +4,16 @@ export interface InvocationTarget {
4
4
  readonly callable: SemanticKey.Callable;
5
5
  readonly receiver?: NodePath;
6
6
  }
7
+ /** The source Kernel's answer to `Function.admit`. */
8
+ export interface Admission {
9
+ readonly target: InvocationTarget;
10
+ /**
11
+ * How long, in seconds from when it was asked, this deployment may reuse the admission: the
12
+ * callable's declared admission age bounded by the Kernel cap. Absent: this call only.
13
+ */
14
+ readonly maxAge?: number;
15
+ }
16
+ /** Admit the `Function.admit` answer: the selected target and the admission age it carries. */
17
+ export declare function admission(input: unknown): Admission;
7
18
  /** Admit the portable target selected by the execution Identity owner. */
8
19
  export declare function target(input: unknown): InvocationTarget;
@@ -1,11 +1,25 @@
1
1
  import { callableAddress, Key } from '@astrale-os/kernel-dsl/v1/addressing';
2
+ import { isAdmissionMaxAge } from '@astrale-os/kernel-dsl/v1/language';
2
3
  import { NodePath as acceptNodePath } from '../../../platform/graph/path/index.js';
3
4
  import { InvocationDefect } from '../result/index.js';
5
+ /** Admit the `Function.admit` answer: the selected target and the admission age it carries. */
6
+ export function admission(input) {
7
+ const value = record(input);
8
+ if (Reflect.ownKeys(value).some((key) => key !== 'callable' && key !== 'receiver' && key !== 'maxAge')) {
9
+ invalid();
10
+ }
11
+ const { maxAge } = value;
12
+ if (maxAge !== undefined && !isAdmissionMaxAge(maxAge))
13
+ invalid();
14
+ const selected = target({
15
+ callable: value.callable,
16
+ ...(value.receiver === undefined ? {} : { receiver: value.receiver }),
17
+ });
18
+ return Object.freeze({ target: selected, ...(maxAge === undefined ? {} : { maxAge }) });
19
+ }
4
20
  /** Admit the portable target selected by the execution Identity owner. */
5
21
  export function target(input) {
6
- if (input === null || typeof input !== 'object' || Array.isArray(input))
7
- invalid();
8
- const value = input;
22
+ const value = record(input);
9
23
  if (!Object.hasOwn(value, 'callable') ||
10
24
  Reflect.ownKeys(value).some((key) => key !== 'callable' && key !== 'receiver') ||
11
25
  typeof value.callable !== 'string') {
@@ -25,6 +39,11 @@ export function target(input) {
25
39
  }
26
40
  return Object.freeze({ callable, ...(receiver === undefined ? {} : { receiver }) });
27
41
  }
42
+ function record(input) {
43
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
44
+ invalid();
45
+ return input;
46
+ }
28
47
  function invalid() {
29
48
  throw new InvocationDefect('INVOCATION_TARGET_INVALID', 'Invocation target is invalid.');
30
49
  }
@@ -1,5 +1,6 @@
1
1
  import { createInlineWorkflowRunner } from '../../application/workflow/runner/index.js';
2
- import { authority } from './admission/index.js';
2
+ import { deploymentCache } from '../cache/index.js';
3
+ import { authority, declaresAdmissionAge } from './admission/index.js';
3
4
  import { invoke } from './invocation.js';
4
5
  import { projectError } from './result/index.js';
5
6
  /** Compose one ready semantic Handler below Kernel Server transport ownership. */
@@ -8,7 +9,7 @@ export function createHandler(input) {
8
9
  invalid();
9
10
  if (!Object.hasOwn(input, 'runtime') ||
10
11
  !Object.hasOwn(input, 'authority') ||
11
- Reflect.ownKeys(input).some((key) => key !== 'runtime' && key !== 'authority' && key !== 'workflows')) {
12
+ Reflect.ownKeys(input).some((key) => key !== 'runtime' && key !== 'authority' && key !== 'workflows' && key !== 'cache')) {
12
13
  invalid();
13
14
  }
14
15
  const capturedAuthority = authority(input.authority);
@@ -17,6 +18,10 @@ export function createHandler(input) {
17
18
  invalid();
18
19
  }
19
20
  const capturedWorkflows = Object.freeze({ run: workflows.run.bind(workflows) });
21
+ // A Domain whose callables declare no admission age never reads its cache.
22
+ const cache = input.cache === undefined || !declaresAdmissionAge(input.runtime.loaded.registry)
23
+ ? undefined
24
+ : deploymentCache(input.cache);
20
25
  return Object.freeze({
21
26
  async dispatch(request) {
22
27
  try {
@@ -24,6 +29,7 @@ export function createHandler(input) {
24
29
  runtime: input.runtime,
25
30
  authority: capturedAuthority,
26
31
  workflows: capturedWorkflows,
32
+ ...(cache === undefined ? {} : { cache }),
27
33
  });
28
34
  }
29
35
  catch (cause) {
@@ -1,10 +1,16 @@
1
1
  import type { HandlerRequest, HandlerResult } from '@astrale-os/kernel-server';
2
2
  import type { WorkflowRunner } from '../../application/workflow/runner/index.js';
3
+ import type { DeploymentCache } from '../cache/index.js';
3
4
  import type { InitializedRuntime } from '../runtime/index.js';
4
5
  import type { InvocationAuthority } from './admission/index.js';
5
6
  export interface InvocationInput {
6
7
  readonly runtime: InitializedRuntime;
7
8
  readonly authority: InvocationAuthority;
8
9
  readonly workflows: WorkflowRunner;
10
+ /**
11
+ * Where admissions of callables that declare an admission age are kept for the next calls. Absent:
12
+ * every call is admitted by its source Kernel.
13
+ */
14
+ readonly cache?: DeploymentCache;
9
15
  }
10
16
  export declare function invoke(request: HandlerRequest, inputConfig: InvocationInput): Promise<HandlerResult>;
@@ -1,6 +1,6 @@
1
1
  import * as errors from '@astrale-os/kernel-protocol/errors';
2
2
  import * as release from '@astrale-os/kernel-protocol/release';
3
- import { authentication, input, requireVia, target } from './admission/index.js';
3
+ import { admission as acceptAdmission, admissionKey, admissionScope, authentication, credentialAuthentication, input, keepAdmission, recallAdmission, requireVia, } from './admission/index.js';
4
4
  import { action, context, select, workflow } from './dispatch/index.js';
5
5
  import { active, settle } from './lifecycle/index.js';
6
6
  import { project, protocolError } from './result/index.js';
@@ -15,27 +15,102 @@ export async function invoke(request, inputConfig) {
15
15
  if (request.credential !== undefined && typeof request.credential !== 'string') {
16
16
  throw protocolError('AUTH_INVALID', 'Credential is invalid.');
17
17
  }
18
- const admitted = await admit(inputConfig.authority, {
18
+ const admission = {
19
19
  call: request.call,
20
20
  ...(request.credential === undefined ? {} : { credential: request.credential }),
21
21
  via: requireVia(request),
22
22
  domain: inputConfig.runtime.loaded.domain,
23
23
  context: request.context,
24
- });
25
- if (admitted === undefined)
26
- return MISS;
27
- const selected = select(inputConfig.runtime.loaded.registry, admitted);
28
- if (selected === undefined)
29
- return MISS;
30
- if (selected.implementation.kind === 'workflow' && selected.implementation.durable) {
31
- // The Kernel records a durable call as an Execution and runs it on the engine; running it
32
- // inline here would silently drop durability.
33
- throw protocolError('UNSUPPORTED', 'A durable Workflow runs only as a Kernel Execution.');
24
+ };
25
+ const { cache } = inputConfig;
26
+ const scope = cache === undefined
27
+ ? undefined
28
+ : admissionScope(inputConfig.runtime.loaded.registry, request.call);
29
+ if (cache === undefined || scope === undefined) {
30
+ return dispatchAdmitted(request, inputConfig, await admittedTarget(inputConfig, admission));
31
+ }
32
+ // The caller is verified before any kept admission is read: an unproven credential never learns
33
+ // whether another caller's admission is kept. A credential this verification refuses is decided
34
+ // as without a cache, the Kernel's admission first.
35
+ let established;
36
+ if (request.credential !== undefined) {
37
+ try {
38
+ established = await credentialAuthentication(request, inputConfig.runtime.loaded.domain, inputConfig.authority);
39
+ }
40
+ catch {
41
+ return dispatchAdmitted(request, inputConfig, await admittedTarget(inputConfig, admission));
42
+ }
43
+ }
44
+ let admitted;
45
+ try {
46
+ admitted = await recallOrAdmit(cache, scope, admission, inputConfig, established);
47
+ }
48
+ catch (cause) {
49
+ established?.close();
50
+ throw cause;
34
51
  }
35
- input(selected.implementation.callable, request.call.input, request.context.binary);
36
- const admittedAuthentication = await authentication(selected.implementation.callable, request, inputConfig.runtime.loaded.domain, inputConfig.authority);
37
- const close = once(admittedAuthentication.close);
52
+ return dispatchAdmitted(request, inputConfig, admitted, established);
53
+ }
54
+ /**
55
+ * The admission of a call whose callable declares an admission age: one this deployment kept while
56
+ * it is younger than the age it carried, else the source Kernel's, kept for the calls that follow.
57
+ */
58
+ async function recallOrAdmit(cache, scope, admission, inputConfig, established) {
59
+ let key;
60
+ try {
61
+ key = await admissionKey(scope, {
62
+ kernel: admission.via.issuer,
63
+ release: inputConfig.runtime.loaded.release.document.digest,
64
+ ...(established === undefined
65
+ ? {}
66
+ : {
67
+ caller: {
68
+ principal: established.caller.principal,
69
+ identity: established.caller.identity,
70
+ },
71
+ }),
72
+ }, admission.call);
73
+ }
74
+ catch {
75
+ // Without a key nothing is kept or read: the Kernel admits this call.
76
+ return admittedTarget(inputConfig, admission);
77
+ }
78
+ const kept = await recallAdmission(cache, key, scope);
79
+ if (kept !== undefined)
80
+ return kept;
81
+ const sentAt = Date.now();
82
+ const admitted = await admit(inputConfig.authority, admission);
83
+ if (admitted === undefined)
84
+ return undefined;
85
+ keepAdmission(cache, key, scope, admitted, sentAt, admission.context.background);
86
+ return admitted.target;
87
+ }
88
+ /** The target the source Kernel admits, or undefined when it no longer places the call here. */
89
+ async function admittedTarget(inputConfig, admission) {
90
+ return (await admit(inputConfig.authority, admission))?.target;
91
+ }
92
+ /** Run the admitted target: everything after admission, identical however it was admitted. */
93
+ async function dispatchAdmitted(request, inputConfig, admitted, established) {
94
+ let admittedAuthentication = established;
95
+ let close = established === undefined ? undefined : once(established.close);
38
96
  try {
97
+ if (admitted === undefined) {
98
+ close?.();
99
+ return MISS;
100
+ }
101
+ const selected = select(inputConfig.runtime.loaded.registry, admitted);
102
+ if (selected === undefined) {
103
+ close?.();
104
+ return MISS;
105
+ }
106
+ if (selected.implementation.kind === 'workflow' && selected.implementation.durable) {
107
+ // The Kernel records a durable call as an Execution and runs it on the engine; running it
108
+ // inline here would silently drop durability.
109
+ throw protocolError('UNSUPPORTED', 'A durable Workflow runs only as a Kernel Execution.');
110
+ }
111
+ input(selected.implementation.callable, request.call.input, request.context.binary);
112
+ admittedAuthentication ??= await authentication(selected.implementation.callable, request, inputConfig.runtime.loaded.domain, inputConfig.authority);
113
+ close ??= once(admittedAuthentication.close);
39
114
  active(request.context);
40
115
  const dispatchContext = context(request, inputConfig.runtime, admittedAuthentication, selected.self);
41
116
  const output = selected.implementation.kind === 'action'
@@ -44,7 +119,7 @@ export async function invoke(request, inputConfig) {
44
119
  return settle(project(selected.implementation.callable, output), close);
45
120
  }
46
121
  catch (cause) {
47
- close();
122
+ close?.();
48
123
  throw cause;
49
124
  }
50
125
  }
@@ -64,7 +139,7 @@ async function admit(authority, admission) {
64
139
  return undefined;
65
140
  throw cause;
66
141
  }
67
- return target(admitted);
142
+ return acceptAdmission(admitted);
68
143
  }
69
144
  function refusedAsMiss(cause) {
70
145
  return (cause instanceof errors.ProtocolError &&
@@ -2,6 +2,7 @@ import type { ServerCloseOptions } from '@astrale-os/kernel-server';
2
2
  import type { release } from '@astrale-os/kernel-server';
3
3
  import type { Runtime } from '../application/runtime/index.js';
4
4
  import type { WorkflowRunner } from '../application/workflow/runner/index.js';
5
+ import type { DeploymentCache } from './cache/index.js';
5
6
  import type { Identity } from './identity/index.js';
6
7
  import type { ReleaseLoader } from './service/index.js';
7
8
  import type { ServiceServerOptions } from './service/index.js';
@@ -18,6 +19,13 @@ export interface ServeInput<Environment, RuntimeValue extends Runtime, RuntimeEn
18
19
  readonly server: ServiceServerOptions;
19
20
  readonly delivery?: (environment: Environment) => release.delivery.Provider | undefined;
20
21
  readonly workflows?: WorkflowRunner;
22
+ /**
23
+ * The deployment's cache of disposable entries, created once with the Service. It keeps the
24
+ * admissions of callables whose Schema declares an admission age, so that a call within that age
25
+ * skips its `Function.admit` round trip. Defaults to a bounded memory cache of this process or
26
+ * isolate; `false` keeps nothing, and every call is admitted by its source Kernel.
27
+ */
28
+ readonly cache?: ((environment: Environment) => DeploymentCache) | false;
21
29
  /**
22
30
  * Serve durable Workflows to the workflow engine; required when the Release declares any.
23
31
  * Undefined for an environment that names no engine: engine requests are then refused.
@@ -1,3 +1,4 @@
1
+ import { memoryCache } from './cache/index.js';
1
2
  import { createService } from './service/index.js';
2
3
  /** Create one provider-neutral Fetch entrypoint around a lazily initialized current Service. */
3
4
  export function serve(input) {
@@ -11,6 +12,9 @@ export function serve(input) {
11
12
  return service;
12
13
  if (typeof input.identity !== 'function')
13
14
  invalid();
15
+ if (input.cache !== undefined && input.cache !== false && typeof input.cache !== 'function') {
16
+ invalid();
17
+ }
14
18
  const runtimeEnvironment = input.initialize === undefined
15
19
  ? environment
16
20
  : input.initialize(environment);
@@ -22,6 +26,9 @@ export function serve(input) {
22
26
  server: input.server,
23
27
  ...(input.delivery === undefined ? {} : { delivery: input.delivery(environment) }),
24
28
  ...(input.workflows === undefined ? {} : { workflows: input.workflows }),
29
+ ...(input.cache === false
30
+ ? {}
31
+ : { cache: input.cache === undefined ? memoryCache() : input.cache(environment) }),
25
32
  ...(input.durable === undefined
26
33
  ? {}
27
34
  : { durable: (request) => input.durable(environment, request) }),
@@ -54,6 +54,7 @@ export function createService(input) {
54
54
  runtime,
55
55
  authority: selectedIdentity,
56
56
  ...(input.workflows === undefined ? {} : { workflows: input.workflows }),
57
+ ...(input.cache === undefined ? {} : { cache: input.cache }),
57
58
  }));
58
59
  running = Object.freeze({ release, runtime, server });
59
60
  if (!state.accepting)
@@ -2,6 +2,7 @@ import type { ApplicationHost, ServerCloseOptions, ServerOptions } from '@astral
2
2
  import type { release } from '@astrale-os/kernel-server';
3
3
  import type { Runtime } from '../../application/runtime/index.js';
4
4
  import type { WorkflowRunner } from '../../application/workflow/runner/index.js';
5
+ import type { DeploymentCache } from '../cache/index.js';
5
6
  import type { Identity } from '../identity/index.js';
6
7
  import type { ServedRelease } from '../runtime/index.js';
7
8
  import type { DurableServing, DurableServingProvider } from '../workflows/index.js';
@@ -18,6 +19,11 @@ export interface ServiceInput<Environment, RuntimeValue extends Runtime = Runtim
18
19
  */
19
20
  readonly delivery?: release.delivery.Provider;
20
21
  readonly workflows?: WorkflowRunner;
22
+ /**
23
+ * Where admissions of callables that declare an admission age are kept for the next calls.
24
+ * Absent: every call is admitted by its source Kernel.
25
+ */
26
+ readonly cache?: DeploymentCache;
21
27
  /**
22
28
  * Serve the Release's durable Workflows. A provider is resolved on the first engine request,
23
29
  * shared by concurrent requests, and retried after initialization fails.
@@ -18,7 +18,7 @@ export type { ClassIcon } from './class-icon.js';
18
18
  export type { StoredPropertyInputOf } from './property-input.js';
19
19
  export type { CallablePolicyBuilder, CallablePolicyContext, ClassRuleBuilder, ClassRuleContext, PolicyCheckContext, CallableProjection, AuthoredMethodProjection, AuthoredPropertyProjection, ClassIdentityProjection, ClassOwnerProjection, CoreEdge, CoreEndpoint, CoreNode, DefinedSchema, DirectedEdgeClass, ClassReferenceProjection, ClassProjection, FunctionProjection, EdgeClass, Function, Method, MethodProjection, Lazy, NodeClass, Policy, PolicyExistsContext, PolicyNodeBinder, PolicyPatternContext, Property, PropertyProjection, ResolvedDomainOfInput, View, } from '@astrale-os/kernel-dsl/v1/builder';
20
20
  export type { Array, Object, Value } from '@astrale-os/kernel-dsl/value';
21
- export type { CallableExecution, ClassKind, DirectedEdgeContract, EdgeClassPolicies, NodeClassDefinition, PolicyCheckObject, PolicyObjectRef, PropertyFacet, PropertyImmutability, StateTopology, UndirectedEdgeContract, } from '@astrale-os/kernel-dsl/v1/language';
21
+ export type { CallableAdmission, CallableExecution, ClassKind, DirectedEdgeContract, EdgeClassPolicies, NodeClassDefinition, PolicyCheckObject, PolicyObjectRef, PropertyFacet, PropertyImmutability, StateTopology, UndirectedEdgeContract, } from '@astrale-os/kernel-dsl/v1/language';
22
22
  export type { DomainSchema } from '@astrale-os/kernel-dsl/v1/schema';
23
23
  /** Kernel-owned Schema values used by authored product Schemas and graph operations. */
24
24
  export { AuthorityOperationOrder, AuthorityOperations, AuthorityRelations, KernelRootFunctions, OperationCore, operationOf, } from '@astrale-os/kernel-core/schema';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/sdk",
3
- "version": "0.6.0-beta.35",
3
+ "version": "0.6.0-beta.36",
4
4
  "description": "Schema-first SDK for defining, composing, and deploying Astrale domains",
5
5
  "keywords": [
6
6
  "astrale",
@@ -257,11 +257,11 @@
257
257
  "registry": "https://registry.npmjs.org/"
258
258
  },
259
259
  "dependencies": {
260
- "@astrale-os/kernel-client": "1.0.0-beta.11",
261
- "@astrale-os/kernel-core": "1.0.0-beta.9",
262
- "@astrale-os/kernel-dsl": "1.0.0-beta.4",
263
- "@astrale-os/kernel-protocol": "1.0.0-beta.9",
264
- "@astrale-os/kernel-server": "1.0.0-beta.9",
260
+ "@astrale-os/kernel-client": "1.0.0-beta.12",
261
+ "@astrale-os/kernel-core": "1.0.0-beta.10",
262
+ "@astrale-os/kernel-dsl": "1.0.0-beta.5",
263
+ "@astrale-os/kernel-protocol": "1.0.0-beta.10",
264
+ "@astrale-os/kernel-server": "1.0.0-beta.10",
265
265
  "@lucide/icons": "1.34.0",
266
266
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
267
267
  "cross-spawn": "7.0.6",