@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
package/dist/server/service.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { ServerPorts } from './ports.js';
|
|
2
|
+
import { type CompiledArtifactsProfile } from '../profile/index.js';
|
|
3
|
+
import { type ArtifactKeyDerivationBinding } from './command-identity.js';
|
|
4
|
+
import type { ArtifactInvocationContext, ArtifactCommitCoordinator, FinalizeTxClient, InternalIdempotencyKey } from './commit/types.js';
|
|
5
|
+
import type { ArtifactOutcome } from './failures.js';
|
|
2
6
|
export interface EnsurePrivateViewInput {
|
|
3
7
|
actorUserId: string;
|
|
4
8
|
ownerId: string;
|
|
5
9
|
storageScopeId: string;
|
|
6
10
|
bindingName: string;
|
|
7
11
|
idempotencyKey: string;
|
|
8
|
-
|
|
12
|
+
/** @deprecated Ignored; the service computes its own hash. */
|
|
13
|
+
paramsHash?: string;
|
|
14
|
+
/** Validated provider parameters supplied by the application layer. */
|
|
15
|
+
parameters?: Record<string, string | number | boolean | null>;
|
|
9
16
|
}
|
|
10
17
|
export interface EnsurePrivateViewResult {
|
|
11
18
|
artifactId: string;
|
|
@@ -17,3 +24,59 @@ export interface ArtifactService {
|
|
|
17
24
|
}
|
|
18
25
|
/** Create the artifact service over explicit injected ports (no defaults). */
|
|
19
26
|
export declare function createArtifactService(ports: ServerPorts): ArtifactService;
|
|
27
|
+
/**
|
|
28
|
+
* L5 (immo #861) — coordinator-bound ensure (spec r2 §§3.1 A1-A8, 3.2).
|
|
29
|
+
*
|
|
30
|
+
* The legacy split finalizer above stays intact for the L6-owned HTTP and
|
|
31
|
+
* MCP consumers; L6 removes it during transport wiring. This composition is
|
|
32
|
+
* the only guarded ensure path: closed profile validation, server-derived
|
|
33
|
+
* command identity (never a caller hash or scope), request-bound guard
|
|
34
|
+
* invoked after all waits inside one atomic unit, typed outcomes shared
|
|
35
|
+
* with the failure projection. Unknown legacy codecs propagate as
|
|
36
|
+
* quarantine for the L6 migration projection.
|
|
37
|
+
*/
|
|
38
|
+
export interface GuardedEnsureArgs {
|
|
39
|
+
readonly bindingName: string;
|
|
40
|
+
readonly parameters?: Record<string, string | number | boolean | null>;
|
|
41
|
+
readonly idempotencyKey: string;
|
|
42
|
+
readonly envelopeKey?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface GuardedEnsureResult {
|
|
45
|
+
readonly artifactId: string;
|
|
46
|
+
readonly created: boolean;
|
|
47
|
+
readonly replayed: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** Binding identity resolved inside the finalization unit. */
|
|
50
|
+
export interface EnsureBindingInput {
|
|
51
|
+
readonly ownerId: string;
|
|
52
|
+
readonly storageScopeId: string;
|
|
53
|
+
readonly bindingName: string;
|
|
54
|
+
readonly attemptId: string;
|
|
55
|
+
}
|
|
56
|
+
export interface EnsureBindingReceipt {
|
|
57
|
+
readonly artifactId: string;
|
|
58
|
+
readonly created: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** Host-owned ensure effects inside the coordinator transaction. */
|
|
61
|
+
export interface GuardedEnsureHost {
|
|
62
|
+
lockBinding(client: FinalizeTxClient, binding: EnsureBindingInput): Promise<void>;
|
|
63
|
+
resolveBinding(client: FinalizeTxClient, binding: EnsureBindingInput): Promise<EnsureBindingReceipt>;
|
|
64
|
+
abandonAttempt(attemptId: string): Promise<void>;
|
|
65
|
+
readPolicyRevision(): Promise<string>;
|
|
66
|
+
}
|
|
67
|
+
export interface GuardedEnsureDeps {
|
|
68
|
+
readonly profile: CompiledArtifactsProfile;
|
|
69
|
+
readonly coordinator: ArtifactCommitCoordinator;
|
|
70
|
+
readonly derivation: ArtifactKeyDerivationBinding;
|
|
71
|
+
readonly host: GuardedEnsureHost;
|
|
72
|
+
}
|
|
73
|
+
export interface GuardedReplayArgs {
|
|
74
|
+
readonly internalKey: InternalIdempotencyKey;
|
|
75
|
+
readonly commandHash: string;
|
|
76
|
+
readonly binding?: EnsureBindingInput;
|
|
77
|
+
}
|
|
78
|
+
export interface GuardedEnsureService {
|
|
79
|
+
ensure(invocation: ArtifactInvocationContext, args: GuardedEnsureArgs): Promise<ArtifactOutcome<GuardedEnsureResult>>;
|
|
80
|
+
replay(invocation: ArtifactInvocationContext, args: GuardedReplayArgs): Promise<ArtifactOutcome<GuardedEnsureResult>>;
|
|
81
|
+
}
|
|
82
|
+
export declare function createGuardedEnsureService(deps: GuardedEnsureDeps): GuardedEnsureService;
|
package/dist/server/service.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
import { assertArtifactAccess } from './authorization.js';
|
|
11
11
|
import { createBindingService } from './bindings.js';
|
|
12
12
|
import { createIdempotencyStore } from './idempotency.js';
|
|
13
|
+
import { manifestDigest } from '../codecs.js';
|
|
14
|
+
import { ArtifactProfileInputError } from '../profile/index.js';
|
|
15
|
+
import { createArtifactCommand, } from './command-identity.js';
|
|
16
|
+
import { projectEnsureReceipt, replayEnsureGuarded, } from './authorized-replay.js';
|
|
13
17
|
/** Create the artifact service over explicit injected ports (no defaults). */
|
|
14
18
|
export function createArtifactService(ports) {
|
|
15
19
|
const bindings = createBindingService(ports.kv, ports.uuid);
|
|
@@ -21,7 +25,11 @@ export function createArtifactService(ports) {
|
|
|
21
25
|
artifactOwnerId: input.ownerId,
|
|
22
26
|
action: 'mutate',
|
|
23
27
|
});
|
|
24
|
-
const { result, replayed } = await idempotent.execute(
|
|
28
|
+
const { result, replayed } = await idempotent.execute(JSON.stringify(['ensure', input.actorUserId, input.idempotencyKey]), manifestDigest({
|
|
29
|
+
schemaVersion: 'artifacts/v1', ownerId: input.ownerId,
|
|
30
|
+
storageScopeId: input.storageScopeId, bindingName: input.bindingName,
|
|
31
|
+
parameters: input.parameters ?? {},
|
|
32
|
+
}), () => bindings.ensure({
|
|
25
33
|
ownerId: input.ownerId,
|
|
26
34
|
storageScopeId: input.storageScopeId,
|
|
27
35
|
bindingName: input.bindingName,
|
|
@@ -30,3 +38,181 @@ export function createArtifactService(ports) {
|
|
|
30
38
|
},
|
|
31
39
|
};
|
|
32
40
|
}
|
|
41
|
+
/** Private causes stay on the failure object but never serialize: projection picks fields. */
|
|
42
|
+
const conceal = (failure) => {
|
|
43
|
+
Object.defineProperty(failure, 'cause', { value: failure.cause, enumerable: false });
|
|
44
|
+
return Object.freeze(failure);
|
|
45
|
+
};
|
|
46
|
+
const profileEntryFailure = (error, invocationId) => {
|
|
47
|
+
if (error.kind === 'operational') {
|
|
48
|
+
return conceal({
|
|
49
|
+
kind: 'operational', category: 'capability_unselected', variant: error.variant,
|
|
50
|
+
httpStatus: error.httpStatus, publicCode: error.publicCode, retriable: false,
|
|
51
|
+
invocationId, operation: 'ensure', phase: error.phase, commitState: 'not_committed', cause: error,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return conceal({
|
|
55
|
+
kind: 'ordinary', category: 'invalid_request',
|
|
56
|
+
httpStatus: error.httpStatus, publicCode: error.publicCode, retriable: false,
|
|
57
|
+
invocationId, operation: 'ensure', phase: error.phase, commitState: 'not_committed', cause: error,
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const internalFailure = (invocationId, phase, cause) => conceal({
|
|
61
|
+
kind: 'operational', category: 'provider_error', variant: 'unclassified',
|
|
62
|
+
httpStatus: 500, publicCode: 'internal_error', retriable: false,
|
|
63
|
+
invocationId, operation: 'ensure', phase, commitState: 'not_committed', cause,
|
|
64
|
+
});
|
|
65
|
+
const unavailableFailure = (invocationId, phase, cause) => conceal({
|
|
66
|
+
kind: 'operational', category: 'provider_unavailable', variant: 'dispatch_failure',
|
|
67
|
+
httpStatus: 503, publicCode: 'capability_unavailable', retriable: true,
|
|
68
|
+
invocationId, operation: 'ensure', phase, commitState: 'not_committed', cause,
|
|
69
|
+
});
|
|
70
|
+
const anonymousFailure = (invocation) => conceal({
|
|
71
|
+
kind: 'admission', category: 'anonymous',
|
|
72
|
+
httpStatus: 401, publicCode: 'anonymous', retriable: false,
|
|
73
|
+
invocationId: invocation.invocationId, operation: 'ensure', phase: 'admit',
|
|
74
|
+
commitState: 'not_committed', cause: { invocation: invocation.invocationId },
|
|
75
|
+
});
|
|
76
|
+
const coordinatorFailure = (failure) => conceal({
|
|
77
|
+
kind: 'operational', category: failure.category, variant: failure.variant,
|
|
78
|
+
httpStatus: failure.httpStatus, publicCode: failure.publicCode, retriable: failure.retriable,
|
|
79
|
+
invocationId: failure.invocationId, operation: failure.operation, phase: failure.phase,
|
|
80
|
+
commitState: failure.commitState, cause: failure.cause,
|
|
81
|
+
});
|
|
82
|
+
const ensureResult = (receipt, replayed, invocationId) => ({
|
|
83
|
+
ok: true,
|
|
84
|
+
result: { artifactId: receipt.artifactId, created: receipt.created, replayed },
|
|
85
|
+
invocationId,
|
|
86
|
+
commitState: 'committed',
|
|
87
|
+
replayed,
|
|
88
|
+
});
|
|
89
|
+
export function createGuardedEnsureService(deps) {
|
|
90
|
+
const finalizeRequest = (invocation, command, policyRevision) => {
|
|
91
|
+
const binding = {
|
|
92
|
+
ownerId: invocation.actorId,
|
|
93
|
+
storageScopeId: invocation.storageScopeId,
|
|
94
|
+
bindingName: command.payload.bindingName,
|
|
95
|
+
attemptId: invocation.invocationId,
|
|
96
|
+
};
|
|
97
|
+
const captured = {};
|
|
98
|
+
const request = {
|
|
99
|
+
scopeId: invocation.storageScopeId,
|
|
100
|
+
internalKey: command.identity.internalKey,
|
|
101
|
+
commandHash: command.identity.commandHash,
|
|
102
|
+
codecVersion: command.identity.codecVersion,
|
|
103
|
+
invocationId: invocation.invocationId,
|
|
104
|
+
actorId: invocation.actorId,
|
|
105
|
+
operation: 'ensure',
|
|
106
|
+
get artifactId() { return captured.artifactId ?? null; },
|
|
107
|
+
bindingName: binding.bindingName,
|
|
108
|
+
policyRevision,
|
|
109
|
+
locks: { 'binding-identity': [async (client) => {
|
|
110
|
+
await deps.host.lockBinding(client, binding);
|
|
111
|
+
const receipt = await deps.host.resolveBinding(client, binding);
|
|
112
|
+
captured.artifactId = receipt.artifactId;
|
|
113
|
+
captured.created = receipt.created;
|
|
114
|
+
}] },
|
|
115
|
+
validateReplayReceipt: (receipt) => captured.created === false &&
|
|
116
|
+
projectEnsureReceipt(receipt).artifactId === captured.artifactId,
|
|
117
|
+
noteAbandonedAttempt: (attemptId) => deps.host.abandonAttempt(attemptId),
|
|
118
|
+
attemptId: invocation.invocationId,
|
|
119
|
+
guard: invocation.authorizeCommit,
|
|
120
|
+
receiptData: captured,
|
|
121
|
+
};
|
|
122
|
+
return { request, captured };
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
async ensure(invocation, args) {
|
|
126
|
+
if (invocation.actorId.length === 0 || invocation.storageScopeId.length === 0) {
|
|
127
|
+
return { ok: false, failure: anonymousFailure(invocation) };
|
|
128
|
+
}
|
|
129
|
+
let command;
|
|
130
|
+
try {
|
|
131
|
+
const { envelopeKey, ...commandArgs } = args;
|
|
132
|
+
command = createArtifactCommand(deps.profile, 'artifacts_ensure', commandArgs, { storageScopeId: invocation.storageScopeId, actorId: invocation.actorId }, deps.derivation, envelopeKey);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof ArtifactProfileInputError) {
|
|
136
|
+
return { ok: false, failure: profileEntryFailure(error, invocation.invocationId) };
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, failure: internalFailure(invocation.invocationId, 'entry', error) };
|
|
139
|
+
}
|
|
140
|
+
let policyRevision;
|
|
141
|
+
try {
|
|
142
|
+
policyRevision = await deps.host.readPolicyRevision();
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return { ok: false, failure: unavailableFailure(invocation.invocationId, 'acquire', error) };
|
|
146
|
+
}
|
|
147
|
+
const { request } = finalizeRequest(invocation, command, policyRevision);
|
|
148
|
+
const outcome = await deps.coordinator.finalizePrepared(request);
|
|
149
|
+
if (!outcome.ok)
|
|
150
|
+
return { ok: false, failure: coordinatorFailure(outcome.failure) };
|
|
151
|
+
let receipt;
|
|
152
|
+
try {
|
|
153
|
+
receipt = projectEnsureReceipt(outcome.result);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return { ok: false, failure: internalFailure(invocation.invocationId, 'resolve', error) };
|
|
157
|
+
}
|
|
158
|
+
return ensureResult(receipt, outcome.replayed, outcome.invocationId);
|
|
159
|
+
},
|
|
160
|
+
async replay(invocation, args) {
|
|
161
|
+
if (invocation.actorId.length === 0 || invocation.storageScopeId.length === 0) {
|
|
162
|
+
return { ok: false, failure: anonymousFailure(invocation) };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
deps.profile.assertSelected('artifacts_ensure');
|
|
166
|
+
if (!args.binding || args.binding.ownerId !== invocation.actorId ||
|
|
167
|
+
args.binding.storageScopeId !== invocation.storageScopeId || !args.binding.bindingName) {
|
|
168
|
+
throw new ArtifactProfileInputError();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error instanceof ArtifactProfileInputError) {
|
|
173
|
+
return { ok: false, failure: profileEntryFailure(error, invocation.invocationId) };
|
|
174
|
+
}
|
|
175
|
+
return { ok: false, failure: internalFailure(invocation.invocationId, 'entry', error) };
|
|
176
|
+
}
|
|
177
|
+
let policyRevision;
|
|
178
|
+
try {
|
|
179
|
+
policyRevision = await deps.host.readPolicyRevision();
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return { ok: false, failure: unavailableFailure(invocation.invocationId, 'acquire', error) };
|
|
183
|
+
}
|
|
184
|
+
let artifactId;
|
|
185
|
+
let missingTarget = false;
|
|
186
|
+
const binding = args.binding;
|
|
187
|
+
const outcome = await replayEnsureGuarded(deps.coordinator, {
|
|
188
|
+
invocation,
|
|
189
|
+
internalKey: args.internalKey,
|
|
190
|
+
commandHash: args.commandHash,
|
|
191
|
+
policyRevision,
|
|
192
|
+
get artifactId() { return artifactId; },
|
|
193
|
+
bindingName: binding?.bindingName,
|
|
194
|
+
validateReplayReceipt: (receipt) => !missingTarget &&
|
|
195
|
+
projectEnsureReceipt(receipt).artifactId === artifactId,
|
|
196
|
+
noteAbandonedAttempt: (attemptId) => deps.host.abandonAttempt(attemptId),
|
|
197
|
+
locks: binding === undefined ? undefined : {
|
|
198
|
+
'binding-identity': [async (client) => {
|
|
199
|
+
await deps.host.lockBinding(client, binding);
|
|
200
|
+
const target = await deps.host.resolveBinding(client, binding);
|
|
201
|
+
missingTarget = target.created;
|
|
202
|
+
artifactId = target.artifactId;
|
|
203
|
+
}],
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!outcome.ok)
|
|
207
|
+
return { ok: false, failure: coordinatorFailure(outcome.failure) };
|
|
208
|
+
let receipt;
|
|
209
|
+
try {
|
|
210
|
+
receipt = projectEnsureReceipt(outcome.result);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return { ok: false, failure: internalFailure(invocation.invocationId, 'resolve', error) };
|
|
214
|
+
}
|
|
215
|
+
return ensureResult(receipt, outcome.replayed, outcome.invocationId);
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
package/dist/server/uploads.d.ts
CHANGED
|
@@ -20,10 +20,13 @@ export interface UploadService {
|
|
|
20
20
|
begin(input: BeginUploadInput): Promise<{
|
|
21
21
|
uploadId: string;
|
|
22
22
|
}>;
|
|
23
|
+
/** Check that the session is open and optionally validate its sequential offset before decoding. */
|
|
24
|
+
remainingBytes(uploadId: string, offset?: number): number;
|
|
23
25
|
append(uploadId: string, offset: number, chunk: Uint8Array): Promise<{
|
|
24
26
|
size: number;
|
|
25
27
|
}>;
|
|
26
|
-
commit
|
|
28
|
+
/** Validate the commit snapshot before consuming the session; rejected validation remains retryable. */
|
|
29
|
+
commit(uploadId: string, expected: BlobRef, validate?: (bytes: Uint8Array) => void): Promise<BlobRef>;
|
|
27
30
|
}
|
|
28
31
|
/** Create an upload service over an injected staging store (no S3 imports). */
|
|
29
32
|
export declare function createUploadService(staging: StagingStore, options: UploadServiceOptions, ids?: () => string): UploadService;
|
package/dist/server/uploads.js
CHANGED
|
@@ -33,54 +33,82 @@ export function createMemoryStaging() {
|
|
|
33
33
|
}
|
|
34
34
|
/** Create an upload service over an injected staging store (no S3 imports). */
|
|
35
35
|
export function createUploadService(staging, options, ids = () => crypto.randomUUID()) {
|
|
36
|
+
if (!Number.isSafeInteger(options.maxUploadBytes) || options.maxUploadBytes < 1) {
|
|
37
|
+
throw new Error('invalid upload byte budget');
|
|
38
|
+
}
|
|
36
39
|
const sessions = new Map();
|
|
40
|
+
function openSession(uploadId) {
|
|
41
|
+
const session = sessions.get(uploadId);
|
|
42
|
+
if (session === undefined)
|
|
43
|
+
throw new Error(`unknown upload: ${uploadId}`);
|
|
44
|
+
if (session.committed)
|
|
45
|
+
throw new Error(`upload already committed: ${uploadId}`);
|
|
46
|
+
if (session.busy)
|
|
47
|
+
throw new Error(`upload conflict: operation in progress for ${uploadId}`);
|
|
48
|
+
return session;
|
|
49
|
+
}
|
|
50
|
+
async function exclusive(uploadId, run) {
|
|
51
|
+
const session = openSession(uploadId);
|
|
52
|
+
session.busy = true;
|
|
53
|
+
try {
|
|
54
|
+
return await run(session);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
session.busy = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function remainingBytes(uploadId, offset) {
|
|
61
|
+
const session = openSession(uploadId);
|
|
62
|
+
if (offset !== undefined && (!Number.isSafeInteger(offset) || offset !== session.size)) {
|
|
63
|
+
throw new Error(`upload offset mismatch for ${uploadId}: expected ${session.size}, got ${offset}`);
|
|
64
|
+
}
|
|
65
|
+
return session.maxSize - session.size;
|
|
66
|
+
}
|
|
37
67
|
return {
|
|
68
|
+
remainingBytes,
|
|
38
69
|
async begin(input) {
|
|
70
|
+
if (input.maxSize !== undefined && (!Number.isSafeInteger(input.maxSize) || input.maxSize < 1)) {
|
|
71
|
+
throw new Error('invalid upload byte budget');
|
|
72
|
+
}
|
|
39
73
|
const uploadId = ids();
|
|
40
74
|
sessions.set(uploadId, {
|
|
41
75
|
committed: false,
|
|
42
|
-
|
|
76
|
+
busy: false,
|
|
77
|
+
maxSize: Math.min(input.maxSize ?? options.maxUploadBytes, options.maxUploadBytes),
|
|
43
78
|
size: 0,
|
|
44
79
|
});
|
|
45
80
|
await staging.write(uploadId, new Uint8Array(0));
|
|
46
81
|
return { uploadId };
|
|
47
82
|
},
|
|
48
83
|
async append(uploadId, offset, chunk) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
next.set(current, 0);
|
|
63
|
-
next.set(chunk, current.length);
|
|
64
|
-
await staging.write(uploadId, next);
|
|
65
|
-
session.size = next.length;
|
|
66
|
-
return { size: session.size };
|
|
84
|
+
const remaining = remainingBytes(uploadId, offset);
|
|
85
|
+
return exclusive(uploadId, async (session) => {
|
|
86
|
+
if (chunk.length > remaining) {
|
|
87
|
+
throw new Error(`upload budget exceeded for ${uploadId}: max ${session.maxSize} bytes`);
|
|
88
|
+
}
|
|
89
|
+
const current = await staging.read(uploadId);
|
|
90
|
+
const next = new Uint8Array(current.length + chunk.length);
|
|
91
|
+
next.set(current, 0);
|
|
92
|
+
next.set(chunk, current.length);
|
|
93
|
+
await staging.write(uploadId, next);
|
|
94
|
+
session.size = next.length;
|
|
95
|
+
return { size: session.size };
|
|
96
|
+
});
|
|
67
97
|
},
|
|
68
|
-
async commit(uploadId, expected) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
session.committed = true;
|
|
83
|
-
return { digest: actual, size: bytes.length };
|
|
98
|
+
async commit(uploadId, expected, validate) {
|
|
99
|
+
return exclusive(uploadId, async (session) => {
|
|
100
|
+
const bytes = new Uint8Array(await staging.read(uploadId));
|
|
101
|
+
validate?.(bytes);
|
|
102
|
+
if (bytes.length !== expected.size) {
|
|
103
|
+
throw new Error(`upload size mismatch for ${uploadId}: staged ${bytes.length}, declared ${expected.size}`);
|
|
104
|
+
}
|
|
105
|
+
const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
106
|
+
if (actual !== expected.digest) {
|
|
107
|
+
throw new Error(`upload digest mismatch for ${uploadId}: staged ${actual}`);
|
|
108
|
+
}
|
|
109
|
+
session.committed = true;
|
|
110
|
+
return { digest: actual, size: bytes.length };
|
|
111
|
+
});
|
|
84
112
|
},
|
|
85
113
|
};
|
|
86
114
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3 (immo #861) — test-only commit barrier (spec r2 §3.2).
|
|
3
|
+
*
|
|
4
|
+
* Test seam for pausing finalization at deterministic points with
|
|
5
|
+
* invocation correlation. `waitUntilReached` resolves after arrival;
|
|
6
|
+
* `release` resumes only that invocation/point pair. The coordinator
|
|
7
|
+
* rechecks authority after a resumed barrier; a barrier can never mark
|
|
8
|
+
* an operation authorized or successful.
|
|
9
|
+
*
|
|
10
|
+
* Pass `observer` as the coordinator `testObserver`. Register a wait before
|
|
11
|
+
* starting the operation to pause that invocation/point; other arrivals
|
|
12
|
+
* are observed without blocking. The three preparation points are fired by L4
|
|
13
|
+
* preparation adapters holding this same observer, never by the
|
|
14
|
+
* coordinator (arming one throws at construction).
|
|
15
|
+
*/
|
|
16
|
+
import type { InspectableDeletionClaimStore } from './server/gc.js';
|
|
17
|
+
import type { PublicationBlobStore } from './server/publication.js';
|
|
18
|
+
export declare const COMMIT_BARRIER_POINTS: readonly ["before_connection_acquire", "before_key_lock", "before_blob_put", "after_blob_put_before_verify", "before_blob_readback", "before_finalization_row_lock", "after_finalization_locks_before_guard", "after_replay_lookup_before_guard", "before_commit"];
|
|
19
|
+
export type CommitBarrierPoint = (typeof COMMIT_BARRIER_POINTS)[number];
|
|
20
|
+
export type CommitBarrierObserver = (point: CommitBarrierPoint, invocationId: string) => Promise<void>;
|
|
21
|
+
export interface CommitArrivalObserver {
|
|
22
|
+
arrive(point: CommitBarrierPoint, invocationId: string): void;
|
|
23
|
+
}
|
|
24
|
+
/** Explicit host binding: purge must use the supplied deletion adapter for this call only. */
|
|
25
|
+
export interface PurgeTestHost {
|
|
26
|
+
readonly claims: InspectableDeletionClaimStore;
|
|
27
|
+
readonly storageScopeId: string;
|
|
28
|
+
readonly blobs: PublicationBlobStore;
|
|
29
|
+
purge(artifactId: string, deleting: PublicationBlobStore): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function inspectDeletionClaims(host: Pick<PurgeTestHost, 'claims' | 'storageScopeId'>): Promise<Array<{
|
|
32
|
+
claimId: string;
|
|
33
|
+
state: string;
|
|
34
|
+
blobKeys: string[];
|
|
35
|
+
}>>;
|
|
36
|
+
/** Start one correlated purge and pause immediately before its first external DELETE. */
|
|
37
|
+
export declare function pausePurgeBeforeDelete(host: PurgeTestHost, artifactId: string): Promise<{
|
|
38
|
+
claimId: string;
|
|
39
|
+
blobKeys: string[];
|
|
40
|
+
release(): void;
|
|
41
|
+
finished: Promise<void>;
|
|
42
|
+
}>;
|
|
43
|
+
/** Create a correlated test barrier for one finalization run. */
|
|
44
|
+
export declare function createArtifactCommitBarrier(options?: {
|
|
45
|
+
readonly points: readonly CommitBarrierPoint[];
|
|
46
|
+
}): {
|
|
47
|
+
observer: CommitBarrierObserver & CommitArrivalObserver;
|
|
48
|
+
waitUntilReached(point: CommitBarrierPoint, invocationId: string): Promise<void>;
|
|
49
|
+
release(point: CommitBarrierPoint, invocationId: string): void;
|
|
50
|
+
};
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const COMMIT_BARRIER_POINTS = [
|
|
2
|
+
'before_connection_acquire',
|
|
3
|
+
'before_key_lock',
|
|
4
|
+
'before_blob_put',
|
|
5
|
+
'after_blob_put_before_verify',
|
|
6
|
+
'before_blob_readback',
|
|
7
|
+
'before_finalization_row_lock',
|
|
8
|
+
'after_finalization_locks_before_guard',
|
|
9
|
+
'after_replay_lookup_before_guard',
|
|
10
|
+
'before_commit',
|
|
11
|
+
];
|
|
12
|
+
export async function inspectDeletionClaims(host) {
|
|
13
|
+
return (await host.claims.listClaims()).map((claim) => ({ claimId: claim.claimId, state: claim.state,
|
|
14
|
+
blobKeys: [`artifacts/${host.storageScopeId}/blobs/sha256/${claim.digest.slice(7)}`] }));
|
|
15
|
+
}
|
|
16
|
+
/** Start one correlated purge and pause immediately before its first external DELETE. */
|
|
17
|
+
export async function pausePurgeBeforeDelete(host, artifactId) {
|
|
18
|
+
let release;
|
|
19
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
20
|
+
let reached;
|
|
21
|
+
let fail;
|
|
22
|
+
const arrival = new Promise((resolve, reject) => {
|
|
23
|
+
reached = resolve;
|
|
24
|
+
fail = reject;
|
|
25
|
+
});
|
|
26
|
+
const deleting = { ...host.blobs,
|
|
27
|
+
putBlob: (key, bytes) => host.blobs.putBlob(key, bytes),
|
|
28
|
+
getBlob: (key) => host.blobs.getBlob(key), listKeys: (prefix) => host.blobs.listKeys(prefix),
|
|
29
|
+
async deleteBlob(key) {
|
|
30
|
+
const claims = (await host.claims.listClaims()).filter((claim) => claim.holder === `purge:${artifactId}` && claim.state === 'pending');
|
|
31
|
+
const matching = claims.find((claim) => key.endsWith(`/sha256/${claim.digest.slice(7)}`));
|
|
32
|
+
if (matching === undefined)
|
|
33
|
+
return host.blobs.deleteBlob(key);
|
|
34
|
+
reached({ claimId: matching.claimId,
|
|
35
|
+
blobKeys: claims.map((claim) => `artifacts/${host.storageScopeId}/blobs/sha256/${claim.digest.slice(7)}`) });
|
|
36
|
+
await gate;
|
|
37
|
+
await host.blobs.deleteBlob(key);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const finished = host.purge(artifactId, deleting);
|
|
41
|
+
void finished.then(() => fail(new Error('purge finished without external DELETE')), fail);
|
|
42
|
+
return { ...await arrival, release, finished };
|
|
43
|
+
}
|
|
44
|
+
/** Create a correlated test barrier for one finalization run. */
|
|
45
|
+
export function createArtifactCommitBarrier(options) {
|
|
46
|
+
const gates = new Map();
|
|
47
|
+
const gateFor = (point, invocationId) => {
|
|
48
|
+
const key = `${point}:${invocationId}`;
|
|
49
|
+
const existing = gates.get(key);
|
|
50
|
+
if (existing !== undefined)
|
|
51
|
+
return existing;
|
|
52
|
+
const created = { arrivals: 0, armed: options?.points.includes(point) ?? false, waiters: [], releases: [] };
|
|
53
|
+
gates.set(key, created);
|
|
54
|
+
return created;
|
|
55
|
+
};
|
|
56
|
+
const arrive = (point, invocationId) => {
|
|
57
|
+
const gate = gateFor(point, invocationId);
|
|
58
|
+
gate.arrivals += 1;
|
|
59
|
+
for (const waiter of gate.waiters.splice(0))
|
|
60
|
+
waiter();
|
|
61
|
+
if (!gate.armed)
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
gate.releases.push(resolve);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
observer: Object.assign(arrive, { arrive }),
|
|
69
|
+
waitUntilReached: (point, invocationId) => {
|
|
70
|
+
const gate = gateFor(point, invocationId);
|
|
71
|
+
if (gate.arrivals > 0)
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
gate.armed = true;
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
gate.waiters.push(resolve);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
release: (point, invocationId) => {
|
|
79
|
+
const gate = gateFor(point, invocationId);
|
|
80
|
+
const resume = gate.releases.shift();
|
|
81
|
+
if (resume === undefined)
|
|
82
|
+
throw new Error(`no paused barrier at ${point} for ${invocationId}`);
|
|
83
|
+
resume();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|