@sentropic/artifacts 0.2.5 → 0.2.7
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/CHANGELOG.md +37 -0
- package/README.md +20 -6
- package/dist/adapters/postgres.js +11 -4
- package/dist/http.d.ts +4 -3
- package/dist/http.js +225 -37
- package/dist/mcp-core.d.ts +3 -3
- package/dist/mcp-core.js +23 -10
- package/dist/profile/contracts.d.ts +15 -5
- package/dist/profile/contracts.js +9 -6
- package/dist/profile/index.js +33 -4
- package/dist/profile/radar.js +4 -1
- package/dist/profile/schemas.js +35 -6
- package/dist/profile/validation.d.ts +3 -1
- package/dist/profile/validation.js +32 -13
- package/dist/server/access.d.ts +8 -0
- package/dist/server/command-identity.d.ts +4 -2
- package/dist/server/command-identity.js +5 -5
- package/dist/server/commit/coordinator.js +12 -9
- package/dist/server/commit/types.d.ts +4 -0
- package/dist/server/edits.d.ts +17 -0
- package/dist/server/edits.js +43 -0
- package/dist/server/failures.d.ts +31 -2
- package/dist/server/failures.js +14 -3
- package/dist/server/lifecycle.d.ts +2 -0
- package/dist/server/lifecycle.js +3 -0
- package/dist/server/management.d.ts +1 -0
- package/dist/server/management.js +9 -2
- package/dist/server/mime.d.ts +7 -0
- package/dist/server/mime.js +7 -0
- package/dist/server/outcome-projection.d.ts +2 -2
- package/dist/server/outcome-projection.js +25 -9
- package/dist/server/publication.d.ts +18 -1
- package/dist/server/publication.js +54 -5
- package/dist/server/result-schema.d.ts +14 -0
- package/dist/server/result-schema.js +43 -0
- package/dist/server/service.d.ts +2 -0
- package/dist/server/service.js +2 -0
- package/dist/server/source-read.d.ts +23 -3
- package/dist/server/source-read.js +17 -2
- package/dist/server/summary.d.ts +4 -3
- package/dist/server/summary.js +4 -3
- package/dist/server/templates.d.ts +5 -0
- package/dist/server/templates.js +30 -3
- package/dist/server/versions.d.ts +29 -0
- package/dist/server/versions.js +39 -0
- package/docs/migration-l6.md +8 -1
- package/migrations/0005_metadata.sql +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parity P0 — small old/new edits on publish.
|
|
3
|
+
*
|
|
4
|
+
* Edits apply to the current release's text files without resending the
|
|
5
|
+
* whole file set. `oldStr` must occur exactly once in the file text; any
|
|
6
|
+
* other count (or an unknown/non-text path) raises a typed `edit_mismatch`
|
|
7
|
+
* before anything is staged, so a failed call changes nothing. The edited
|
|
8
|
+
* files then become the new release through the same commit path.
|
|
9
|
+
*/
|
|
10
|
+
import { ArtifactOperationError } from './failures.js';
|
|
11
|
+
function countOccurrences(haystack, needle) {
|
|
12
|
+
if (needle.length === 0)
|
|
13
|
+
return 0;
|
|
14
|
+
let count = 0;
|
|
15
|
+
let index = haystack.indexOf(needle);
|
|
16
|
+
while (index !== -1) {
|
|
17
|
+
count += 1;
|
|
18
|
+
index = haystack.indexOf(needle, index + Math.max(1, needle.length));
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Apply edits to a copy of the file list. Throws ArtifactOperationError
|
|
24
|
+
* ('edit_mismatch') on the first violation; the input files are never mutated.
|
|
25
|
+
*/
|
|
26
|
+
export function applyEdits(files, edits) {
|
|
27
|
+
const result = files.map((file) => ({ ...file }));
|
|
28
|
+
for (const [editIndex, edit] of edits.entries()) {
|
|
29
|
+
const file = result.find((entry) => entry.path === edit.path);
|
|
30
|
+
if (file === undefined || file.text === undefined) {
|
|
31
|
+
throw new ArtifactOperationError('edit_mismatch', { edit: { path: edit.path, occurrences: -1,
|
|
32
|
+
editIndex, reason: file === undefined ? 'file_missing' : 'not_text' } });
|
|
33
|
+
}
|
|
34
|
+
const occurrences = countOccurrences(file.text, edit.oldStr);
|
|
35
|
+
if (occurrences !== 1) {
|
|
36
|
+
throw new ArtifactOperationError('edit_mismatch', { edit: { path: edit.path, occurrences,
|
|
37
|
+
editIndex, reason: occurrences === 0 ? 'missing' : 'ambiguous' } });
|
|
38
|
+
}
|
|
39
|
+
file.text = file.text.slice(0, file.text.indexOf(edit.oldStr)) + edit.newStr +
|
|
40
|
+
file.text.slice(file.text.indexOf(edit.oldStr) + edit.oldStr.length);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
@@ -4,7 +4,25 @@ export type ArtifactCommitState = 'not_committed' | 'committed' | 'unknown';
|
|
|
4
4
|
export type ArtifactInternalKey = string & {
|
|
5
5
|
readonly __brand: 'internal-key';
|
|
6
6
|
};
|
|
7
|
-
export type ArtifactErrorKind = 'provider_unavailable' | 'command_conflict' | 'pointer_conflict' | 'unauthorized_at_commit' | 'capability_unselected' | 'admission_unavailable' | 'invalid_request' | 'anonymous' | 'access_denied' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed';
|
|
7
|
+
export type ArtifactErrorKind = 'provider_unavailable' | 'command_conflict' | 'pointer_conflict' | 'unauthorized_at_commit' | 'capability_unselected' | 'admission_unavailable' | 'invalid_request' | 'anonymous' | 'access_denied' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed' | 'edit_mismatch' | 'precondition_required';
|
|
8
|
+
/** Optimistic-concurrency payload: the caller re-reads and merges against this. */
|
|
9
|
+
export interface ArtifactConflictDetails {
|
|
10
|
+
readonly artifactId?: string;
|
|
11
|
+
readonly expectedCurrentReleaseId?: string | null;
|
|
12
|
+
readonly currentReleaseId: string | null;
|
|
13
|
+
readonly currentDigest: string | null;
|
|
14
|
+
readonly currentSource?: Readonly<Record<string, unknown>>;
|
|
15
|
+
}
|
|
16
|
+
/** Edit-application payload: which path failed the exactly-once rule and how often oldStr occurred. */
|
|
17
|
+
export interface ArtifactEditDetails {
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly occurrences: number;
|
|
20
|
+
readonly editIndex?: number;
|
|
21
|
+
readonly reason?: 'missing' | 'ambiguous' | 'not_text' | 'file_missing';
|
|
22
|
+
}
|
|
23
|
+
export interface ArtifactReleaseUnavailableDetails {
|
|
24
|
+
readonly reason: 'purged' | 'not_retained' | 'unpublished';
|
|
25
|
+
}
|
|
8
26
|
export type ArtifactFailureCategory = ArtifactFailure['category'];
|
|
9
27
|
interface ArtifactFailureFields {
|
|
10
28
|
readonly details?: Readonly<Record<string, unknown>>;
|
|
@@ -16,6 +34,11 @@ interface ArtifactFailureFields {
|
|
|
16
34
|
readonly operation: string;
|
|
17
35
|
readonly phase: string;
|
|
18
36
|
readonly commitState: ArtifactCommitState;
|
|
37
|
+
/** Present on pointer/command conflicts so the caller can re-read and merge. */
|
|
38
|
+
readonly conflict?: ArtifactConflictDetails;
|
|
39
|
+
/** Present on edit_mismatch so the caller sees the failing path and occurrence count. */
|
|
40
|
+
readonly edit?: ArtifactEditDetails;
|
|
41
|
+
readonly release?: ArtifactReleaseUnavailableDetails;
|
|
19
42
|
/** Original thrown value, retained by identity and deliberately non-enumerable. */
|
|
20
43
|
readonly cause: unknown;
|
|
21
44
|
}
|
|
@@ -25,7 +48,7 @@ export interface ArtifactOperationalFailure extends ArtifactFailureFields {
|
|
|
25
48
|
}
|
|
26
49
|
export interface ArtifactOrdinaryFailure extends Omit<ArtifactFailureFields, 'variant' | 'retriable'> {
|
|
27
50
|
readonly kind: 'ordinary';
|
|
28
|
-
readonly category: 'invalid_request' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed';
|
|
51
|
+
readonly category: 'invalid_request' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed' | 'edit_mismatch' | 'precondition_required';
|
|
29
52
|
readonly retriable: false;
|
|
30
53
|
}
|
|
31
54
|
/** Entry authority outcomes are separate from ordinary and operational failures. */
|
|
@@ -67,11 +90,17 @@ export declare class ArtifactOperationError extends Error {
|
|
|
67
90
|
readonly cause?: unknown;
|
|
68
91
|
readonly refusal?: ArtifactPublicRefusal;
|
|
69
92
|
readonly publicDetails?: Readonly<Record<string, unknown>>;
|
|
93
|
+
readonly conflict?: ArtifactConflictDetails;
|
|
94
|
+
readonly edit?: ArtifactEditDetails;
|
|
95
|
+
readonly release?: ArtifactReleaseUnavailableDetails;
|
|
70
96
|
};
|
|
71
97
|
constructor(kind: ArtifactErrorKind, details?: {
|
|
72
98
|
readonly cause?: unknown;
|
|
73
99
|
readonly refusal?: ArtifactPublicRefusal;
|
|
74
100
|
readonly publicDetails?: Readonly<Record<string, unknown>>;
|
|
101
|
+
readonly conflict?: ArtifactConflictDetails;
|
|
102
|
+
readonly edit?: ArtifactEditDetails;
|
|
103
|
+
readonly release?: ArtifactReleaseUnavailableDetails;
|
|
75
104
|
});
|
|
76
105
|
}
|
|
77
106
|
/** Mint only at verified per-key row contention, using the full scoped operation key. */
|
package/dist/server/failures.js
CHANGED
|
@@ -28,7 +28,8 @@ const UNAVAILABLE_CODES = new Set([
|
|
|
28
28
|
const TIMEOUT_NAMES = new Set(['TimeoutError', 'RequestTimeout', 'RequestTimeoutError']);
|
|
29
29
|
const DOMAIN_STATUS = {
|
|
30
30
|
invalid_request: 400, anonymous: 401, access_denied: 403,
|
|
31
|
-
not_found: 404, gone: 410, release_unavailable: 410, limit_exceeded: 413, unprocessable: 422, verification_failed: 422,
|
|
31
|
+
not_found: 404, gone: 410, release_unavailable: 410, limit_exceeded: 413, unprocessable: 422, verification_failed: 422, edit_mismatch: 422,
|
|
32
|
+
precondition_required: 428,
|
|
32
33
|
};
|
|
33
34
|
function field(value, name) {
|
|
34
35
|
if (typeof value !== 'object' || value === null)
|
|
@@ -64,7 +65,10 @@ function operationProjection(error) {
|
|
|
64
65
|
case 'provider_unavailable':
|
|
65
66
|
case 'admission_unavailable': return unavailable(kind);
|
|
66
67
|
case 'command_conflict':
|
|
67
|
-
case 'pointer_conflict':
|
|
68
|
+
case 'pointer_conflict': {
|
|
69
|
+
const base = conflict(kind);
|
|
70
|
+
return error.details.conflict === undefined ? base : { ...base, conflict: error.details.conflict };
|
|
71
|
+
}
|
|
68
72
|
case 'capability_unselected': return {
|
|
69
73
|
kind: 'operational', category: kind, variant: 'unselected_operation',
|
|
70
74
|
httpStatus: 503, publicCode: 'capability_unavailable', retriable: false,
|
|
@@ -86,7 +90,14 @@ function operationProjection(error) {
|
|
|
86
90
|
case 'anonymous':
|
|
87
91
|
case 'access_denied':
|
|
88
92
|
return { kind: 'admission', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
|
|
89
|
-
default:
|
|
93
|
+
default: {
|
|
94
|
+
const base = { kind: 'ordinary', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
|
|
95
|
+
if (kind === 'precondition_required')
|
|
96
|
+
return { ...base, details: error.details.publicDetails };
|
|
97
|
+
if (kind === 'release_unavailable' && error.details.release !== undefined)
|
|
98
|
+
return { ...base, release: error.details.release };
|
|
99
|
+
return kind === 'edit_mismatch' && error.details.edit !== undefined ? { ...base, edit: error.details.edit } : base;
|
|
100
|
+
}
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
function classify(cause, context, mapHostError) {
|
|
@@ -55,6 +55,8 @@ export interface LifecycleService {
|
|
|
55
55
|
listReleases(artifactId: string): Promise<string[]>;
|
|
56
56
|
/** Track a published release so promotion sees it (idempotent). */
|
|
57
57
|
trackRelease(artifactId: string, releaseId: string): Promise<void>;
|
|
58
|
+
/** Per-release lifecycle state (ready/retired/purged; ready when untracked). */
|
|
59
|
+
getReleaseState(releaseId: string): Promise<ReleaseState>;
|
|
58
60
|
}
|
|
59
61
|
/** Create a lifecycle service over an injected store (mutations owner-only). */
|
|
60
62
|
export declare function createLifecycleService(store: LifecycleStore, clock?: () => string, current?: CurrentPointer, claims?: DeletionClaimStore): LifecycleService;
|
package/dist/server/lifecycle.js
CHANGED
|
@@ -98,6 +98,9 @@ export function createLifecycleService(store, clock = () => new Date().toISOStri
|
|
|
98
98
|
async stateOf(artifactId) {
|
|
99
99
|
return store.getArtifactState(artifactId);
|
|
100
100
|
},
|
|
101
|
+
async getReleaseState(releaseId) {
|
|
102
|
+
return store.getReleaseState(releaseId);
|
|
103
|
+
},
|
|
101
104
|
canRead(artifact, acl, actorId, action = 'read', isApproved = () => false) {
|
|
102
105
|
if (artifact.state === 'deleted' || artifact.state === 'purged')
|
|
103
106
|
return false;
|
|
@@ -42,4 +42,5 @@ export declare function manageArtifact(deps: {
|
|
|
42
42
|
readPolicyRevision(): Promise<string>;
|
|
43
43
|
isApprovedPrincipal(principalId: string): boolean | Promise<boolean>;
|
|
44
44
|
confirmation?: ArtifactConfirmationPort;
|
|
45
|
+
validateNewCommand?(): void;
|
|
45
46
|
}, invocation: ArtifactInvocationContext, command: EffectiveArtifactCommand, proof?: string): Promise<ArtifactOutcome<unknown>>;
|
|
@@ -26,7 +26,7 @@ export async function manageArtifact(deps, invocation, command, proof) {
|
|
|
26
26
|
throw refused('access_denied');
|
|
27
27
|
return target;
|
|
28
28
|
};
|
|
29
|
-
const
|
|
29
|
+
const request = {
|
|
30
30
|
...command.identity, scopeId: invocation.storageScopeId, actorId: invocation.actorId,
|
|
31
31
|
invocationId: invocation.invocationId, operation: command.operation, artifactId,
|
|
32
32
|
policyRevision: await deps.readPolicyRevision(), receiptData: receipt,
|
|
@@ -41,6 +41,9 @@ export async function manageArtifact(deps, invocation, command, proof) {
|
|
|
41
41
|
throw refused('gone');
|
|
42
42
|
const now = new Date().toISOString();
|
|
43
43
|
if (deleting) {
|
|
44
|
+
// Bind omitted v1 input to the locked confirmation context, without rewriting the command.
|
|
45
|
+
if (!Object.hasOwn(command.payload, 'expectedCurrentReleaseId'))
|
|
46
|
+
context.expectedCurrentReleaseId = target.currentReleaseId;
|
|
44
47
|
if (target.currentReleaseId !== context.expectedCurrentReleaseId) {
|
|
45
48
|
if (proof)
|
|
46
49
|
throw confirmationFailure('confirmation_stale');
|
|
@@ -73,7 +76,11 @@ export async function manageArtifact(deps, invocation, command, proof) {
|
|
|
73
76
|
return invocation.authorizeCommit(ctx, tx);
|
|
74
77
|
},
|
|
75
78
|
validateReplayReceipt: (stored) => stored.receipt?.artifactId === artifactId,
|
|
76
|
-
}
|
|
79
|
+
};
|
|
80
|
+
const replay = await deps.coordinator.findReplayPrepared(request);
|
|
81
|
+
if (replay === undefined)
|
|
82
|
+
deps.validateNewCommand?.();
|
|
83
|
+
const result = replay ?? await deps.coordinator.finalizePrepared(request);
|
|
77
84
|
return result.ok ? { ...result, result: result.result.receipt }
|
|
78
85
|
: { ok: false, failure: { ...result.failure, kind: 'operational' } };
|
|
79
86
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical text-mime pattern shared by publication input rules and source
|
|
3
|
+
* reads. Leaf module: both `http.ts` and `server/source-read.ts` import it
|
|
4
|
+
* without an import cycle.
|
|
5
|
+
*/
|
|
6
|
+
export declare const TEXT_MIME_PATTERN = "^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])";
|
|
7
|
+
export declare const TEXT_MIME_RE: RegExp;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical text-mime pattern shared by publication input rules and source
|
|
3
|
+
* reads. Leaf module: both `http.ts` and `server/source-read.ts` import it
|
|
4
|
+
* without an import cycle.
|
|
5
|
+
*/
|
|
6
|
+
export const TEXT_MIME_PATTERN = '^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])';
|
|
7
|
+
export const TEXT_MIME_RE = new RegExp(TEXT_MIME_PATTERN);
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import type { AppToolResult } from '@sentropic/mcp-platform';
|
|
3
3
|
import type { HttpResponse } from '../http.js';
|
|
4
4
|
import type { ArtifactFailure, ArtifactOutcome } from './failures.js';
|
|
5
|
-
export declare function projectArtifactHttpOutcome(outcome: ArtifactOutcome<HttpResponse
|
|
5
|
+
export declare function projectArtifactHttpOutcome(outcome: ArtifactOutcome<HttpResponse>, complete?: boolean): HttpResponse;
|
|
6
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;
|
|
7
|
+
export declare function projectArtifactMcpOutcome(outcome: ArtifactOutcome<HttpResponse>, auditId: string, resultEnvelopeVersion?: 1 | 2): AppToolResult;
|
|
8
8
|
export interface ArtifactWriteFailure {
|
|
9
9
|
readonly invocationId: string;
|
|
10
10
|
readonly operation: string;
|
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
import { PRECONDITION_REQUIRED_MESSAGE } from '../profile/contracts.js';
|
|
2
|
+
export function projectArtifactHttpOutcome(outcome, complete = false) {
|
|
3
|
+
if (complete) {
|
|
4
|
+
const { auditId: _audit, redactionClass: _redaction, ...body } = projectArtifactMcpOutcome(outcome, '');
|
|
5
|
+
return { status: outcome.ok ? outcome.result.status : outcome.failure.httpStatus, body };
|
|
6
|
+
}
|
|
2
7
|
if (outcome.ok)
|
|
3
8
|
return outcome.result;
|
|
4
|
-
|
|
5
|
-
|
|
9
|
+
const { failure } = outcome;
|
|
10
|
+
return { status: failure.httpStatus, body: { error: failure.publicCode,
|
|
11
|
+
...(failure.publicCode === 'precondition_required' ? { message: PRECONDITION_REQUIRED_MESSAGE, retryable: false } : {}),
|
|
12
|
+
...(failure.details ? { details: failure.details } : {}),
|
|
13
|
+
...(failure.conflict !== undefined ? { conflict: failure.conflict } : {}),
|
|
14
|
+
...(failure.release !== undefined ? { details: failure.release } : {}),
|
|
15
|
+
...(failure.edit !== undefined ? { edit: failure.edit } : {}) } };
|
|
6
16
|
}
|
|
7
17
|
/** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
|
|
8
|
-
export function projectArtifactMcpOutcome(outcome, auditId) {
|
|
18
|
+
export function projectArtifactMcpOutcome(outcome, auditId, resultEnvelopeVersion = 2) {
|
|
9
19
|
if (outcome.ok)
|
|
10
|
-
return { ok: true, output: outcome.result.body, auditId, redactionClass: 'low'
|
|
11
|
-
|
|
20
|
+
return { ok: true, output: outcome.result.body, auditId, redactionClass: 'low',
|
|
21
|
+
...(resultEnvelopeVersion === 2 ? { invocationId: outcome.invocationId,
|
|
22
|
+
commitState: outcome.commitState, replayed: outcome.replayed } : {}) };
|
|
23
|
+
const { publicCode, retriable, conflict, edit, release, invocationId, commitState } = outcome.failure;
|
|
24
|
+
const variant = 'variant' in outcome.failure ? outcome.failure.variant : undefined;
|
|
25
|
+
const details = conflict !== undefined ? { variant: 'pointer_conflict', ...conflict }
|
|
26
|
+
: edit !== undefined ? { ...edit } : release !== undefined ? { ...release }
|
|
27
|
+
: outcome.failure.details ?? (variant !== undefined ? { variant } : undefined);
|
|
12
28
|
return {
|
|
13
|
-
ok: false, auditId, redactionClass: 'low',
|
|
14
|
-
error: { code: publicCode, message: publicCode
|
|
15
|
-
...(
|
|
29
|
+
ok: false, auditId, redactionClass: 'low', ...(resultEnvelopeVersion === 2 ? { invocationId, commitState } : {}),
|
|
30
|
+
error: { code: publicCode, message: publicCode === 'precondition_required' ? PRECONDITION_REQUIRED_MESSAGE : publicCode,
|
|
31
|
+
retriable, ...(details === undefined ? {} : { details }) },
|
|
16
32
|
};
|
|
17
33
|
}
|
|
18
34
|
/**
|
|
@@ -3,13 +3,16 @@ import type { IntentStore } from './object-intents.js';
|
|
|
3
3
|
import type { IdempotencyStore } from './idempotency.js';
|
|
4
4
|
import { type SourceRef, type SourceStore } from './sources.js';
|
|
5
5
|
import { type PublicationLimits } from './publication-limits.js';
|
|
6
|
-
import type { ArtifactAcl } from './access.js';
|
|
6
|
+
import type { ArtifactAcl, ArtifactMetadata } from './access.js';
|
|
7
7
|
import type { ArtifactState, ReleaseState } from './lifecycle.js';
|
|
8
8
|
import { type RefcountStore } from './gc.js';
|
|
9
9
|
import type { CommandIdentity } from './command-identity.js';
|
|
10
10
|
import type { AuthorizeCommit, CommitLockFn, ArtifactCommitCoordinator, FinalizeResult, FinalizeTxClient } from './commit/types.js';
|
|
11
11
|
export { DEFAULT_PUBLICATION_LIMITS, type PublicationLimits } from './publication-limits.js';
|
|
12
12
|
export interface ReleaseRecord {
|
|
13
|
+
authorPrincipalId?: string | null;
|
|
14
|
+
parentReleaseId?: string | null;
|
|
15
|
+
revertedFromReleaseId?: string | null;
|
|
13
16
|
releaseId: string;
|
|
14
17
|
artifactId: string;
|
|
15
18
|
manifestDigest: string;
|
|
@@ -20,6 +23,8 @@ export interface ReleaseRecord {
|
|
|
20
23
|
export interface PublicationStateStore {
|
|
21
24
|
putRelease(release: ReleaseRecord): Promise<void>;
|
|
22
25
|
getRelease(releaseId: string): Promise<ReleaseRecord | undefined>;
|
|
26
|
+
/** Full release history of one artifact, oldest first (creation order, then id). */
|
|
27
|
+
listReleases(artifactId: string): Promise<ReleaseRecord[]>;
|
|
23
28
|
getCurrentReleaseId(artifactId: string): Promise<string | undefined>;
|
|
24
29
|
compareAndSwapCurrent(artifactId: string, expected: string | null, next: string): Promise<boolean>;
|
|
25
30
|
}
|
|
@@ -63,6 +68,8 @@ export interface PublicationService {
|
|
|
63
68
|
publish(input: PublishInput, sources?: SourceStore): Promise<PublishResult>;
|
|
64
69
|
getCurrent(artifactId: string): Promise<ReleaseRecord | undefined>;
|
|
65
70
|
getRelease(releaseId: string): Promise<ReleaseRecord | undefined>;
|
|
71
|
+
/** Release history records (digest, manifest, createdAt) for list_versions. */
|
|
72
|
+
listReleases(artifactId: string): Promise<ReleaseRecord[]>;
|
|
66
73
|
}
|
|
67
74
|
/** Create a publication service over injected stores (no driver imports). */
|
|
68
75
|
export declare function createPublicationService(deps: {
|
|
@@ -103,6 +110,12 @@ export declare function decidePublishAuthorization(input: PublishAuthorizationIn
|
|
|
103
110
|
};
|
|
104
111
|
/** Transaction-scoped provisional writers for the finalized unit. */
|
|
105
112
|
export interface PublicationUnitWriter {
|
|
113
|
+
/** Lock the artifact row, including when no pointer exists, then read the current release. */
|
|
114
|
+
lockCurrent(artifactId: string): Promise<{
|
|
115
|
+
currentReleaseId: string | null;
|
|
116
|
+
currentDigest: string | null;
|
|
117
|
+
}>;
|
|
118
|
+
updateMetadata(artifactId: string, metadata: ArtifactMetadata): Promise<ArtifactMetadata>;
|
|
106
119
|
readAuthorization(artifactId: string): Promise<{
|
|
107
120
|
state: ArtifactState;
|
|
108
121
|
acl: ArtifactAcl | undefined;
|
|
@@ -117,6 +130,9 @@ export interface PublicationUnitWriter {
|
|
|
117
130
|
/** PostgreSQL unit writer over the coordinator's leased client. */
|
|
118
131
|
export declare function createPostgresPublicationUnitWriter(client: FinalizeTxClient): PublicationUnitWriter;
|
|
119
132
|
export interface GuardedPublishInput extends PublishInput {
|
|
133
|
+
readonly expectedCurrentReleaseId?: string | null;
|
|
134
|
+
readonly metadata?: ArtifactMetadata;
|
|
135
|
+
readonly operation?: 'publish' | 'revert';
|
|
120
136
|
/** Frozen trusted identity supplied by the host's L1 command normalizer. */
|
|
121
137
|
readonly identity: CommandIdentity;
|
|
122
138
|
readonly actorId: string;
|
|
@@ -141,6 +157,7 @@ export interface GuardedPublicationDeps {
|
|
|
141
157
|
readonly limits?: Partial<PublicationLimits>;
|
|
142
158
|
}
|
|
143
159
|
export interface GuardedPublicationService {
|
|
160
|
+
replayFinalized(input: Omit<GuardedPublishInput, 'files' | 'source' | 'profile'>): Promise<FinalizeResult | undefined>;
|
|
144
161
|
publishFinalized(input: GuardedPublishInput): Promise<FinalizeResult>;
|
|
145
162
|
}
|
|
146
163
|
/** Create a publication service whose commits finalize through L3 ports. */
|
|
@@ -16,6 +16,7 @@ import { assertBlobPrefix } from './blob-prefix.js';
|
|
|
16
16
|
import { canonicalManifest, manifestDigest } from '../codecs.js';
|
|
17
17
|
import { parseSourceRef } from './sources.js';
|
|
18
18
|
import { publicationLimits, validatePublicationFiles } from './publication-limits.js';
|
|
19
|
+
import { ArtifactOperationError } from './failures.js';
|
|
19
20
|
import { createPostgresDeletionClaimStore } from './gc.js';
|
|
20
21
|
export { DEFAULT_PUBLICATION_LIMITS } from './publication-limits.js';
|
|
21
22
|
/** Derive the `sha256:<hex>` digest from a digest-addressed blob key. */
|
|
@@ -37,6 +38,12 @@ export function createMemoryStateStore() {
|
|
|
37
38
|
getRelease(releaseId) {
|
|
38
39
|
return Promise.resolve(releases.get(releaseId));
|
|
39
40
|
},
|
|
41
|
+
listReleases(artifactId) {
|
|
42
|
+
// Stable sort: createdAt ties keep insertion (creation) order.
|
|
43
|
+
return Promise.resolve([...releases.values()]
|
|
44
|
+
.filter((release) => release.artifactId === artifactId)
|
|
45
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0)));
|
|
46
|
+
},
|
|
40
47
|
getCurrentReleaseId(artifactId) {
|
|
41
48
|
return Promise.resolve(current.get(artifactId));
|
|
42
49
|
},
|
|
@@ -176,6 +183,9 @@ export function createPublicationService(deps) {
|
|
|
176
183
|
return undefined;
|
|
177
184
|
return release;
|
|
178
185
|
},
|
|
186
|
+
async listReleases(artifactId) {
|
|
187
|
+
return deps.state.listReleases(artifactId);
|
|
188
|
+
},
|
|
179
189
|
};
|
|
180
190
|
}
|
|
181
191
|
/**
|
|
@@ -199,6 +209,17 @@ export function decidePublishAuthorization(input) {
|
|
|
199
209
|
/** PostgreSQL unit writer over the coordinator's leased client. */
|
|
200
210
|
export function createPostgresPublicationUnitWriter(client) {
|
|
201
211
|
return {
|
|
212
|
+
async lockCurrent(artifactId) {
|
|
213
|
+
await client.query('SELECT id FROM artifacts.artifacts WHERE id = $1 FOR UPDATE', [artifactId]);
|
|
214
|
+
const { rows } = await client.query(`SELECT c.release_id, r.manifest_digest FROM artifacts.artifact_current c
|
|
215
|
+
JOIN artifacts.releases r ON r.id = c.release_id WHERE c.artifact_id = $1`, [artifactId]);
|
|
216
|
+
return { currentReleaseId: rows[0]?.release_id ?? null, currentDigest: rows[0]?.manifest_digest ?? null };
|
|
217
|
+
},
|
|
218
|
+
async updateMetadata(artifactId, metadata) {
|
|
219
|
+
const { rows } = await client.query(`UPDATE artifacts.artifacts SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb
|
|
220
|
+
WHERE id = $1 RETURNING metadata`, [artifactId, JSON.stringify(metadata)]);
|
|
221
|
+
return rows[0]?.metadata ?? {};
|
|
222
|
+
},
|
|
202
223
|
async readAuthorization(artifactId) {
|
|
203
224
|
const { rows } = await client.query('SELECT lifecycle_state, owner_id FROM artifacts.artifacts WHERE id = $1', [artifactId]);
|
|
204
225
|
const row = rows[0];
|
|
@@ -213,8 +234,10 @@ export function createPostgresPublicationUnitWriter(client) {
|
|
|
213
234
|
source.kind === 'recipe' ? JSON.stringify(source.steps) : null]);
|
|
214
235
|
},
|
|
215
236
|
async putRelease(release) {
|
|
216
|
-
await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at
|
|
217
|
-
|
|
237
|
+
await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at,
|
|
238
|
+
author_principal_id, parent_release_id, reverted_from_release_id)
|
|
239
|
+
VALUES ($1, $2, $3, $4, 'ready', $5, $6, $7, $8)`, [release.releaseId, release.artifactId, release.manifestDigest, release.manifest, release.createdAt,
|
|
240
|
+
release.authorPrincipalId ?? null, release.parentReleaseId ?? null, release.revertedFromReleaseId ?? null]);
|
|
218
241
|
},
|
|
219
242
|
async registerBlob(storageScopeId, digest, size) {
|
|
220
243
|
await client.query(`INSERT INTO artifacts.blobs (storage_scope_id, digest, size)
|
|
@@ -280,6 +303,16 @@ export function createGuardedPublicationService(deps) {
|
|
|
280
303
|
return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
|
|
281
304
|
};
|
|
282
305
|
return {
|
|
306
|
+
async replayFinalized(input) {
|
|
307
|
+
let client;
|
|
308
|
+
return deps.coordinator.findReplayPrepared({ ...input.identity,
|
|
309
|
+
scopeId: input.storageScopeId, invocationId: input.invocationId, actorId: input.actorId,
|
|
310
|
+
artifactId: input.artifactId, operation: input.operation ?? 'publish', policyRevision: input.policyRevision,
|
|
311
|
+
receiptData: null, locks: { ...deps.locks, 'artifact-lifecycle': [
|
|
312
|
+
async (tx) => { client = tx; await deps.createUnitWriter(tx).lockCurrent(input.artifactId); },
|
|
313
|
+
...(deps.locks?.['artifact-lifecycle'] ?? []),
|
|
314
|
+
] }, guard: guard(() => client, input.authorizeCommit), });
|
|
315
|
+
},
|
|
283
316
|
async publishFinalized(input) {
|
|
284
317
|
validatePublicationFiles(input.files, limits);
|
|
285
318
|
const prepared = preparePublication(input);
|
|
@@ -321,6 +354,10 @@ export function createGuardedPublicationService(deps) {
|
|
|
321
354
|
const addedRefs = [];
|
|
322
355
|
const source = input.source === undefined ? undefined : parseSourceRef(input.source);
|
|
323
356
|
let finalizationClient;
|
|
357
|
+
let current;
|
|
358
|
+
const revertedFromReleaseId = input.operation === 'revert' && source?.kind === 'base-commit' ? source.commit : null;
|
|
359
|
+
const receiptData = { releaseId, manifest: prepared.manifest, metadata: {},
|
|
360
|
+
previousReleaseId: null, updatedAt: new Date(nowMs).toISOString(), revertedFromReleaseId };
|
|
324
361
|
const request = {
|
|
325
362
|
scopeId: input.storageScopeId,
|
|
326
363
|
internalKey,
|
|
@@ -328,18 +365,29 @@ export function createGuardedPublicationService(deps) {
|
|
|
328
365
|
codecVersion,
|
|
329
366
|
invocationId,
|
|
330
367
|
actorId: input.actorId,
|
|
331
|
-
operation: 'publish',
|
|
368
|
+
operation: input.operation ?? 'publish',
|
|
332
369
|
artifactId: input.artifactId,
|
|
333
370
|
bindingName: null,
|
|
334
371
|
policyRevision: input.policyRevision,
|
|
335
372
|
quotaBytes: prepared.entries.reduce((total, entry) => total + entry.bytes.byteLength, 0),
|
|
336
373
|
locks: { ...deps.locks, 'artifact-lifecycle': [
|
|
337
|
-
async (client) => {
|
|
374
|
+
async (client) => {
|
|
375
|
+
finalizationClient = client;
|
|
376
|
+
current = await deps.createUnitWriter(client).lockCurrent(input.artifactId);
|
|
377
|
+
},
|
|
338
378
|
...(deps.locks?.['artifact-lifecycle'] ?? []),
|
|
339
379
|
] },
|
|
380
|
+
assertPrecondition: () => {
|
|
381
|
+
if (input.expectedCurrentReleaseId !== undefined && current.currentReleaseId !== input.expectedCurrentReleaseId) {
|
|
382
|
+
throw new ArtifactOperationError('pointer_conflict', { conflict: { ...current,
|
|
383
|
+
artifactId: input.artifactId, expectedCurrentReleaseId: input.expectedCurrentReleaseId } });
|
|
384
|
+
}
|
|
385
|
+
},
|
|
340
386
|
provisional: async (client) => {
|
|
341
387
|
const unit = deps.createUnitWriter(client);
|
|
388
|
+
receiptData.previousReleaseId = current.currentReleaseId;
|
|
342
389
|
await unit.putRelease({
|
|
390
|
+
authorPrincipalId: input.actorId, parentReleaseId: current.currentReleaseId, revertedFromReleaseId,
|
|
343
391
|
releaseId,
|
|
344
392
|
artifactId: input.artifactId,
|
|
345
393
|
manifestDigest: prepared.digest,
|
|
@@ -353,6 +401,7 @@ export function createGuardedPublicationService(deps) {
|
|
|
353
401
|
addedRefs.push(staged.entry.digest);
|
|
354
402
|
}
|
|
355
403
|
await unit.promoteCurrentLatestWins(input.artifactId, releaseId);
|
|
404
|
+
receiptData.metadata = await unit.updateMetadata(input.artifactId, input.metadata ?? {});
|
|
356
405
|
if (source !== undefined)
|
|
357
406
|
await unit.putSource(input.artifactId, source);
|
|
358
407
|
},
|
|
@@ -370,7 +419,7 @@ export function createGuardedPublicationService(deps) {
|
|
|
370
419
|
await deps.noteAbandoned?.(attemptId);
|
|
371
420
|
},
|
|
372
421
|
attemptId,
|
|
373
|
-
receiptData
|
|
422
|
+
receiptData,
|
|
374
423
|
};
|
|
375
424
|
preservePins = true;
|
|
376
425
|
const outcome = await deps.coordinator.finalizePrepared(request);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const ARTIFACT_RESULT_SCHEMA: {
|
|
2
|
+
readonly type: "object";
|
|
3
|
+
readonly oneOf: readonly [{
|
|
4
|
+
type: string;
|
|
5
|
+
properties: Record<string, unknown>;
|
|
6
|
+
required: string[];
|
|
7
|
+
additionalProperties: boolean;
|
|
8
|
+
}, {
|
|
9
|
+
type: string;
|
|
10
|
+
properties: Record<string, unknown>;
|
|
11
|
+
required: string[];
|
|
12
|
+
additionalProperties: boolean;
|
|
13
|
+
}];
|
|
14
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Published domain-result envelope; error details are selected by code and variant. */
|
|
2
|
+
const string = { type: 'string' };
|
|
3
|
+
const nullable = { type: ['string', 'null'] };
|
|
4
|
+
const object = (properties, required = Object.keys(properties)) => ({
|
|
5
|
+
type: 'object', properties, required, additionalProperties: false,
|
|
6
|
+
});
|
|
7
|
+
const error = (code, details) => object({
|
|
8
|
+
code: { enum: [code] }, message: string, retriable: { type: 'boolean' }, details,
|
|
9
|
+
});
|
|
10
|
+
/** get_source file: verified raw bytes encoded as UTF-8 text or base64, never an integer array. */
|
|
11
|
+
const sourceFile = { type: 'object', additionalProperties: false,
|
|
12
|
+
properties: { path: string, mimeType: string, size: { type: 'integer', minimum: 0 }, digest: string,
|
|
13
|
+
encoding: { enum: ['utf8', 'base64'] }, text: string, base64: string },
|
|
14
|
+
required: ['path', 'mimeType', 'size', 'digest', 'encoding'],
|
|
15
|
+
oneOf: [{ required: ['text'], not: { required: ['base64'] } },
|
|
16
|
+
{ required: ['base64'], not: { required: ['text'] } }] };
|
|
17
|
+
const guideRef = object({ tool: string, version: string }, ['tool', 'version']);
|
|
18
|
+
const pointer = object({ variant: { enum: ['pointer_conflict'] }, artifactId: string,
|
|
19
|
+
expectedCurrentReleaseId: nullable, currentReleaseId: nullable, currentDigest: nullable,
|
|
20
|
+
currentSource: object({ artifactId: string, releaseId: string, digest: nullable,
|
|
21
|
+
manifest: string, files: { type: 'array', items: sourceFile }, sourceRef: { type: ['object', 'null'] },
|
|
22
|
+
guideRef, stableUrl: string, pinnedUrl: string }, ['artifactId', 'releaseId', 'digest', 'manifest', 'files', 'sourceRef', 'guideRef']),
|
|
23
|
+
}, ['variant', 'artifactId', 'expectedCurrentReleaseId', 'currentReleaseId', 'currentDigest']);
|
|
24
|
+
const common = { invocationId: string,
|
|
25
|
+
commitState: { enum: ['not_committed', 'committed', 'unknown'] } };
|
|
26
|
+
export const ARTIFACT_RESULT_SCHEMA = {
|
|
27
|
+
type: 'object',
|
|
28
|
+
oneOf: [
|
|
29
|
+
object({ ok: { enum: [true] }, ...common, replayed: { type: 'boolean' }, output: { type: 'object' } }),
|
|
30
|
+
object({ ok: { enum: [false] }, ...common, error: { oneOf: [
|
|
31
|
+
error('conflict', { oneOf: [pointer, object({ variant: { enum: ['command_conflict', 'key_contention', 'quota_exhausted'] } })] }),
|
|
32
|
+
error('edit_mismatch', object({ path: string, editIndex: { type: 'integer', minimum: 0 },
|
|
33
|
+
reason: { enum: ['missing', 'ambiguous', 'not_text', 'file_missing'] }, occurrences: { type: 'integer' } })),
|
|
34
|
+
error('release_unavailable', object({ reason: { enum: ['purged', 'not_retained', 'unpublished'] } })),
|
|
35
|
+
error('precondition_required', object({ profileRevisionId: string, resultEnvelopeVersion: { enum: [1, 2] },
|
|
36
|
+
requiredField: { enum: ['expectedCurrentReleaseId'] }, readTools: { type: 'array', items: string } })),
|
|
37
|
+
error('confirmation_required', object({ confirmationRef: string, managementUrl: string, expiresAt: string })),
|
|
38
|
+
object({ code: { type: 'string', not: { enum: ['conflict', 'edit_mismatch', 'release_unavailable',
|
|
39
|
+
'precondition_required', 'confirmation_required'] } },
|
|
40
|
+
message: string, retriable: { type: 'boolean' }, details: object({ variant: string }) }, ['code', 'message', 'retriable']),
|
|
41
|
+
] } }),
|
|
42
|
+
],
|
|
43
|
+
};
|
package/dist/server/service.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type CompiledArtifactsProfile } from '../profile/index.js';
|
|
|
3
3
|
import { type ArtifactKeyDerivationBinding } from './command-identity.js';
|
|
4
4
|
import type { ArtifactInvocationContext, ArtifactCommitCoordinator, FinalizeTxClient, InternalIdempotencyKey } from './commit/types.js';
|
|
5
5
|
import type { ArtifactOutcome } from './failures.js';
|
|
6
|
+
import type { ArtifactMetadata } from './access.js';
|
|
6
7
|
export interface EnsurePrivateViewInput {
|
|
7
8
|
actorUserId: string;
|
|
8
9
|
ownerId: string;
|
|
@@ -48,6 +49,7 @@ export interface GuardedEnsureResult {
|
|
|
48
49
|
}
|
|
49
50
|
/** Binding identity resolved inside the finalization unit. */
|
|
50
51
|
export interface EnsureBindingInput {
|
|
52
|
+
readonly metadata?: ArtifactMetadata;
|
|
51
53
|
readonly ownerId: string;
|
|
52
54
|
readonly storageScopeId: string;
|
|
53
55
|
readonly bindingName: string;
|
package/dist/server/service.js
CHANGED
|
@@ -88,7 +88,9 @@ const ensureResult = (receipt, replayed, invocationId) => ({
|
|
|
88
88
|
});
|
|
89
89
|
export function createGuardedEnsureService(deps) {
|
|
90
90
|
const finalizeRequest = (invocation, command, policyRevision) => {
|
|
91
|
+
const metadata = Object.fromEntries(['title', 'description', 'icon'].filter(key => Object.hasOwn(command.payload, key)).map(key => [key, command.payload[key]]));
|
|
91
92
|
const binding = {
|
|
93
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
92
94
|
ownerId: invocation.actorId,
|
|
93
95
|
storageScopeId: invocation.storageScopeId,
|
|
94
96
|
bindingName: command.payload.bindingName,
|
|
@@ -11,6 +11,11 @@ export interface SourceReadPorts {
|
|
|
11
11
|
stableUrl: string;
|
|
12
12
|
pinnedUrl: string;
|
|
13
13
|
};
|
|
14
|
+
/** Guide reference returned instead of embedding the guide body. */
|
|
15
|
+
guideRef?: {
|
|
16
|
+
tool: string;
|
|
17
|
+
version: string;
|
|
18
|
+
};
|
|
14
19
|
maxFiles: number;
|
|
15
20
|
maxTotalBytes: number;
|
|
16
21
|
}
|
|
@@ -27,12 +32,27 @@ export declare function readArtifactSource(ports: SourceReadPorts, input: Source
|
|
|
27
32
|
releaseId: string;
|
|
28
33
|
digest: string;
|
|
29
34
|
manifest: string;
|
|
30
|
-
files: {
|
|
31
|
-
|
|
35
|
+
files: ({
|
|
36
|
+
text: string;
|
|
37
|
+
encoding: "utf8";
|
|
38
|
+
path: string;
|
|
39
|
+
mimeType: string;
|
|
40
|
+
digest: string;
|
|
41
|
+
size: number;
|
|
42
|
+
} | {
|
|
43
|
+
base64: string;
|
|
44
|
+
encoding: "base64";
|
|
32
45
|
path: string;
|
|
33
46
|
mimeType: string;
|
|
34
47
|
digest: string;
|
|
35
48
|
size: number;
|
|
36
|
-
}[];
|
|
49
|
+
})[];
|
|
37
50
|
sourceRef: SourceRef | null;
|
|
51
|
+
guideRef: {
|
|
52
|
+
readonly tool: "artifacts_get_api_guide";
|
|
53
|
+
readonly version: "v1";
|
|
54
|
+
} | {
|
|
55
|
+
tool: string;
|
|
56
|
+
version: string;
|
|
57
|
+
};
|
|
38
58
|
}>;
|