@fabrika/operations-contract 0.2.0 → 0.3.2
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/package.json +3 -2
- package/src/catalog.ts +24 -38
- package/src/index.ts +13 -17
- package/src/release-schemas.ts +80 -0
- package/src/releases.ts +34 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/operations-contract",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"typecheck": "tsc --noEmit"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"
|
|
62
|
+
"zod": "^3.25.76",
|
|
63
|
+
"@fabrika/app": "^0.3.2"
|
|
63
64
|
}
|
|
64
65
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -1,73 +1,59 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Private Control → Operations source-catalog protocol; both services ship together. */
|
|
2
2
|
|
|
3
|
-
export const OPERATIONS_CATALOG_PROTOCOL_VERSION = 2
|
|
4
3
|
export const DEFAULT_OPERATIONS_SERVICE_KEY = 'default'
|
|
5
4
|
|
|
6
|
-
export interface
|
|
5
|
+
export interface OperationsSourceCoordinate {
|
|
7
6
|
appId: string
|
|
8
7
|
environment: string
|
|
9
8
|
/** Omitted by Control while an app environment has only its default service. */
|
|
10
9
|
serviceKey?: string
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
export interface
|
|
12
|
+
export interface CanonicalOperationsSourceCoordinate {
|
|
14
13
|
appId: string
|
|
15
14
|
environment: string
|
|
16
15
|
serviceKey: string
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
export interface OperationsCatalogSourceV1 {
|
|
20
|
-
coordinate: OperationsSourceCoordinateV1
|
|
21
|
-
displayName: string
|
|
22
|
-
/** Canonical externally reachable origin when Control owns one; null when it does not. */
|
|
23
|
-
publicOrigin?: string | null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface CanonicalOperationsCatalogSourceV1 {
|
|
27
|
-
coordinate: CanonicalOperationsSourceCoordinateV1
|
|
28
|
-
displayName: string
|
|
29
|
-
publicOrigin: string | null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
18
|
/** Caller-owned write credential transported only over the private catalog channel. */
|
|
33
|
-
export interface
|
|
19
|
+
export interface OperationsCatalogIngestCredential {
|
|
34
20
|
id: string
|
|
35
21
|
publicKey: string
|
|
36
22
|
}
|
|
37
23
|
|
|
38
|
-
export interface
|
|
39
|
-
|
|
24
|
+
export interface OperationsCatalogSource {
|
|
25
|
+
coordinate: OperationsSourceCoordinate
|
|
26
|
+
displayName: string
|
|
27
|
+
/** Canonical externally reachable origin when Control owns one; null when it does not. */
|
|
28
|
+
publicOrigin?: string | null
|
|
29
|
+
ingestCredential: OperationsCatalogIngestCredential
|
|
40
30
|
}
|
|
41
31
|
|
|
42
|
-
export interface
|
|
43
|
-
|
|
32
|
+
export interface CanonicalOperationsCatalogSource {
|
|
33
|
+
coordinate: CanonicalOperationsSourceCoordinate
|
|
34
|
+
displayName: string
|
|
35
|
+
publicOrigin: string | null
|
|
36
|
+
ingestCredential: OperationsCatalogIngestCredential
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
export interface
|
|
47
|
-
protocolVersion: typeof OPERATIONS_CATALOG_PROTOCOL_VERSION
|
|
39
|
+
export interface OperationsCatalogReconcileRequest {
|
|
48
40
|
revision: number
|
|
49
41
|
snapshotHash: string
|
|
50
|
-
sources:
|
|
42
|
+
sources: OperationsCatalogSource[]
|
|
51
43
|
}
|
|
52
44
|
|
|
53
45
|
export type OperationsCatalogReconcileOutcome = 'applied' | 'unchanged' | 'stale'
|
|
54
46
|
|
|
55
|
-
export interface
|
|
56
|
-
coordinate:
|
|
47
|
+
export interface OperationsCatalogIngestResult {
|
|
48
|
+
coordinate: CanonicalOperationsSourceCoordinate
|
|
57
49
|
credentialId: string
|
|
58
50
|
ingestProjectId: string
|
|
59
51
|
}
|
|
60
52
|
|
|
61
|
-
export interface
|
|
62
|
-
protocolVersion: typeof OPERATIONS_CATALOG_PROTOCOL_VERSION
|
|
53
|
+
export interface OperationsCatalogReconcileResponse {
|
|
63
54
|
revision: number
|
|
64
55
|
outcome: OperationsCatalogReconcileOutcome
|
|
65
|
-
|
|
66
|
-
updated: number
|
|
67
|
-
disabled: number
|
|
68
|
-
reenabled: number
|
|
69
|
-
unchanged: number
|
|
70
|
-
ingest: OperationsCatalogIngestResultV2[]
|
|
56
|
+
ingest: OperationsCatalogIngestResult[]
|
|
71
57
|
}
|
|
72
58
|
|
|
73
59
|
export function canonicalOperationsServiceKey(serviceKey: string | undefined): string {
|
|
@@ -75,8 +61,8 @@ export function canonicalOperationsServiceKey(serviceKey: string | undefined): s
|
|
|
75
61
|
}
|
|
76
62
|
|
|
77
63
|
export function canonicalOperationsCatalogSources(
|
|
78
|
-
sources: readonly
|
|
79
|
-
):
|
|
64
|
+
sources: readonly OperationsCatalogSource[],
|
|
65
|
+
): CanonicalOperationsCatalogSource[] {
|
|
80
66
|
return sources.map((source) => ({
|
|
81
67
|
coordinate: {
|
|
82
68
|
appId: source.coordinate.appId,
|
|
@@ -96,7 +82,7 @@ export function canonicalOperationsCatalogSources(
|
|
|
96
82
|
}
|
|
97
83
|
|
|
98
84
|
/** Hash the canonical source array only; revision is transport ordering, not catalog content. */
|
|
99
|
-
export async function operationsCatalogSnapshotHash(sources: readonly
|
|
85
|
+
export async function operationsCatalogSnapshotHash(sources: readonly OperationsCatalogSource[]): Promise<string> {
|
|
100
86
|
const bytes = new TextEncoder().encode(JSON.stringify(canonicalOperationsCatalogSources(sources)))
|
|
101
87
|
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
102
88
|
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
package/src/index.ts
CHANGED
|
@@ -4,21 +4,18 @@ export {
|
|
|
4
4
|
canonicalOperationsCatalogSources,
|
|
5
5
|
canonicalOperationsServiceKey,
|
|
6
6
|
DEFAULT_OPERATIONS_SERVICE_KEY,
|
|
7
|
-
OPERATIONS_CATALOG_PROTOCOL_VERSION,
|
|
8
7
|
operationsCatalogSnapshotHash,
|
|
9
8
|
} from './catalog.js'
|
|
10
9
|
export type {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
OperationsCatalogIngestResultV2,
|
|
10
|
+
CanonicalOperationsCatalogSource,
|
|
11
|
+
CanonicalOperationsSourceCoordinate,
|
|
12
|
+
OperationsCatalogIngestCredential,
|
|
13
|
+
OperationsCatalogIngestResult,
|
|
16
14
|
OperationsCatalogReconcileOutcome,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
OperationsSourceCoordinateV1,
|
|
15
|
+
OperationsCatalogReconcileRequest,
|
|
16
|
+
OperationsCatalogReconcileResponse,
|
|
17
|
+
OperationsCatalogSource,
|
|
18
|
+
OperationsSourceCoordinate,
|
|
22
19
|
} from './catalog.js'
|
|
23
20
|
export {
|
|
24
21
|
buildOperationsDsn,
|
|
@@ -98,7 +95,6 @@ export {
|
|
|
98
95
|
FABRIKA_RELEASE,
|
|
99
96
|
normalizeOperationsCommit,
|
|
100
97
|
OPERATIONS_ARTIFACT_HEADERS,
|
|
101
|
-
OPERATIONS_RELEASE_PROTOCOL_VERSION,
|
|
102
98
|
OPERATIONS_RELEASE_RECONCILE_PATH,
|
|
103
99
|
OPERATIONS_SOURCE_MAP_UPLOAD_PATH,
|
|
104
100
|
operationsReleaseName,
|
|
@@ -107,14 +103,14 @@ export {
|
|
|
107
103
|
export type {
|
|
108
104
|
OperationsArtifactState,
|
|
109
105
|
OperationsArtifactUploadConfiguration,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
OperationsArtifactUploadCredential,
|
|
107
|
+
OperationsReleaseAvailability,
|
|
108
|
+
OperationsReleaseCoordinate,
|
|
113
109
|
OperationsReleaseOutcome,
|
|
114
110
|
OperationsReleasePhase,
|
|
115
111
|
OperationsReleaseReconcileOutcome,
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
OperationsReleaseReconcileRequest,
|
|
113
|
+
OperationsReleaseReconcileResponse,
|
|
118
114
|
OperationsReleaseUnavailableReason,
|
|
119
115
|
} from './releases.js'
|
|
120
116
|
export type { OperationsIssueQuery, OperationsReleaseQuery, OperationsRpcContract } from './rpc.js'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const operationsReleaseCoordinateSchema = z.object({
|
|
4
|
+
appId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
|
|
5
|
+
environment: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
|
|
6
|
+
serviceKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,62}$/).optional(),
|
|
7
|
+
}).transform(({ appId, environment, serviceKey }) => ({ appId, environment, ...(serviceKey === undefined ? {} : { serviceKey }) }))
|
|
8
|
+
|
|
9
|
+
export const operationsReleaseAvailabilitySchema = z.discriminatedUnion('kind', [
|
|
10
|
+
z.object({
|
|
11
|
+
kind: z.literal('available'),
|
|
12
|
+
name: z.string().max(512),
|
|
13
|
+
commitSha: z.string().toLowerCase().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/),
|
|
14
|
+
}),
|
|
15
|
+
z.object({ kind: z.literal('unavailable'), reason: z.enum(['dry_run', 'missing_commit']) }),
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export const operationsArtifactUploadCredentialSchema = z.object({
|
|
19
|
+
verifier: z.string().regex(/^[0-9a-f]{64}$/),
|
|
20
|
+
expiresAt: z.number().int().safe(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const operationsReleasePayloadSchema = z.object({
|
|
24
|
+
runId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
|
|
25
|
+
coordinate: operationsReleaseCoordinateSchema,
|
|
26
|
+
phase: z.enum(['started', 'provider_accepted', 'terminal']),
|
|
27
|
+
providerRunId: z.string().max(256).nullable(),
|
|
28
|
+
outcome: z.enum(['succeeded', 'failed']).nullable(),
|
|
29
|
+
artifactState: z.enum(['pending', 'complete', 'incomplete', 'not_applicable']),
|
|
30
|
+
release: operationsReleaseAvailabilitySchema,
|
|
31
|
+
uploadCredential: operationsArtifactUploadCredentialSchema.optional(),
|
|
32
|
+
observedAt: z.number().int().safe().nonnegative(),
|
|
33
|
+
}).superRefine((value, ctx) => {
|
|
34
|
+
if (value.uploadCredential !== undefined && value.uploadCredential.expiresAt <= value.observedAt) {
|
|
35
|
+
ctx.addIssue({ code: 'custom', path: ['uploadCredential'], message: 'invalid upload credential' })
|
|
36
|
+
}
|
|
37
|
+
if (value.release.kind === 'unavailable' && value.uploadCredential !== undefined) {
|
|
38
|
+
ctx.addIssue({ code: 'custom', message: 'unavailable releases cannot accept artifacts' })
|
|
39
|
+
}
|
|
40
|
+
if (value.phase === 'terminal' && value.outcome === null) {
|
|
41
|
+
ctx.addIssue({ code: 'custom', message: 'terminal release has no outcome' })
|
|
42
|
+
}
|
|
43
|
+
if (value.phase !== 'terminal' && value.outcome !== null) {
|
|
44
|
+
ctx.addIssue({ code: 'custom', message: 'non-terminal release has an outcome' })
|
|
45
|
+
}
|
|
46
|
+
}).transform(({ uploadCredential, observedAt, ...payload }) => ({
|
|
47
|
+
...payload,
|
|
48
|
+
...(uploadCredential === undefined ? {} : { uploadCredential }),
|
|
49
|
+
observedAt,
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const releaseRequestHeaderSchema = z.object({ revision: z.number().int().safe().min(1) }).passthrough()
|
|
53
|
+
|
|
54
|
+
export const operationsReleaseRequestSchema = releaseRequestHeaderSchema.transform((value, ctx) => {
|
|
55
|
+
const payload = operationsReleasePayloadSchema.safeParse(value)
|
|
56
|
+
if (!payload.success) {
|
|
57
|
+
for (const issue of payload.error.issues) ctx.addIssue(issue)
|
|
58
|
+
return z.NEVER
|
|
59
|
+
}
|
|
60
|
+
return { revision: value.revision, ...payload.data }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** Preserve the public decoder's error categories and first-failure precedence. */
|
|
64
|
+
export function operationsReleaseIssueMessage(issues: z.ZodIssue[]): string {
|
|
65
|
+
if (
|
|
66
|
+
issues.some((issue) => {
|
|
67
|
+
const field = issue.path[0]
|
|
68
|
+
return issue.code !== 'custom' && (field !== 'coordinate' && field !== 'release' && field !== 'uploadCredential'
|
|
69
|
+
|| ((field === 'coordinate' || field === 'release') && issue.path.length === 1 && issue.code === 'invalid_type'))
|
|
70
|
+
})
|
|
71
|
+
) return 'invalid release request'
|
|
72
|
+
if (issues.some((issue) => issue.path[0] === 'coordinate')) return 'invalid release coordinates'
|
|
73
|
+
const release = issues.find((issue) => issue.path[0] === 'release')
|
|
74
|
+
if (release !== undefined) {
|
|
75
|
+
if (release.code === 'invalid_union_discriminator') return 'invalid release availability'
|
|
76
|
+
return release.path[1] === 'reason' ? 'invalid unavailable release' : 'invalid available release'
|
|
77
|
+
}
|
|
78
|
+
if (issues.some((issue) => issue.path[0] === 'uploadCredential')) return 'invalid upload credential'
|
|
79
|
+
return issues[0]?.message ?? 'invalid release request'
|
|
80
|
+
}
|
package/src/releases.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Private Delivery → Operations release and artifact protocol; both services ship together. */
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_OPERATIONS_SERVICE_KEY } from './catalog.js'
|
|
4
|
+
import { operationsReleaseIssueMessage, operationsReleasePayloadSchema, operationsReleaseRequestSchema } from './release-schemas.js'
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
operationsArtifactUploadCredentialSchema,
|
|
8
|
+
operationsReleaseAvailabilitySchema,
|
|
9
|
+
operationsReleaseCoordinateSchema,
|
|
10
|
+
operationsReleasePayloadSchema,
|
|
11
|
+
operationsReleaseRequestSchema,
|
|
12
|
+
} from './release-schemas.js'
|
|
4
13
|
|
|
5
|
-
export const OPERATIONS_RELEASE_PROTOCOL_VERSION = 1
|
|
6
14
|
export const FABRIKA_RELEASE = 'FABRIKA_RELEASE'
|
|
7
15
|
export const OPERATIONS_RELEASE_RECONCILE_PATH = '/private/releases/reconcile'
|
|
8
16
|
export const OPERATIONS_SOURCE_MAP_UPLOAD_PATH = '/api/artifacts/source-maps/'
|
|
@@ -22,13 +30,13 @@ export type OperationsReleaseOutcome = 'succeeded' | 'failed' | null
|
|
|
22
30
|
export type OperationsArtifactState = 'pending' | 'complete' | 'incomplete' | 'not_applicable'
|
|
23
31
|
export type OperationsReleaseUnavailableReason = 'dry_run' | 'missing_commit'
|
|
24
32
|
|
|
25
|
-
export interface
|
|
33
|
+
export interface OperationsReleaseCoordinate {
|
|
26
34
|
appId: string
|
|
27
35
|
environment: string
|
|
28
36
|
serviceKey?: string
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
export type
|
|
39
|
+
export type OperationsReleaseAvailability =
|
|
32
40
|
| {
|
|
33
41
|
kind: 'available'
|
|
34
42
|
name: string
|
|
@@ -39,33 +47,46 @@ export type OperationsReleaseAvailabilityV1 =
|
|
|
39
47
|
reason: OperationsReleaseUnavailableReason
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
export interface
|
|
50
|
+
export interface OperationsArtifactUploadCredential {
|
|
43
51
|
/** SHA-256 verifier. The bearer itself never crosses the private projection boundary. */
|
|
44
52
|
verifier: string
|
|
45
53
|
expiresAt: number
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
export interface
|
|
49
|
-
protocolVersion: typeof OPERATIONS_RELEASE_PROTOCOL_VERSION
|
|
56
|
+
export interface OperationsReleaseReconcileRequest {
|
|
50
57
|
revision: number
|
|
51
58
|
runId: string
|
|
52
|
-
coordinate:
|
|
59
|
+
coordinate: OperationsReleaseCoordinate
|
|
53
60
|
phase: OperationsReleasePhase
|
|
54
61
|
providerRunId: string | null
|
|
55
62
|
outcome: OperationsReleaseOutcome
|
|
56
63
|
artifactState: OperationsArtifactState
|
|
57
|
-
release:
|
|
58
|
-
uploadCredential?:
|
|
64
|
+
release: OperationsReleaseAvailability
|
|
65
|
+
uploadCredential?: OperationsArtifactUploadCredential
|
|
59
66
|
observedAt: number
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
export type OperationsReleaseProjectionPayload = Omit<OperationsReleaseReconcileRequest, 'revision'>
|
|
70
|
+
|
|
71
|
+
export class OperationsReleaseDecodeError extends Error {}
|
|
72
|
+
|
|
73
|
+
export function parseOperationsReleaseRequest(value: unknown): OperationsReleaseReconcileRequest {
|
|
74
|
+
const result = operationsReleaseRequestSchema.safeParse(value)
|
|
75
|
+
if (!result.success) throw new OperationsReleaseDecodeError(operationsReleaseIssueMessage(result.error.issues))
|
|
76
|
+
return result.data
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseOperationsReleasePayload(value: unknown): OperationsReleaseProjectionPayload {
|
|
80
|
+
const result = operationsReleasePayloadSchema.safeParse(value)
|
|
81
|
+
if (!result.success) throw new OperationsReleaseDecodeError(operationsReleaseIssueMessage(result.error.issues))
|
|
82
|
+
return result.data
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
export type OperationsReleaseReconcileOutcome = 'applied' | 'unchanged' | 'stale'
|
|
63
86
|
|
|
64
|
-
export interface
|
|
65
|
-
protocolVersion: typeof OPERATIONS_RELEASE_PROTOCOL_VERSION
|
|
87
|
+
export interface OperationsReleaseReconcileResponse {
|
|
66
88
|
revision: number
|
|
67
89
|
outcome: OperationsReleaseReconcileOutcome
|
|
68
|
-
releaseId: string | null
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export interface OperationsArtifactUploadConfiguration {
|