Skip to content

Commit f84f954

Browse files
committed
write v2 format to apps-enabled during createAppDeployment for 100% dedup
1 parent 5cc6655 commit f84f954

File tree

5 files changed

+132
-37
lines changed

5 files changed

+132
-37
lines changed

integration-tests/tests/api/app-deployments.spec.ts

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5991,13 +5991,10 @@ test('v1 format documents are accessible via CDN using format from apps-enabled
59915991
}).then(res => res.expectNoGraphQLErrors());
59925992

59935993
// Not activated: apps-enabled body should be v1-inactive, CDN should reject
5994-
const inactiveResponse = await fetch(
5995-
`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/another-hash`,
5996-
{
5997-
method: 'GET',
5998-
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
5999-
},
6000-
);
5994+
const inactiveResponse = await fetch(`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/another-hash`, {
5995+
method: 'GET',
5996+
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
5997+
});
60015998
expect(inactiveResponse.status).toBe(404);
60025999
});
60036000

@@ -6292,8 +6289,7 @@ test('CDN uses format from apps-enabled key to resolve documents without fallbac
62926289
});
62936290

62946291
const cdnAccess = await createCdnAccess();
6295-
const sha256Hash =
6296-
'ec2e01311ab3b02f3d8c8c712f9e579356d332cd007ac4c1ea5df727f482f05f';
6292+
const sha256Hash = 'ec2e01311ab3b02f3d8c8c712f9e579356d332cd007ac4c1ea5df727f482f05f';
62976293

62986294
// Deploy with v2 format
62996295
await execute({
@@ -6350,12 +6346,85 @@ test('CDN uses format from apps-enabled key to resolve documents without fallbac
63506346
}).then(res => res.expectNoGraphQLErrors());
63516347

63526348
// v2.0.0 is not activated, apps-enabled body should be v2-inactive
6353-
const inactiveResponse = await fetch(
6354-
`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/${sha256Hash}`,
6355-
{
6356-
method: 'GET',
6357-
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
6358-
},
6359-
);
6349+
const inactiveResponse = await fetch(`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/${sha256Hash}`, {
6350+
method: 'GET',
6351+
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
6352+
});
63606353
expect(inactiveResponse.status).toBe(404);
63616354
});
6355+
6356+
test('v2 deployment with 100% dedup still resolves correctly via CDN', async () => {
6357+
const { createOrg } = await initSeed().createOwner();
6358+
const { createProject, setFeatureFlag } = await createOrg();
6359+
await setFeatureFlag('appDeployments', true);
6360+
const { createTargetAccessToken, createCdnAccess } = await createProject();
6361+
const token = await createTargetAccessToken({});
6362+
6363+
await token.publishSchema({
6364+
sdl: /* GraphQL */ `
6365+
type Query {
6366+
hello: String
6367+
}
6368+
`,
6369+
});
6370+
6371+
const cdnAccess = await createCdnAccess();
6372+
const sha256Hash = 'ec2e01311ab3b02f3d8c8c712f9e579356d332cd007ac4c1ea5df727f482f05f';
6373+
6374+
// Create and upload v1.0.0 with the document
6375+
await execute({
6376+
document: CreateAppDeployment,
6377+
variables: { input: { appName: 'my-app', appVersion: '1.0.0' } },
6378+
authToken: token.secret,
6379+
}).then(res => res.expectNoGraphQLErrors());
6380+
6381+
await execute({
6382+
document: AddDocumentsToAppDeploymentWithFormat,
6383+
variables: {
6384+
input: {
6385+
appName: 'my-app',
6386+
appVersion: '1.0.0',
6387+
documents: [{ hash: sha256Hash, body: 'query { hello }' }],
6388+
format: AppDeploymentFormatType.V2,
6389+
},
6390+
},
6391+
authToken: token.secret,
6392+
}).then(res => res.expectNoGraphQLErrors());
6393+
6394+
await execute({
6395+
document: ActivateAppDeployment,
6396+
variables: { input: { appName: 'my-app', appVersion: '1.0.0' } },
6397+
authToken: token.secret,
6398+
}).then(res => res.expectNoGraphQLErrors());
6399+
6400+
// Create v2.0.0 with hashes: all documents already exist (100% dedup)
6401+
const { createAppDeployment } = await execute({
6402+
document: CreateAppDeploymentWithHashes,
6403+
variables: {
6404+
input: {
6405+
appName: 'my-app',
6406+
appVersion: '2.0.0',
6407+
hashes: [sha256Hash],
6408+
},
6409+
},
6410+
authToken: token.secret,
6411+
}).then(res => res.expectNoGraphQLErrors());
6412+
6413+
// All hashes should be existing: CLI would skip upload entirely
6414+
expect(createAppDeployment.ok?.existingHashes).toContain(sha256Hash);
6415+
6416+
// Activate v2.0.0 (no documents were uploaded, but format should be v2)
6417+
await execute({
6418+
document: ActivateAppDeployment,
6419+
variables: { input: { appName: 'my-app', appVersion: '2.0.0' } },
6420+
authToken: token.secret,
6421+
}).then(res => res.expectNoGraphQLErrors());
6422+
6423+
// CDN should resolve the document via v2 key (format was written during createAppDeployment)
6424+
const response = await fetch(`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/${sha256Hash}`, {
6425+
method: 'GET',
6426+
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
6427+
});
6428+
expect(response.status).toBe(200);
6429+
expect(await response.text()).toBe('query { hello }');
6430+
});

packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,24 @@ export class AppDeploymentsManager {
9898
return result;
9999
}
100100

101-
const existingHashes = args.hashes?.length
101+
const hashes = args.hashes != null && args.hashes.length > 0 ? args.hashes : null;
102+
103+
// Write format to apps-enabled immediately so activation knows the format,
104+
// even if all documents are deduped and no upload happens.
105+
if (hashes) {
106+
await this.appDeployments.writeAppDeploymentFormat({
107+
targetId: selector.targetId,
108+
appName: args.appDeployment.name,
109+
appVersion: args.appDeployment.version,
110+
format: 'v2-inactive',
111+
});
112+
}
113+
114+
const existingHashes = hashes
102115
? await this.appDeployments.getExistingDocumentHashes({
103116
targetId: selector.targetId,
104117
appName: args.appDeployment.name,
105-
hashes: args.hashes,
118+
hashes,
106119
appDeploymentId: result.appDeployment.id,
107120
})
108121
: [];

packages/services/api/src/modules/app-deployments/providers/app-deployments.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -513,19 +513,16 @@ export class AppDeployments {
513513

514514
// Write the active format to all S3 endpoints
515515
for (const s3 of this.s3) {
516-
const result = await s3.client.fetch(
517-
[s3.endpoint, s3.bucket, enabledKey].join('/'),
518-
{
519-
method: 'PUT',
520-
body: activeFormat,
521-
headers: {
522-
'content-type': 'text/plain',
523-
},
524-
aws: {
525-
signQuery: true,
526-
},
516+
const result = await s3.client.fetch([s3.endpoint, s3.bucket, enabledKey].join('/'), {
517+
method: 'PUT',
518+
body: activeFormat,
519+
headers: {
520+
'content-type': 'text/plain',
527521
},
528-
);
522+
aws: {
523+
signQuery: true,
524+
},
525+
});
529526

530527
if (result.statusCode !== 200) {
531528
throw new Error(`Failed to enable app deployment: ${result.statusMessage}`);
@@ -1711,6 +1708,27 @@ export class AppDeployments {
17111708
const sha256Regex = /^(sha256:)?[a-f0-9]{64}$/i;
17121709
return sha256Regex.test(parsed[0].hash) ? 'v2' : 'v1';
17131710
}
1711+
1712+
/** Write the format to the apps-enabled S3 key */
1713+
async writeAppDeploymentFormat(args: {
1714+
targetId: string;
1715+
appName: string;
1716+
appVersion: string;
1717+
format: string;
1718+
}) {
1719+
const enabledKey = buildAppDeploymentIsEnabledKey(args.targetId, args.appName, args.appVersion);
1720+
for (const s3 of this.s3) {
1721+
const result = await s3.client.fetch([s3.endpoint, s3.bucket, enabledKey].join('/'), {
1722+
method: 'PUT',
1723+
body: args.format,
1724+
headers: { 'content-type': 'text/plain' },
1725+
aws: { signQuery: true },
1726+
});
1727+
if (result.statusCode !== 200) {
1728+
throw new Error(`Failed to write app deployment format: ${result.statusMessage}`);
1729+
}
1730+
}
1731+
}
17141732
}
17151733

17161734
const appDeploymentFields = sql`

packages/services/cdn-worker/src/artifact-storage-reader.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -455,9 +455,7 @@ export class ArtifactStorageReader {
455455
const manifestBody = await manifestResponse.text();
456456
const allowedHashes = new Set(manifestBody.split('\n').filter(Boolean));
457457
if (!allowedHashes.has(hash)) {
458-
this.breadcrumb(
459-
`Version isolation: hash ${hash} not in manifest for ${manifestKey}`,
460-
);
458+
this.breadcrumb(`Version isolation: hash ${hash} not in manifest for ${manifestKey}`);
461459
return { type: 'notFound' } as const;
462460
}
463461
} else {

packages/services/cdn-worker/src/is-app-deployment-active.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import zod from 'zod';
2-
import {
3-
type AppDeploymentStatus,
4-
type ArtifactStorageReader,
5-
} from './artifact-storage-reader';
2+
import { type AppDeploymentStatus, type ArtifactStorageReader } from './artifact-storage-reader';
63

74
const AppDeploymentIsEnabledKeyModel = zod.tuple([
85
zod.string().uuid(),

0 commit comments

Comments
 (0)