@astrale-os/kernel-client 0.6.0-beta.59 → 0.6.0-beta.60

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.
@@ -2,14 +2,18 @@ import type { bundle } from '@astrale-os/kernel-dsl/v1';
2
2
  import type { publication } from '@astrale-os/kernel-protocol';
3
3
  import type { Fetch } from '../../transport/http/index.js';
4
4
  import type { SessionOperation, SessionPolicy } from '../context/index.js';
5
+ import { ClientError } from '../../errors/index.js';
5
6
  export interface BundleDiscoveryOptions {
6
7
  readonly fetch: Fetch;
7
8
  readonly policy: Readonly<SessionPolicy>;
8
9
  readonly maximumBytes: number;
9
10
  }
11
+ /** Internal evidence that a selected immutable artifact is no longer served. */
12
+ export declare class BundleUnavailableError extends ClientError {
13
+ }
10
14
  export declare class BundleDiscovery {
11
15
  private readonly options;
12
- private readonly bundles;
16
+ private retained?;
13
17
  constructor(options: BundleDiscoveryOptions);
14
18
  get(publication: publication.Publication, operation: SessionOperation): Promise<bundle.Bundle>;
15
19
  clear(): void;
@@ -1,39 +1,48 @@
1
+ import { bundleUrl } from '@astrale-os/kernel-protocol/publication';
1
2
  import { decodeBundleValue } from '@astrale-os/kernel-protocol/publication/bundle';
2
3
  import { ClientError } from '../../errors/index.js';
3
4
  import { allowEndpoint } from '../context/index.js';
4
5
  import { boundedResponse, requireExactResponse } from './publication.js';
5
6
  import { runAcquisition } from './transport.js';
7
+ /** Internal evidence that a selected immutable artifact is no longer served. */
8
+ export class BundleUnavailableError extends ClientError {
9
+ }
6
10
  export class BundleDiscovery {
7
11
  options;
8
- bundles = new Map();
12
+ retained;
9
13
  constructor(options) {
10
14
  this.options = options;
11
15
  }
12
16
  async get(publication, operation) {
13
17
  const key = bundleKey(publication);
14
- const existing = this.bundles.get(key);
15
- if (existing !== undefined)
16
- return existing;
17
- const discovered = await this.fetch(publication, operation);
18
- const retained = this.bundles.get(key) ?? discovered;
19
- this.bundles.set(key, retained);
20
- return retained;
18
+ allowEndpoint(publication.schema.bundle.href, this.options.policy);
19
+ if (this.retained?.key === key)
20
+ return this.retained.value;
21
+ const value = await this.fetch(publication, operation);
22
+ this.retained = { key, value };
23
+ return value;
21
24
  }
22
25
  clear() {
23
- this.bundles.clear();
26
+ this.retained = undefined;
24
27
  }
25
28
  async fetch(publication, operation) {
26
29
  if (publication.schema.bundle.ref.size > this.options.maximumBytes) {
27
30
  throw new ClientError('Bundle artifact exceeds its byte limit.');
28
31
  }
29
32
  const href = publication.schema.bundle.href;
30
- allowEndpoint(href, this.options.policy);
33
+ const immutable = href === bundleUrl(publication.identity.issuer, publication.schema.bundle.ref);
31
34
  const response = await runAcquisition(operation, 'bundle', 'unknown', 'Kernel bundle could not be retrieved.', (signal) => this.options.fetch(href, {
32
35
  headers: { accept: publication.schema.bundle.ref.mediaType },
36
+ cache: immutable ? 'default' : 'no-cache',
33
37
  redirect: 'manual',
34
38
  signal,
35
39
  }));
36
40
  requireExactResponse(response, href, 'Bundle retrieval');
41
+ if (immutable && (response.status === 404 || response.status === 410)) {
42
+ // Cleanup must not hold discovery open on an injected body cancellation.
43
+ void response.body?.cancel().catch(() => undefined);
44
+ throw new BundleUnavailableError(`Bundle retrieval returned HTTP ${response.status}.`);
45
+ }
37
46
  if (!response.ok)
38
47
  throw new ClientError(`Bundle retrieval returned HTTP ${response.status}.`);
39
48
  if (response.headers.get('content-type') !== publication.schema.bundle.ref.mediaType) {
@@ -51,5 +60,14 @@ export class BundleDiscovery {
51
60
  }
52
61
  }
53
62
  function bundleKey(publication) {
54
- return `${new URL(publication.schema.bundle.href).origin}\u0000${publication.etag}`;
63
+ const { href, ref } = publication.schema.bundle;
64
+ return JSON.stringify([
65
+ publication.identity.issuer,
66
+ new URL(href).origin,
67
+ publication.origin,
68
+ publication.schema.revision,
69
+ ref.digest,
70
+ ref.mediaType,
71
+ ref.size,
72
+ ]);
55
73
  }
@@ -1,8 +1,9 @@
1
1
  import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
2
+ import type { publication } from '@astrale-os/kernel-protocol';
2
3
  import type { SessionOperation, SessionPolicy } from '../context/index.js';
3
- import type { BundleDiscovery } from './bundle.js';
4
4
  import type { PublicationDiscovery } from './publication.js';
5
5
  import type { SessionSnapshot } from './snapshot.js';
6
+ import { type BundleDiscovery } from './bundle.js';
6
7
  export declare class SessionDiscovery {
7
8
  readonly kernel: IssuerId;
8
9
  private readonly policy;
@@ -12,7 +13,9 @@ export declare class SessionDiscovery {
12
13
  private readonly acquisitionOperation;
13
14
  private retained?;
14
15
  private flight?;
16
+ private publicationFlight?;
15
17
  constructor(kernel: IssuerId, policy: Readonly<SessionPolicy>, publications: PublicationDiscovery, bundles: BundleDiscovery, requireOpen: () => void, acquisitionOperation: () => SessionOperation);
18
+ publication(operation: SessionOperation): Promise<publication.Publication>;
16
19
  snapshot(operation: SessionOperation): Promise<SessionSnapshot>;
17
20
  clear(): void;
18
21
  private acquireSnapshot;
@@ -1,5 +1,6 @@
1
1
  import { runSessionOperation } from '../context/index.js';
2
2
  import { rootViewCatalog } from '../views/index.js';
3
+ import { BundleUnavailableError } from './bundle.js';
3
4
  import { createSessionSnapshot } from './snapshot.js';
4
5
  export class SessionDiscovery {
5
6
  kernel;
@@ -10,6 +11,7 @@ export class SessionDiscovery {
10
11
  acquisitionOperation;
11
12
  retained;
12
13
  flight;
14
+ publicationFlight;
13
15
  constructor(kernel, policy, publications, bundles, requireOpen, acquisitionOperation) {
14
16
  this.kernel = kernel;
15
17
  this.policy = policy;
@@ -18,6 +20,24 @@ export class SessionDiscovery {
18
20
  this.requireOpen = requireOpen;
19
21
  this.acquisitionOperation = acquisitionOperation;
20
22
  }
23
+ publication(operation) {
24
+ this.requireOpen();
25
+ const current = this.publicationFlight;
26
+ if (current !== undefined)
27
+ return runSessionOperation(operation, () => current);
28
+ const flight = this.publications.revalidate(this.acquisitionOperation()).then((value) => {
29
+ this.requireOpen();
30
+ return value;
31
+ });
32
+ this.publicationFlight = flight;
33
+ void flight
34
+ .finally(() => {
35
+ if (this.publicationFlight === flight)
36
+ this.publicationFlight = undefined;
37
+ })
38
+ .catch(() => undefined);
39
+ return runSessionOperation(operation, () => flight);
40
+ }
21
41
  snapshot(operation) {
22
42
  this.requireOpen();
23
43
  const current = this.flight;
@@ -39,11 +59,20 @@ export class SessionDiscovery {
39
59
  this.retained = undefined;
40
60
  }
41
61
  async acquireSnapshot(operation) {
42
- const previous = this.publications.current();
43
- const publication = await this.publications.revalidate(operation);
44
- if (previous !== undefined && previous.etag !== publication.etag)
45
- this.bundles.clear();
46
- const artifact = await this.bundles.get(publication, operation);
62
+ let publication = await this.publication(operation);
63
+ let artifact;
64
+ try {
65
+ artifact = await this.bundles.get(publication, operation);
66
+ }
67
+ catch (cause) {
68
+ if (!(cause instanceof BundleUnavailableError))
69
+ throw cause;
70
+ const current = await this.publication(operation);
71
+ if (current.etag === publication.etag)
72
+ throw cause;
73
+ publication = current;
74
+ artifact = await this.bundles.get(publication, operation);
75
+ }
47
76
  this.requireOpen();
48
77
  if (this.retained?.publication.etag === publication.etag)
49
78
  return this.retained;
@@ -1,10 +1,12 @@
1
1
  import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
2
+ import type { publication } from '@astrale-os/kernel-protocol';
2
3
  import type { Fetch } from '../../transport/http/index.js';
3
4
  import type { SessionOperation, SessionPolicy } from '../context/index.js';
4
5
  import type { SessionSnapshot } from './snapshot.js';
5
6
  export type { SessionSnapshot } from './snapshot.js';
6
7
  export interface SessionDiscovery {
7
8
  readonly kernel: IssuerId;
9
+ publication(operation: SessionOperation): Promise<publication.Publication>;
8
10
  snapshot(operation: SessionOperation): Promise<SessionSnapshot>;
9
11
  clear(): void;
10
12
  }
@@ -1,6 +1,7 @@
1
1
  import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
2
2
  import type { PathLike } from '@astrale-os/kernel-core/path';
3
3
  import type { Domain } from '@astrale-os/kernel-dsl/v1/domain';
4
+ import type { publication } from '@astrale-os/kernel-protocol';
4
5
  import type { Call, Input, Result } from '../client/index.js';
5
6
  import type { DomainBinding, GraphApi } from '../graph/index.js';
6
7
  import type { ClientFactory, ClientPool as ClientPoolType } from '../pool/index.js';
@@ -52,6 +53,8 @@ export declare class ClientSession {
52
53
  constructor(options: ClientSessionOptions);
53
54
  /** Bind one admitted DSL Domain to authenticated graph projection and Method dispatch. */
54
55
  bind<const DomainValue extends Domain>(domain: DomainValue): DomainBinding<DomainValue>;
56
+ /** Revalidate the intrinsic Publication without acquiring its bundle or Views. */
57
+ publication(options?: SessionControlOptions): Promise<publication.Publication>;
55
58
  snapshot(options?: SessionControlOptions): Promise<SessionSnapshot>;
56
59
  viewsFor(target: PathLike, options?: SessionControlOptions): Promise<ViewCatalog>;
57
60
  dispatch(call: Call, options?: SessionRequestOptions): Promise<Result>;
@@ -101,6 +101,10 @@ export class ClientSession {
101
101
  bind(domain) {
102
102
  return createDomainBinding(domain, this.graph, (node, method, input) => this.invokeDomainMethod(domain, node, method, input));
103
103
  }
104
+ /** Revalidate the intrinsic Publication without acquiring its bundle or Views. */
105
+ publication(options) {
106
+ return this.discovery.publication(this.operation(admitControlOptions(options)));
107
+ }
104
108
  snapshot(options) {
105
109
  return this.discovery.snapshot(this.operation(admitControlOptions(options)));
106
110
  }
@@ -10,6 +10,8 @@ void envelope;
10
10
  void policy;
11
11
  void request;
12
12
  void snapshot;
13
+ const observedPublication = session.publication(control);
14
+ void observedPublication;
13
15
  const dynamicReference = reference(dynamicDomain, dynamicMethod)(Path.id(NodeId('dynamic-node')));
14
16
  void session.invoke(dynamicReference, dynamicInput);
15
17
  const dynamicInputIsClientInput = true;
@@ -51,10 +51,15 @@ export class GraphMemory {
51
51
  return observeRead(shape.inflight, options, this.options.lifecycle);
52
52
  }
53
53
  const prior = shape.response;
54
+ const invalidation = shape.invalidation;
54
55
  this.publish(shape, prior === undefined ? 'loading' : this.overlay.hasAny() ? 'optimistic' : 'refreshing');
55
56
  const inflight = this.schedule(async () => {
56
57
  const result = await this.options.graph.query(shape.ast, queryOptions(shape.page, this.options.lifecycle, shape.expected));
57
58
  this.options.requireOpen();
59
+ if (shape.invalidation !== invalidation) {
60
+ this.publish(shape, prior === undefined ? 'cold' : this.overlay.hasAny() ? 'optimistic' : 'stale');
61
+ return result;
62
+ }
58
63
  const integration = this.entities.integrate(result.result);
59
64
  shape.references = integration.references;
60
65
  shape.confirmed = result;
@@ -82,8 +87,14 @@ export class GraphMemory {
82
87
  throw error;
83
88
  })
84
89
  .finally(() => {
85
- if (shape.inflight === inflight)
86
- shape.inflight = undefined;
90
+ if (shape.inflight !== inflight)
91
+ return;
92
+ shape.inflight = undefined;
93
+ if (shape.invalidation !== invalidation &&
94
+ shape.observed &&
95
+ !this.options.lifecycle.aborted) {
96
+ void this.read(shape, { page: shape.page }).catch((error) => this.report(error));
97
+ }
87
98
  });
88
99
  shape.inflight = inflight;
89
100
  return observeRead(inflight, options, this.options.lifecycle);
@@ -105,6 +116,7 @@ export class GraphMemory {
105
116
  invalidate(shape) {
106
117
  this.options.requireOpen();
107
118
  for (const candidate of shape === undefined ? this.shapes.values() : [shape]) {
119
+ candidate.invalidation += 1;
108
120
  if (candidate.response === undefined)
109
121
  continue;
110
122
  this.publish(candidate, this.overlay.hasAny() ? 'optimistic' : 'stale');
@@ -2,4 +2,4 @@ import type { MutationAST as MutationASTValue, MutationInput, MutationResult } f
2
2
  import type { GraphApi, MutationAuthor } from '../graph/index.js';
3
3
  import type { GraphMemory } from './memory.js';
4
4
  import type { StoreMutationOptions } from './store.js';
5
- export declare function mutateStore(graph: GraphApi, memory: GraphMemory, input: MutationAuthor | MutationASTValue | MutationInput, options?: StoreMutationOptions): Promise<MutationResult>;
5
+ export declare function mutateStore(graph: GraphApi, memory: GraphMemory, lifecycle: AbortSignal, input: MutationAuthor | MutationASTValue | MutationInput, options?: StoreMutationOptions): Promise<MutationResult>;
@@ -1,5 +1,5 @@
1
1
  import { MutationAST } from '@astrale-os/kernel-core/graph/mutate';
2
- export async function mutateStore(graph, memory, input, options = {}) {
2
+ export async function mutateStore(graph, memory, lifecycle, input, options = {}) {
3
3
  if (options === null || typeof options !== 'object') {
4
4
  throw new TypeError('Store mutation options are invalid.');
5
5
  }
@@ -11,12 +11,15 @@ export async function mutateStore(graph, memory, input, options = {}) {
11
11
  : 'format' in input
12
12
  ? MutationAST.decode(input)
13
13
  : MutationAST.create(input);
14
+ const { optimistic: _optimistic, signal: callerSignal, ...request } = options;
15
+ const signal = callerSignal === undefined ? lifecycle : AbortSignal.any([lifecycle, callerSignal]);
16
+ signal.throwIfAborted();
14
17
  const transaction = options.optimistic === false ? undefined : memory.overlay.add(ast);
15
18
  if (transaction !== undefined)
16
19
  memory.optimistic();
17
- const { optimistic: _optimistic, ...request } = options;
18
20
  try {
19
- const result = await graph.mutate(ast, request);
21
+ signal.throwIfAborted();
22
+ const result = await graph.mutate(ast, { ...request, signal });
20
23
  if (transaction !== undefined) {
21
24
  memory.overlay.delete(transaction);
22
25
  memory.settleOptimistic();
@@ -16,6 +16,7 @@ export declare class QueryShape {
16
16
  response?: StoreQueryResponse;
17
17
  references?: FactReferences;
18
18
  inflight?: Promise<StoreQueryResponse>;
19
+ invalidation: number;
19
20
  releasedAt?: number;
20
21
  accessedAt: number;
21
22
  refreshedAt?: number;
@@ -9,6 +9,7 @@ export class QueryShape {
9
9
  response;
10
10
  references;
11
11
  inflight;
12
+ invalidation = 0;
12
13
  releasedAt;
13
14
  accessedAt;
14
15
  refreshedAt;
@@ -76,7 +76,7 @@ export class GraphStore {
76
76
  }
77
77
  mutate(input, options) {
78
78
  this.requireOpen();
79
- return mutateStore(this.graph, this.memory, input, options);
79
+ return mutateStore(this.graph, this.memory, this.lifecycle.signal, input, options);
80
80
  }
81
81
  invalidate(query, options) {
82
82
  this.requireOpen();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-client",
3
- "version": "0.6.0-beta.59",
3
+ "version": "0.6.0-beta.60",
4
4
  "description": "Universal Client — protocol-first, transport-agnostic",
5
5
  "keywords": [
6
6
  "astrale",
@@ -67,12 +67,12 @@
67
67
  "dependencies": {
68
68
  "@astrale-os/kernel-core": "0.9.0-beta.46",
69
69
  "@astrale-os/kernel-dsl": "0.2.0-beta.33",
70
- "@astrale-os/kernel-protocol": "0.5.0-beta.50",
70
+ "@astrale-os/kernel-protocol": "0.5.0-beta.51",
71
71
  "@tanstack/pacer": "0.20.1",
72
72
  "msgpackr": "^2.0.5"
73
73
  },
74
74
  "devDependencies": {
75
- "@astrale-os/kernel-server": "0.5.0-beta.52",
75
+ "@astrale-os/kernel-server": "0.5.0-beta.53",
76
76
  "@astrale/typescript-config": "npm:@jsr/astrale__typescript-config@~1.1.0",
77
77
  "@types/node": "^22.0.0",
78
78
  "typescript": "7.0.2",