@sentropic/artifacts 0.1.4 → 0.2.1
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 +47 -0
- 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/http.d.ts +43 -30
- package/dist/http.js +158 -507
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp-core.d.ts +32 -39
- package/dist/mcp-core.js +83 -244
- package/dist/mcp.js +5 -5
- 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 +382 -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.d.ts +78 -0
- package/dist/server/publication.js +263 -31
- package/dist/server/service.d.ts +60 -0
- package/dist/server/service.js +181 -0
- package/dist/testing.d.ts +50 -0
- package/dist/testing.js +86 -0
- package/docs/migration-l6.md +134 -0
- package/examples/radar-commit-setup.ts +302 -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 +27 -1
- package/src/contracts.ts +125 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export class ArtifactOperationError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
details;
|
|
4
|
+
constructor(kind, details = {}) {
|
|
5
|
+
super(kind, { cause: details.cause });
|
|
6
|
+
this.kind = kind;
|
|
7
|
+
this.details = details;
|
|
8
|
+
this.name = 'ArtifactOperationError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Mint only at verified per-key row contention, using the full scoped operation key. */
|
|
12
|
+
export class ArtifactKeyLockConflict extends Error {
|
|
13
|
+
storageScopeId;
|
|
14
|
+
operationKey;
|
|
15
|
+
constructor(storageScopeId, operationKey, cause) {
|
|
16
|
+
super('artifact key lock contention', { cause });
|
|
17
|
+
this.storageScopeId = storageScopeId;
|
|
18
|
+
this.operationKey = operationKey;
|
|
19
|
+
this.name = 'ArtifactKeyLockConflict';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const UNAVAILABLE_CODES = new Set([
|
|
23
|
+
'artifact_connection_lost', '57P06', '55P03', 'lock_timeout', '40001', '40P01', '57014',
|
|
24
|
+
'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENETUNREACH', 'EHOSTUNREACH',
|
|
25
|
+
'ENOTFOUND', 'EAI_AGAIN', 'ERR_STREAM_PREMATURE_CLOSE',
|
|
26
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
|
|
27
|
+
]);
|
|
28
|
+
const TIMEOUT_NAMES = new Set(['TimeoutError', 'RequestTimeout', 'RequestTimeoutError']);
|
|
29
|
+
const DOMAIN_STATUS = {
|
|
30
|
+
invalid_request: 400, anonymous: 401, access_denied: 403,
|
|
31
|
+
not_found: 404, gone: 410, unprocessable: 422, verification_failed: 422,
|
|
32
|
+
};
|
|
33
|
+
function field(value, name) {
|
|
34
|
+
if (typeof value !== 'object' || value === null)
|
|
35
|
+
return undefined;
|
|
36
|
+
// Unknown thrown objects can have getters; inspection must not replace the original failure.
|
|
37
|
+
try {
|
|
38
|
+
return value[name];
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function chain(cause) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const values = [];
|
|
47
|
+
for (let current = cause; current !== undefined && !seen.has(current); current = field(current, 'cause')) {
|
|
48
|
+
seen.add(current);
|
|
49
|
+
values.push(current);
|
|
50
|
+
}
|
|
51
|
+
return values;
|
|
52
|
+
}
|
|
53
|
+
const unavailable = (category = 'provider_unavailable') => {
|
|
54
|
+
const fields = { httpStatus: 503, publicCode: 'capability_unavailable', retriable: true };
|
|
55
|
+
return category === 'admission_unavailable'
|
|
56
|
+
? { ...fields, kind: 'admission', category, variant: 'entry_outage' }
|
|
57
|
+
: { ...fields, kind: 'operational', category, variant: 'dispatch_failure' };
|
|
58
|
+
};
|
|
59
|
+
const conflict = (variant = 'key_contention') => ({ kind: 'operational', category: 'conflict', variant, httpStatus: 409, publicCode: 'conflict', retriable: false });
|
|
60
|
+
const internal = () => ({ kind: 'operational', category: 'provider_error', variant: 'unclassified', httpStatus: 500, publicCode: 'internal_error', retriable: false });
|
|
61
|
+
function operationProjection(error) {
|
|
62
|
+
const { kind } = error;
|
|
63
|
+
switch (kind) {
|
|
64
|
+
case 'provider_unavailable':
|
|
65
|
+
case 'admission_unavailable': return unavailable(kind);
|
|
66
|
+
case 'command_conflict':
|
|
67
|
+
case 'pointer_conflict': return conflict(kind);
|
|
68
|
+
case 'capability_unselected': return {
|
|
69
|
+
kind: 'operational', category: kind, variant: 'unselected_operation',
|
|
70
|
+
httpStatus: 503, publicCode: 'capability_unavailable', retriable: false,
|
|
71
|
+
};
|
|
72
|
+
case 'unauthorized_at_commit': {
|
|
73
|
+
const refusal = error.details.refusal ?? { allowed: false, reason: 'denied', status: 403, publicCode: 'access_denied', retriable: false };
|
|
74
|
+
// Stable host-approved codes only; a malformed mapping never exposes its text.
|
|
75
|
+
if (!Number.isInteger(refusal.status) || refusal.status < 400 || refusal.status > 599
|
|
76
|
+
|| !/^[a-z][a-z0-9_]{0,63}$/.test(refusal.publicCode))
|
|
77
|
+
return internal();
|
|
78
|
+
const authorityUnavailable = refusal.reason === 'unavailable' && refusal.status === 503;
|
|
79
|
+
return {
|
|
80
|
+
kind: 'operational', category: kind, httpStatus: refusal.status,
|
|
81
|
+
variant: authorityUnavailable ? 'authority_unavailable' : 'policy_denial',
|
|
82
|
+
publicCode: refusal.status === 503 ? 'capability_unavailable' : refusal.publicCode,
|
|
83
|
+
retriable: authorityUnavailable,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
case 'anonymous':
|
|
87
|
+
case 'access_denied':
|
|
88
|
+
return { kind: 'admission', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
|
|
89
|
+
default: return { kind: 'ordinary', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function classify(cause, context, mapHostError) {
|
|
93
|
+
for (const current of chain(cause)) {
|
|
94
|
+
if (current instanceof ArtifactOperationError)
|
|
95
|
+
return operationProjection(current);
|
|
96
|
+
if (current instanceof ArtifactKeyLockConflict
|
|
97
|
+
&& context.phase === 'lock'
|
|
98
|
+
&& /^[a-f0-9]{64}$/.test(current.operationKey)
|
|
99
|
+
&& context.storageScopeId === current.storageScopeId
|
|
100
|
+
&& context.operationKey === current.operationKey)
|
|
101
|
+
return conflict();
|
|
102
|
+
if (mapHostError !== undefined) {
|
|
103
|
+
try {
|
|
104
|
+
const mapped = mapHostError(current);
|
|
105
|
+
if (mapped instanceof ArtifactOperationError)
|
|
106
|
+
return operationProjection(mapped);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return internal();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const code = field(current, 'code');
|
|
113
|
+
if (typeof code === 'string' && (UNAVAILABLE_CODES.has(code) || /^08[A-Z0-9]{3}$/.test(code)))
|
|
114
|
+
return unavailable();
|
|
115
|
+
const name = field(current, 'name');
|
|
116
|
+
if (current instanceof Error && typeof name === 'string' && TIMEOUT_NAMES.has(name))
|
|
117
|
+
return unavailable();
|
|
118
|
+
}
|
|
119
|
+
return internal();
|
|
120
|
+
}
|
|
121
|
+
/** Pure per-invocation classification. Cleanup errors must not replace the primary cause. */
|
|
122
|
+
export function classifyArtifactFailure(cause, context, mapHostError) {
|
|
123
|
+
let projection = classify(cause, context, mapHostError);
|
|
124
|
+
// Monitoring after a proven commit cannot become a prevented finalization.
|
|
125
|
+
if (context.commitState === 'committed' && projection.category === 'unauthorized_at_commit')
|
|
126
|
+
projection = unavailable();
|
|
127
|
+
const failure = {
|
|
128
|
+
...projection, invocationId: context.invocationId, operation: context.operation,
|
|
129
|
+
phase: context.phase, commitState: context.commitState, cause,
|
|
130
|
+
};
|
|
131
|
+
Object.defineProperty(failure, 'cause', { value: cause, enumerable: false });
|
|
132
|
+
return Object.freeze(failure);
|
|
133
|
+
}
|
package/dist/server/gc.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CommitExecutor, FinalizeTxClient } from './commit/types.js';
|
|
1
2
|
import { type PublicationBlobStore } from './publication.js';
|
|
2
3
|
export interface RefcountStore {
|
|
3
4
|
addRef(digest: string, holder: string): Promise<void>;
|
|
@@ -18,7 +19,47 @@ export interface GcReport {
|
|
|
18
19
|
}[];
|
|
19
20
|
}
|
|
20
21
|
export interface GcService {
|
|
21
|
-
collect(blobs: PublicationBlobStore, prefix: string): Promise<GcReport>;
|
|
22
|
+
collect(blobs: PublicationBlobStore, prefix: string, claims: DeletionClaimStore): Promise<GcReport>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Durable exclusive deletion claims (spec r2 A4/A6). Purge claims a digest
|
|
26
|
+
* before dereferencing it; reference creation MUST check the claim and
|
|
27
|
+
* refuse new references to claimed digests; erasure acknowledges the claim.
|
|
28
|
+
*/
|
|
29
|
+
export interface DeletionClaimStore {
|
|
30
|
+
/** Share this exclusion across claims, reference/pin creation and erasure. */
|
|
31
|
+
withDigestLock<T>(digest: string, action: (locked: DeletionClaimAccess) => Promise<T>): Promise<T>;
|
|
32
|
+
/** Exclusive claim; throws when the digest is already claimed. */
|
|
33
|
+
claim(digest: string, holder: string): Promise<void>;
|
|
34
|
+
/** Release the claim after the blob is gone (or the purge aborts). */
|
|
35
|
+
releaseClaim(digest: string): Promise<void>;
|
|
36
|
+
isClaimed(digest: string): Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
type DeletionClaimAccess = Pick<DeletionClaimStore, 'claim' | 'releaseClaim' | 'isClaimed'>;
|
|
39
|
+
export interface DeletionClaimRecord {
|
|
40
|
+
readonly claimId: string;
|
|
41
|
+
readonly digest: string;
|
|
42
|
+
readonly holder: string;
|
|
43
|
+
readonly state: 'pending' | 'done';
|
|
22
44
|
}
|
|
45
|
+
export interface InspectableDeletionClaimStore extends DeletionClaimStore {
|
|
46
|
+
listClaims(): Promise<DeletionClaimRecord[]>;
|
|
47
|
+
}
|
|
48
|
+
/** GC keeps the advisory lock's transaction open through deletion and claim completion. */
|
|
49
|
+
export declare function createPostgresGcService(executor: CommitExecutor, refsForTransaction: (client: FinalizeTxClient) => RefcountStore): {
|
|
50
|
+
collect(blobs: PublicationBlobStore, prefix: string): Promise<GcReport>;
|
|
51
|
+
};
|
|
52
|
+
/** Short preparation transactions; no leased connection spans blob I/O. */
|
|
53
|
+
export declare function createPostgresClaimGuardedRefs(refs: RefcountStore, executor: CommitExecutor): RefcountStore;
|
|
54
|
+
/** Transaction-scoped claims; callers commit the claim before external deletion. */
|
|
55
|
+
export declare function createPostgresDeletionClaimStore(client: FinalizeTxClient): InspectableDeletionClaimStore;
|
|
56
|
+
export declare function createMemoryDeletionClaimStore(): InspectableDeletionClaimStore;
|
|
57
|
+
/**
|
|
58
|
+
* A RefcountStore that refuses new references to claimed digests. Purge
|
|
59
|
+
* claims first, so racing publications cannot pin a blob scheduled for
|
|
60
|
+
* deletion; already-held references still protect the blob in collect.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createClaimGuardedRefs(refs: RefcountStore, claims: DeletionClaimStore): RefcountStore;
|
|
23
63
|
/** Create a GC service over an injected refcount store (no driver imports). */
|
|
24
64
|
export declare function createGcService(refs: RefcountStore): GcService;
|
|
65
|
+
export {};
|
package/dist/server/gc.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* throwing, so revoked access, pending purge and actual erasure stay
|
|
8
8
|
* distinguishable. Reference updates serialize per digest (in-process
|
|
9
9
|
* mutex); the memory double yields mid-update to simulate store latency so
|
|
10
|
-
* races are genuinely exercised.
|
|
11
|
-
*
|
|
10
|
+
* races are genuinely exercised. Collection claims and re-checks each
|
|
11
|
+
* digest under the exclusion shared with reference and pin creation.
|
|
12
12
|
*/
|
|
13
13
|
import { createKeyMutex } from './ports.js';
|
|
14
14
|
import { blobDigestOfKey } from './publication.js';
|
|
@@ -58,10 +58,155 @@ export function createMemoryRefcountStore() {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
/** GC keeps the advisory lock's transaction open through deletion and claim completion. */
|
|
62
|
+
export function createPostgresGcService(executor, refsForTransaction) {
|
|
63
|
+
return {
|
|
64
|
+
async collect(blobs, prefix) {
|
|
65
|
+
const client = await executor.beginFinalization();
|
|
66
|
+
let failure;
|
|
67
|
+
try {
|
|
68
|
+
const refs = refsForTransaction(client);
|
|
69
|
+
const claims = createPostgresDeletionClaimStore(client);
|
|
70
|
+
const report = await createGcService(refs).collect(blobs, prefix, claims);
|
|
71
|
+
await client.commit();
|
|
72
|
+
return report;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
failure = error;
|
|
76
|
+
try {
|
|
77
|
+
await client.rollback();
|
|
78
|
+
}
|
|
79
|
+
catch (rollbackError) {
|
|
80
|
+
failure = rollbackError;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
client.release(failure);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Short preparation transactions; no leased connection spans blob I/O. */
|
|
91
|
+
export function createPostgresClaimGuardedRefs(refs, executor) {
|
|
92
|
+
const insert = async (digest, holder, sql) => {
|
|
93
|
+
const client = await executor.beginFinalization();
|
|
94
|
+
try {
|
|
95
|
+
await createPostgresDeletionClaimStore(client).withDigestLock(digest, async (claims) => {
|
|
96
|
+
if (await claims.isClaimed(digest))
|
|
97
|
+
throw new Error(`blob scheduled for deletion: ${digest}`);
|
|
98
|
+
await client.query(sql, [digest, holder]);
|
|
99
|
+
});
|
|
100
|
+
await client.commit();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
await client.rollback();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
client.release();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return { ...refs,
|
|
111
|
+
addRef: (digest, holder) => insert(digest, holder, 'INSERT INTO artifacts.blob_ref_holders (digest, holder) VALUES ($1, $2) ON CONFLICT DO NOTHING'),
|
|
112
|
+
pin: (digest, reason) => insert(digest, reason, 'INSERT INTO artifacts.blob_pins (digest, reason) VALUES ($1, $2)'),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Transaction-scoped claims; callers commit the claim before external deletion. */
|
|
116
|
+
export function createPostgresDeletionClaimStore(client) {
|
|
117
|
+
const predicate = `kind = 'delete-blob' AND state = 'pending'
|
|
118
|
+
AND payload->>'deletionClaim' = 'true' AND payload->>'digest' = $1`;
|
|
119
|
+
const access = {
|
|
120
|
+
async isClaimed(digest) {
|
|
121
|
+
const { rows } = await client.query(`SELECT EXISTS (SELECT 1 FROM artifacts.object_intents WHERE ${predicate}) AS present`, [digest]);
|
|
122
|
+
return rows[0]?.present === true;
|
|
123
|
+
},
|
|
124
|
+
async claim(digest, holder) {
|
|
125
|
+
if (await access.isClaimed(digest))
|
|
126
|
+
throw new Error(`deletion already claimed: ${digest}`);
|
|
127
|
+
await client.query(`INSERT INTO artifacts.object_intents (id, kind, payload)
|
|
128
|
+
VALUES ($1, 'delete-blob', $2::jsonb)`, [crypto.randomUUID(), JSON.stringify({ deletionClaim: true, digest, holder })]);
|
|
129
|
+
},
|
|
130
|
+
async releaseClaim(digest) {
|
|
131
|
+
const { rowCount } = await client.query(`UPDATE artifacts.object_intents SET state = 'done', updated_at = now() WHERE ${predicate}`, [digest]);
|
|
132
|
+
if (rowCount === 0)
|
|
133
|
+
throw new Error(`deletion not claimed: ${digest}`);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const withDigestLock = async (digest, action) => {
|
|
137
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`artifacts-blob:${digest}`]);
|
|
138
|
+
return action(access);
|
|
139
|
+
};
|
|
140
|
+
return { ...access, withDigestLock,
|
|
141
|
+
async listClaims() {
|
|
142
|
+
const { rows } = await client.query(`SELECT id, state, payload->>'digest' AS digest, payload->>'holder' AS holder
|
|
143
|
+
FROM artifacts.object_intents WHERE kind = 'delete-blob'
|
|
144
|
+
AND payload->>'deletionClaim' = 'true' ORDER BY created_at, id`, []);
|
|
145
|
+
return rows.map((row) => ({ claimId: row.id, digest: row.digest, holder: row.holder, state: row.state }));
|
|
146
|
+
},
|
|
147
|
+
claim: (digest, holder) => withDigestLock(digest, (locked) => locked.claim(digest, holder)),
|
|
148
|
+
releaseClaim: (digest) => withDigestLock(digest, (locked) => locked.releaseClaim(digest)) };
|
|
149
|
+
}
|
|
150
|
+
export function createMemoryDeletionClaimStore() {
|
|
151
|
+
const holders = new Map();
|
|
152
|
+
const history = [];
|
|
153
|
+
const mutex = createKeyMutex();
|
|
154
|
+
const access = {
|
|
155
|
+
async claim(digest, holder) {
|
|
156
|
+
if (holders.has(digest))
|
|
157
|
+
throw new Error(`deletion already claimed: ${digest}`);
|
|
158
|
+
holders.set(digest, holder);
|
|
159
|
+
history.push({ claimId: crypto.randomUUID(), digest, holder, state: 'pending' });
|
|
160
|
+
},
|
|
161
|
+
async releaseClaim(digest) {
|
|
162
|
+
if (!holders.has(digest))
|
|
163
|
+
throw new Error(`deletion not claimed: ${digest}`);
|
|
164
|
+
holders.delete(digest);
|
|
165
|
+
const claim = history.find((entry) => entry.digest === digest && entry.state === 'pending');
|
|
166
|
+
if (claim !== undefined)
|
|
167
|
+
claim.state = 'done';
|
|
168
|
+
},
|
|
169
|
+
async isClaimed(digest) {
|
|
170
|
+
return holders.has(digest);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
...access,
|
|
175
|
+
listClaims: async () => structuredClone(history),
|
|
176
|
+
claim: (digest, holder) => mutex.runSerialized(digest, () => access.claim(digest, holder)),
|
|
177
|
+
releaseClaim: (digest) => mutex.runSerialized(digest, () => access.releaseClaim(digest)),
|
|
178
|
+
withDigestLock: (digest, action) => mutex.runSerialized(digest, () => action(access)),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A RefcountStore that refuses new references to claimed digests. Purge
|
|
183
|
+
* claims first, so racing publications cannot pin a blob scheduled for
|
|
184
|
+
* deletion; already-held references still protect the blob in collect.
|
|
185
|
+
*/
|
|
186
|
+
export function createClaimGuardedRefs(refs, claims) {
|
|
187
|
+
return {
|
|
188
|
+
addRef: async (digest, holder) => {
|
|
189
|
+
await claims.withDigestLock(digest, async (locked) => {
|
|
190
|
+
if (await locked.isClaimed(digest))
|
|
191
|
+
throw new Error(`blob scheduled for deletion: ${digest}`);
|
|
192
|
+
await refs.addRef(digest, holder);
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
releaseRef: (digest, holder) => refs.releaseRef(digest, holder),
|
|
196
|
+
count: (digest) => refs.count(digest),
|
|
197
|
+
isPinned: (digest) => refs.isPinned(digest),
|
|
198
|
+
pin: (digest, holder) => claims.withDigestLock(digest, async (locked) => {
|
|
199
|
+
if (await locked.isClaimed(digest))
|
|
200
|
+
throw new Error(`blob scheduled for deletion: ${digest}`);
|
|
201
|
+
await refs.pin(digest, holder);
|
|
202
|
+
}),
|
|
203
|
+
unpin: (digest, holder) => refs.unpin(digest, holder),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
61
206
|
/** Create a GC service over an injected refcount store (no driver imports). */
|
|
62
207
|
export function createGcService(refs) {
|
|
63
208
|
return {
|
|
64
|
-
async collect(blobs, prefix) {
|
|
209
|
+
async collect(blobs, prefix, claims) {
|
|
65
210
|
const report = { deleted: [], retained: [], failures: [] };
|
|
66
211
|
for (const key of await blobs.listKeys(prefix)) {
|
|
67
212
|
let digest;
|
|
@@ -77,8 +222,26 @@ export function createGcService(refs) {
|
|
|
77
222
|
continue;
|
|
78
223
|
}
|
|
79
224
|
try {
|
|
80
|
-
await
|
|
81
|
-
|
|
225
|
+
await claims.withDigestLock(digest, async (locked) => {
|
|
226
|
+
const alreadyClaimed = await locked.isClaimed(digest);
|
|
227
|
+
if (!alreadyClaimed)
|
|
228
|
+
await locked.claim(digest, 'gc');
|
|
229
|
+
let deleted = false;
|
|
230
|
+
try {
|
|
231
|
+
if ((await refs.count(digest)) !== 0 || (await refs.isPinned(digest))) {
|
|
232
|
+
report.retained.push(key);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
await blobs.deleteBlob(key);
|
|
236
|
+
deleted = true;
|
|
237
|
+
report.deleted.push(key);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
// Preserve a purge claim until erasure; always release our GC claim.
|
|
241
|
+
if (!alreadyClaimed || deleted)
|
|
242
|
+
await locked.releaseClaim(digest);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
82
245
|
}
|
|
83
246
|
catch (error) {
|
|
84
247
|
report.failures.push({ key, error: String(error) });
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* KvPort only (ports.ts); the memory double serves unit tests.
|
|
11
11
|
*/
|
|
12
12
|
import { type KvPort } from './ports.js';
|
|
13
|
+
import { type ArtifactInvocationContext } from './commit/types.js';
|
|
14
|
+
import type { EffectiveArtifactCommand } from './command-identity.js';
|
|
15
|
+
/** Known 0.1.x durable key encodings. Their sorted hashes cannot prove command order or authority. */
|
|
16
|
+
export declare function assertArtifactReplayMigration(records: Pick<KvPort, 'get'>, command: EffectiveArtifactCommand, invocation: ArtifactInvocationContext, callerKey: string): Promise<void>;
|
|
13
17
|
export interface IdempotentResult<T> {
|
|
14
18
|
result: T;
|
|
15
19
|
replayed: boolean;
|
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
* KvPort only (ports.ts); the memory double serves unit tests.
|
|
11
11
|
*/
|
|
12
12
|
import { createKeyMutex, createMemoryKv } from './ports.js';
|
|
13
|
+
import { CommitQuarantineError } from './commit/types.js';
|
|
14
|
+
/** Known 0.1.x durable key encodings. Their sorted hashes cannot prove command order or authority. */
|
|
15
|
+
export async function assertArtifactReplayMigration(records, command, invocation, callerKey) {
|
|
16
|
+
const { internalKey } = command.identity;
|
|
17
|
+
const keys = new Set([callerKey, internalKey]);
|
|
18
|
+
for (const key of [callerKey, internalKey]) {
|
|
19
|
+
keys.add(JSON.stringify([command.operation, invocation.actorId, key]));
|
|
20
|
+
}
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
if (await records.get(`idempotency:${key}`) !== undefined) {
|
|
23
|
+
throw new CommitQuarantineError(invocation.storageScopeId, internalKey, 'legacy-unknown');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
13
27
|
/** Memory-backed KvPort for unit tests (re-exported from the ports module). */
|
|
14
28
|
export function createMemoryIdempotencyKv() {
|
|
15
29
|
return createMemoryKv();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AccessAction, type ArtifactAcl } from './access.js';
|
|
2
|
+
import type { DeletionClaimStore } from './gc.js';
|
|
2
3
|
import type { PublicationStateStore } from './publication.js';
|
|
3
4
|
/**
|
|
4
5
|
* The current-release pointer the service promotes through. A subset of the
|
|
@@ -54,4 +55,4 @@ export interface LifecycleService {
|
|
|
54
55
|
trackRelease(artifactId: string, releaseId: string): Promise<void>;
|
|
55
56
|
}
|
|
56
57
|
/** Create a lifecycle service over an injected store (mutations owner-only). */
|
|
57
|
-
export declare function createLifecycleService(store: LifecycleStore, clock?: () => string, current?: CurrentPointer): LifecycleService;
|
|
58
|
+
export declare function createLifecycleService(store: LifecycleStore, clock?: () => string, current?: CurrentPointer, claims?: DeletionClaimStore): LifecycleService;
|
package/dist/server/lifecycle.js
CHANGED
|
@@ -71,7 +71,7 @@ export function createMemoryLifecycleStore() {
|
|
|
71
71
|
return store;
|
|
72
72
|
}
|
|
73
73
|
/** Create a lifecycle service over an injected store (mutations owner-only). */
|
|
74
|
-
export function createLifecycleService(store, clock = () => new Date().toISOString(), current) {
|
|
74
|
+
export function createLifecycleService(store, clock = () => new Date().toISOString(), current, claims) {
|
|
75
75
|
async function ownerOf(artifactId) {
|
|
76
76
|
const owner = await store.getOwner(artifactId);
|
|
77
77
|
if (owner === undefined)
|
|
@@ -131,6 +131,20 @@ export function createLifecycleService(store, clock = () => new Date().toISOStri
|
|
|
131
131
|
throw new Error(`can only purge a deleted artifact: ${artifactId} is ${state}`);
|
|
132
132
|
}
|
|
133
133
|
const releases = await store.listArtifactReleases(artifactId);
|
|
134
|
+
const acquired = [];
|
|
135
|
+
if (claims !== undefined) {
|
|
136
|
+
try {
|
|
137
|
+
for (const digest of new Set(blobDigests)) {
|
|
138
|
+
await claims.claim(digest, `purge:${artifactId}`);
|
|
139
|
+
acquired.push(digest);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
for (const digest of acquired)
|
|
144
|
+
await claims.releaseClaim(digest);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
134
148
|
for (const releaseId of releases) {
|
|
135
149
|
await store.setReleaseState(releaseId, 'purged');
|
|
136
150
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Postgres adapter serves the L-5b integration proof.
|
|
10
10
|
*/
|
|
11
11
|
import type { JsonValue } from '../contracts.js';
|
|
12
|
+
export { inspectPreparations, scheduleBoundedPreparationCleanup } from './preparation-cleanup.js';
|
|
13
|
+
export type { PreparationCleanupHost, PreparationAttempt } from './preparation-cleanup.js';
|
|
12
14
|
export type IntentKind = 'commit-release' | 'delete-blob' | 'promote-release';
|
|
13
15
|
export type IntentState = 'pending' | 'done' | 'failed';
|
|
14
16
|
export interface ObjectIntent {
|
|
@@ -27,8 +29,19 @@ export interface IntentStore {
|
|
|
27
29
|
get(intentId: string): Promise<ObjectIntent | undefined>;
|
|
28
30
|
listPending(): Promise<ObjectIntent[]>;
|
|
29
31
|
}
|
|
32
|
+
/** Durable preparation history; cleanup never turns a failed attempt into success. */
|
|
33
|
+
export interface PreparationIntentQuery {
|
|
34
|
+
readonly artifactId?: string;
|
|
35
|
+
readonly throughMs?: number;
|
|
36
|
+
readonly limit?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface PreparationIntentStore extends IntentStore {
|
|
39
|
+
listPreparationIntents(query?: PreparationIntentQuery): Promise<ObjectIntent[]>;
|
|
40
|
+
claimPreparationCleanup(intentId: string, throughMs: number): Promise<ObjectIntent | undefined>;
|
|
41
|
+
settlePreparationCleanup(intentId: string): Promise<void>;
|
|
42
|
+
}
|
|
30
43
|
/** In-memory IntentStore double for unit tests. */
|
|
31
|
-
export declare function createMemoryIntentStore(clock?: () => string, ids?: () => string):
|
|
44
|
+
export declare function createMemoryIntentStore(clock?: () => string, ids?: () => string): PreparationIntentStore;
|
|
32
45
|
export type IntentResolution = 'done' | 'failed';
|
|
33
46
|
/**
|
|
34
47
|
* Reconcile pending intents after a crash: the resolver inspects each
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { inspectPreparations, scheduleBoundedPreparationCleanup } from './preparation-cleanup.js';
|
|
1
2
|
/** In-memory IntentStore double for unit tests. */
|
|
2
3
|
export function createMemoryIntentStore(clock = () => new Date().toISOString(), ids = () => crypto.randomUUID()) {
|
|
3
4
|
const intents = new Map();
|
|
@@ -38,6 +39,36 @@ export function createMemoryIntentStore(clock = () => new Date().toISOString(),
|
|
|
38
39
|
.filter((intent) => intent.state === 'pending')
|
|
39
40
|
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
|
|
40
41
|
},
|
|
42
|
+
async listPreparationIntents(query = {}) {
|
|
43
|
+
return structuredClone([...intents.values()].filter((intent) => {
|
|
44
|
+
const deadline = intent.payload.cleanupDeadlineMs;
|
|
45
|
+
return intent.kind === 'commit-release' && intent.state !== 'done' && deadline !== undefined &&
|
|
46
|
+
(query.artifactId === undefined || intent.payload.artifactId === query.artifactId) &&
|
|
47
|
+
(query.throughMs === undefined || (!intent.error?.startsWith('invalid preparation metadata') && Number(deadline) <= query.throughMs &&
|
|
48
|
+
(intent.payload.cleanupState !== 'failed-cleaned' || Number(deadline) >= query.throughMs - 60_000)));
|
|
49
|
+
}).sort((a, b) => query.throughMs === undefined ? 0 : Number(a.payload.cleanupState === 'failed-cleaned') -
|
|
50
|
+
Number(b.payload.cleanupState === 'failed-cleaned') ||
|
|
51
|
+
Number(a.payload.cleanupDeadlineMs) - Number(b.payload.cleanupDeadlineMs))
|
|
52
|
+
.slice(0, query.limit));
|
|
53
|
+
},
|
|
54
|
+
async claimPreparationCleanup(intentId, throughMs) {
|
|
55
|
+
const intent = intents.get(intentId);
|
|
56
|
+
if (intent === undefined || intent.state === 'done' ||
|
|
57
|
+
typeof intent.payload.cleanupDeadlineMs !== 'number' ||
|
|
58
|
+
intent.payload.cleanupDeadlineMs > throughMs)
|
|
59
|
+
return undefined;
|
|
60
|
+
intent.state = 'failed';
|
|
61
|
+
intent.error ??= 'preparation expired; explicit retry required';
|
|
62
|
+
intent.updatedAt = clock();
|
|
63
|
+
return structuredClone(intent);
|
|
64
|
+
},
|
|
65
|
+
async settlePreparationCleanup(intentId) {
|
|
66
|
+
const intent = intents.get(intentId);
|
|
67
|
+
if (intent === undefined || intent.state !== 'failed')
|
|
68
|
+
throw new Error('cleanup needs a failed preparation');
|
|
69
|
+
intent.payload.cleanupState = 'failed-cleaned';
|
|
70
|
+
intent.updatedAt = clock();
|
|
71
|
+
},
|
|
41
72
|
};
|
|
42
73
|
}
|
|
43
74
|
/**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** HTTP and platform result projections share one classified outcome; neither inspects causes. */
|
|
2
|
+
import type { AppToolResult } from '@sentropic/mcp-platform';
|
|
3
|
+
import type { HttpResponse } from '../http.js';
|
|
4
|
+
import type { ArtifactFailure, ArtifactOutcome } from './failures.js';
|
|
5
|
+
export declare function projectArtifactHttpOutcome(outcome: ArtifactOutcome<HttpResponse>): HttpResponse;
|
|
6
|
+
/** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
|
|
7
|
+
export declare function projectArtifactMcpOutcome(outcome: ArtifactOutcome<HttpResponse>, auditId: string): AppToolResult;
|
|
8
|
+
export interface ArtifactWriteFailure {
|
|
9
|
+
readonly invocationId: string;
|
|
10
|
+
readonly operation: string;
|
|
11
|
+
readonly reason: 'provider_unavailable' | 'provider_conflict' | 'unauthorized_at_commit' | 'provider_error';
|
|
12
|
+
readonly status: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Pure accounting descriptor. The host persists/deduplicates by invocation ID;
|
|
16
|
+
* observing the same outcome in nested adapters never records another event here.
|
|
17
|
+
*/
|
|
18
|
+
export declare function projectArtifactWriteFailure(failure: ArtifactFailure, dispatch: {
|
|
19
|
+
readonly dispatched: boolean;
|
|
20
|
+
readonly write: boolean;
|
|
21
|
+
}): ArtifactWriteFailure | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function projectArtifactHttpOutcome(outcome) {
|
|
2
|
+
if (outcome.ok)
|
|
3
|
+
return outcome.result;
|
|
4
|
+
return { status: outcome.failure.httpStatus, body: { error: outcome.failure.publicCode } };
|
|
5
|
+
}
|
|
6
|
+
/** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
|
|
7
|
+
export function projectArtifactMcpOutcome(outcome, auditId) {
|
|
8
|
+
if (outcome.ok)
|
|
9
|
+
return { ok: true, output: outcome.result.body, auditId, redactionClass: 'low' };
|
|
10
|
+
const { publicCode, retriable } = outcome.failure;
|
|
11
|
+
return {
|
|
12
|
+
ok: false, auditId, redactionClass: 'low',
|
|
13
|
+
error: { code: publicCode, message: publicCode, retriable },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Pure accounting descriptor. The host persists/deduplicates by invocation ID;
|
|
18
|
+
* observing the same outcome in nested adapters never records another event here.
|
|
19
|
+
*/
|
|
20
|
+
export function projectArtifactWriteFailure(failure, dispatch) {
|
|
21
|
+
if (failure.kind !== 'operational' || !dispatch.dispatched || !dispatch.write
|
|
22
|
+
|| failure.phase === 'selection' || failure.phase === 'admit'
|
|
23
|
+
|| failure.category === 'capability_unselected')
|
|
24
|
+
return undefined;
|
|
25
|
+
const reason = failure.category === 'conflict' ? 'provider_conflict'
|
|
26
|
+
: failure.category === 'provider_unavailable' || failure.category === 'unauthorized_at_commit'
|
|
27
|
+
? failure.category : 'provider_error';
|
|
28
|
+
return { invocationId: failure.invocationId, operation: failure.operation, reason, status: failure.httpStatus };
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PreparationIntentStore } from './object-intents.js';
|
|
2
|
+
import { type PublicationBlobStore } from './publication.js';
|
|
3
|
+
import type { DeletionClaimStore, RefcountStore } from './gc.js';
|
|
4
|
+
export interface PreparationCleanupHost {
|
|
5
|
+
readonly intents: PreparationIntentStore;
|
|
6
|
+
readonly blobs: PublicationBlobStore;
|
|
7
|
+
readonly refs: RefcountStore;
|
|
8
|
+
/** PostgreSQL callers retain the transaction-scoped exclusion through DELETE. */
|
|
9
|
+
readonly claims: DeletionClaimStore;
|
|
10
|
+
readonly cleanupBatchSize?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface PreparationAttempt {
|
|
13
|
+
readonly attemptId: string;
|
|
14
|
+
readonly invocationId: string;
|
|
15
|
+
readonly preparedAtMs: number;
|
|
16
|
+
readonly cleanupDeadlineMs: number;
|
|
17
|
+
readonly blobKeys: string[];
|
|
18
|
+
readonly state: 'pending-cleanup' | 'failed' | 'failed-cleaned';
|
|
19
|
+
}
|
|
20
|
+
/** Historical attempt attribution plus current protection, scoped to one artifact. */
|
|
21
|
+
export declare function inspectPreparations(host: PreparationCleanupHost, artifactId: string): Promise<{
|
|
22
|
+
attempts: PreparationAttempt[];
|
|
23
|
+
protectedKeys: string[];
|
|
24
|
+
abandonedKeys: string[];
|
|
25
|
+
invalidIntents: Array<{
|
|
26
|
+
intentId: string;
|
|
27
|
+
error: string;
|
|
28
|
+
}>;
|
|
29
|
+
}>;
|
|
30
|
+
/** Run one deterministic, bounded sweep through the supplied clock deadline. */
|
|
31
|
+
export declare function scheduleBoundedPreparationCleanup(host: PreparationCleanupHost, throughMs: number): Promise<void>;
|