@sentropic/artifacts 0.1.3 → 0.2.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/README.md +74 -4
- package/dist/adapters/postgres-commit.d.ts +15 -0
- package/dist/adapters/postgres-commit.js +80 -0
- package/dist/adapters/postgres.d.ts +5 -2
- package/dist/adapters/postgres.js +39 -0
- package/dist/content/handler.js +79 -63
- package/dist/http.d.ts +42 -29
- package/dist/http.js +168 -491
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp-core.d.ts +52 -0
- package/dist/mcp-core.js +111 -0
- package/dist/mcp.d.ts +4 -60
- package/dist/mcp.js +7 -276
- package/dist/profile/contracts.d.ts +67 -0
- package/dist/profile/contracts.js +31 -0
- package/dist/profile/index.d.ts +6 -0
- package/dist/profile/index.js +117 -0
- package/dist/profile/json.d.ts +10 -0
- package/dist/profile/json.js +76 -0
- package/dist/profile/radar.d.ts +3 -0
- package/dist/profile/radar.js +21 -0
- package/dist/profile/schemas.d.ts +6 -0
- package/dist/profile/schemas.js +76 -0
- package/dist/profile/validation.d.ts +5 -0
- package/dist/profile/validation.js +104 -0
- package/dist/profile/validators.d.ts +3 -0
- package/dist/profile/validators.js +51 -0
- package/dist/server/authorized-replay.d.ts +33 -0
- package/dist/server/authorized-replay.js +31 -0
- package/dist/server/command-identity.d.ts +52 -0
- package/dist/server/command-identity.js +71 -0
- package/dist/server/commit/brands.d.ts +7 -0
- package/dist/server/commit/brands.js +3 -0
- package/dist/server/commit/coordinator.d.ts +2 -0
- package/dist/server/commit/coordinator.js +729 -0
- package/dist/server/commit/types.d.ts +379 -0
- package/dist/server/commit/types.js +94 -0
- package/dist/server/commit-contract.d.ts +4 -0
- package/dist/server/commit-contract.js +2 -0
- package/dist/server/failures.d.ts +84 -0
- package/dist/server/failures.js +133 -0
- package/dist/server/gc.d.ts +42 -1
- package/dist/server/gc.js +168 -5
- package/dist/server/idempotency.d.ts +4 -0
- package/dist/server/idempotency.js +14 -0
- package/dist/server/lifecycle.d.ts +2 -1
- package/dist/server/lifecycle.js +15 -1
- package/dist/server/object-intents.d.ts +14 -1
- package/dist/server/object-intents.js +31 -0
- package/dist/server/outcome-contract.d.ts +3 -0
- package/dist/server/outcome-contract.js +3 -0
- package/dist/server/outcome-projection.d.ts +21 -0
- package/dist/server/outcome-projection.js +29 -0
- package/dist/server/preparation-cleanup.d.ts +31 -0
- package/dist/server/preparation-cleanup.js +106 -0
- package/dist/server/publication-limits.d.ts +10 -0
- package/dist/server/publication-limits.js +38 -0
- package/dist/server/publication.d.ts +85 -1
- package/dist/server/publication.js +288 -31
- package/dist/server/service.d.ts +64 -1
- package/dist/server/service.js +187 -1
- package/dist/server/uploads.d.ts +4 -1
- package/dist/server/uploads.js +63 -35
- package/dist/testing.d.ts +50 -0
- package/dist/testing.js +86 -0
- package/docs/migration-l6.md +129 -0
- package/examples/radar-commit-setup.ts +299 -0
- package/l0-freeze/command-identity-contract.ts +303 -0
- package/l0-freeze/commit-contract.ts +421 -0
- package/l0-freeze/failure-contract.ts +174 -0
- package/l0-freeze/profile-contract.ts +219 -0
- package/migrations/0003_coordinator.sql +28 -0
- package/package.json +31 -1
- package/src/contracts.ts +125 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Artifacts 0.2.0 transport migration
|
|
2
|
+
|
|
3
|
+
The registry baseline checked for this change is `@sentropic/artifacts@0.1.4`.
|
|
4
|
+
Radar 0.1.2 and upstream 0.1.4 hosts must migrate their composition before
|
|
5
|
+
loading the 0.2.0 HTTP or MCP surface. Publication is a separate owner-approved
|
|
6
|
+
release step; this branch does not publish packages.
|
|
7
|
+
|
|
8
|
+
## Required host composition
|
|
9
|
+
|
|
10
|
+
Compile an explicit immutable profile with `compileArtifactsProfile`. Radar
|
|
11
|
+
selects `artifacts_ensure`, `artifacts_publish`, `artifacts_list` and
|
|
12
|
+
`artifacts_get`. Pass the same compiled profile to `createHttpArtifactApp`,
|
|
13
|
+
the MCP adapter/executor and `createArtifactsRegistryDefinition`. Empty
|
|
14
|
+
selection exposes nothing. Missing configuration fails construction.
|
|
15
|
+
|
|
16
|
+
Supply a raw PostgreSQL pool to `createPostgresCommitExecutor` and a
|
|
17
|
+
transaction-bound policy participant factory to `createArtifactCommitCoordinator`.
|
|
18
|
+
The coordinator owns BEGIN, key locking, COMMIT, rollback and release.
|
|
19
|
+
Remove session advisory-lock wrappers, pool-routing proxies, nested BEGIN
|
|
20
|
+
and no-op release wrappers from the old provider composition.
|
|
21
|
+
|
|
22
|
+
Create one trusted `ArtifactInvocationContext` for every request, containing
|
|
23
|
+
the verified actor, trusted scope, invocation ID and `authorizeCommit` guard.
|
|
24
|
+
Pass it to `app.execute(request, invocation)` or `app.handle(request, invocation)`.
|
|
25
|
+
Accounting hosts call `execute` once and project its typed outcome themselves.
|
|
26
|
+
Do not call both methods for the same operation. The MCP adapter requires
|
|
27
|
+
`createInvocation` and verifies its actor against `ctx.principal.sub`.
|
|
28
|
+
HTTP and MCP must use the identical canonical verified actor string.
|
|
29
|
+
|
|
30
|
+
Supply one qualified `createRadarKeyDerivation` binding to both transports.
|
|
31
|
+
Caller arguments contain the original caller key; actor, scope, internal key,
|
|
32
|
+
hash and guard come from the host. An optional platform envelope must exactly
|
|
33
|
+
match the argument key. Keep publication URLs under one shared
|
|
34
|
+
`publicationUrls` policy so HTTP and MCP return identical receipts.
|
|
35
|
+
|
|
36
|
+
## Public coordinator example and cleanup
|
|
37
|
+
|
|
38
|
+
The packed `examples/radar-commit-setup.ts` uses public entrypoints only:
|
|
39
|
+
`@sentropic/artifacts/adapters/postgres-commit`, `/server/commit-contract`
|
|
40
|
+
and `/mcp-core`. The commit contract exports `CommitExecutor`,
|
|
41
|
+
`CommitCoordinatorOptions`, `ArtifactPolicyDriver` and `CoordinatorTestPoint`.
|
|
42
|
+
The example compiles the frozen `ArtifactProviderProfile` shape, binds policy
|
|
43
|
+
reads/quota to the acquired PostgreSQL transaction and mints its guard fence.
|
|
44
|
+
|
|
45
|
+
Before activation, configure `cleanupWindowMs` on guarded publication and
|
|
46
|
+
start a cleanup scheduler; the example supplies a 30-second preparation
|
|
47
|
+
lifetime and a one-second serialized sweep. Each sweep processes at most
|
|
48
|
+
100 attempts. Capacity, sweep cadence and storage availability determine
|
|
49
|
+
whether the configured deadline is met; failed cleanup remains observable debt.
|
|
50
|
+
Reads are limited to the same batch size, with unsettled debt first. Settled
|
|
51
|
+
attempts receive metadata-only existence checks for 60 seconds after their
|
|
52
|
+
deadline; subsequent orphan recovery uses the host's ordinary GC. No cleanup
|
|
53
|
+
or inspection downloads blob contents. Invalid metadata is retained as failed
|
|
54
|
+
debt, excluded from subsequent sweeps, and returned in inspection's
|
|
55
|
+
`invalidIntents`; the sweep reports it after processing the remaining batch.
|
|
56
|
+
|
|
57
|
+
`/server/object-intents` exposes `inspectPreparations(host, artifactId)` and
|
|
58
|
+
`scheduleBoundedPreparationCleanup(host, throughMs)`. Bind `host.intents`
|
|
59
|
+
to the PostgreSQL artifact store, plus `blobs`, `refs` and `claims`.
|
|
60
|
+
Keep the claim store's transaction open through external DELETE, then commit
|
|
61
|
+
or roll back and release it, as `startRadarPreparationCleanup` demonstrates.
|
|
62
|
+
Inspection returns `attempts`, `protectedKeys` and `abandonedKeys`; every
|
|
63
|
+
attempt retains its ID, invocation, blob keys, preparation time and deadline.
|
|
64
|
+
Cleanup invalidates expired preparations before removing their pins, retains
|
|
65
|
+
other references/pins, and never promotes an abandoned attempt to success.
|
|
66
|
+
|
|
67
|
+
`/testing` exports `pausePurgeBeforeDelete(host, artifactId)` and
|
|
68
|
+
`inspectDeletionClaims(host)`. Bind the test host's `storageScopeId`, `blobs`,
|
|
69
|
+
inspectable `claims`, and `purge(artifactId, deletingBlobs)` callback. The
|
|
70
|
+
callback must commit its durable `purge:<artifactId>` claims and use the
|
|
71
|
+
supplied deletion adapter for that purge only. The pause returns `claimId`,
|
|
72
|
+
`blobKeys`, `release()` and `finished`; release it in a `finally` block.
|
|
73
|
+
Inspection reads durable claim history without releasing exclusion or
|
|
74
|
+
granting publication authority. The seams do not alter L3 ports or profiles.
|
|
75
|
+
|
|
76
|
+
## Domain adapters and transaction boundaries
|
|
77
|
+
|
|
78
|
+
`ensureHost.lockBinding` and `resolveBinding` receive the coordinator's
|
|
79
|
+
transaction client. Resolve the binding and write ACL/lifecycle state on that
|
|
80
|
+
same client. Concurrent commands must serialize on the binding identity,
|
|
81
|
+
including when their caller keys differ. Replay resolves current identity
|
|
82
|
+
under this lock and compares it to the stored receipt. Missing or changed
|
|
83
|
+
targets deny before the host guard; explicit replay requires a binding.
|
|
84
|
+
|
|
85
|
+
Use `createGuardedEnsureService` and `createGuardedPublicationService` for
|
|
86
|
+
custom services. The canonical coordinator exposes `finalizeOperation` and
|
|
87
|
+
`replayOperation` with the frozen L0 inputs. Bind fresh domain request plans
|
|
88
|
+
through its `operations` option; no process-local replay cache is used.
|
|
89
|
+
Provider adapters use `finalizePrepared` and `replayPrepared` on that same
|
|
90
|
+
coordinator. Guards run after all blocking waits and must be side-effect-free.
|
|
91
|
+
Policy/quota registration is trusted construction, never a public argument.
|
|
92
|
+
|
|
93
|
+
`ArtifactCommitParticipant.readAdmission` reads current approval/revision on
|
|
94
|
+
the leased transaction. Keep policy locks through COMMIT. Quota reservation
|
|
95
|
+
is provisional and rolls back with denial. Object authorization also runs
|
|
96
|
+
for replay; a stored success alone confers no authority.
|
|
97
|
+
|
|
98
|
+
Use `createPostgresGcService(executor, refsForTransaction)` for PostgreSQL GC.
|
|
99
|
+
The reference-store factory must bind its reads to the supplied client.
|
|
100
|
+
GC keeps the transaction and digest exclusion through external deletion and
|
|
101
|
+
claim completion. Do not pass a raw pool to transaction-scoped deletion claims.
|
|
102
|
+
|
|
103
|
+
## Durable state and failures
|
|
104
|
+
|
|
105
|
+
Stop old writers before enabling the new composition. Apply migrations
|
|
106
|
+
`0001_artifacts.sql`, `0002_lifecycle.sql` and additive `0003_coordinator.sql`.
|
|
107
|
+
Preserve historical rows and objects. Supply `legacyRecords`, using
|
|
108
|
+
`createPostgresLegacyArtifactRecords(pool)` for PostgreSQL. An empty inventory
|
|
109
|
+
reader is appropriate only for a verified fresh store.
|
|
110
|
+
|
|
111
|
+
Ambiguous legacy keys and unknown command codecs return a stable
|
|
112
|
+
503 `capability_unavailable`, non-retriable migration condition. They are
|
|
113
|
+
never promoted to newly authorized successes and never trigger allocation
|
|
114
|
+
under another key. Reconcile actual pilot inventory separately; unknown
|
|
115
|
+
historical array order cannot be recovered from a sorted manifest.
|
|
116
|
+
|
|
117
|
+
New receipts store the ordered `artifacts-command/v1` identity in
|
|
118
|
+
`artifacts.operation_receipts`. Manifest digests retain their historical
|
|
119
|
+
codec. A denied attempt has no success receipt; reauthorization may retry the
|
|
120
|
+
same key. An unknown COMMIT outcome requires the same key and a fresh guarded
|
|
121
|
+
lookup. Private failure causes remain internal. HTTP and MCP project the same
|
|
122
|
+
public code and retry classification; status 503 alone does not imply retry.
|
|
123
|
+
|
|
124
|
+
Public host imports are `@sentropic/artifacts/server/commit-contract`,
|
|
125
|
+
`@sentropic/artifacts/server/outcome-contract` and `@sentropic/artifacts/testing`.
|
|
126
|
+
The `/mcp-core` runtime stays independent of the MCP platform runtime.
|
|
127
|
+
For tests that subscribe after arrival, explicitly arm barrier points with
|
|
128
|
+
`createArtifactCommitBarrier({ points: ['before_commit'] })`. Other arrivals
|
|
129
|
+
remain observational; a barrier cannot grant authority or certify success.
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3 (immo #861) — typed Radar construction example (conductor-directed).
|
|
3
|
+
*
|
|
4
|
+
* Shows the exact construction Radar wires: a real coordinator over the
|
|
5
|
+
* PostgreSQL executor, a transaction-bound PG policy/quota participant
|
|
6
|
+
* registered at construction, fence minting in the host guard, and the
|
|
7
|
+
* preparation-abandonment queue bound to a cleanup sweeper. Imports only
|
|
8
|
+
* public package entrypoints. Pass this coordinator plus the compiled profile to
|
|
9
|
+
* `createHttpArtifactApp` (r2 section 3.2); nothing here invents that seam.
|
|
10
|
+
* The `radar_commit_example` tables below are illustrative host tables so
|
|
11
|
+
* the example runs standalone; Radar's real adapter owns its own tables.
|
|
12
|
+
*/
|
|
13
|
+
import type { Pool } from 'pg';
|
|
14
|
+
import { createPostgresCommitExecutor } from '@sentropic/artifacts/adapters/postgres-commit';
|
|
15
|
+
import { createArtifactCommitCoordinator } from '@sentropic/artifacts/server/commit-contract';
|
|
16
|
+
import { compileArtifactsProfile, RADAR_ARTIFACTS_PROFILE } from '@sentropic/artifacts/mcp-core';
|
|
17
|
+
import { createPostgresArtifactStore } from '@sentropic/artifacts/adapters/postgres';
|
|
18
|
+
import { createPostgresDeletionClaimStore } from '@sentropic/artifacts/server/gc';
|
|
19
|
+
import { scheduleBoundedPreparationCleanup } from '@sentropic/artifacts/server/object-intents';
|
|
20
|
+
import type { PublicationBlobStore } from '@sentropic/artifacts/server/publication';
|
|
21
|
+
import type {
|
|
22
|
+
ArtifactAuthorizationDecision,
|
|
23
|
+
ArtifactCommitContext,
|
|
24
|
+
ArtifactCommitTransaction,
|
|
25
|
+
ArtifactPolicyDriver,
|
|
26
|
+
CommitPolicyParticipantFactory,
|
|
27
|
+
CommitCoordinatorOptions,
|
|
28
|
+
CoordinatorTestPoint,
|
|
29
|
+
ArtifactCommitCoordinator,
|
|
30
|
+
FinalizeRequest,
|
|
31
|
+
FinalizeResult,
|
|
32
|
+
InternalIdempotencyKey,
|
|
33
|
+
} from '@sentropic/artifacts/server/commit-contract';
|
|
34
|
+
|
|
35
|
+
/** One abandoned preparation awaiting host cleanup. */
|
|
36
|
+
export interface AbandonedAttempt {
|
|
37
|
+
readonly attemptId: string;
|
|
38
|
+
readonly scopeId: string;
|
|
39
|
+
readonly internalKey: string;
|
|
40
|
+
readonly atMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Configure publication with this lifetime; sweep cadence is part of the activation bound. */
|
|
44
|
+
export const RADAR_PREPARATION_LIFETIME_MS = 30_000;
|
|
45
|
+
|
|
46
|
+
/** Bind durable intent cleanup before activation; observe failures as preparation debt. */
|
|
47
|
+
export function startRadarPreparationCleanup(options: {
|
|
48
|
+
pool: Pool;
|
|
49
|
+
blobs: PublicationBlobStore;
|
|
50
|
+
onError(error: unknown): void;
|
|
51
|
+
intervalMs?: number;
|
|
52
|
+
}): { runOnce(): Promise<void>; stop(): void } {
|
|
53
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
54
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1 || intervalMs > RADAR_PREPARATION_LIFETIME_MS) {
|
|
55
|
+
throw new Error('invalid preparation cleanup interval');
|
|
56
|
+
}
|
|
57
|
+
const executor = createPostgresCommitExecutor(options.pool);
|
|
58
|
+
const store = createPostgresArtifactStore(options.pool);
|
|
59
|
+
let running: Promise<void> | undefined;
|
|
60
|
+
const runOnce = (): Promise<void> => {
|
|
61
|
+
if (running !== undefined) return running;
|
|
62
|
+
running = (async () => {
|
|
63
|
+
const client = await executor.beginFinalization();
|
|
64
|
+
let failure: unknown;
|
|
65
|
+
try {
|
|
66
|
+
await client.beginReadCommitted();
|
|
67
|
+
await scheduleBoundedPreparationCleanup({ intents: store, refs: store,
|
|
68
|
+
claims: createPostgresDeletionClaimStore(client), blobs: options.blobs }, Date.now());
|
|
69
|
+
await client.commit();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
failure = error;
|
|
72
|
+
try { await client.rollback(); } catch (rollbackError) { failure = rollbackError; }
|
|
73
|
+
throw error;
|
|
74
|
+
} finally { client.release(failure); }
|
|
75
|
+
})().finally(() => { running = undefined; });
|
|
76
|
+
return running;
|
|
77
|
+
};
|
|
78
|
+
const timer = setInterval(() => { void runOnce().catch(options.onError); }, intervalMs);
|
|
79
|
+
return { runOnce, stop: () => clearInterval(timer) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Create the illustrative host tables and seed one approved actor plus quota. */
|
|
83
|
+
export async function prepareRadarCommitExample(pool: Pool): Promise<void> {
|
|
84
|
+
await pool.query('CREATE SCHEMA IF NOT EXISTS radar_commit_example');
|
|
85
|
+
await pool.query(
|
|
86
|
+
`CREATE TABLE IF NOT EXISTS radar_commit_example.policy_revision
|
|
87
|
+
(id INT PRIMARY KEY CHECK (id = 1), revision TEXT NOT NULL)`,
|
|
88
|
+
);
|
|
89
|
+
await pool.query(
|
|
90
|
+
`INSERT INTO radar_commit_example.policy_revision (id, revision)
|
|
91
|
+
VALUES (1, 'rev-1') ON CONFLICT (id) DO UPDATE SET revision = 'rev-1'`,
|
|
92
|
+
);
|
|
93
|
+
await pool.query(
|
|
94
|
+
`CREATE TABLE IF NOT EXISTS radar_commit_example.approvals
|
|
95
|
+
(actor_id TEXT PRIMARY KEY, approved BOOLEAN NOT NULL, expires_at_ms BIGINT NOT NULL)`,
|
|
96
|
+
);
|
|
97
|
+
await pool.query(
|
|
98
|
+
`INSERT INTO radar_commit_example.approvals (actor_id, approved, expires_at_ms)
|
|
99
|
+
VALUES ('radar-actor', true, 9999999999999)
|
|
100
|
+
ON CONFLICT (actor_id) DO UPDATE SET approved = true, expires_at_ms = 9999999999999`,
|
|
101
|
+
);
|
|
102
|
+
await pool.query(
|
|
103
|
+
`CREATE TABLE IF NOT EXISTS radar_commit_example.quotas
|
|
104
|
+
(quota_key TEXT PRIMARY KEY, total INT NOT NULL, used INT NOT NULL)`,
|
|
105
|
+
);
|
|
106
|
+
await pool.query(
|
|
107
|
+
`INSERT INTO radar_commit_example.quotas (quota_key, total, used)
|
|
108
|
+
VALUES ('radar-quota', 100, 0) ON CONFLICT (quota_key) DO UPDATE SET used = 0`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The host guard Radar wires: current revision, approval and fence minting. */
|
|
113
|
+
export async function allowRadarGuard(
|
|
114
|
+
context: ArtifactCommitContext,
|
|
115
|
+
transaction: ArtifactCommitTransaction,
|
|
116
|
+
): Promise<ArtifactAuthorizationDecision> {
|
|
117
|
+
const revision = await transaction.participant.readRevision();
|
|
118
|
+
if (context.policyRevision !== revision) {
|
|
119
|
+
return {
|
|
120
|
+
allowed: false, reason: 'revoked', publicCode: 'access_denied', status: 403, retriable: false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const check = await transaction.participant.checkApproval({
|
|
124
|
+
actorId: context.actorId,
|
|
125
|
+
operation: context.operation,
|
|
126
|
+
});
|
|
127
|
+
if (!check.approved) {
|
|
128
|
+
return {
|
|
129
|
+
allowed: false, reason: check.reason ?? 'denied',
|
|
130
|
+
publicCode: 'access_denied', status: 403, retriable: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const createParticipantFactory = (
|
|
137
|
+
quotaKey: string,
|
|
138
|
+
clock: { nowMs: number },
|
|
139
|
+
): CommitPolicyParticipantFactory => ({
|
|
140
|
+
createParticipant: (client, transactionToken): ArtifactPolicyDriver => ({
|
|
141
|
+
async readRevision(): Promise<string> {
|
|
142
|
+
const { rows } = await client.query<{ revision: string }>(
|
|
143
|
+
'SELECT revision FROM radar_commit_example.policy_revision WHERE id = 1 FOR SHARE',
|
|
144
|
+
[],
|
|
145
|
+
);
|
|
146
|
+
const row = rows[0];
|
|
147
|
+
if (row === undefined) throw new Error('missing policy revision');
|
|
148
|
+
return row.revision;
|
|
149
|
+
},
|
|
150
|
+
async checkApproval(input: { actorId: string }) {
|
|
151
|
+
const { rows } = await client.query<{ approved: boolean; expires_at_ms: string }>(
|
|
152
|
+
'SELECT approved, expires_at_ms FROM radar_commit_example.approvals WHERE actor_id = $1 FOR SHARE',
|
|
153
|
+
[input.actorId],
|
|
154
|
+
);
|
|
155
|
+
const row = rows[0];
|
|
156
|
+
if (row === undefined || !row.approved) return { approved: false as const, reason: 'denied' as const };
|
|
157
|
+
if (Number(row.expires_at_ms) <= clock.nowMs) {
|
|
158
|
+
return { approved: false as const, reason: 'expired' as const };
|
|
159
|
+
}
|
|
160
|
+
return { approved: true as const };
|
|
161
|
+
},
|
|
162
|
+
async reserveQuota(): Promise<{ reserved: boolean }> {
|
|
163
|
+
const { rowCount } = await client.query(
|
|
164
|
+
'UPDATE radar_commit_example.quotas SET used = used + 1 WHERE quota_key = $1 AND used < total',
|
|
165
|
+
[quotaKey],
|
|
166
|
+
);
|
|
167
|
+
return { reserved: rowCount > 0 };
|
|
168
|
+
},
|
|
169
|
+
mintFence: (revision: string, invocationId: string) => ({
|
|
170
|
+
fenceId: `fence-${transactionToken}`,
|
|
171
|
+
transactionId: transactionToken,
|
|
172
|
+
invocationId,
|
|
173
|
+
policyRevision: revision,
|
|
174
|
+
}),
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export interface RadarCommitInput {
|
|
179
|
+
readonly scopeId: string;
|
|
180
|
+
readonly internalKey: string;
|
|
181
|
+
readonly commandHash: string;
|
|
182
|
+
readonly invocationId: string;
|
|
183
|
+
readonly operation: string;
|
|
184
|
+
readonly receiptData: unknown;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface RadarCommitSetup {
|
|
188
|
+
readonly coordinator: ArtifactCommitCoordinator;
|
|
189
|
+
readonly profile: ReturnType<typeof compileArtifactsProfile>;
|
|
190
|
+
readonly abandonedAttempts: AbandonedAttempt[];
|
|
191
|
+
finalizePrepared(input: RadarCommitInput): Promise<FinalizeResult>;
|
|
192
|
+
replayPrepared(input: RadarCommitInput): Promise<FinalizeResult>;
|
|
193
|
+
drainAbandonedAttempts(onSweep: (batch: AbandonedAttempt[]) => void): void;
|
|
194
|
+
startAbandonedAttemptSweep(
|
|
195
|
+
onSweep: (batch: AbandonedAttempt[]) => void | Promise<void>,
|
|
196
|
+
intervalMs: number,
|
|
197
|
+
): () => void;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build the exact Radar construction over one pool. The optional
|
|
202
|
+
* `testObserver` forwards barrier observations to the coordinator;
|
|
203
|
+
* `testPoints` optionally filters them (see `testing.ts`).
|
|
204
|
+
*/
|
|
205
|
+
export function createRadarCommitSetup(options: {
|
|
206
|
+
pool: Pool;
|
|
207
|
+
clock: { nowMs: number };
|
|
208
|
+
quotaKey?: string;
|
|
209
|
+
testObserver?: CommitCoordinatorOptions['testObserver'];
|
|
210
|
+
testPoints?: readonly CoordinatorTestPoint[];
|
|
211
|
+
}): RadarCommitSetup {
|
|
212
|
+
const quotaKey = options.quotaKey ?? 'radar-quota';
|
|
213
|
+
const abandonedAttempts: AbandonedAttempt[] = [];
|
|
214
|
+
const coordinator = createArtifactCommitCoordinator({
|
|
215
|
+
executor: createPostgresCommitExecutor(options.pool),
|
|
216
|
+
policy: createParticipantFactory(quotaKey, options.clock),
|
|
217
|
+
nowMs: () => options.clock.nowMs,
|
|
218
|
+
testObserver: options.testObserver,
|
|
219
|
+
testPoints: options.testPoints,
|
|
220
|
+
});
|
|
221
|
+
const toRequest = (
|
|
222
|
+
input: RadarCommitInput,
|
|
223
|
+
): FinalizeRequest & { attemptId: string } => ({
|
|
224
|
+
scopeId: input.scopeId,
|
|
225
|
+
internalKey: input.internalKey as InternalIdempotencyKey,
|
|
226
|
+
commandHash: input.commandHash,
|
|
227
|
+
codecVersion: 'artifacts-command/v1',
|
|
228
|
+
invocationId: input.invocationId,
|
|
229
|
+
actorId: 'radar-actor',
|
|
230
|
+
operation: input.operation,
|
|
231
|
+
artifactId: null,
|
|
232
|
+
bindingName: null,
|
|
233
|
+
policyRevision: 'rev-1',
|
|
234
|
+
guard: allowRadarGuard,
|
|
235
|
+
receiptData: input.receiptData,
|
|
236
|
+
attemptId: input.invocationId,
|
|
237
|
+
noteAbandonedAttempt: async (attemptId: string) => {
|
|
238
|
+
abandonedAttempts.push({
|
|
239
|
+
attemptId,
|
|
240
|
+
scopeId: input.scopeId,
|
|
241
|
+
internalKey: input.internalKey,
|
|
242
|
+
atMs: options.clock.nowMs,
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const drainAbandonedAttempts = (onSweep: (batch: AbandonedAttempt[]) => void): void => {
|
|
247
|
+
if (abandonedAttempts.length === 0) return;
|
|
248
|
+
// Retain-then-remove: a throwing sweeper keeps the batch for the next
|
|
249
|
+
// sweep instead of losing it. Durable cleanup state, per-attempt
|
|
250
|
+
// deadlines and restart recovery stay L4-owned (intent metadata).
|
|
251
|
+
const batch = abandonedAttempts.slice();
|
|
252
|
+
onSweep(batch);
|
|
253
|
+
abandonedAttempts.splice(0, batch.length);
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
coordinator,
|
|
257
|
+
profile: compileArtifactsProfile(RADAR_ARTIFACTS_PROFILE),
|
|
258
|
+
abandonedAttempts,
|
|
259
|
+
finalizePrepared: (input: RadarCommitInput) => coordinator.finalizePrepared(toRequest(input)),
|
|
260
|
+
replayPrepared: (input: RadarCommitInput) => coordinator.replayPrepared(toRequest(input)),
|
|
261
|
+
drainAbandonedAttempts,
|
|
262
|
+
startAbandonedAttemptSweep: (onSweep, intervalMs) => {
|
|
263
|
+
// Serialized sweeps: an overlapping tick queues behind the running
|
|
264
|
+
// sweep instead of sweeping concurrently, and only the swept entries
|
|
265
|
+
// are removed, so arrivals landing mid-sweep are never deleted.
|
|
266
|
+
let sweeping = false;
|
|
267
|
+
let queued = false;
|
|
268
|
+
const runSweep = (): void => {
|
|
269
|
+
if (sweeping) {
|
|
270
|
+
queued = true;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
sweeping = true;
|
|
274
|
+
void (async () => {
|
|
275
|
+
try {
|
|
276
|
+
do {
|
|
277
|
+
queued = false;
|
|
278
|
+
const batch = abandonedAttempts.slice();
|
|
279
|
+
try {
|
|
280
|
+
await onSweep(batch);
|
|
281
|
+
} catch {
|
|
282
|
+
/* retained for the next sweep */
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
for (const entry of batch) {
|
|
286
|
+
const index = abandonedAttempts.indexOf(entry);
|
|
287
|
+
if (index !== -1) abandonedAttempts.splice(index, 1);
|
|
288
|
+
}
|
|
289
|
+
} while (queued);
|
|
290
|
+
} finally {
|
|
291
|
+
sweeping = false;
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
294
|
+
};
|
|
295
|
+
const timer = setInterval(runSweep, intervalMs);
|
|
296
|
+
return () => clearInterval(timer);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|