Skip to content

Commit 8991e50

Browse files
committed
auto-detect format and move delta optimization into createAppDeployment
1 parent 1aa5646 commit 8991e50

File tree

4 files changed

+70
-69
lines changed

4 files changed

+70
-69
lines changed

packages/libraries/cli/src/commands/app/create.ts

Lines changed: 38 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ export default class AppCreate extends Command<typeof AppCreate> {
4444
}),
4545
format: Flags.string({
4646
description:
47-
'Storage format version. "v1" (default) uses per-version storage and allows any hash format. "v2" enables cross-version deduplication and requires sha256 hashes.',
48-
default: 'v1',
47+
'Storage format version. "v1" uses per-version storage and allows any hash format. "v2" enables cross-version deduplication and requires sha256 hashes. Auto-detected from hash format if not specified.',
4948
options: ['v1', 'v2'],
5049
}),
5150
};
@@ -105,37 +104,40 @@ export default class AppCreate extends Command<typeof AppCreate> {
105104
throw new PersistedOperationsMalformedError(file);
106105
}
107106

108-
// Validate hashes are sha256 and match content (unless v1 format)
109-
if (flags.format !== 'v1') {
107+
// Auto-detect format from hash patterns if not explicitly specified
108+
let format: 'v1' | 'v2';
109+
if (flags.format === 'v1' || flags.format === 'v2') {
110+
format = flags.format;
111+
} else {
110112
const sha256Regex = /^(sha256:)?[a-f0-9]{64}$/i;
111-
const invalidFormatHashes: string[] = [];
113+
const hashes = Object.keys(validationResult.data);
114+
const allSha256 = hashes.length > 0 && hashes.every(hash => sha256Regex.test(hash));
115+
format = allSha256 ? 'v2' : 'v1';
116+
117+
if (format === 'v2') {
118+
this.log(
119+
`Detected sha256 hashes — using v2 format for faster uploads and cross-version deduplication.`,
120+
);
121+
} else {
122+
this.log(
123+
`Hashes are not sha256 — using v1 format. For faster uploads and cross-version deduplication, ` +
124+
`configure your code generator to use sha256 hashes. See https://the-guild.dev/graphql/hive/docs/app-deployments/persisted-documents`,
125+
);
126+
}
127+
}
128+
129+
// Validate hashes match content for v2 format
130+
if (format === 'v2') {
112131
const mismatchedHashes: Array<{ hash: string; expected: string }> = [];
113132

114133
for (const [hash, body] of Object.entries(validationResult.data)) {
115-
if (!sha256Regex.test(hash)) {
116-
invalidFormatHashes.push(hash);
117-
} else {
118-
// Verify hash matches content
119-
const computedHash = createHash('sha256').update(body).digest('hex');
120-
const providedHash = hash.replace(/^sha256:/i, '').toLowerCase();
121-
if (computedHash !== providedHash) {
122-
mismatchedHashes.push({ hash: providedHash, expected: computedHash });
123-
}
134+
const computedHash = createHash('sha256').update(body).digest('hex');
135+
const providedHash = hash.replace(/^sha256:/i, '').toLowerCase();
136+
if (computedHash !== providedHash) {
137+
mismatchedHashes.push({ hash: providedHash, expected: computedHash });
124138
}
125139
}
126140

127-
if (invalidFormatHashes.length > 0) {
128-
const examples = invalidFormatHashes.slice(0, 3).join(', ');
129-
const more =
130-
invalidFormatHashes.length > 3 ? ` (and ${invalidFormatHashes.length - 3} more)` : '';
131-
throw new APIError(
132-
`Invalid hash format detected: ${examples}${more}\n` +
133-
`Hashes must be sha256 (64 hexadecimal characters, optionally prefixed with "sha256:").\n` +
134-
`This is required for safe cross-version document deduplication.\n` +
135-
`Use --format=v1 to bypass this check (disables cross-version deduplication).`,
136-
);
137-
}
138-
139141
if (mismatchedHashes.length > 0) {
140142
const example = mismatchedHashes[0];
141143
const more =
@@ -149,13 +151,17 @@ export default class AppCreate extends Command<typeof AppCreate> {
149151
}
150152
}
151153

154+
const allDocuments = Object.entries(validationResult.data);
155+
const localHashes = format === 'v2' ? allDocuments.map(([hash]) => hash) : undefined;
156+
152157
const result = await this.registryApi(endpoint, accessToken).request({
153158
operation: CreateAppDeploymentMutation,
154159
variables: {
155160
input: {
156161
appName: flags['name'],
157162
appVersion: flags['version'],
158163
target,
164+
hashes: localHashes,
159165
},
160166
},
161167
});
@@ -175,37 +181,12 @@ export default class AppCreate extends Command<typeof AppCreate> {
175181
return;
176182
}
177183

178-
const allDocuments = Object.entries(validationResult.data);
179184
const totalDocuments = allDocuments.length;
180185

181-
// Fetch existing hashes for delta upload
182-
let existingHashes = new Set<string>();
183-
if (flags.format !== 'v1') {
184-
if (!target) {
185-
throw new APIError(
186-
'The --target flag is required when using --format=v2 for delta optimization.',
187-
);
188-
}
189-
const localHashes = allDocuments.map(([hash]) => hash);
190-
const hashesResult = await this.registryApi(endpoint, accessToken).request({
191-
operation: GetExistingDocumentHashesQuery,
192-
variables: {
193-
target,
194-
appName: flags['name'],
195-
hashes: localHashes,
196-
},
197-
});
198-
199-
if (!hashesResult.target) {
200-
this.logWarning(
201-
`Target not found when fetching existing hashes. Delta optimization disabled.`,
202-
);
203-
} else {
204-
existingHashes = new Set(hashesResult.target.appDeploymentDocumentHashes);
205-
if (flags.showTiming) {
206-
this.log(`Found ${existingHashes.size} existing documents (will skip)`);
207-
}
208-
}
186+
// Use existing hashes from createAppDeployment response for delta upload
187+
const existingHashes = new Set(result.createAppDeployment.ok.existingHashes);
188+
if (flags.showTiming && existingHashes.size > 0) {
189+
this.log(`Found ${existingHashes.size} existing documents (will skip)`);
209190
}
210191

211192
// Filter out already-existing documents
@@ -241,7 +222,7 @@ export default class AppCreate extends Command<typeof AppCreate> {
241222
appVersion: flags['version'],
242223
documents: buffer,
243224
format:
244-
flags.format === 'v1' ? AppDeploymentFormatType.V1 : AppDeploymentFormatType.V2,
225+
format === 'v1' ? AppDeploymentFormatType.V1 : AppDeploymentFormatType.V2,
245226
},
246227
},
247228
});
@@ -319,6 +300,7 @@ const CreateAppDeploymentMutation = graphql(/* GraphQL */ `
319300
version
320301
status
321302
}
303+
existingHashes
322304
}
323305
error {
324306
message
@@ -358,15 +340,3 @@ const AddDocumentsToAppDeploymentMutation = graphql(/* GraphQL */ `
358340
}
359341
}
360342
`);
361-
362-
const GetExistingDocumentHashesQuery = graphql(/* GraphQL */ `
363-
query GetExistingDocumentHashes(
364-
$target: TargetReferenceInput!
365-
$appName: String!
366-
$hashes: [String!]!
367-
) {
368-
target(reference: $target) {
369-
appDeploymentDocumentHashes(appName: $appName, hashes: $hashes)
370-
}
371-
}
372-
`);

packages/services/api/src/modules/app-deployments/module.graphql.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ export default gql`
211211
target: TargetReferenceInput
212212
appName: String!
213213
appVersion: String!
214+
"""
215+
Optional list of document hashes the client intends to upload.
216+
If provided, the response will include the subset that already exist on the server,
217+
enabling delta uploads (skip uploading documents that already exist).
218+
"""
219+
hashes: [String!]
214220
}
215221
216222
type CreateAppDeploymentErrorDetails {
@@ -231,6 +237,11 @@ export default gql`
231237
232238
type CreateAppDeploymentOk {
233239
createdAppDeployment: AppDeployment!
240+
"""
241+
Document hashes from the input that already exist on the server.
242+
Only populated when hashes are provided in the input.
243+
"""
244+
existingHashes: [String!]!
234245
}
235246
236247
type CreateAppDeploymentResult {

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class AppDeploymentsManager {
6767
name: string;
6868
version: string;
6969
};
70+
hashes?: readonly string[] | null;
7071
}) {
7172
const selector = await this.idTranslator.resolveTargetReference({
7273
reference: args.reference,
@@ -87,11 +88,28 @@ export class AppDeploymentsManager {
8788
},
8889
});
8990

90-
return await this.appDeployments.createAppDeployment({
91+
const result = await this.appDeployments.createAppDeployment({
9192
organizationId: selector.organizationId,
9293
targetId: selector.targetId,
9394
appDeployment: args.appDeployment,
9495
});
96+
97+
if (result.type !== 'success') {
98+
return result;
99+
}
100+
101+
const existingHashes = args.hashes?.length
102+
? await this.appDeployments.getExistingDocumentHashes({
103+
targetId: selector.targetId,
104+
appName: args.appDeployment.name,
105+
hashes: args.hashes,
106+
})
107+
: [];
108+
109+
return {
110+
...result,
111+
existingHashes,
112+
};
95113
}
96114

97115
async addDocumentsToAppDeployment(args: {

packages/services/api/src/modules/app-deployments/resolvers/Mutation/createAppDeployment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const createAppDeployment: NonNullable<MutationResolvers['createAppDeploy
1212
name: input.appName,
1313
version: input.appVersion,
1414
},
15+
hashes: input.hashes,
1516
});
1617

1718
if (result.type === 'error') {
@@ -28,6 +29,7 @@ export const createAppDeployment: NonNullable<MutationResolvers['createAppDeploy
2829
error: null,
2930
ok: {
3031
createdAppDeployment: result.appDeployment,
32+
existingHashes: result.existingHashes,
3133
},
3234
};
3335
};

0 commit comments

Comments
 (0)