@fungi.computer/puffball 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +443 -2
- package/dist/app-api.d.ts +7 -0
- package/dist/app-api.js +61 -0
- package/dist/app-backend-binding-vocabulary.d.ts +299 -0
- package/dist/app-backend-binding-vocabulary.js +257 -0
- package/dist/app-backend-bindings.d.ts +21 -0
- package/dist/app-backend-bindings.js +47 -0
- package/dist/app-declaration.d.ts +46 -0
- package/dist/app-declaration.js +115 -0
- package/dist/app-event-facts.d.ts +40 -0
- package/dist/app-event-facts.js +28 -0
- package/dist/app-method-context.d.ts +109 -0
- package/dist/app-method-context.js +9 -0
- package/dist/app-method-runtime.d.ts +86 -0
- package/dist/app-method-runtime.js +122 -0
- package/dist/app-origin-paths.d.ts +6 -0
- package/dist/app-origin-paths.js +6 -0
- package/dist/app-vocabulary.d.ts +189 -0
- package/dist/app-vocabulary.js +103 -0
- package/dist/app.d.ts +379 -0
- package/dist/app.js +71 -0
- package/dist/artifact-dispositions.d.ts +115 -0
- package/dist/artifact-dispositions.js +234 -0
- package/dist/assets.d.ts +54 -0
- package/dist/assets.js +89 -0
- package/dist/backend.d.ts +41 -0
- package/dist/backend.js +29 -0
- package/dist/background-replay.d.ts +115 -0
- package/dist/background-replay.js +175 -0
- package/dist/buyer-references.d.ts +69 -0
- package/dist/buyer-references.js +154 -0
- package/dist/cloudflare-assets.d.ts +48 -0
- package/dist/cloudflare-assets.js +105 -0
- package/dist/cloudflare.d.ts +254 -0
- package/dist/cloudflare.js +1619 -0
- package/dist/effect.d.ts +73 -0
- package/dist/effect.js +325 -0
- package/dist/git-representation.d.ts +13 -0
- package/dist/git-representation.js +16 -0
- package/dist/inbox.d.ts +132 -0
- package/dist/inbox.js +81 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +85 -0
- package/dist/internal/app-description.d.ts +14 -0
- package/dist/internal/app-description.js +98 -0
- package/dist/internal/app-release-artifact-reader.d.ts +26 -0
- package/dist/internal/app-release-artifact-reader.js +1 -0
- package/dist/internal/app-release-contract.d.ts +540 -0
- package/dist/internal/app-release-contract.js +169 -0
- package/dist/internal/app-release-schema.d.ts +1676 -0
- package/dist/internal/app-release-schema.js +386 -0
- package/dist/internal/app-release-store.d.ts +47 -0
- package/dist/internal/app-release-store.js +1 -0
- package/dist/internal/background-sync.d.ts +7 -0
- package/dist/internal/background-sync.js +10 -0
- package/dist/internal/deterministic-memory-app-release-store.d.ts +14 -0
- package/dist/internal/deterministic-memory-app-release-store.js +319 -0
- package/dist/internal/failure.d.ts +2 -0
- package/dist/internal/failure.js +9 -0
- package/dist/internal/immutable-release-material.d.ts +3 -0
- package/dist/internal/immutable-release-material.js +13 -0
- package/dist/internal/promise-app-release-store-adapter.d.ts +9 -0
- package/dist/internal/promise-app-release-store-adapter.js +65 -0
- package/dist/internal/promise-artifact-reader-adapter.d.ts +9 -0
- package/dist/internal/promise-artifact-reader-adapter.js +18 -0
- package/dist/internal/public-route-sql.d.ts +10 -0
- package/dist/internal/public-route-sql.js +239 -0
- package/dist/internal/public-website-authority.d.ts +338 -0
- package/dist/internal/public-website-authority.js +50 -0
- package/dist/internal/public-website-delivery.d.ts +59 -0
- package/dist/internal/public-website-delivery.js +232 -0
- package/dist/internal/public-website-fetch.d.ts +8 -0
- package/dist/internal/public-website-fetch.js +54 -0
- package/dist/internal/release-transition-material.d.ts +3 -0
- package/dist/internal/release-transition-material.js +11 -0
- package/dist/internal/verified-artifact-snapshot.d.ts +41 -0
- package/dist/internal/verified-artifact-snapshot.js +91 -0
- package/dist/public-route-moderation.d.ts +83 -0
- package/dist/public-route-moderation.js +236 -0
- package/dist/public-route-policy.d.ts +51 -0
- package/dist/public-route-policy.js +88 -0
- package/dist/public-routes.d.ts +34 -0
- package/dist/public-routes.js +136 -0
- package/dist/publisher.d.ts +61 -0
- package/dist/publisher.js +16 -0
- package/dist/safety-takedown.d.ts +56 -0
- package/dist/safety-takedown.js +146 -0
- package/dist/seller-closure-notice.d.ts +14 -0
- package/dist/seller-closure-notice.js +14 -0
- package/dist/seller-closure.d.ts +15 -0
- package/dist/seller-closure.js +31 -0
- package/dist/testing.d.ts +8 -0
- package/dist/testing.js +47 -0
- package/docs/api/README.md +26 -0
- package/docs/api/app-backend-binding-vocabulary.md +276 -0
- package/docs/api/app-backend-bindings.md +141 -0
- package/docs/api/app-event-facts.md +110 -0
- package/docs/api/app-method-context.md +166 -0
- package/docs/api/app-method-runtime.md +330 -0
- package/docs/api/backend.md +108 -0
- package/docs/api/background-replay.md +516 -0
- package/docs/api/cloudflare.md +1044 -0
- package/docs/api/effect.md +2429 -0
- package/docs/api/git-representation.md +56 -0
- package/docs/api/inbox.md +289 -0
- package/docs/api/index.md +2868 -0
- package/docs/api/publisher.md +147 -0
- package/docs/api/testing.md +31 -0
- package/package.json +158 -3
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
import { artifactDispositionSchemaSql, artifactDispositionReadbackSchema, createD1ArtifactDispositions, } from "./artifact-dispositions.js";
|
|
2
|
+
import { buyerReferenceSchemaSql, createD1BuyerReferences, } from "./buyer-references.js";
|
|
3
|
+
import { publicSurfaceTableSql, publicRouteSchemaSql, platformOwnerReplaySchemaSql, insertPublicSurfaceSql, } from "./internal/public-route-sql.js";
|
|
4
|
+
import { publicRouteKey, publicRoutePolicy } from "./public-route-policy.js";
|
|
5
|
+
import { safetyTakedownSchemaSql } from "./safety-takedown.js";
|
|
6
|
+
import { assetBuildMediaType } from "./assets.js";
|
|
7
|
+
import { decodeAssetBuild } from "./assets.js";
|
|
8
|
+
import { appMediaType } from "./app.js";
|
|
9
|
+
import * as Schema from "effect/Schema";
|
|
10
|
+
import { createArtifactSnapshotPolicy, DEFAULT_ARTIFACT_SNAPSHOT_POLICY, } from "./internal/verified-artifact-snapshot.js";
|
|
11
|
+
import { parseAppId, parseAppTransitionId, parseArtifactRef, parseIdempotencyKey, parseReleaseId, parseSha256Digest, parseSourceRevision, parsePublicSurfaceId, parseStableRoute, } from "./index.js";
|
|
12
|
+
import { sameReleaseTransitionMaterial } from "./internal/release-transition-material.js";
|
|
13
|
+
import { teamAppPublisherRegistrationSchema, } from "./publisher.js";
|
|
14
|
+
import { registerPromisePublicWebsiteAuthorityReader } from "./internal/public-website-authority.js";
|
|
15
|
+
import { reportFailure } from "./internal/failure.js";
|
|
16
|
+
import { appIdSchema, appErasureIdCodec, artifactMediaTypeSchema, appTransitionIdSchema, publicSurfaceIdSchema, releaseIdSchema, stableRouteSchema, sourceRevisionSchema, verifiedAppDescriptionSchema, } from "./internal/app-release-schema.js";
|
|
17
|
+
const transitionObligationReadOptionsSchema = Schema.Struct({
|
|
18
|
+
afterSequence: Schema.optional(Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThanOrEqualTo(0)))),
|
|
19
|
+
limit: Schema.optional(Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isBetween({ minimum: 1, maximum: 256 })))),
|
|
20
|
+
});
|
|
21
|
+
const teamRecipientIdSchema = Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(256)));
|
|
22
|
+
const opaqueCursorSchema = Schema.String.pipe(Schema.check(Schema.isMaxLength(4096)));
|
|
23
|
+
const teamRecipientPageSchema = Schema.Struct({
|
|
24
|
+
cursor: Schema.optional(Schema.NullOr(opaqueCursorSchema)),
|
|
25
|
+
nextCursor: Schema.optional(Schema.NullOr(opaqueCursorSchema)),
|
|
26
|
+
teamIds: Schema.Array(teamRecipientIdSchema).pipe(Schema.check(Schema.isMaxLength(256)), Schema.check(Schema.makeFilter((teamIds) => new Set(teamIds).size === teamIds.length, {
|
|
27
|
+
message: "Team recipient page contains duplicate IDs",
|
|
28
|
+
}))),
|
|
29
|
+
complete: Schema.Boolean,
|
|
30
|
+
}).pipe(Schema.check(Schema.makeFilter((page) => page.complete ===
|
|
31
|
+
(page.nextCursor === null || page.nextCursor === undefined), {
|
|
32
|
+
message: "Team recipient page completion must match its next cursor",
|
|
33
|
+
})));
|
|
34
|
+
const pendingTeamRecipientsReadOptionsSchema = Schema.Struct({
|
|
35
|
+
sequence: Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0))),
|
|
36
|
+
limit: Schema.optional(Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isBetween({ minimum: 1, maximum: 256 })))),
|
|
37
|
+
});
|
|
38
|
+
const teamRecipientOutcomeSchema = Schema.Literals([
|
|
39
|
+
"applied",
|
|
40
|
+
"no-op",
|
|
41
|
+
"expired",
|
|
42
|
+
]);
|
|
43
|
+
const teamRecipientStateSchema = Schema.Literals([
|
|
44
|
+
"pending",
|
|
45
|
+
"applied",
|
|
46
|
+
"no-op",
|
|
47
|
+
"expired",
|
|
48
|
+
]);
|
|
49
|
+
const teamIdsJsonSchema = Schema.fromJsonString(Schema.Array(teamRecipientIdSchema).pipe(Schema.check(Schema.isMaxLength(256))));
|
|
50
|
+
const transitionObligationValueSchema = Schema.Struct({
|
|
51
|
+
sequence: Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0))),
|
|
52
|
+
transitionId: appTransitionIdSchema,
|
|
53
|
+
affectedPublicSurfaceIds: Schema.Array(publicSurfaceIdSchema),
|
|
54
|
+
affectedSurfaces: Schema.Array(Schema.Struct({
|
|
55
|
+
publicSurfaceId: publicSurfaceIdSchema,
|
|
56
|
+
appId: appIdSchema,
|
|
57
|
+
stableRoute: stableRouteSchema,
|
|
58
|
+
})),
|
|
59
|
+
affectedBuyerApps: Schema.Array(appIdSchema).pipe(Schema.check(Schema.isMaxLength(1))),
|
|
60
|
+
enumeration: Schema.Struct({
|
|
61
|
+
cursor: Schema.NullOr(opaqueCursorSchema),
|
|
62
|
+
complete: Schema.Boolean,
|
|
63
|
+
}),
|
|
64
|
+
state: Schema.Literals(["pending", "acknowledged"]),
|
|
65
|
+
});
|
|
66
|
+
const decodeTransitionObligationReadOptions = Schema.decodeUnknownResult(transitionObligationReadOptionsSchema, { onExcessProperty: "error" });
|
|
67
|
+
const decodeTeamRecipientPage = Schema.decodeUnknownResult(teamRecipientPageSchema, { onExcessProperty: "error" });
|
|
68
|
+
const decodePendingTeamRecipientsReadOptions = Schema.decodeUnknownResult(pendingTeamRecipientsReadOptionsSchema, { onExcessProperty: "error" });
|
|
69
|
+
const appRowSchema = Schema.Struct({ app_id: Schema.String });
|
|
70
|
+
const releaseRowSchema = Schema.Struct({
|
|
71
|
+
release_id: Schema.String,
|
|
72
|
+
app_id: Schema.String,
|
|
73
|
+
artifact_ref: Schema.String,
|
|
74
|
+
artifact_digest: Schema.String,
|
|
75
|
+
source_revision: Schema.String,
|
|
76
|
+
source_tree_digest: Schema.String,
|
|
77
|
+
build_recipe_digest: Schema.String,
|
|
78
|
+
});
|
|
79
|
+
const releaseLifecycleRowSchema = Schema.Struct({
|
|
80
|
+
...releaseRowSchema.fields,
|
|
81
|
+
verification_digest: Schema.NullOr(Schema.String),
|
|
82
|
+
verification_media_type: Schema.NullOr(Schema.String),
|
|
83
|
+
verification_metadata_digest: Schema.NullOr(Schema.String),
|
|
84
|
+
verification_description_json: Schema.NullOr(Schema.String),
|
|
85
|
+
release_is_revoked: Schema.Literals([0, 1]),
|
|
86
|
+
});
|
|
87
|
+
const releaseVerificationMetadataRowSchema = Schema.Struct({
|
|
88
|
+
verification_artifact_digest: Schema.String,
|
|
89
|
+
media_type: Schema.String,
|
|
90
|
+
metadata_artifact_digest: Schema.String,
|
|
91
|
+
description_json: Schema.String,
|
|
92
|
+
});
|
|
93
|
+
const publicSurfaceRowSchema = Schema.Struct({
|
|
94
|
+
public_surface_id: Schema.String,
|
|
95
|
+
app_id: Schema.String,
|
|
96
|
+
stable_route: Schema.String,
|
|
97
|
+
});
|
|
98
|
+
const publicSurfaceDefinitionRowSchema = Schema.Struct({
|
|
99
|
+
...publicSurfaceRowSchema.fields,
|
|
100
|
+
publisher_team_id: Schema.String,
|
|
101
|
+
publisher_user_id: Schema.String,
|
|
102
|
+
authority_kind: Schema.Literals(["customer", "platform"]),
|
|
103
|
+
request_key: Schema.String,
|
|
104
|
+
});
|
|
105
|
+
const publicWebsiteAuthorityRowSchema = Schema.Struct({
|
|
106
|
+
app_id: Schema.NullOr(Schema.String),
|
|
107
|
+
public_surface_id: Schema.String,
|
|
108
|
+
surface_app_id: Schema.String,
|
|
109
|
+
stable_route: Schema.String,
|
|
110
|
+
selected_release_id: Schema.NullOr(Schema.String),
|
|
111
|
+
current_release_id: Schema.NullOr(Schema.String),
|
|
112
|
+
selected_transition_sequence: Schema.NullOr(Schema.Number),
|
|
113
|
+
release_id: Schema.NullOr(Schema.String),
|
|
114
|
+
release_app_id: Schema.NullOr(Schema.String),
|
|
115
|
+
artifact_ref: Schema.NullOr(Schema.String),
|
|
116
|
+
artifact_digest: Schema.NullOr(Schema.String),
|
|
117
|
+
source_revision: Schema.NullOr(Schema.String),
|
|
118
|
+
source_tree_digest: Schema.NullOr(Schema.String),
|
|
119
|
+
build_recipe_digest: Schema.NullOr(Schema.String),
|
|
120
|
+
verification_digest: Schema.NullOr(Schema.String),
|
|
121
|
+
verification_media_type: Schema.NullOr(Schema.String),
|
|
122
|
+
verification_metadata_digest: Schema.NullOr(Schema.String),
|
|
123
|
+
verification_description_json: Schema.NullOr(Schema.String),
|
|
124
|
+
release_is_revoked: Schema.Number,
|
|
125
|
+
});
|
|
126
|
+
const transitionRowSchema = Schema.Struct({
|
|
127
|
+
transition_id: Schema.String,
|
|
128
|
+
operation_kind: Schema.String,
|
|
129
|
+
idempotency_key: Schema.String,
|
|
130
|
+
release_id: Schema.String,
|
|
131
|
+
public_surface_id: Schema.NullOr(Schema.String),
|
|
132
|
+
});
|
|
133
|
+
const transitionObligationRowSchema = Schema.Struct({
|
|
134
|
+
sequence: Schema.Number,
|
|
135
|
+
transition_id: Schema.String,
|
|
136
|
+
operation_kind: Schema.String,
|
|
137
|
+
idempotency_key: Schema.String,
|
|
138
|
+
release_id: Schema.String,
|
|
139
|
+
public_surface_id: Schema.NullOr(Schema.String),
|
|
140
|
+
state: Schema.String,
|
|
141
|
+
});
|
|
142
|
+
const transitionObligationEnumerationRowSchema = Schema.Struct({
|
|
143
|
+
sequence: Schema.Number,
|
|
144
|
+
cursor: Schema.NullOr(opaqueCursorSchema),
|
|
145
|
+
started: Schema.Number,
|
|
146
|
+
complete: Schema.Number,
|
|
147
|
+
});
|
|
148
|
+
const transitionObligationPageRowSchema = Schema.Struct({
|
|
149
|
+
sequence: Schema.Number,
|
|
150
|
+
cursor: Schema.NullOr(opaqueCursorSchema),
|
|
151
|
+
cursor_is_initial: Schema.Number,
|
|
152
|
+
next_cursor: Schema.NullOr(Schema.String),
|
|
153
|
+
complete: Schema.Number,
|
|
154
|
+
team_ids_json: Schema.String,
|
|
155
|
+
});
|
|
156
|
+
const transitionObligationRecipientRowSchema = Schema.Struct({
|
|
157
|
+
team_id: teamRecipientIdSchema,
|
|
158
|
+
state: teamRecipientStateSchema,
|
|
159
|
+
});
|
|
160
|
+
const erasureAppRow = Schema.Struct({ app_id: Schema.String });
|
|
161
|
+
const erasurePublisherRow = Schema.Struct({
|
|
162
|
+
app_id: Schema.String,
|
|
163
|
+
source_provider: Schema.Literals(["artifacts", "github"]),
|
|
164
|
+
source_repository: Schema.String,
|
|
165
|
+
});
|
|
166
|
+
const erasureReleaseRow = Schema.Struct({
|
|
167
|
+
description_json: Schema.NullOr(Schema.String),
|
|
168
|
+
app_id: Schema.String,
|
|
169
|
+
artifact_ref: Schema.String,
|
|
170
|
+
media_type: Schema.NullOr(Schema.String),
|
|
171
|
+
});
|
|
172
|
+
const erasureJobRow = Schema.Struct({
|
|
173
|
+
app_id: Schema.String,
|
|
174
|
+
object_keys_json: Schema.String,
|
|
175
|
+
repository_name: Schema.NullOr(Schema.String),
|
|
176
|
+
});
|
|
177
|
+
/** Read every catalog reference before deleting content-addressed R2 objects. */
|
|
178
|
+
export function createTeamAppReleaseErasure(database, bucket, assetPolicy, now = Date.now) {
|
|
179
|
+
const rows = async (sql, schema, ...values) => {
|
|
180
|
+
const result = await database
|
|
181
|
+
.prepare(sql)
|
|
182
|
+
.bind(...values)
|
|
183
|
+
.all();
|
|
184
|
+
if (!result.success)
|
|
185
|
+
throw new Error("App Release erasure read failed");
|
|
186
|
+
return result.results.map((row) => Schema.decodeUnknownSync(schema)(row, { onExcessProperty: "error" }));
|
|
187
|
+
};
|
|
188
|
+
const catalogReferences = async (retainedForSeller = null) => {
|
|
189
|
+
const releases = await rows(`SELECT release.app_id,release.artifact_ref,format.media_type,metadata.description_json
|
|
190
|
+
FROM app_release_releases AS release
|
|
191
|
+
LEFT JOIN app_release_release_verification_formats AS format USING(release_id)
|
|
192
|
+
LEFT JOIN app_release_release_verification_metadata AS metadata USING(release_id)
|
|
193
|
+
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=release.release_id AND b.buyer_team_id<>?))
|
|
194
|
+
ORDER BY release.release_id LIMIT 10001`, erasureReleaseRow, retainedForSeller, retainedForSeller);
|
|
195
|
+
if (releases.length > 10_000)
|
|
196
|
+
throw new Error("App Release erasure reference inventory exceeds bound");
|
|
197
|
+
const references = new Map();
|
|
198
|
+
for (const release of releases) {
|
|
199
|
+
const owned = references.get(release.artifact_ref) ?? new Set();
|
|
200
|
+
owned.add(release.app_id);
|
|
201
|
+
references.set(release.artifact_ref, owned);
|
|
202
|
+
const description = release.description_json === null
|
|
203
|
+
? null
|
|
204
|
+
: Schema.decodeUnknownSync(Schema.fromJsonString(verifiedAppDescriptionSchema))(release.description_json);
|
|
205
|
+
const children = description === null
|
|
206
|
+
? []
|
|
207
|
+
: [description.ui, description.backend?.artifact ?? null].filter((child) => child !== null);
|
|
208
|
+
for (const child of children) {
|
|
209
|
+
const owners = references.get(child.artifactRef) ?? new Set();
|
|
210
|
+
owners.add(release.app_id);
|
|
211
|
+
references.set(child.artifactRef, owners);
|
|
212
|
+
}
|
|
213
|
+
const manifests = [
|
|
214
|
+
...(release.media_type === assetBuildMediaType
|
|
215
|
+
? [release.artifact_ref]
|
|
216
|
+
: []),
|
|
217
|
+
...children
|
|
218
|
+
.filter((child) => child.mediaType === assetBuildMediaType)
|
|
219
|
+
.map((child) => child.artifactRef),
|
|
220
|
+
];
|
|
221
|
+
for (const manifest of manifests) {
|
|
222
|
+
if (!assetPolicy)
|
|
223
|
+
throw new Error("App Release asset policy required for erasure inventory");
|
|
224
|
+
const object = await bucket.get(manifest);
|
|
225
|
+
if (!object || object.size > assetPolicy.maxManifestBytes)
|
|
226
|
+
throw new Error("App Release asset manifest is unavailable");
|
|
227
|
+
const build = decodeAssetBuild(new Uint8Array(await object.arrayBuffer()), assetPolicy);
|
|
228
|
+
for (const asset of build.assets) {
|
|
229
|
+
const owners = references.get(asset.artifactRef) ?? new Set();
|
|
230
|
+
owners.add(release.app_id);
|
|
231
|
+
references.set(asset.artifactRef, owners);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return references;
|
|
236
|
+
};
|
|
237
|
+
const jobs = (teamId) => rows("SELECT app_id,object_keys_json,repository_name FROM app_release_erasure_jobs WHERE team_id=? ORDER BY app_id", erasureJobRow, teamId);
|
|
238
|
+
return {
|
|
239
|
+
/** Reject any live App or R2 key outside this Team's current or retry scope. */
|
|
240
|
+
async verifyManifest(teamId, appIds, keys) {
|
|
241
|
+
const plan = await this.preflight(teamId);
|
|
242
|
+
const plannedIds = new Set(plan.apps.map(({ appId }) => appId));
|
|
243
|
+
const plannedKeys = new Set(plan.apps.flatMap(({ keys: appKeys }) => appKeys));
|
|
244
|
+
if (plan.apps.some(({ appId }) => !appIds.includes(appId)))
|
|
245
|
+
throw new Error("App Release manifest omits a Team App");
|
|
246
|
+
for (const appId of appIds) {
|
|
247
|
+
if (plannedIds.has(appId))
|
|
248
|
+
continue;
|
|
249
|
+
if ((await rows("SELECT app_id FROM app_release_apps WHERE app_id=?", erasureAppRow, appId)).length)
|
|
250
|
+
throw new Error("App Release manifest names an unscoped App");
|
|
251
|
+
}
|
|
252
|
+
const references = await catalogReferences();
|
|
253
|
+
for (const key of keys) {
|
|
254
|
+
if (plannedKeys.has(key))
|
|
255
|
+
continue;
|
|
256
|
+
if (references.has(key) || (await bucket.head(key)))
|
|
257
|
+
throw new Error("App Release manifest names an unscoped R2 object");
|
|
258
|
+
}
|
|
259
|
+
return plan;
|
|
260
|
+
},
|
|
261
|
+
/** Inventory Team catalog rows, exclusive objects, and source repositories. */
|
|
262
|
+
async preflight(teamId) {
|
|
263
|
+
const scope = await rows("SELECT app_id FROM app_release_erasure_scope", erasureAppRow);
|
|
264
|
+
if (scope.length !== 0)
|
|
265
|
+
throw new Error("App Release erasure scope is not empty");
|
|
266
|
+
const apps = await rows("SELECT app_id,source_provider,source_repository FROM app_release_team_publishers WHERE team_id=? ORDER BY app_id", erasurePublisherRow, teamId);
|
|
267
|
+
const references = await catalogReferences();
|
|
268
|
+
const retainedReferences = await catalogReferences(teamId);
|
|
269
|
+
const pending = await jobs(teamId);
|
|
270
|
+
const appIds = [
|
|
271
|
+
...new Set([...apps, ...pending].map(({ app_id }) => app_id)),
|
|
272
|
+
];
|
|
273
|
+
const planned = appIds.map((appId) => {
|
|
274
|
+
const existing = pending.find((row) => row.app_id === appId);
|
|
275
|
+
const keys = existing
|
|
276
|
+
? Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String)))(existing.object_keys_json)
|
|
277
|
+
: [...references.entries()]
|
|
278
|
+
.filter(([key, owners]) => owners.has(appId) &&
|
|
279
|
+
owners.size === 1 &&
|
|
280
|
+
!retainedReferences.has(key))
|
|
281
|
+
.map(([key]) => key)
|
|
282
|
+
.sort();
|
|
283
|
+
return { appId, keys };
|
|
284
|
+
});
|
|
285
|
+
for (const app of planned)
|
|
286
|
+
for (const key of app.keys)
|
|
287
|
+
if (!(await bucket.head(key)) &&
|
|
288
|
+
apps.some((row) => row.app_id === app.appId))
|
|
289
|
+
throw new Error("App Release R2 object is missing before erasure");
|
|
290
|
+
const repositories = [
|
|
291
|
+
...new Set([
|
|
292
|
+
...apps
|
|
293
|
+
.filter((app) => app.source_provider === "artifacts")
|
|
294
|
+
.map((app) => app.source_repository),
|
|
295
|
+
...pending.flatMap((job) => job.repository_name === null ? [] : [job.repository_name]),
|
|
296
|
+
]),
|
|
297
|
+
].sort();
|
|
298
|
+
for (const repository of repositories) {
|
|
299
|
+
const users = await rows("SELECT app_id FROM app_release_team_publishers WHERE source_provider='artifacts' AND source_repository=?", erasureAppRow, repository);
|
|
300
|
+
if (users.some(({ app_id }) => !appIds.includes(app_id)))
|
|
301
|
+
throw new Error("Artifacts repository is shared with another Team");
|
|
302
|
+
const children = await rows("SELECT repository_name AS app_id FROM publisher_artifacts_repositories WHERE parent_repository=?", erasureAppRow, repository);
|
|
303
|
+
if (children.length)
|
|
304
|
+
throw new Error("Artifacts repository has descendant forks");
|
|
305
|
+
}
|
|
306
|
+
return { teamId, apps: planned, repositories };
|
|
307
|
+
},
|
|
308
|
+
/** Remove one Team's catalog and exclusive objects, resuming pending jobs. */
|
|
309
|
+
async erase(teamId) {
|
|
310
|
+
const plan = await this.preflight(teamId);
|
|
311
|
+
const teamErasureId = appErasureIdCodec.generate();
|
|
312
|
+
const obligationCleanup = await database.batch([
|
|
313
|
+
database
|
|
314
|
+
.prepare("DELETE FROM app_release_team_route_blocks WHERE team_id=?")
|
|
315
|
+
.bind(teamId),
|
|
316
|
+
database
|
|
317
|
+
.prepare("UPDATE app_release_route_operation_steps SET operator_id=? WHERE operation_id IN (SELECT operation_id FROM app_release_route_operations WHERE team_id=?)")
|
|
318
|
+
.bind(teamErasureId, teamId),
|
|
319
|
+
database
|
|
320
|
+
.prepare("UPDATE app_release_route_operations SET team_id=?,initiated_by=?,reason='Erased',state=CASE WHEN state='pending' THEN 'superseded-by-erasure' ELSE state END WHERE team_id=?")
|
|
321
|
+
.bind(teamErasureId, teamErasureId, teamId),
|
|
322
|
+
database
|
|
323
|
+
.prepare(`UPDATE app_release_artifact_notice_recipients
|
|
324
|
+
SET buyer_team_id=NULL,receipt=NULL,erased_by=COALESCE(erased_by,'team-erasure'),erased_at=COALESCE(erased_at,?)
|
|
325
|
+
WHERE buyer_team_id=?`)
|
|
326
|
+
.bind(now(), teamId),
|
|
327
|
+
database
|
|
328
|
+
.prepare("DELETE FROM app_release_buyer_references WHERE buyer_team_id=?")
|
|
329
|
+
.bind(teamId),
|
|
330
|
+
database
|
|
331
|
+
.prepare("DELETE FROM app_release_transition_obligation_teams WHERE team_id=?")
|
|
332
|
+
.bind(teamId),
|
|
333
|
+
database
|
|
334
|
+
.prepare(`UPDATE app_release_transition_obligation_pages
|
|
335
|
+
SET team_ids_json=(SELECT json_group_array(value) FROM json_each(team_ids_json) WHERE value<>?)
|
|
336
|
+
WHERE EXISTS (SELECT 1 FROM json_each(team_ids_json) WHERE value=?)`)
|
|
337
|
+
.bind(teamId, teamId),
|
|
338
|
+
]);
|
|
339
|
+
if (obligationCleanup.some((result) => !result.success))
|
|
340
|
+
throw new Error("App Release Team obligation erasure failed");
|
|
341
|
+
for (const app of plan.apps) {
|
|
342
|
+
const existing = (await jobs(teamId)).find((row) => row.app_id === app.appId);
|
|
343
|
+
if (!existing) {
|
|
344
|
+
const statement = (sql, ...values) => database.prepare(sql).bind(...values);
|
|
345
|
+
const appId = app.appId;
|
|
346
|
+
const erasureId = appErasureIdCodec.generate();
|
|
347
|
+
const erasedAtMs = now();
|
|
348
|
+
const erasedAt = Math.floor(erasedAtMs / 1000);
|
|
349
|
+
const transaction = [
|
|
350
|
+
statement("INSERT INTO app_release_erasure_jobs(app_id,team_id,object_keys_json,repository_name) SELECT app_id,?,?,CASE WHEN source_provider='artifacts' THEN source_repository ELSE NULL END FROM app_release_team_publishers WHERE app_id=? AND team_id=?", teamId, JSON.stringify(app.keys), appId, teamId),
|
|
351
|
+
statement("INSERT INTO app_release_erasure_scope(singleton,app_id) SELECT 1,app_id FROM app_release_team_publishers WHERE team_id=? AND app_id=?", teamId, appId),
|
|
352
|
+
statement(`INSERT INTO app_release_artifact_dispositions(disposition_id,app_id,release_ids_json,artifact_roots_json,created_at,state)
|
|
353
|
+
SELECT ?,?,json_group_array(DISTINCT r.release_id),json_group_array(DISTINCT r.artifact_ref),?,'pending'
|
|
354
|
+
FROM app_release_releases r WHERE r.app_id=? AND EXISTS (
|
|
355
|
+
SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=r.release_id AND b.buyer_team_id<>?)
|
|
356
|
+
HAVING count(*)>0 ON CONFLICT DO NOTHING`, erasureId, appId, erasedAtMs, appId, teamId),
|
|
357
|
+
statement(`INSERT INTO app_release_artifact_notice_recipients(disposition_id,buyer_team_id)
|
|
358
|
+
SELECT d.disposition_id,b.buyer_team_id FROM app_release_artifact_dispositions d
|
|
359
|
+
JOIN app_release_buyer_references b ON b.app_id=d.app_id
|
|
360
|
+
WHERE d.app_id=? AND b.buyer_team_id<>? AND d.state='pending'
|
|
361
|
+
ON CONFLICT DO NOTHING`, appId, teamId),
|
|
362
|
+
statement(`INSERT INTO app_release_route_holds(route_key,stable_route,until_s,cause,operation_id)
|
|
363
|
+
SELECT s.route_key,s.stable_route,?,'erasure',? FROM app_release_public_surfaces s
|
|
364
|
+
WHERE s.app_id=? AND (
|
|
365
|
+
EXISTS (SELECT 1 FROM app_release_route_claims c WHERE c.public_surface_id=s.public_surface_id)
|
|
366
|
+
OR EXISTS (SELECT 1 FROM app_release_surface_retirements x WHERE x.public_surface_id=s.public_surface_id))
|
|
367
|
+
AND NOT EXISTS (SELECT 1 FROM app_release_route_claims c WHERE c.route_key=s.route_key AND c.public_surface_id<>s.public_surface_id)
|
|
368
|
+
ON CONFLICT(route_key) DO UPDATE SET until_s=max(until_s,excluded.until_s),cause='erasure',operation_id=excluded.operation_id`, erasedAt + publicRoutePolicy.erasureTombstoneSeconds, erasureId, appId),
|
|
369
|
+
statement("DELETE FROM app_release_route_claims WHERE public_surface_id IN (SELECT public_surface_id FROM app_release_public_surfaces WHERE app_id=?)", appId),
|
|
370
|
+
statement("DELETE FROM app_release_surface_retirements WHERE public_surface_id IN (SELECT public_surface_id FROM app_release_public_surfaces WHERE app_id=?)", appId),
|
|
371
|
+
statement("DELETE FROM app_release_team_route_blocks WHERE team_id=?", teamId),
|
|
372
|
+
statement("UPDATE app_release_route_operation_steps SET operator_id=? WHERE operation_id IN (SELECT operation_id FROM app_release_route_operations WHERE team_id=? OR app_id=?)", erasureId, teamId, appId),
|
|
373
|
+
statement("UPDATE app_release_route_operations SET team_id=?,initiated_by=?,reason='Erased',state=CASE WHEN state='pending' THEN 'superseded-by-erasure' ELSE state END WHERE team_id=? OR app_id=?", erasureId, erasureId, teamId, appId),
|
|
374
|
+
statement("DELETE FROM app_release_transition_obligation_teams WHERE sequence IN (SELECT transition.sequence FROM app_release_transitions AS transition JOIN app_release_releases AS release ON release.release_id=transition.release_id WHERE release.app_id=? AND (transition.operation_kind<>'revocation' OR NOT EXISTS (SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=transition.release_id)))", appId),
|
|
375
|
+
statement("DELETE FROM app_release_platform_owner_replays WHERE app_id=?", appId),
|
|
376
|
+
...[
|
|
377
|
+
"app_release_transition_obligation_pages",
|
|
378
|
+
"app_release_transition_obligation_enumeration",
|
|
379
|
+
"app_release_transition_obligations",
|
|
380
|
+
].map((table) => statement(`DELETE FROM ${table} WHERE sequence IN (SELECT transition.sequence FROM app_release_transitions AS transition JOIN app_release_releases AS release ON release.release_id=transition.release_id WHERE release.app_id=? AND (transition.operation_kind<>'revocation' OR NOT EXISTS (SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=transition.release_id)))`, appId)),
|
|
381
|
+
statement("DELETE FROM app_release_transitions WHERE release_id IN (SELECT release_id FROM app_release_releases WHERE app_id=?) AND (operation_kind<>'revocation' OR NOT EXISTS (SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=app_release_transitions.release_id))", appId),
|
|
382
|
+
...[
|
|
383
|
+
"app_release_release_verification_metadata",
|
|
384
|
+
"app_release_release_verification_formats",
|
|
385
|
+
"app_release_release_verifications",
|
|
386
|
+
].map((table) => statement(`DELETE FROM ${table} WHERE release_id IN (SELECT release_id FROM app_release_releases WHERE app_id=?) AND NOT EXISTS (SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=${table}.release_id)`, appId)),
|
|
387
|
+
statement("DELETE FROM app_release_public_surfaces WHERE app_id=?", appId),
|
|
388
|
+
statement("DELETE FROM publisher_artifacts_repositories_pending WHERE repository_name=(SELECT source_repository FROM app_release_team_publishers WHERE app_id=? AND source_provider='artifacts')", appId),
|
|
389
|
+
statement("DELETE FROM publisher_artifacts_repositories WHERE repository_name=(SELECT source_repository FROM app_release_team_publishers WHERE app_id=? AND source_provider='artifacts')", appId),
|
|
390
|
+
statement("DELETE FROM app_release_team_publishers WHERE app_id=?", appId),
|
|
391
|
+
statement("DELETE FROM app_release_releases WHERE app_id=? AND NOT EXISTS (SELECT 1 FROM app_release_buyer_references b WHERE b.release_id=app_release_releases.release_id)", appId),
|
|
392
|
+
statement("DELETE FROM app_release_apps WHERE app_id=? AND NOT EXISTS (SELECT 1 FROM app_release_releases r WHERE r.app_id=app_release_apps.app_id) AND NOT EXISTS (SELECT 1 FROM app_release_artifact_dispositions d WHERE d.app_id=app_release_apps.app_id)", appId),
|
|
393
|
+
statement("DELETE FROM app_release_erasure_scope WHERE singleton=1"),
|
|
394
|
+
];
|
|
395
|
+
const results = await database.batch(transaction);
|
|
396
|
+
if (results.length !== transaction.length ||
|
|
397
|
+
results.some((row) => !row.success))
|
|
398
|
+
throw new Error("App Release erasure transaction failed");
|
|
399
|
+
if (!(await jobs(teamId)).some((row) => row.app_id === appId))
|
|
400
|
+
throw new Error("App Release erasure scope changed before commit");
|
|
401
|
+
}
|
|
402
|
+
const currentReferences = await catalogReferences();
|
|
403
|
+
for (const key of app.keys) {
|
|
404
|
+
if (currentReferences.has(key))
|
|
405
|
+
continue;
|
|
406
|
+
await bucket.delete(key);
|
|
407
|
+
if (await bucket.head(key))
|
|
408
|
+
throw new Error("App Release R2 object remains after delete");
|
|
409
|
+
}
|
|
410
|
+
const removed = await database
|
|
411
|
+
.prepare("DELETE FROM app_release_erasure_jobs WHERE app_id=? AND team_id=?")
|
|
412
|
+
.bind(app.appId, teamId)
|
|
413
|
+
.run();
|
|
414
|
+
if (!removed.success)
|
|
415
|
+
throw new Error("App Release erasure job remains");
|
|
416
|
+
}
|
|
417
|
+
return { apps: plan.apps.length };
|
|
418
|
+
},
|
|
419
|
+
/** Verify the named Apps, pending jobs, obligations and exclusive objects. */
|
|
420
|
+
async readback(teamId, appIds, keys) {
|
|
421
|
+
const remaining = await rows("SELECT app_id FROM app_release_team_publishers WHERE team_id=?", erasureAppRow, teamId);
|
|
422
|
+
const pending = await jobs(teamId);
|
|
423
|
+
const obligationTeams = await rows("SELECT team_id AS app_id FROM app_release_transition_obligation_teams WHERE team_id=?", erasureAppRow, teamId);
|
|
424
|
+
const obligationPages = await rows("SELECT CAST(sequence AS TEXT) AS app_id FROM app_release_transition_obligation_pages WHERE EXISTS (SELECT 1 FROM json_each(team_ids_json) WHERE value=?)", erasureAppRow, teamId);
|
|
425
|
+
const buyerReferences = await rows("SELECT installation_id AS app_id FROM app_release_buyer_references WHERE buyer_team_id=?", erasureAppRow, teamId);
|
|
426
|
+
const noticeRecipients = await rows("SELECT disposition_id AS app_id FROM app_release_artifact_notice_recipients WHERE buyer_team_id=?", erasureAppRow, teamId);
|
|
427
|
+
const routeIdentities = await rows("SELECT operation_id AS app_id FROM app_release_route_operations WHERE team_id=? UNION SELECT removal_operation_id AS app_id FROM app_release_team_route_blocks WHERE team_id=?", erasureAppRow, teamId, teamId);
|
|
428
|
+
const references = await catalogReferences();
|
|
429
|
+
const objects = [];
|
|
430
|
+
for (const key of keys)
|
|
431
|
+
if (!references.has(key) && (await bucket.head(key)))
|
|
432
|
+
objects.push(key);
|
|
433
|
+
const retained = [];
|
|
434
|
+
const dispositions = createD1ArtifactDispositions(database);
|
|
435
|
+
for (const appId of appIds) {
|
|
436
|
+
const row = await database
|
|
437
|
+
.prepare("SELECT disposition_id FROM app_release_artifact_dispositions WHERE app_id=? AND state IN ('pending','retaining','revoking')")
|
|
438
|
+
.bind(appId)
|
|
439
|
+
.first();
|
|
440
|
+
if (row !== null) {
|
|
441
|
+
const identity = Schema.decodeUnknownSync(Schema.Struct({ disposition_id: Schema.String }))(row);
|
|
442
|
+
const disposition = await dispositions.read(identity.disposition_id);
|
|
443
|
+
if (disposition !== null)
|
|
444
|
+
retained.push(disposition);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const personalRows = await Promise.all(appIds.map((appId) => database
|
|
448
|
+
.prepare(`
|
|
449
|
+
SELECT a.app_id FROM app_release_apps a WHERE a.app_id=? AND (
|
|
450
|
+
NOT EXISTS (SELECT 1 FROM app_release_artifact_dispositions d WHERE d.app_id=a.app_id)
|
|
451
|
+
OR EXISTS (SELECT 1 FROM app_release_public_surfaces s WHERE s.app_id=a.app_id)
|
|
452
|
+
OR EXISTS (SELECT 1 FROM app_release_platform_owner_replays replay WHERE replay.app_id=a.app_id)
|
|
453
|
+
OR EXISTS (SELECT 1 FROM app_release_team_publishers p WHERE p.app_id=a.app_id)
|
|
454
|
+
OR EXISTS (SELECT 1 FROM app_release_releases r WHERE r.app_id=a.app_id AND NOT EXISTS (
|
|
455
|
+
SELECT 1 FROM app_release_artifact_dispositions d,json_each(d.release_ids_json) held
|
|
456
|
+
WHERE d.app_id=a.app_id AND held.value=r.release_id))) LIMIT 1`)
|
|
457
|
+
.bind(appId)
|
|
458
|
+
.first()));
|
|
459
|
+
const personalClear = remaining.length === 0 &&
|
|
460
|
+
pending.length === 0 &&
|
|
461
|
+
obligationTeams.length === 0 &&
|
|
462
|
+
obligationPages.length === 0 &&
|
|
463
|
+
buyerReferences.length === 0 &&
|
|
464
|
+
noticeRecipients.length === 0 &&
|
|
465
|
+
routeIdentities.length === 0 &&
|
|
466
|
+
objects.length === 0 &&
|
|
467
|
+
personalRows.every((row) => row === null);
|
|
468
|
+
return {
|
|
469
|
+
clear: personalClear && retained.length === 0,
|
|
470
|
+
personalClear,
|
|
471
|
+
retainedArtifacts: retained,
|
|
472
|
+
remaining: remaining.map(({ app_id }) => app_id),
|
|
473
|
+
pending: pending.map(({ app_id }) => app_id),
|
|
474
|
+
obligationTeams: obligationTeams.map(({ app_id }) => app_id),
|
|
475
|
+
obligationPages: obligationPages.map(({ app_id }) => app_id),
|
|
476
|
+
objects,
|
|
477
|
+
};
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** The erasure coordinator consumes this owner-issued readback, including outstanding buyer custody. */
|
|
482
|
+
export const appArtifactErasureReadbackSchema = Schema.Struct({
|
|
483
|
+
clear: Schema.Boolean,
|
|
484
|
+
personalClear: Schema.Boolean,
|
|
485
|
+
retainedArtifacts: Schema.Array(artifactDispositionReadbackSchema).pipe(Schema.check(Schema.isMaxLength(10000))),
|
|
486
|
+
remaining: Schema.Array(Schema.String),
|
|
487
|
+
pending: Schema.Array(Schema.String),
|
|
488
|
+
obligationTeams: Schema.Array(Schema.String),
|
|
489
|
+
obligationPages: Schema.Array(Schema.String),
|
|
490
|
+
objects: Schema.Array(Schema.String),
|
|
491
|
+
}).pipe(Schema.check(Schema.makeFilter((value) => value.clear ===
|
|
492
|
+
(value.personalClear && value.retainedArtifacts.length === 0), { message: "App artifact erasure readback contradicts its held custody" })));
|
|
493
|
+
/** The one current App Release D1 schema for an empty catalog. */
|
|
494
|
+
export const APP_RELEASE_D1_FRESH_SCHEMA = `
|
|
495
|
+
CREATE TABLE app_release_apps (app_id TEXT PRIMARY KEY) STRICT;
|
|
496
|
+
CREATE TABLE app_release_releases (release_id TEXT PRIMARY KEY, app_id TEXT NOT NULL REFERENCES app_release_apps(app_id), artifact_ref TEXT NOT NULL, artifact_digest TEXT NOT NULL, source_revision TEXT NOT NULL, source_tree_digest TEXT NOT NULL, build_recipe_digest TEXT NOT NULL) STRICT;
|
|
497
|
+
CREATE TABLE app_release_release_verifications (release_id TEXT PRIMARY KEY REFERENCES app_release_releases(release_id), artifact_digest TEXT NOT NULL) STRICT;
|
|
498
|
+
CREATE TABLE app_release_release_verification_formats (release_id TEXT PRIMARY KEY REFERENCES app_release_releases(release_id), media_type TEXT NOT NULL) STRICT;
|
|
499
|
+
${publicSurfaceTableSql}
|
|
500
|
+
CREATE TABLE app_release_transitions (sequence INTEGER PRIMARY KEY AUTOINCREMENT, transition_id TEXT NOT NULL UNIQUE, operation_kind TEXT NOT NULL, idempotency_key TEXT NOT NULL, release_id TEXT NOT NULL, public_surface_id TEXT, UNIQUE (operation_kind, idempotency_key)) STRICT;
|
|
501
|
+
CREATE INDEX app_release_public_surfaces_stable_route_idx ON app_release_public_surfaces (stable_route, public_surface_id);
|
|
502
|
+
CREATE INDEX app_release_transitions_selected_surface_idx ON app_release_transitions (public_surface_id, operation_kind, sequence DESC) WHERE operation_kind IN ('promotion', 'rollback');
|
|
503
|
+
CREATE INDEX app_release_transitions_revoked_release_idx ON app_release_transitions (release_id, operation_kind) WHERE operation_kind = 'revocation';
|
|
504
|
+
CREATE TABLE app_release_transition_obligations (sequence INTEGER PRIMARY KEY REFERENCES app_release_transitions(sequence), transition_id TEXT NOT NULL UNIQUE, operation_kind TEXT NOT NULL, idempotency_key TEXT NOT NULL, release_id TEXT NOT NULL, public_surface_id TEXT, state TEXT NOT NULL CHECK(state IN ('pending','acknowledged'))) STRICT;
|
|
505
|
+
CREATE INDEX app_release_transition_obligations_pending_idx ON app_release_transition_obligations(state, sequence);
|
|
506
|
+
CREATE TABLE app_release_transition_obligation_enumeration (sequence INTEGER PRIMARY KEY REFERENCES app_release_transition_obligations(sequence), cursor TEXT, started INTEGER NOT NULL DEFAULT 0 CHECK(started IN (0, 1)), complete INTEGER NOT NULL DEFAULT 0 CHECK(complete IN (0, 1))) STRICT;
|
|
507
|
+
CREATE TABLE app_release_transition_obligation_pages (sequence INTEGER NOT NULL REFERENCES app_release_transition_obligations(sequence), cursor TEXT, cursor_is_initial INTEGER NOT NULL CHECK(cursor_is_initial IN (0, 1)), cursor_key TEXT NOT NULL, next_cursor TEXT, complete INTEGER NOT NULL CHECK(complete IN (0, 1)), team_ids_json TEXT NOT NULL CHECK(json_valid(team_ids_json)), PRIMARY KEY(sequence, cursor_is_initial, cursor_key)) STRICT;
|
|
508
|
+
CREATE TABLE app_release_transition_obligation_teams (sequence INTEGER NOT NULL REFERENCES app_release_transition_obligations(sequence), team_id TEXT NOT NULL CHECK(length(team_id) BETWEEN 1 AND 256), state TEXT NOT NULL CHECK(state IN ('pending', 'applied', 'no-op', 'expired')), PRIMARY KEY(sequence, team_id)) STRICT;
|
|
509
|
+
CREATE INDEX app_release_transition_obligation_teams_pending_idx ON app_release_transition_obligation_teams(sequence, state, team_id);
|
|
510
|
+
CREATE TABLE app_release_team_publishers (app_id TEXT PRIMARY KEY REFERENCES app_release_apps(app_id), team_id TEXT NOT NULL CHECK(length(team_id) BETWEEN 1 AND 256), source_provider TEXT NOT NULL CHECK(source_provider IN ('artifacts','github')), source_repository TEXT NOT NULL CHECK(length(source_repository) BETWEEN 1 AND 1024), source_branch TEXT NOT NULL CHECK(source_branch = 'main'), template_app_id TEXT, template_seed_revision TEXT, CHECK((template_app_id IS NULL) = (template_seed_revision IS NULL))) STRICT;
|
|
511
|
+
CREATE INDEX app_release_team_publishers_team_idx ON app_release_team_publishers(team_id, app_id);
|
|
512
|
+
CREATE TABLE app_release_release_verification_metadata (release_id TEXT PRIMARY KEY REFERENCES app_release_release_verifications(release_id), artifact_digest TEXT NOT NULL, description_json TEXT NOT NULL CHECK(json_valid(description_json))) STRICT;
|
|
513
|
+
CREATE TABLE publisher_template_seed_receipts (template_repository TEXT NOT NULL, seed_revision TEXT NOT NULL, platform_app_id TEXT NOT NULL, source_revision TEXT NOT NULL, source_tree_digest TEXT NOT NULL, recipe_json TEXT CHECK(recipe_json IS NULL OR json_valid(recipe_json)), build_recipe_digest TEXT CHECK(build_recipe_digest IS NULL OR (length(build_recipe_digest) = 71 AND substr(build_recipe_digest, 1, 7) = 'sha256:' AND substr(build_recipe_digest, 8) NOT GLOB '*[^0-9a-f]*')), CHECK((recipe_json IS NULL) = (build_recipe_digest IS NULL)), PRIMARY KEY (template_repository, seed_revision)) STRICT;
|
|
514
|
+
CREATE TABLE publisher_artifacts_repositories (repository_name TEXT PRIMARY KEY CHECK(length(repository_name) BETWEEN 1 AND 128), provider_id TEXT NOT NULL CHECK(length(provider_id) > 0), default_branch TEXT NOT NULL CHECK(default_branch = 'main'), remote TEXT NOT NULL CHECK(length(remote) > 0), parent_repository TEXT REFERENCES publisher_artifacts_repositories(repository_name), CHECK(parent_repository IS NULL OR parent_repository <> repository_name)) STRICT;
|
|
515
|
+
${publicRouteSchemaSql}
|
|
516
|
+
${platformOwnerReplaySchemaSql}
|
|
517
|
+
${buyerReferenceSchemaSql}
|
|
518
|
+
${safetyTakedownSchemaSql}
|
|
519
|
+
${artifactDispositionSchemaSql}
|
|
520
|
+
CREATE TRIGGER app_release_apps_write_once BEFORE UPDATE ON app_release_apps BEGIN SELECT RAISE(ABORT, 'App Release apps are write-once'); END;
|
|
521
|
+
CREATE TRIGGER app_release_releases_write_once BEFORE UPDATE ON app_release_releases BEGIN SELECT RAISE(ABORT, 'App Release releases are write-once'); END;
|
|
522
|
+
CREATE TRIGGER app_release_verifications_write_once BEFORE UPDATE ON app_release_release_verifications BEGIN SELECT RAISE(ABORT, 'App Release verifications are write-once'); END;
|
|
523
|
+
CREATE TRIGGER app_release_verification_formats_write_once BEFORE UPDATE ON app_release_release_verification_formats BEGIN SELECT RAISE(ABORT, 'App Release verification formats are write-once'); END;
|
|
524
|
+
CREATE TRIGGER app_release_public_surfaces_write_once BEFORE UPDATE ON app_release_public_surfaces BEGIN SELECT RAISE(ABORT, 'App Release public Surfaces are write-once'); END;
|
|
525
|
+
CREATE TRIGGER app_release_transitions_write_once BEFORE UPDATE ON app_release_transitions BEGIN SELECT RAISE(ABORT, 'App Release transitions are write-once'); END;
|
|
526
|
+
CREATE TRIGGER app_release_transition_obligation_enumeration_after_insert AFTER INSERT ON app_release_transition_obligations BEGIN INSERT OR IGNORE INTO app_release_transition_obligation_enumeration(sequence) VALUES (NEW.sequence); END;
|
|
527
|
+
CREATE TRIGGER app_release_transition_obligation_after_insert AFTER INSERT ON app_release_transitions WHEN NEW.operation_kind = 'revocation' BEGIN INSERT INTO app_release_transition_obligations (sequence, transition_id, operation_kind, idempotency_key, release_id, public_surface_id, state) VALUES (NEW.sequence, NEW.transition_id, NEW.operation_kind, NEW.idempotency_key, NEW.release_id, NEW.public_surface_id, 'pending'); END;
|
|
528
|
+
CREATE TRIGGER app_release_team_publishers_write_once BEFORE UPDATE ON app_release_team_publishers BEGIN SELECT RAISE(ABORT, 'App publisher registrations are write-once'); END;
|
|
529
|
+
CREATE TRIGGER app_release_verification_metadata_write_once BEFORE UPDATE ON app_release_release_verification_metadata BEGIN SELECT RAISE(ABORT, 'App Release verification metadata is write-once'); END;
|
|
530
|
+
CREATE TRIGGER app_release_team_publishers_template_seed_pair BEFORE INSERT ON app_release_team_publishers WHEN (NEW.template_app_id IS NULL) != (NEW.template_seed_revision IS NULL) BEGIN SELECT RAISE(ABORT, 'App publisher template seed pointer is incomplete'); END;
|
|
531
|
+
CREATE TRIGGER app_release_team_publishers_artifacts_seed_required BEFORE INSERT ON app_release_team_publishers WHEN NEW.source_provider = 'artifacts' AND NEW.template_app_id IS NULL BEGIN SELECT RAISE(ABORT, 'Artifacts App publisher requires verified template seed provenance'); END;
|
|
532
|
+
CREATE TRIGGER app_release_team_publishers_artifacts_seed_verified BEFORE INSERT ON app_release_team_publishers WHEN NEW.source_provider = 'artifacts' AND NOT EXISTS (SELECT 1 FROM publisher_template_seed_receipts WHERE platform_app_id = NEW.template_app_id AND seed_revision = NEW.template_seed_revision AND recipe_json IS NOT NULL AND build_recipe_digest IS NOT NULL) BEGIN SELECT RAISE(ABORT, 'Artifacts App publisher template seed is not verified'); END;
|
|
533
|
+
CREATE TRIGGER publisher_template_seed_receipts_write_once BEFORE UPDATE ON publisher_template_seed_receipts BEGIN SELECT RAISE(ABORT, 'Publisher template seed receipts are write-once'); END;
|
|
534
|
+
CREATE TRIGGER publisher_template_seed_receipts_append_only BEFORE DELETE ON publisher_template_seed_receipts BEGIN SELECT RAISE(ABORT, 'Publisher template seed receipts are append-only'); END;
|
|
535
|
+
CREATE TRIGGER publisher_template_seed_recipe_required BEFORE INSERT ON publisher_template_seed_receipts WHEN NEW.recipe_json IS NULL OR json_valid(NEW.recipe_json) = 0 OR NEW.build_recipe_digest IS NULL BEGIN SELECT RAISE(ABORT, 'Publisher template seed recipe proof is incomplete'); END;
|
|
536
|
+
CREATE TRIGGER publisher_artifacts_repositories_write_once BEFORE UPDATE ON publisher_artifacts_repositories BEGIN SELECT RAISE(ABORT, 'Publisher Artifacts repository metadata is write-once'); END;
|
|
537
|
+
CREATE TABLE publisher_artifacts_repositories_pending (repository_name TEXT PRIMARY KEY CHECK(length(repository_name) BETWEEN 1 AND 128), provider_id TEXT NOT NULL CHECK(length(provider_id) > 0), default_branch TEXT NOT NULL CHECK(default_branch = 'main'), remote TEXT NOT NULL CHECK(length(remote) > 0), parent_repository TEXT REFERENCES publisher_artifacts_repositories(repository_name), initial_token TEXT NOT NULL CHECK(length(initial_token) > 0), CHECK(parent_repository IS NULL OR parent_repository <> repository_name)) STRICT;
|
|
538
|
+
CREATE TRIGGER publisher_artifacts_repositories_pending_write_once BEFORE UPDATE ON publisher_artifacts_repositories_pending BEGIN SELECT RAISE(ABORT, 'Publisher Artifacts recovery state is write-once'); END;
|
|
539
|
+
CREATE TABLE publisher_tracker_grants (team_id TEXT PRIMARY KEY, repository TEXT NOT NULL UNIQUE, provider TEXT NOT NULL, generation TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision > 0), owner_user_id TEXT, consented_at INTEGER, state TEXT NOT NULL CHECK(state IN ('authorizing','active','revoking','revoked','erasing','erased')), credential_json TEXT CHECK(credential_json IS NULL OR json_valid(credential_json)), pending_id TEXT, pending_credential_json TEXT CHECK(pending_credential_json IS NULL OR json_valid(pending_credential_json)), CHECK(pending_id IS NOT NULL OR pending_credential_json IS NULL)) STRICT;
|
|
540
|
+
CREATE TABLE publisher_tracker_capabilities (token_hash TEXT PRIMARY KEY, team_id TEXT NOT NULL REFERENCES publisher_tracker_grants(team_id), user_id TEXT NOT NULL, client_id TEXT NOT NULL, login_id TEXT NOT NULL, generation TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read','write')), expires_at INTEGER NOT NULL) STRICT;
|
|
541
|
+
CREATE INDEX publisher_tracker_capabilities_expiry ON publisher_tracker_capabilities(expires_at);
|
|
542
|
+
CREATE INDEX publisher_tracker_capabilities_team ON publisher_tracker_capabilities(team_id);
|
|
543
|
+
CREATE TABLE app_release_erasure_scope (singleton INTEGER PRIMARY KEY CHECK(singleton=1), app_id TEXT NOT NULL) STRICT;
|
|
544
|
+
CREATE TABLE app_release_erasure_jobs (app_id TEXT PRIMARY KEY, team_id TEXT NOT NULL, object_keys_json TEXT NOT NULL CHECK(json_valid(object_keys_json)), repository_name TEXT) STRICT;
|
|
545
|
+
CREATE TRIGGER app_release_apps_append_only BEFORE DELETE ON app_release_apps WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=OLD.app_id) BEGIN SELECT RAISE(ABORT, 'App Release apps are append-only'); END;
|
|
546
|
+
CREATE TRIGGER app_release_releases_append_only BEFORE DELETE ON app_release_releases WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=OLD.app_id) BEGIN SELECT RAISE(ABORT, 'App Release releases are append-only'); END;
|
|
547
|
+
CREATE TRIGGER app_release_verifications_append_only BEFORE DELETE ON app_release_release_verifications WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=(SELECT app_id FROM app_release_releases WHERE release_id=OLD.release_id)) BEGIN SELECT RAISE(ABORT, 'App Release verifications are append-only'); END;
|
|
548
|
+
CREATE TRIGGER app_release_verification_formats_append_only BEFORE DELETE ON app_release_release_verification_formats WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=(SELECT app_id FROM app_release_releases WHERE release_id=OLD.release_id)) BEGIN SELECT RAISE(ABORT, 'App Release verification formats are append-only'); END;
|
|
549
|
+
CREATE TRIGGER app_release_public_surfaces_append_only BEFORE DELETE ON app_release_public_surfaces WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=OLD.app_id) BEGIN SELECT RAISE(ABORT, 'App Release public Surfaces are append-only'); END;
|
|
550
|
+
CREATE TRIGGER app_release_transitions_append_only BEFORE DELETE ON app_release_transitions WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=(SELECT app_id FROM app_release_releases WHERE release_id=OLD.release_id)) BEGIN SELECT RAISE(ABORT, 'App Release transitions are append-only'); END;
|
|
551
|
+
CREATE TRIGGER app_release_team_publishers_append_only BEFORE DELETE ON app_release_team_publishers WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=OLD.app_id) BEGIN SELECT RAISE(ABORT, 'App publisher registrations are append-only'); END;
|
|
552
|
+
CREATE TRIGGER app_release_verification_metadata_append_only BEFORE DELETE ON app_release_release_verification_metadata WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=(SELECT app_id FROM app_release_releases WHERE release_id=OLD.release_id)) BEGIN SELECT RAISE(ABORT, 'App Release verification metadata is append-only'); END;
|
|
553
|
+
CREATE TRIGGER publisher_artifacts_repositories_append_only BEFORE DELETE ON publisher_artifacts_repositories WHEN NOT EXISTS (SELECT 1 FROM app_release_erasure_scope WHERE app_id=(SELECT app_id FROM app_release_team_publishers WHERE source_provider='artifacts' AND source_repository=OLD.repository_name)) AND NOT EXISTS (SELECT 1 FROM publisher_tracker_grants WHERE repository=OLD.repository_name AND state='erasing') BEGIN SELECT RAISE(ABORT, 'Publisher Artifacts repository metadata is append-only'); END;
|
|
554
|
+
CREATE TABLE app_release_closed_sellers (team_id TEXT PRIMARY KEY CHECK(length(team_id) BETWEEN 1 AND 256), request_id TEXT NOT NULL CHECK(length(request_id) BETWEEN 1 AND 256), closed_at INTEGER NOT NULL CHECK(closed_at >= 0 AND closed_at <= 9007199254740991)) STRICT;
|
|
555
|
+
CREATE TRIGGER app_release_closed_sellers_no_update BEFORE UPDATE ON app_release_closed_sellers BEGIN SELECT RAISE(ABORT, 'Seller closure is irreversible'); END;
|
|
556
|
+
CREATE TRIGGER app_release_closed_sellers_no_delete BEFORE DELETE ON app_release_closed_sellers BEGIN SELECT RAISE(ABORT, 'Seller closure is irreversible'); END;
|
|
557
|
+
`;
|
|
558
|
+
const teamAppPublisherRowSchema = Schema.Struct({
|
|
559
|
+
app_id: appIdSchema,
|
|
560
|
+
team_id: Schema.String,
|
|
561
|
+
source_provider: Schema.Literals(["artifacts", "github"]),
|
|
562
|
+
source_repository: Schema.String,
|
|
563
|
+
source_branch: Schema.Literal("main"),
|
|
564
|
+
template_app_id: Schema.NullOr(appIdSchema),
|
|
565
|
+
template_seed_revision: Schema.NullOr(sourceRevisionSchema),
|
|
566
|
+
});
|
|
567
|
+
function registrationFromRow(value) {
|
|
568
|
+
const registration = {
|
|
569
|
+
appId: value.app_id,
|
|
570
|
+
teamId: value.team_id,
|
|
571
|
+
source: {
|
|
572
|
+
provider: value.source_provider,
|
|
573
|
+
repository: value.source_repository,
|
|
574
|
+
branch: value.source_branch,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
return Schema.decodeUnknownSync(teamAppPublisherRegistrationSchema)(value.template_app_id !== null && value.template_seed_revision !== null
|
|
578
|
+
? {
|
|
579
|
+
...registration,
|
|
580
|
+
templateSeed: {
|
|
581
|
+
appId: value.template_app_id,
|
|
582
|
+
revision: value.template_seed_revision,
|
|
583
|
+
},
|
|
584
|
+
}
|
|
585
|
+
: registration);
|
|
586
|
+
}
|
|
587
|
+
/** Create the native registry that joins a Team publisher to one App/source. */
|
|
588
|
+
export function createD1TeamAppPublisherRegistry(database) {
|
|
589
|
+
const read = async (appId) => {
|
|
590
|
+
const row = await database
|
|
591
|
+
.prepare(`SELECT app_id, team_id, source_provider, source_repository, source_branch,
|
|
592
|
+
template_app_id, template_seed_revision
|
|
593
|
+
FROM app_release_team_publishers WHERE app_id = ?`)
|
|
594
|
+
.bind(appId)
|
|
595
|
+
.first();
|
|
596
|
+
return row === null
|
|
597
|
+
? { kind: "absent" }
|
|
598
|
+
: {
|
|
599
|
+
kind: "found",
|
|
600
|
+
registration: registrationFromRow(Schema.decodeUnknownSync(teamAppPublisherRowSchema)(row)),
|
|
601
|
+
};
|
|
602
|
+
};
|
|
603
|
+
return {
|
|
604
|
+
read,
|
|
605
|
+
register: async (input) => {
|
|
606
|
+
const registration = Schema.decodeUnknownSync(teamAppPublisherRegistrationSchema)(input);
|
|
607
|
+
const [appResult, publisherResult] = await database.batch([
|
|
608
|
+
database
|
|
609
|
+
.prepare("INSERT INTO app_release_apps (app_id) VALUES (?) ON CONFLICT (app_id) DO NOTHING")
|
|
610
|
+
.bind(registration.appId),
|
|
611
|
+
database
|
|
612
|
+
.prepare(`INSERT INTO app_release_team_publishers
|
|
613
|
+
(app_id, team_id, source_provider, source_repository, source_branch,
|
|
614
|
+
template_app_id, template_seed_revision)
|
|
615
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
616
|
+
ON CONFLICT (app_id) DO NOTHING
|
|
617
|
+
RETURNING app_id`)
|
|
618
|
+
.bind(registration.appId, registration.teamId, registration.source.provider, registration.source.repository, registration.source.branch, registration.templateSeed?.appId ?? null, registration.templateSeed?.revision ?? null),
|
|
619
|
+
]);
|
|
620
|
+
if (appResult === undefined || publisherResult === undefined)
|
|
621
|
+
throw new Error("App publisher registration was not retained");
|
|
622
|
+
const current = await read(registration.appId);
|
|
623
|
+
if (current.kind === "absent")
|
|
624
|
+
throw new Error("App publisher registration was not retained");
|
|
625
|
+
const matches = current.registration.teamId === registration.teamId &&
|
|
626
|
+
current.registration.source.provider === registration.source.provider &&
|
|
627
|
+
current.registration.source.repository ===
|
|
628
|
+
registration.source.repository &&
|
|
629
|
+
current.registration.templateSeed?.appId ===
|
|
630
|
+
registration.templateSeed?.appId &&
|
|
631
|
+
current.registration.templateSeed?.revision ===
|
|
632
|
+
registration.templateSeed?.revision;
|
|
633
|
+
if (!matches)
|
|
634
|
+
return { kind: "conflict" };
|
|
635
|
+
return {
|
|
636
|
+
kind: publisherResult.results.length === 0 ? "existing" : "created",
|
|
637
|
+
registration: current.registration,
|
|
638
|
+
};
|
|
639
|
+
},
|
|
640
|
+
authorize: async (appId, teamId) => {
|
|
641
|
+
const current = await read(appId);
|
|
642
|
+
if (current.kind === "absent")
|
|
643
|
+
return current;
|
|
644
|
+
return current.registration.teamId === teamId
|
|
645
|
+
? { kind: "authorized", registration: current.registration }
|
|
646
|
+
: { kind: "denied" };
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/** Create the D1-backed App Release store with private public-website authority. */
|
|
651
|
+
export function createD1AppReleaseStore(database) {
|
|
652
|
+
const transitionObligations = {
|
|
653
|
+
readByTransitionId: (transitionId) => storeOperation(async () => {
|
|
654
|
+
const decodedTransitionId = Schema.decodeUnknownResult(appTransitionIdSchema)(transitionId);
|
|
655
|
+
if (decodedTransitionId._tag === "Failure")
|
|
656
|
+
throw new Error("Invalid transition obligation identity");
|
|
657
|
+
const row = await readTransitionObligationByTransitionId(database, decodedTransitionId.success);
|
|
658
|
+
if (row === null)
|
|
659
|
+
return { kind: "absent" };
|
|
660
|
+
return transitionObligationFromRow(database, row);
|
|
661
|
+
}),
|
|
662
|
+
read: (options) => storeOperation(async () => {
|
|
663
|
+
const decodedOptions = decodeTransitionObligationReadOptions(options);
|
|
664
|
+
if (decodedOptions._tag === "Failure")
|
|
665
|
+
throw new Error("Invalid transition obligation read options");
|
|
666
|
+
const afterSequence = decodedOptions.success.afterSequence ?? 0;
|
|
667
|
+
const limit = decodedOptions.success.limit ?? 16;
|
|
668
|
+
const result = await database
|
|
669
|
+
.prepare(`SELECT sequence, transition_id, operation_kind, idempotency_key,
|
|
670
|
+
release_id, public_surface_id, state
|
|
671
|
+
FROM app_release_transition_obligations
|
|
672
|
+
WHERE sequence > ? AND state = 'pending'
|
|
673
|
+
ORDER BY sequence LIMIT ?`)
|
|
674
|
+
.bind(afterSequence, limit)
|
|
675
|
+
.all();
|
|
676
|
+
return Promise.all(result.results.map((row) => transitionObligationFromRow(database, Schema.decodeUnknownSync(transitionObligationRowSchema)(row))));
|
|
677
|
+
}),
|
|
678
|
+
recordTeamRecipientPage: (sequence, page) => storeOperation(async () => {
|
|
679
|
+
const decodedPage = decodeTeamRecipientPage(page);
|
|
680
|
+
if (decodedPage._tag === "Failure")
|
|
681
|
+
throw new Error("Invalid Team recipient page");
|
|
682
|
+
const value = decodedPage.success;
|
|
683
|
+
const cursor = value.cursor ?? null;
|
|
684
|
+
const nextCursor = value.nextCursor ?? null;
|
|
685
|
+
if (!value.complete && nextCursor === null)
|
|
686
|
+
throw new Error("Incomplete Team recipient page needs a next cursor");
|
|
687
|
+
const obligation = await readTransitionObligationRow(database, sequence);
|
|
688
|
+
if (obligation === null)
|
|
689
|
+
return { kind: "absent" };
|
|
690
|
+
if (obligation.state !== "pending")
|
|
691
|
+
throw new Error("Acknowledged transition obligation is immutable");
|
|
692
|
+
const enumeration = await readTransitionObligationEnumerationRow(database, sequence);
|
|
693
|
+
if (enumeration === null)
|
|
694
|
+
throw new Error("Transition obligation enumeration was absent");
|
|
695
|
+
const pageKey = recipientPageKey(cursor);
|
|
696
|
+
const existingPage = await readTransitionObligationPage(database, sequence, cursor === null ? 1 : 0, pageKey);
|
|
697
|
+
if (existingPage !== null) {
|
|
698
|
+
const existingTeamIds = Schema.decodeUnknownSync(teamIdsJsonSchema)(existingPage.team_ids_json);
|
|
699
|
+
if (existingPage.next_cursor !== nextCursor ||
|
|
700
|
+
existingPage.complete !== (value.complete ? 1 : 0) ||
|
|
701
|
+
!sameStringArray(existingTeamIds, value.teamIds))
|
|
702
|
+
throw new Error("Conflicting Team recipient page replay");
|
|
703
|
+
return await transitionObligationFromRow(database, obligation);
|
|
704
|
+
}
|
|
705
|
+
const cursorIsInitial = cursor === null ? 1 : 0;
|
|
706
|
+
if ((enumeration.started === 0 && cursor !== null) ||
|
|
707
|
+
(enumeration.started === 1 &&
|
|
708
|
+
(enumeration.complete !== 0 || enumeration.cursor !== cursor)))
|
|
709
|
+
throw new Error("Team recipient page cursor is out of order");
|
|
710
|
+
const serializedTeamIds = JSON.stringify(value.teamIds);
|
|
711
|
+
const statements = [
|
|
712
|
+
database
|
|
713
|
+
.prepare(`INSERT INTO app_release_transition_obligation_pages
|
|
714
|
+
(sequence, cursor, cursor_is_initial, cursor_key, next_cursor, complete, team_ids_json)
|
|
715
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
716
|
+
WHERE EXISTS (
|
|
717
|
+
SELECT 1 FROM app_release_transition_obligations
|
|
718
|
+
WHERE sequence = ? AND state = 'pending'
|
|
719
|
+
)`)
|
|
720
|
+
.bind(sequence, cursor, cursorIsInitial, pageKey, nextCursor, value.complete ? 1 : 0, serializedTeamIds, sequence),
|
|
721
|
+
];
|
|
722
|
+
statements.push(database
|
|
723
|
+
.prepare(`INSERT OR IGNORE INTO app_release_transition_obligation_teams
|
|
724
|
+
(sequence, team_id, state)
|
|
725
|
+
SELECT sequence, json_each.value, 'pending'
|
|
726
|
+
FROM app_release_transition_obligation_pages, json_each(team_ids_json)
|
|
727
|
+
WHERE app_release_transition_obligation_pages.sequence = ?
|
|
728
|
+
AND cursor_is_initial = ? AND cursor_key = ?`)
|
|
729
|
+
.bind(sequence, cursorIsInitial, pageKey));
|
|
730
|
+
statements.push(database
|
|
731
|
+
.prepare(`UPDATE app_release_transition_obligation_enumeration
|
|
732
|
+
SET cursor = ?, started = 1, complete = ?
|
|
733
|
+
WHERE sequence = ? AND started = ? AND complete = ?
|
|
734
|
+
AND cursor IS ?
|
|
735
|
+
AND EXISTS (
|
|
736
|
+
SELECT 1 FROM app_release_transition_obligation_pages
|
|
737
|
+
WHERE sequence = ? AND cursor_is_initial = ? AND cursor_key = ?
|
|
738
|
+
)`)
|
|
739
|
+
.bind(nextCursor, value.complete ? 1 : 0, sequence, enumeration.started, enumeration.complete, enumeration.cursor, sequence, cursorIsInitial, pageKey));
|
|
740
|
+
await database.batch(statements);
|
|
741
|
+
const retained = await readTransitionObligationRow(database, sequence);
|
|
742
|
+
if (retained === null)
|
|
743
|
+
throw new Error("Transition obligation disappeared");
|
|
744
|
+
const retainedEnumeration = await readTransitionObligationEnumerationRow(database, sequence);
|
|
745
|
+
if (retainedEnumeration?.started !== 1 ||
|
|
746
|
+
retainedEnumeration.cursor !== nextCursor ||
|
|
747
|
+
retainedEnumeration.complete !== (value.complete ? 1 : 0))
|
|
748
|
+
throw new Error("Team recipient page was not retained");
|
|
749
|
+
return await transitionObligationFromRow(database, retained);
|
|
750
|
+
}),
|
|
751
|
+
readPendingTeamRecipients: (options) => storeOperation(async () => {
|
|
752
|
+
const decodedOptions = decodePendingTeamRecipientsReadOptions(options);
|
|
753
|
+
if (decodedOptions._tag === "Failure")
|
|
754
|
+
throw new Error("Invalid pending Team recipient read options");
|
|
755
|
+
const { sequence, limit = 16 } = decodedOptions.success;
|
|
756
|
+
const result = await database
|
|
757
|
+
.prepare(`SELECT team_id, state
|
|
758
|
+
FROM app_release_transition_obligation_teams
|
|
759
|
+
WHERE sequence = ? AND state = 'pending'
|
|
760
|
+
ORDER BY team_id LIMIT ?`)
|
|
761
|
+
.bind(sequence, limit)
|
|
762
|
+
.all();
|
|
763
|
+
return result.results.map((row) => teamRecipientFromRow(Schema.decodeUnknownSync(transitionObligationRecipientRowSchema)(row)));
|
|
764
|
+
}),
|
|
765
|
+
recordTeamRecipientOutcome: (sequence, teamId, outcome) => storeOperation(async () => {
|
|
766
|
+
const decodedTeamId = Schema.decodeUnknownResult(teamRecipientIdSchema)(teamId);
|
|
767
|
+
const decodedOutcome = Schema.decodeUnknownResult(teamRecipientOutcomeSchema)(outcome);
|
|
768
|
+
if (decodedTeamId._tag === "Failure" ||
|
|
769
|
+
decodedOutcome._tag === "Failure")
|
|
770
|
+
throw new Error("Invalid Team recipient outcome");
|
|
771
|
+
const parsedTeamId = decodedTeamId.success;
|
|
772
|
+
const parsedOutcome = decodedOutcome.success;
|
|
773
|
+
const updated = await database
|
|
774
|
+
.prepare(`UPDATE app_release_transition_obligation_teams
|
|
775
|
+
SET state = ?
|
|
776
|
+
WHERE sequence = ? AND team_id = ? AND state = 'pending'
|
|
777
|
+
RETURNING team_id, state`)
|
|
778
|
+
.bind(parsedOutcome, sequence, parsedTeamId)
|
|
779
|
+
.first();
|
|
780
|
+
if (updated !== null) {
|
|
781
|
+
return teamRecipientFromRow(Schema.decodeUnknownSync(transitionObligationRecipientRowSchema)(updated));
|
|
782
|
+
}
|
|
783
|
+
const current = await database
|
|
784
|
+
.prepare(`SELECT team_id, state
|
|
785
|
+
FROM app_release_transition_obligation_teams
|
|
786
|
+
WHERE sequence = ? AND team_id = ?`)
|
|
787
|
+
.bind(sequence, parsedTeamId)
|
|
788
|
+
.first();
|
|
789
|
+
if (current === null)
|
|
790
|
+
return { kind: "absent" };
|
|
791
|
+
const recipient = teamRecipientFromRow(Schema.decodeUnknownSync(transitionObligationRecipientRowSchema)(current));
|
|
792
|
+
if (recipient.state !== parsedOutcome)
|
|
793
|
+
throw new Error("Conflicting Team recipient outcome replay");
|
|
794
|
+
return recipient;
|
|
795
|
+
}),
|
|
796
|
+
acknowledge: (sequence) => storeOperation(async () => {
|
|
797
|
+
const acknowledged = await database
|
|
798
|
+
.prepare(`UPDATE app_release_transition_obligations
|
|
799
|
+
SET state = 'acknowledged'
|
|
800
|
+
WHERE sequence = ? AND state = 'pending'
|
|
801
|
+
AND EXISTS (
|
|
802
|
+
SELECT 1
|
|
803
|
+
FROM app_release_transition_obligation_enumeration
|
|
804
|
+
WHERE sequence = ? AND complete = 1
|
|
805
|
+
)
|
|
806
|
+
AND NOT EXISTS (
|
|
807
|
+
SELECT 1
|
|
808
|
+
FROM app_release_transition_obligation_teams
|
|
809
|
+
WHERE sequence = ? AND state = 'pending'
|
|
810
|
+
)
|
|
811
|
+
RETURNING sequence, transition_id, operation_kind, idempotency_key,
|
|
812
|
+
release_id, public_surface_id, state`)
|
|
813
|
+
.bind(sequence, sequence, sequence)
|
|
814
|
+
.first();
|
|
815
|
+
if (acknowledged !== null) {
|
|
816
|
+
return await transitionObligationFromRow(database, Schema.decodeUnknownSync(transitionObligationRowSchema)(acknowledged));
|
|
817
|
+
}
|
|
818
|
+
const current = await readTransitionObligationRow(database, sequence);
|
|
819
|
+
if (current === null)
|
|
820
|
+
return { kind: "absent" };
|
|
821
|
+
return await transitionObligationFromRow(database, current);
|
|
822
|
+
}),
|
|
823
|
+
};
|
|
824
|
+
const store = {
|
|
825
|
+
readApp: (appId) => storeOperation(async () => {
|
|
826
|
+
const app = await readApp(database, appId);
|
|
827
|
+
return app === null ? { kind: "absent" } : { kind: "found", app };
|
|
828
|
+
}),
|
|
829
|
+
createAppIfAbsent: (app) => storeOperation(async () => {
|
|
830
|
+
const inserted = await database
|
|
831
|
+
.prepare("INSERT INTO app_release_apps (app_id) VALUES (?) ON CONFLICT (app_id) DO NOTHING RETURNING app_id")
|
|
832
|
+
.bind(app.appId)
|
|
833
|
+
.first();
|
|
834
|
+
const canonical = inserted === null
|
|
835
|
+
? await readApp(database, app.appId)
|
|
836
|
+
: appFromRow(Schema.decodeUnknownSync(appRowSchema)(inserted));
|
|
837
|
+
if (canonical === null)
|
|
838
|
+
throw new Error("App insert was not retained");
|
|
839
|
+
return {
|
|
840
|
+
kind: inserted === null ? "existing" : "created",
|
|
841
|
+
app: canonical,
|
|
842
|
+
};
|
|
843
|
+
}),
|
|
844
|
+
readRelease: (releaseId) => storeOperation(async () => {
|
|
845
|
+
const release = await readRelease(database, releaseId);
|
|
846
|
+
return release === null
|
|
847
|
+
? { kind: "absent" }
|
|
848
|
+
: { kind: "found", release };
|
|
849
|
+
}),
|
|
850
|
+
createReleaseIfAbsent: (release) => storeOperation(async () => {
|
|
851
|
+
const inserted = await database
|
|
852
|
+
.prepare(`INSERT INTO app_release_releases (
|
|
853
|
+
release_id,
|
|
854
|
+
app_id,
|
|
855
|
+
artifact_ref,
|
|
856
|
+
artifact_digest,
|
|
857
|
+
source_revision,
|
|
858
|
+
source_tree_digest,
|
|
859
|
+
build_recipe_digest
|
|
860
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
861
|
+
ON CONFLICT (release_id) DO NOTHING
|
|
862
|
+
RETURNING release_id, app_id, artifact_ref, artifact_digest,
|
|
863
|
+
source_revision, source_tree_digest, build_recipe_digest`)
|
|
864
|
+
.bind(release.releaseId, release.appId, release.artifact.artifactRef, release.artifact.artifactDigest, release.artifact.provenance.sourceRevision, release.artifact.provenance.sourceTreeDigest, release.artifact.provenance.buildRecipeDigest)
|
|
865
|
+
.first();
|
|
866
|
+
const canonical = inserted === null
|
|
867
|
+
? await readRelease(database, release.releaseId)
|
|
868
|
+
: releaseFromRow(Schema.decodeUnknownSync(releaseRowSchema)(inserted));
|
|
869
|
+
if (canonical === null)
|
|
870
|
+
throw new Error("Release insert was not retained");
|
|
871
|
+
return {
|
|
872
|
+
kind: inserted === null ? "existing" : "created",
|
|
873
|
+
release: canonical,
|
|
874
|
+
};
|
|
875
|
+
}),
|
|
876
|
+
readPublicSurface: (publicSurfaceId) => storeOperation(async () => {
|
|
877
|
+
const surface = await readPublicSurface(database, publicSurfaceId);
|
|
878
|
+
return surface === null
|
|
879
|
+
? { kind: "absent" }
|
|
880
|
+
: { kind: "found", surface };
|
|
881
|
+
}),
|
|
882
|
+
createPublicSurfaceIfAbsent: (surface) => storeOperation(async () => {
|
|
883
|
+
const inserted = await database
|
|
884
|
+
.prepare(insertPublicSurfaceSql)
|
|
885
|
+
.bind(surface.publicSurfaceId, surface.appId, surface.stableRoute, publicRouteKey(surface.stableRoute), surface.publisher.teamId, surface.publisher.userId, surface.publisher.kind, surface.publisher.requestKey)
|
|
886
|
+
.first();
|
|
887
|
+
const row = inserted ??
|
|
888
|
+
(await database
|
|
889
|
+
.prepare(`SELECT * FROM app_release_public_surfaces
|
|
890
|
+
WHERE publisher_team_id=? AND request_key=?`)
|
|
891
|
+
.bind(surface.publisher.teamId, surface.publisher.requestKey)
|
|
892
|
+
.first());
|
|
893
|
+
if (row === null)
|
|
894
|
+
return { kind: "conflict" };
|
|
895
|
+
const canonical = Schema.decodeUnknownSync(publicSurfaceDefinitionRowSchema)(row);
|
|
896
|
+
if (canonical.app_id !== surface.appId ||
|
|
897
|
+
canonical.stable_route !== surface.stableRoute ||
|
|
898
|
+
canonical.authority_kind !== surface.publisher.kind ||
|
|
899
|
+
canonical.publisher_user_id !== surface.publisher.userId)
|
|
900
|
+
return { kind: "conflict" };
|
|
901
|
+
return {
|
|
902
|
+
kind: inserted === null ? "existing" : "created",
|
|
903
|
+
surface: publicSurfaceFromRow(canonical),
|
|
904
|
+
};
|
|
905
|
+
}),
|
|
906
|
+
readReleaseLifecycle: (releaseId) => storeOperation(async () => {
|
|
907
|
+
const stored = await database
|
|
908
|
+
.prepare(`SELECT release.release_id, release.app_id, release.artifact_ref,
|
|
909
|
+
release.artifact_digest, release.source_revision,
|
|
910
|
+
release.source_tree_digest, release.build_recipe_digest,
|
|
911
|
+
verification.artifact_digest AS verification_digest,
|
|
912
|
+
format.media_type AS verification_media_type,
|
|
913
|
+
metadata.artifact_digest AS verification_metadata_digest,
|
|
914
|
+
metadata.description_json AS verification_description_json,
|
|
915
|
+
CASE WHEN EXISTS (
|
|
916
|
+
SELECT 1 FROM app_release_transitions AS revocation
|
|
917
|
+
WHERE revocation.operation_kind = 'revocation'
|
|
918
|
+
AND revocation.release_id = release.release_id
|
|
919
|
+
) THEN 1 ELSE 0 END AS release_is_revoked
|
|
920
|
+
FROM app_release_releases AS release
|
|
921
|
+
LEFT JOIN app_release_release_verifications AS verification
|
|
922
|
+
ON verification.release_id = release.release_id
|
|
923
|
+
LEFT JOIN app_release_release_verification_formats AS format
|
|
924
|
+
ON format.release_id = release.release_id
|
|
925
|
+
LEFT JOIN app_release_release_verification_metadata AS metadata
|
|
926
|
+
ON metadata.release_id = release.release_id
|
|
927
|
+
WHERE release.release_id = ?`)
|
|
928
|
+
.bind(releaseId)
|
|
929
|
+
.first();
|
|
930
|
+
if (stored === null)
|
|
931
|
+
return { kind: "absent" };
|
|
932
|
+
const row = Schema.decodeUnknownSync(releaseLifecycleRowSchema)(stored);
|
|
933
|
+
const release = releaseFromRow(row);
|
|
934
|
+
const verification = row.verification_digest === null
|
|
935
|
+
? { kind: "unverified" }
|
|
936
|
+
: releaseVerificationFromStored(row.verification_digest, row.verification_media_type, row.verification_metadata_digest, row.verification_description_json);
|
|
937
|
+
return {
|
|
938
|
+
kind: "found",
|
|
939
|
+
release,
|
|
940
|
+
verification,
|
|
941
|
+
revocation: row.release_is_revoked === 1
|
|
942
|
+
? { kind: "revoked" }
|
|
943
|
+
: { kind: "active" },
|
|
944
|
+
};
|
|
945
|
+
}),
|
|
946
|
+
recordReleaseVerification: (input) => storeOperation(async () => {
|
|
947
|
+
const description = Schema.decodeUnknownSync(verifiedAppDescriptionSchema)(input.description);
|
|
948
|
+
const descriptionJson = JSON.stringify(description);
|
|
949
|
+
await database.batch([
|
|
950
|
+
database
|
|
951
|
+
.prepare(`INSERT INTO app_release_release_verifications (release_id, artifact_digest)
|
|
952
|
+
VALUES (?, ?)
|
|
953
|
+
ON CONFLICT (release_id) DO NOTHING`)
|
|
954
|
+
.bind(input.releaseId, input.digest),
|
|
955
|
+
database
|
|
956
|
+
.prepare(`INSERT INTO app_release_release_verification_formats (release_id, media_type)
|
|
957
|
+
VALUES (?, ?)
|
|
958
|
+
ON CONFLICT (release_id) DO NOTHING`)
|
|
959
|
+
.bind(input.releaseId, input.mediaType),
|
|
960
|
+
database
|
|
961
|
+
.prepare(`INSERT INTO app_release_release_verification_metadata
|
|
962
|
+
(release_id, artifact_digest, description_json)
|
|
963
|
+
VALUES (?, ?, ?)
|
|
964
|
+
ON CONFLICT (release_id) DO NOTHING`)
|
|
965
|
+
.bind(input.releaseId, input.digest, descriptionJson),
|
|
966
|
+
]);
|
|
967
|
+
const current = await database
|
|
968
|
+
.prepare(`SELECT verification.artifact_digest AS verification_artifact_digest,
|
|
969
|
+
format.media_type,
|
|
970
|
+
metadata.artifact_digest AS metadata_artifact_digest,
|
|
971
|
+
metadata.description_json
|
|
972
|
+
FROM app_release_release_verifications AS verification
|
|
973
|
+
LEFT JOIN app_release_release_verification_formats AS format
|
|
974
|
+
ON format.release_id = verification.release_id
|
|
975
|
+
LEFT JOIN app_release_release_verification_metadata AS metadata
|
|
976
|
+
ON metadata.release_id = verification.release_id
|
|
977
|
+
WHERE verification.release_id = ?`)
|
|
978
|
+
.bind(input.releaseId)
|
|
979
|
+
.first();
|
|
980
|
+
if (current === null)
|
|
981
|
+
throw new Error("Release verification was not retained");
|
|
982
|
+
const decoded = Schema.decodeUnknownSync(releaseVerificationMetadataRowSchema)(current);
|
|
983
|
+
if (decoded.verification_artifact_digest !== input.digest ||
|
|
984
|
+
decoded.metadata_artifact_digest !== input.digest ||
|
|
985
|
+
decoded.media_type !== input.mediaType ||
|
|
986
|
+
decoded.description_json !== descriptionJson) {
|
|
987
|
+
throw new Error("Release verification conflicts with existing metadata");
|
|
988
|
+
}
|
|
989
|
+
return { kind: "recorded" };
|
|
990
|
+
}),
|
|
991
|
+
readTransition: (request) => storeOperation(async () => {
|
|
992
|
+
const row = await readTransitionRow(database, request);
|
|
993
|
+
if (row === null)
|
|
994
|
+
return { kind: "absent" };
|
|
995
|
+
const receipt = transitionReceiptFromRow(row);
|
|
996
|
+
return sameReleaseTransitionMaterial(request, receipt.transition)
|
|
997
|
+
? { kind: "found", receipt }
|
|
998
|
+
: { kind: "conflict" };
|
|
999
|
+
}),
|
|
1000
|
+
persistTransition: (request, receipt) => storeOperation(async () => {
|
|
1001
|
+
const insert = request.kind === "revocation"
|
|
1002
|
+
? `INSERT INTO app_release_transitions
|
|
1003
|
+
(transition_id, operation_kind, idempotency_key, release_id, public_surface_id)
|
|
1004
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1005
|
+
ON CONFLICT DO NOTHING
|
|
1006
|
+
RETURNING transition_id, operation_kind, idempotency_key, release_id, public_surface_id`
|
|
1007
|
+
: `INSERT INTO app_release_transitions
|
|
1008
|
+
(transition_id, operation_kind, idempotency_key, release_id, public_surface_id)
|
|
1009
|
+
SELECT ?, ?, ?, ?, ?
|
|
1010
|
+
FROM app_release_selection_ready e
|
|
1011
|
+
WHERE e.public_surface_id=? AND e.release_id=?
|
|
1012
|
+
AND (?='promotion' OR (e.already_held=1 AND EXISTS (
|
|
1013
|
+
SELECT 1 FROM app_release_transitions prior
|
|
1014
|
+
WHERE prior.public_surface_id=e.public_surface_id AND prior.release_id=e.release_id
|
|
1015
|
+
AND prior.operation_kind IN ('promotion','rollback')
|
|
1016
|
+
)))
|
|
1017
|
+
AND NOT EXISTS (SELECT 1 FROM app_release_transitions t
|
|
1018
|
+
WHERE (t.operation_kind=? AND t.idempotency_key=?) OR t.transition_id=?)
|
|
1019
|
+
ON CONFLICT DO NOTHING
|
|
1020
|
+
RETURNING transition_id, operation_kind, idempotency_key, release_id, public_surface_id`;
|
|
1021
|
+
const values = [
|
|
1022
|
+
receipt.transitionId,
|
|
1023
|
+
request.kind,
|
|
1024
|
+
request.idempotencyKey,
|
|
1025
|
+
request.releaseId,
|
|
1026
|
+
request.kind === "revocation" ? null : request.publicSurfaceId,
|
|
1027
|
+
...(request.kind === "revocation"
|
|
1028
|
+
? []
|
|
1029
|
+
: [
|
|
1030
|
+
request.publicSurfaceId,
|
|
1031
|
+
request.releaseId,
|
|
1032
|
+
request.kind,
|
|
1033
|
+
request.kind,
|
|
1034
|
+
request.idempotencyKey,
|
|
1035
|
+
receipt.transitionId,
|
|
1036
|
+
]),
|
|
1037
|
+
];
|
|
1038
|
+
const inserted = await database
|
|
1039
|
+
.prepare(insert)
|
|
1040
|
+
.bind(...values)
|
|
1041
|
+
.first();
|
|
1042
|
+
if (inserted !== null) {
|
|
1043
|
+
return {
|
|
1044
|
+
kind: "created",
|
|
1045
|
+
receipt: transitionReceiptFromRow(Schema.decodeUnknownSync(transitionRowSchema)(inserted)),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
const byKey = await readTransitionRow(database, request);
|
|
1049
|
+
if (byKey !== null) {
|
|
1050
|
+
const existing = transitionReceiptFromRow(byKey);
|
|
1051
|
+
return sameReleaseTransitionMaterial(request, existing.transition)
|
|
1052
|
+
? { kind: "existing", receipt: existing }
|
|
1053
|
+
: { kind: "conflict" };
|
|
1054
|
+
}
|
|
1055
|
+
if (request.kind !== "revocation") {
|
|
1056
|
+
const revocation = await database
|
|
1057
|
+
.prepare(`SELECT transition_id, operation_kind, idempotency_key, release_id,
|
|
1058
|
+
public_surface_id
|
|
1059
|
+
FROM app_release_transitions
|
|
1060
|
+
WHERE operation_kind = 'revocation' AND release_id = ?`)
|
|
1061
|
+
.bind(request.releaseId)
|
|
1062
|
+
.first();
|
|
1063
|
+
if (revocation !== null) {
|
|
1064
|
+
transitionReceiptFromRow(Schema.decodeUnknownSync(transitionRowSchema)(revocation));
|
|
1065
|
+
return { kind: "lifecycle-conflict" };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const byIdentity = await database
|
|
1069
|
+
.prepare("SELECT transition_id, operation_kind, idempotency_key, release_id, public_surface_id FROM app_release_transitions WHERE transition_id = ?")
|
|
1070
|
+
.bind(receipt.transitionId)
|
|
1071
|
+
.first();
|
|
1072
|
+
if (byIdentity !== null)
|
|
1073
|
+
return { kind: "identity-collision" };
|
|
1074
|
+
return { kind: "lifecycle-conflict" };
|
|
1075
|
+
}),
|
|
1076
|
+
};
|
|
1077
|
+
const result = { ...store, transitionObligations };
|
|
1078
|
+
registerPromisePublicWebsiteAuthorityReader(result, (requests) => storeOperation(async () => {
|
|
1079
|
+
if (requests.length === 0)
|
|
1080
|
+
return [];
|
|
1081
|
+
const results = await database.batch(requests.map((request) => publicWebsiteAuthorityStatement(database, request.stableRoute, request.expectedReleaseId)));
|
|
1082
|
+
if (results.length !== requests.length)
|
|
1083
|
+
throw new Error("Incomplete route authority batch");
|
|
1084
|
+
return results.map(publicWebsiteAuthorityResult);
|
|
1085
|
+
}));
|
|
1086
|
+
return result;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Create the R2-backed artifact reader for opaque artifact references.
|
|
1090
|
+
*
|
|
1091
|
+
* Asset manifests are verified cheaply for delivery: the root bytes are
|
|
1092
|
+
* digest-checked here, while the asset handler checks the requested object.
|
|
1093
|
+
* Publication callers pass `verification: "publication"` to retain the
|
|
1094
|
+
* complete manifest object sweep.
|
|
1095
|
+
*/
|
|
1096
|
+
export function createR2ArtifactReader(bucket, policy = DEFAULT_ARTIFACT_SNAPSHOT_POLICY, assets) {
|
|
1097
|
+
const snapshotPolicy = createArtifactSnapshotPolicy(policy.maxBytes, policy.maxManifestBytes);
|
|
1098
|
+
return {
|
|
1099
|
+
read: (input) => readR2Artifact(bucket, input, snapshotPolicy, assets),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
async function readApp(database, appId) {
|
|
1103
|
+
const row = await database
|
|
1104
|
+
.prepare("SELECT app_id FROM app_release_apps WHERE app_id = ?")
|
|
1105
|
+
.bind(appId)
|
|
1106
|
+
.first();
|
|
1107
|
+
return row === null
|
|
1108
|
+
? null
|
|
1109
|
+
: appFromRow(Schema.decodeUnknownSync(appRowSchema)(row));
|
|
1110
|
+
}
|
|
1111
|
+
async function readRelease(database, releaseId) {
|
|
1112
|
+
const row = await database
|
|
1113
|
+
.prepare(`SELECT release_id, app_id, artifact_ref, artifact_digest,
|
|
1114
|
+
source_revision, source_tree_digest, build_recipe_digest
|
|
1115
|
+
FROM app_release_releases WHERE release_id = ?`)
|
|
1116
|
+
.bind(releaseId)
|
|
1117
|
+
.first();
|
|
1118
|
+
return row === null
|
|
1119
|
+
? null
|
|
1120
|
+
: releaseFromRow(Schema.decodeUnknownSync(releaseRowSchema)(row));
|
|
1121
|
+
}
|
|
1122
|
+
async function readPublicSurface(database, publicSurfaceId) {
|
|
1123
|
+
const row = await database
|
|
1124
|
+
.prepare(`SELECT public_surface_id, app_id, stable_route
|
|
1125
|
+
FROM app_release_public_surfaces WHERE public_surface_id = ?`)
|
|
1126
|
+
.bind(publicSurfaceId)
|
|
1127
|
+
.first();
|
|
1128
|
+
return row === null
|
|
1129
|
+
? null
|
|
1130
|
+
: publicSurfaceFromRow(Schema.decodeUnknownSync(publicSurfaceRowSchema)(row));
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Read the route, selection, and selected Release lifecycle in one D1 snapshot.
|
|
1134
|
+
* The route CTE deliberately caps its result at two rows: absence, one authority,
|
|
1135
|
+
* or the fail-closed ambiguous condition are the only A1.2 observations.
|
|
1136
|
+
*/
|
|
1137
|
+
async function readPublicWebsiteAuthority(database, stableRoute, expectedReleaseId) {
|
|
1138
|
+
return publicWebsiteAuthorityResult(await publicWebsiteAuthorityStatement(database, stableRoute, expectedReleaseId).all());
|
|
1139
|
+
}
|
|
1140
|
+
function publicWebsiteAuthorityStatement(database, stableRoute, expectedReleaseId) {
|
|
1141
|
+
return database
|
|
1142
|
+
.prepare(`WITH matching_surfaces AS MATERIALIZED (
|
|
1143
|
+
SELECT s.public_surface_id, s.app_id, s.stable_route
|
|
1144
|
+
FROM app_release_route_claims c
|
|
1145
|
+
JOIN app_release_public_surfaces s ON s.public_surface_id=c.public_surface_id
|
|
1146
|
+
WHERE c.route_key = ? AND c.stable_route = ? AND c.state='held'
|
|
1147
|
+
AND NOT EXISTS (SELECT 1 FROM app_release_surface_retirements x WHERE x.public_surface_id=s.public_surface_id)
|
|
1148
|
+
LIMIT 2
|
|
1149
|
+
), selected_transitions AS (
|
|
1150
|
+
SELECT transition.public_surface_id, transition.release_id,
|
|
1151
|
+
transition.sequence
|
|
1152
|
+
FROM app_release_transitions AS transition
|
|
1153
|
+
INNER JOIN matching_surfaces AS surface
|
|
1154
|
+
ON surface.public_surface_id = transition.public_surface_id
|
|
1155
|
+
WHERE transition.operation_kind IN ('promotion', 'rollback')
|
|
1156
|
+
AND transition.sequence = (
|
|
1157
|
+
SELECT MAX(latest.sequence)
|
|
1158
|
+
FROM app_release_transitions AS latest
|
|
1159
|
+
WHERE latest.public_surface_id = transition.public_surface_id
|
|
1160
|
+
AND latest.operation_kind IN ('promotion', 'rollback')
|
|
1161
|
+
AND (? IS NULL OR latest.release_id = ?)
|
|
1162
|
+
)
|
|
1163
|
+
)
|
|
1164
|
+
SELECT
|
|
1165
|
+
app.app_id,
|
|
1166
|
+
surface.public_surface_id,
|
|
1167
|
+
surface.app_id AS surface_app_id,
|
|
1168
|
+
surface.stable_route,
|
|
1169
|
+
selection.release_id AS selected_release_id,
|
|
1170
|
+
(SELECT latest.release_id FROM app_release_transitions AS latest
|
|
1171
|
+
WHERE latest.public_surface_id = surface.public_surface_id
|
|
1172
|
+
AND latest.operation_kind IN ('promotion', 'rollback')
|
|
1173
|
+
ORDER BY latest.sequence DESC LIMIT 1) AS current_release_id,
|
|
1174
|
+
selection.sequence AS selected_transition_sequence,
|
|
1175
|
+
release.release_id,
|
|
1176
|
+
release.app_id AS release_app_id,
|
|
1177
|
+
release.artifact_ref,
|
|
1178
|
+
release.artifact_digest,
|
|
1179
|
+
release.source_revision,
|
|
1180
|
+
release.source_tree_digest,
|
|
1181
|
+
release.build_recipe_digest,
|
|
1182
|
+
verification.artifact_digest AS verification_digest,
|
|
1183
|
+
format.media_type AS verification_media_type,
|
|
1184
|
+
metadata.artifact_digest AS verification_metadata_digest,
|
|
1185
|
+
metadata.description_json AS verification_description_json,
|
|
1186
|
+
CASE WHEN EXISTS (
|
|
1187
|
+
SELECT 1
|
|
1188
|
+
FROM app_release_transitions AS revocation
|
|
1189
|
+
WHERE revocation.operation_kind = 'revocation'
|
|
1190
|
+
AND revocation.release_id = selection.release_id
|
|
1191
|
+
) THEN 1 ELSE 0 END AS release_is_revoked
|
|
1192
|
+
FROM matching_surfaces AS surface
|
|
1193
|
+
INNER JOIN app_release_apps AS app
|
|
1194
|
+
ON app.app_id = surface.app_id
|
|
1195
|
+
LEFT JOIN selected_transitions AS selection
|
|
1196
|
+
ON selection.public_surface_id = surface.public_surface_id
|
|
1197
|
+
LEFT JOIN app_release_releases AS release
|
|
1198
|
+
ON release.release_id = selection.release_id
|
|
1199
|
+
LEFT JOIN app_release_release_verifications AS verification
|
|
1200
|
+
ON verification.release_id = release.release_id
|
|
1201
|
+
LEFT JOIN app_release_release_verification_formats AS format
|
|
1202
|
+
ON format.release_id = release.release_id
|
|
1203
|
+
LEFT JOIN app_release_release_verification_metadata AS metadata
|
|
1204
|
+
ON metadata.release_id = release.release_id`)
|
|
1205
|
+
.bind(publicRouteKey(stableRoute), stableRoute, expectedReleaseId ?? null, expectedReleaseId ?? null);
|
|
1206
|
+
}
|
|
1207
|
+
function publicWebsiteAuthorityResult(result) {
|
|
1208
|
+
const reportedSuccess = result.success;
|
|
1209
|
+
if (reportedSuccess !== true) {
|
|
1210
|
+
throw new Error("Public website authority query was unsuccessful");
|
|
1211
|
+
}
|
|
1212
|
+
const rows = result.results.map((row) => Schema.decodeUnknownSync(publicWebsiteAuthorityRowSchema)(row));
|
|
1213
|
+
if (rows.length > 1)
|
|
1214
|
+
return { kind: "ambiguous" };
|
|
1215
|
+
const row = rows[0];
|
|
1216
|
+
return row === undefined
|
|
1217
|
+
? { kind: "absent" }
|
|
1218
|
+
: publicWebsiteAuthorityFromRow(row);
|
|
1219
|
+
}
|
|
1220
|
+
function publicWebsiteAuthorityFromRow(row) {
|
|
1221
|
+
const surface = publicSurfaceFromRow({
|
|
1222
|
+
public_surface_id: row.public_surface_id,
|
|
1223
|
+
app_id: row.surface_app_id,
|
|
1224
|
+
stable_route: row.stable_route,
|
|
1225
|
+
});
|
|
1226
|
+
if (row.app_id === null) {
|
|
1227
|
+
throw new Error("Malformed public website authority App row");
|
|
1228
|
+
}
|
|
1229
|
+
const app = appFromRow({ app_id: row.app_id });
|
|
1230
|
+
if (row.selected_release_id === null) {
|
|
1231
|
+
return unselectedPublicWebsiteAuthority(app, surface);
|
|
1232
|
+
}
|
|
1233
|
+
const releaseId = parseReleaseId(row.selected_release_id);
|
|
1234
|
+
const sequence = Schema.decodeUnknownSync(Schema.Number.pipe(Schema.check(Schema.isInt()), Schema.check(Schema.isGreaterThan(0))))(row.selected_transition_sequence);
|
|
1235
|
+
if (row.release_id === null) {
|
|
1236
|
+
return {
|
|
1237
|
+
kind: "selected",
|
|
1238
|
+
app,
|
|
1239
|
+
surface,
|
|
1240
|
+
releaseId,
|
|
1241
|
+
currentReleaseId: Schema.decodeUnknownSync(releaseIdSchema)(row.current_release_id),
|
|
1242
|
+
sequence,
|
|
1243
|
+
release: { kind: "absent" },
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
const stored = decodeCompleteRelease(row);
|
|
1247
|
+
if (stored?.release_id !== row.selected_release_id) {
|
|
1248
|
+
throw new Error("Malformed public website authority row");
|
|
1249
|
+
}
|
|
1250
|
+
return selectedPublicWebsiteAuthority(row, app, surface, releaseId, sequence, stored);
|
|
1251
|
+
}
|
|
1252
|
+
function unselectedPublicWebsiteAuthority(app, surface) {
|
|
1253
|
+
return { kind: "unselected", app, surface };
|
|
1254
|
+
}
|
|
1255
|
+
function decodeCompleteRelease(row) {
|
|
1256
|
+
const decoded = Schema.decodeUnknownResult(Schema.Struct({
|
|
1257
|
+
release_id: Schema.String,
|
|
1258
|
+
release_app_id: Schema.String,
|
|
1259
|
+
artifact_ref: Schema.String,
|
|
1260
|
+
artifact_digest: Schema.String,
|
|
1261
|
+
source_revision: Schema.String,
|
|
1262
|
+
source_tree_digest: Schema.String,
|
|
1263
|
+
build_recipe_digest: Schema.String,
|
|
1264
|
+
release_is_revoked: Schema.Literals([0, 1]),
|
|
1265
|
+
}))(row);
|
|
1266
|
+
return decoded._tag === "Success" ? decoded.success : null;
|
|
1267
|
+
}
|
|
1268
|
+
function selectedPublicWebsiteAuthority(row, app, surface, releaseId, sequence, stored) {
|
|
1269
|
+
return {
|
|
1270
|
+
kind: "selected",
|
|
1271
|
+
app,
|
|
1272
|
+
surface,
|
|
1273
|
+
releaseId,
|
|
1274
|
+
currentReleaseId: Schema.decodeUnknownSync(releaseIdSchema)(row.current_release_id),
|
|
1275
|
+
sequence,
|
|
1276
|
+
release: {
|
|
1277
|
+
kind: "found",
|
|
1278
|
+
release: releaseFromRow({
|
|
1279
|
+
release_id: stored.release_id,
|
|
1280
|
+
app_id: stored.release_app_id,
|
|
1281
|
+
artifact_ref: stored.artifact_ref,
|
|
1282
|
+
artifact_digest: stored.artifact_digest,
|
|
1283
|
+
source_revision: stored.source_revision,
|
|
1284
|
+
source_tree_digest: stored.source_tree_digest,
|
|
1285
|
+
build_recipe_digest: stored.build_recipe_digest,
|
|
1286
|
+
}),
|
|
1287
|
+
verification: row.verification_digest === null
|
|
1288
|
+
? { kind: "unverified" }
|
|
1289
|
+
: releaseVerificationFromStored(row.verification_digest, row.verification_media_type, row.verification_metadata_digest, row.verification_description_json),
|
|
1290
|
+
revocation: stored.release_is_revoked === 1
|
|
1291
|
+
? { kind: "revoked" }
|
|
1292
|
+
: { kind: "active" },
|
|
1293
|
+
},
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function decodeStoredMediaType(value) {
|
|
1297
|
+
const decoded = Schema.decodeUnknownResult(artifactMediaTypeSchema)(value);
|
|
1298
|
+
return decoded._tag === "Success" ? decoded.success : undefined;
|
|
1299
|
+
}
|
|
1300
|
+
function releaseVerificationFromStored(digest, storedMediaType, metadataDigest, descriptionJson) {
|
|
1301
|
+
const parsedDigest = parseSha256Digest(digest);
|
|
1302
|
+
const mediaType = decodeStoredMediaType(storedMediaType);
|
|
1303
|
+
if (mediaType !== "application/vnd.fungi.app+json" ||
|
|
1304
|
+
metadataDigest !== digest ||
|
|
1305
|
+
descriptionJson === null) {
|
|
1306
|
+
return { kind: "unverified" };
|
|
1307
|
+
}
|
|
1308
|
+
const decoded = Schema.decodeUnknownResult(verifiedAppDescriptionSchema)(JSON.parse(descriptionJson));
|
|
1309
|
+
return decoded._tag === "Success"
|
|
1310
|
+
? {
|
|
1311
|
+
kind: "verified",
|
|
1312
|
+
digest: parsedDigest,
|
|
1313
|
+
mediaType,
|
|
1314
|
+
description: decoded.success,
|
|
1315
|
+
}
|
|
1316
|
+
: { kind: "unverified" };
|
|
1317
|
+
}
|
|
1318
|
+
function appFromRow(row) {
|
|
1319
|
+
return { appId: parseAppId(row.app_id) };
|
|
1320
|
+
}
|
|
1321
|
+
function releaseFromRow(row) {
|
|
1322
|
+
return {
|
|
1323
|
+
releaseId: parseReleaseId(row.release_id),
|
|
1324
|
+
appId: parseAppId(row.app_id),
|
|
1325
|
+
artifact: {
|
|
1326
|
+
artifactRef: parseArtifactRef(row.artifact_ref),
|
|
1327
|
+
artifactDigest: parseSha256Digest(row.artifact_digest),
|
|
1328
|
+
provenance: {
|
|
1329
|
+
sourceRevision: parseSourceRevision(row.source_revision),
|
|
1330
|
+
sourceTreeDigest: parseSha256Digest(row.source_tree_digest),
|
|
1331
|
+
buildRecipeDigest: parseSha256Digest(row.build_recipe_digest),
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
function publicSurfaceFromRow(row) {
|
|
1337
|
+
return {
|
|
1338
|
+
publicSurfaceId: parsePublicSurfaceId(row.public_surface_id),
|
|
1339
|
+
appId: parseAppId(row.app_id),
|
|
1340
|
+
stableRoute: parseStableRoute(row.stable_route),
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
async function readTransitionRow(database, request) {
|
|
1344
|
+
const row = await database
|
|
1345
|
+
.prepare(`SELECT transition_id, operation_kind, idempotency_key, release_id,
|
|
1346
|
+
public_surface_id
|
|
1347
|
+
FROM app_release_transitions
|
|
1348
|
+
WHERE operation_kind = ? AND idempotency_key = ?`)
|
|
1349
|
+
.bind(request.kind, request.idempotencyKey)
|
|
1350
|
+
.first();
|
|
1351
|
+
return row === null
|
|
1352
|
+
? null
|
|
1353
|
+
: Schema.decodeUnknownSync(transitionRowSchema)(row);
|
|
1354
|
+
}
|
|
1355
|
+
function transitionReceiptFromRow(row) {
|
|
1356
|
+
const transitionId = parseAppTransitionId(row.transition_id);
|
|
1357
|
+
const releaseId = parseReleaseId(row.release_id);
|
|
1358
|
+
const idempotencyKey = parseIdempotencyKey(row.idempotency_key);
|
|
1359
|
+
switch (row.operation_kind) {
|
|
1360
|
+
case "revocation":
|
|
1361
|
+
if (row.public_surface_id !== null)
|
|
1362
|
+
throw new Error("Malformed transition row");
|
|
1363
|
+
return {
|
|
1364
|
+
transition: { kind: "revocation", releaseId, idempotencyKey },
|
|
1365
|
+
transitionId,
|
|
1366
|
+
};
|
|
1367
|
+
case "promotion":
|
|
1368
|
+
case "rollback":
|
|
1369
|
+
if (row.public_surface_id === null)
|
|
1370
|
+
throw new Error("Malformed transition row");
|
|
1371
|
+
return {
|
|
1372
|
+
transition: {
|
|
1373
|
+
kind: row.operation_kind,
|
|
1374
|
+
publicSurfaceId: parsePublicSurfaceId(row.public_surface_id),
|
|
1375
|
+
releaseId,
|
|
1376
|
+
idempotencyKey,
|
|
1377
|
+
},
|
|
1378
|
+
transitionId,
|
|
1379
|
+
};
|
|
1380
|
+
default:
|
|
1381
|
+
throw new Error("Malformed transition row");
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
async function readTransitionObligationRow(database, sequence) {
|
|
1385
|
+
const row = await database
|
|
1386
|
+
.prepare(`SELECT sequence, transition_id, operation_kind, idempotency_key,
|
|
1387
|
+
release_id, public_surface_id, state
|
|
1388
|
+
FROM app_release_transition_obligations WHERE sequence = ?`)
|
|
1389
|
+
.bind(sequence)
|
|
1390
|
+
.first();
|
|
1391
|
+
return row === null
|
|
1392
|
+
? null
|
|
1393
|
+
: Schema.decodeUnknownSync(transitionObligationRowSchema)(row);
|
|
1394
|
+
}
|
|
1395
|
+
async function readTransitionObligationByTransitionId(database, transitionId) {
|
|
1396
|
+
const row = await database
|
|
1397
|
+
.prepare(`SELECT sequence, transition_id, operation_kind, idempotency_key,
|
|
1398
|
+
release_id, public_surface_id, state
|
|
1399
|
+
FROM app_release_transition_obligations WHERE transition_id = ?`)
|
|
1400
|
+
.bind(transitionId)
|
|
1401
|
+
.first();
|
|
1402
|
+
return row === null
|
|
1403
|
+
? null
|
|
1404
|
+
: Schema.decodeUnknownSync(transitionObligationRowSchema)(row);
|
|
1405
|
+
}
|
|
1406
|
+
async function readTransitionObligationEnumerationRow(database, sequence) {
|
|
1407
|
+
const row = await database
|
|
1408
|
+
.prepare(`SELECT sequence, cursor, started, complete
|
|
1409
|
+
FROM app_release_transition_obligation_enumeration WHERE sequence = ?`)
|
|
1410
|
+
.bind(sequence)
|
|
1411
|
+
.first();
|
|
1412
|
+
return row === null
|
|
1413
|
+
? null
|
|
1414
|
+
: Schema.decodeUnknownSync(transitionObligationEnumerationRowSchema)(row);
|
|
1415
|
+
}
|
|
1416
|
+
function recipientPageKey(cursor) {
|
|
1417
|
+
return cursor ?? "";
|
|
1418
|
+
}
|
|
1419
|
+
async function readTransitionObligationPage(database, sequence, cursorIsInitial, cursorKey) {
|
|
1420
|
+
const row = await database
|
|
1421
|
+
.prepare(`SELECT sequence, cursor, cursor_is_initial, next_cursor, complete, team_ids_json
|
|
1422
|
+
FROM app_release_transition_obligation_pages
|
|
1423
|
+
WHERE sequence = ? AND cursor_is_initial = ? AND cursor_key = ?`)
|
|
1424
|
+
.bind(sequence, cursorIsInitial, cursorKey)
|
|
1425
|
+
.first();
|
|
1426
|
+
return row === null
|
|
1427
|
+
? null
|
|
1428
|
+
: Schema.decodeUnknownSync(transitionObligationPageRowSchema)(row);
|
|
1429
|
+
}
|
|
1430
|
+
function sameStringArray(left, right) {
|
|
1431
|
+
return (left.length === right.length &&
|
|
1432
|
+
left.every((value, index) => value === right[index]));
|
|
1433
|
+
}
|
|
1434
|
+
function teamRecipientFromRow(row) {
|
|
1435
|
+
const state = Schema.decodeUnknownSync(teamRecipientStateSchema)(row.state);
|
|
1436
|
+
return { teamId: row.team_id, state };
|
|
1437
|
+
}
|
|
1438
|
+
async function transitionObligationFromRow(database, row) {
|
|
1439
|
+
const receipt = transitionReceiptFromRow(row);
|
|
1440
|
+
const affectedSurfaces = await readAffectedPublicSurfaces(database, receipt.transition, row.sequence);
|
|
1441
|
+
const enumeration = await readTransitionObligationEnumerationRow(database, row.sequence);
|
|
1442
|
+
if (enumeration === null)
|
|
1443
|
+
throw new Error("Transition enumeration was absent");
|
|
1444
|
+
const valueInput = {
|
|
1445
|
+
sequence: row.sequence,
|
|
1446
|
+
transitionId: receipt.transitionId,
|
|
1447
|
+
affectedPublicSurfaceIds: affectedSurfaces.map((surface) => surface.publicSurfaceId),
|
|
1448
|
+
affectedSurfaces,
|
|
1449
|
+
affectedBuyerApps: receipt.transition.kind === "revocation"
|
|
1450
|
+
? await createD1BuyerReferences(database).affectedApps(receipt.transition.releaseId)
|
|
1451
|
+
: [],
|
|
1452
|
+
enumeration: {
|
|
1453
|
+
cursor: enumeration.cursor,
|
|
1454
|
+
complete: enumeration.complete === 1,
|
|
1455
|
+
},
|
|
1456
|
+
state: row.state,
|
|
1457
|
+
};
|
|
1458
|
+
const value = Schema.decodeUnknownSync(transitionObligationValueSchema)(valueInput);
|
|
1459
|
+
return { ...value, transition: receipt.transition };
|
|
1460
|
+
}
|
|
1461
|
+
async function readAffectedPublicSurfaces(database, transition, sequence) {
|
|
1462
|
+
const surfaceIds = transition.kind !== "revocation" ? [transition.publicSurfaceId] : undefined;
|
|
1463
|
+
const result = await database
|
|
1464
|
+
.prepare(surfaceIds === undefined
|
|
1465
|
+
? `SELECT DISTINCT current.public_surface_id, surface.app_id, surface.stable_route
|
|
1466
|
+
FROM app_release_transitions AS current
|
|
1467
|
+
INNER JOIN app_release_public_surfaces AS surface
|
|
1468
|
+
ON surface.public_surface_id = current.public_surface_id
|
|
1469
|
+
WHERE current.operation_kind IN ('promotion', 'rollback')
|
|
1470
|
+
AND current.release_id = ?
|
|
1471
|
+
AND current.sequence <= ?
|
|
1472
|
+
ORDER BY current.public_surface_id`
|
|
1473
|
+
: `SELECT public_surface_id, app_id, stable_route
|
|
1474
|
+
FROM app_release_public_surfaces WHERE public_surface_id = ?`)
|
|
1475
|
+
.bind(...(surfaceIds === undefined
|
|
1476
|
+
? [transition.releaseId, sequence]
|
|
1477
|
+
: [surfaceIds[0]]))
|
|
1478
|
+
.all();
|
|
1479
|
+
return result.results.map((row) => {
|
|
1480
|
+
const decoded = Schema.decodeUnknownSync(publicSurfaceRowSchema)(row);
|
|
1481
|
+
return {
|
|
1482
|
+
publicSurfaceId: parsePublicSurfaceId(decoded.public_surface_id),
|
|
1483
|
+
appId: parseAppId(decoded.app_id),
|
|
1484
|
+
stableRoute: parseStableRoute(decoded.stable_route),
|
|
1485
|
+
};
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Open the D1-only obligation adjunct for a future owner-driven dispatcher.
|
|
1490
|
+
* The ordinary App Release store contract intentionally does not expose it.
|
|
1491
|
+
*/
|
|
1492
|
+
export function createD1TransitionObligationStore(database) {
|
|
1493
|
+
return createD1AppReleaseStore(database).transitionObligations;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Resolve one previously selected Release on a Surface and retain the exact
|
|
1497
|
+
* transition sequence. The Release need not remain the Surface's current
|
|
1498
|
+
* default, but it must still be verified, active, and owned by that Surface.
|
|
1499
|
+
*/
|
|
1500
|
+
export function readD1SurfaceTransitionBasis(database, stableRoute, releaseId, now = Date.now) {
|
|
1501
|
+
return storeOperation(async () => {
|
|
1502
|
+
const checkedAt = now();
|
|
1503
|
+
const authority = await readPublicWebsiteAuthority(database, stableRoute, releaseId);
|
|
1504
|
+
if (authority.kind === "absent" || authority.kind === "ambiguous") {
|
|
1505
|
+
return authority;
|
|
1506
|
+
}
|
|
1507
|
+
if (authority.kind === "unselected") {
|
|
1508
|
+
return {
|
|
1509
|
+
kind: "none",
|
|
1510
|
+
appId: authority.surface.appId,
|
|
1511
|
+
publicSurfaceId: authority.surface.publicSurfaceId,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
const lifecycle = authority.release;
|
|
1515
|
+
if (lifecycle.kind === "absent")
|
|
1516
|
+
throw new Error("Selected Release is missing from the catalog");
|
|
1517
|
+
return {
|
|
1518
|
+
kind: "selected",
|
|
1519
|
+
appId: authority.surface.appId,
|
|
1520
|
+
publicSurfaceId: authority.surface.publicSurfaceId,
|
|
1521
|
+
releaseId,
|
|
1522
|
+
sequence: authority.sequence,
|
|
1523
|
+
checkedAt,
|
|
1524
|
+
eligibility: lifecycle.release.appId === authority.surface.appId &&
|
|
1525
|
+
lifecycle.verification.kind === "verified" &&
|
|
1526
|
+
lifecycle.verification.digest ===
|
|
1527
|
+
lifecycle.release.artifact.artifactDigest &&
|
|
1528
|
+
lifecycle.revocation.kind === "active"
|
|
1529
|
+
? "eligible"
|
|
1530
|
+
: "ineligible",
|
|
1531
|
+
};
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
/** Match one eligible selected Release without leaking store-result branching to consumers. */
|
|
1535
|
+
export function matchEligibleSurfaceTransitionBasis(result, expected) {
|
|
1536
|
+
if (result.kind !== "success" || result.value.kind !== "selected")
|
|
1537
|
+
return null;
|
|
1538
|
+
const basis = result.value;
|
|
1539
|
+
if (basis.eligibility !== "eligible" ||
|
|
1540
|
+
basis.appId !== expected.appId ||
|
|
1541
|
+
basis.releaseId !== expected.releaseId ||
|
|
1542
|
+
(expected.sequence !== undefined && basis.sequence !== expected.sequence))
|
|
1543
|
+
return null;
|
|
1544
|
+
return basis;
|
|
1545
|
+
}
|
|
1546
|
+
async function readR2Artifact(bucket, input, policy, assets) {
|
|
1547
|
+
try {
|
|
1548
|
+
const object = await bucket.get(input.artifactRef);
|
|
1549
|
+
if (object === null)
|
|
1550
|
+
return { kind: "missing" };
|
|
1551
|
+
if (!matchesExpectedObject(object.version, object.size, input.expectedObject)) {
|
|
1552
|
+
return { kind: "unavailable" };
|
|
1553
|
+
}
|
|
1554
|
+
const format = Schema.decodeUnknownResult(artifactMediaTypeSchema)(object.httpMetadata?.contentType);
|
|
1555
|
+
if (format._tag === "Failure")
|
|
1556
|
+
return { kind: "unavailable" };
|
|
1557
|
+
const mediaType = format.success;
|
|
1558
|
+
const isManifest = mediaType === assetBuildMediaType || mediaType === appMediaType;
|
|
1559
|
+
const maxBytes = isManifest
|
|
1560
|
+
? (policy.maxManifestBytes ?? policy.maxBytes)
|
|
1561
|
+
: policy.maxBytes;
|
|
1562
|
+
if (!isAllowedObjectSize(object.size, maxBytes)) {
|
|
1563
|
+
return { kind: "unavailable" };
|
|
1564
|
+
}
|
|
1565
|
+
const bytes = new Uint8Array(await object.arrayBuffer());
|
|
1566
|
+
const digest = parseSha256Digest(`sha256:${bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)))}`);
|
|
1567
|
+
if (digest !== input.expectedDigest) {
|
|
1568
|
+
return { kind: "digest-mismatch", actualDigest: digest };
|
|
1569
|
+
}
|
|
1570
|
+
if (bytes.byteLength !== object.size)
|
|
1571
|
+
return { kind: "unavailable" };
|
|
1572
|
+
if (mediaType === assetBuildMediaType &&
|
|
1573
|
+
input.verification === "publication") {
|
|
1574
|
+
if (!assets)
|
|
1575
|
+
return { kind: "unavailable" };
|
|
1576
|
+
const { verifyAssetBuildObjects } = await import("./cloudflare-assets.js");
|
|
1577
|
+
await verifyAssetBuildObjects(bytes, assets.bucket, assets.policy);
|
|
1578
|
+
}
|
|
1579
|
+
const snapshot = bytes.slice();
|
|
1580
|
+
return {
|
|
1581
|
+
kind: "verified",
|
|
1582
|
+
digest,
|
|
1583
|
+
mediaType,
|
|
1584
|
+
bytes: {
|
|
1585
|
+
byteLength: snapshot.byteLength,
|
|
1586
|
+
copy: () => snapshot.slice(),
|
|
1587
|
+
},
|
|
1588
|
+
byteLength: snapshot.byteLength,
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
catch (cause) {
|
|
1592
|
+
reportFailure("read R2 artifact", cause);
|
|
1593
|
+
return { kind: "unavailable" };
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
function matchesExpectedObject(version, byteLength, expected) {
|
|
1597
|
+
return (expected === undefined ||
|
|
1598
|
+
(version === expected.version && byteLength === expected.byteLength));
|
|
1599
|
+
}
|
|
1600
|
+
function isAllowedObjectSize(byteLength, maxBytes) {
|
|
1601
|
+
return (maxBytes !== undefined &&
|
|
1602
|
+
Number.isSafeInteger(byteLength) &&
|
|
1603
|
+
byteLength >= 1 &&
|
|
1604
|
+
byteLength <= maxBytes);
|
|
1605
|
+
}
|
|
1606
|
+
function bytesToHex(bytes) {
|
|
1607
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1608
|
+
}
|
|
1609
|
+
function storeOperation(operation) {
|
|
1610
|
+
return operation()
|
|
1611
|
+
.then((value) => ({ kind: "success", value }))
|
|
1612
|
+
.catch((cause) => {
|
|
1613
|
+
reportFailure("persist App Release operation", cause);
|
|
1614
|
+
return unavailableValue();
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
function unavailableValue() {
|
|
1618
|
+
return { kind: "failure", failure: { kind: "unavailable" } };
|
|
1619
|
+
}
|