Skip to content

Commit ac56682

Browse files
committed
feat(amplify-category-api): keep datastore fields when disabling datastore
Needed to e.g. filter for soft deleted data
1 parent e671e8b commit ac56682

4 files changed

Lines changed: 560 additions & 2 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/** Unit tests for `utils/preserve-sync-fields`. */
2+
/* eslint-disable no-underscore-dangle */
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
import * as fs from 'fs-extra';
6+
import { printer } from '@aws-amplify/amplify-prompts';
7+
import {
8+
injectSyncFields,
9+
MIGRATION_GUIDE_URL,
10+
preserveSyncFieldsOnDisable,
11+
SCHEMA_BACKUP_FILENAME,
12+
} from '../../../../provider-utils/awscloudformation/utils/preserve-sync-fields';
13+
14+
jest.mock('@aws-amplify/amplify-prompts');
15+
16+
const mockedPrinter = printer as jest.Mocked<typeof printer>;
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
/** True iff all three sync fields are present with correct scalar types. */
23+
/* eslint-disable @typescript-eslint/no-var-requires, global-require */
24+
const hasAllSyncFields = (schema: string, typeName: string): boolean => {
25+
const { parse, visit } = require('graphql');
26+
const ast = parse(schema, { noLocation: true });
27+
const found = { _version: false, _deleted: false, _lastChangedAt: false };
28+
visit(ast, {
29+
ObjectTypeDefinition: (node: {
30+
name: { value: string };
31+
fields?: ReadonlyArray<{
32+
name: { value: string };
33+
type: { kind: string; name?: { value: string } };
34+
}>;
35+
}) => {
36+
if (node.name.value !== typeName) return undefined;
37+
for (const field of node.fields ?? []) {
38+
const scalar = field.type.kind === 'NamedType' && field.type.name ? field.type.name.value : '';
39+
if (field.name.value === '_version' && scalar === 'Int') found._version = true;
40+
if (field.name.value === '_deleted' && scalar === 'Boolean') found._deleted = true;
41+
if (field.name.value === '_lastChangedAt' && scalar === 'AWSTimestamp') found._lastChangedAt = true;
42+
}
43+
return undefined;
44+
},
45+
});
46+
return found._version && found._deleted && found._lastChangedAt;
47+
};
48+
/* eslint-enable @typescript-eslint/no-var-requires, global-require */
49+
50+
describe('injectSyncFields', () => {
51+
it('adds the three sync fields to a @model type that lacks them', () => {
52+
const schema = `
53+
type Todo @model {
54+
id: ID!
55+
title: String!
56+
}
57+
`;
58+
const result = injectSyncFields(schema);
59+
expect(result.modifiedModels).toEqual(['Todo']);
60+
expect(result.manyToManyRelations).toEqual([]);
61+
expect(hasAllSyncFields(result.updated, 'Todo')).toBe(true);
62+
});
63+
64+
it('is idempotent — running twice yields the same output as once', () => {
65+
const schema = `
66+
type Todo @model {
67+
id: ID!
68+
title: String!
69+
}
70+
`;
71+
const once = injectSyncFields(schema);
72+
const twice = injectSyncFields(once.updated);
73+
expect(twice.updated).toBe(once.updated);
74+
expect(twice.modifiedModels).toEqual([]);
75+
});
76+
77+
it('does not modify a @model that already declares all three fields', () => {
78+
const schema = `
79+
type Todo @model {
80+
id: ID!
81+
title: String!
82+
_version: Int
83+
_deleted: Boolean
84+
_lastChangedAt: AWSTimestamp
85+
}
86+
`;
87+
const result = injectSyncFields(schema);
88+
expect(result.modifiedModels).toEqual([]);
89+
});
90+
91+
it.each([
92+
['_version only', 'type T @model { id: ID! _version: Int }'],
93+
['_deleted only', 'type T @model { id: ID! _deleted: Boolean }'],
94+
['_lastChangedAt only', 'type T @model { id: ID! _lastChangedAt: AWSTimestamp }'],
95+
['_version + _deleted', 'type T @model { id: ID! _version: Int _deleted: Boolean }'],
96+
['_version + _lastChangedAt', 'type T @model { id: ID! _version: Int _lastChangedAt: AWSTimestamp }'],
97+
['_deleted + _lastChangedAt', 'type T @model { id: ID! _deleted: Boolean _lastChangedAt: AWSTimestamp }'],
98+
])('fills in missing fields when some are already declared: %s', (_, schema) => {
99+
const result = injectSyncFields(schema);
100+
expect(result.modifiedModels).toEqual(['T']);
101+
expect(hasAllSyncFields(result.updated, 'T')).toBe(true);
102+
for (const field of ['_version', '_deleted', '_lastChangedAt']) {
103+
const count = (result.updated.match(new RegExp(`${field}:`, 'g')) ?? []).length;
104+
expect(count).toBe(1);
105+
}
106+
});
107+
108+
it('ignores object types that are not annotated with @model', () => {
109+
const schema = `
110+
type Todo @model {
111+
id: ID!
112+
}
113+
type NotAModel {
114+
id: ID!
115+
}
116+
`;
117+
const result = injectSyncFields(schema);
118+
expect(result.modifiedModels).toEqual(['Todo']);
119+
expect(result.updated).not.toMatch(/type\s+NotAModel\s*\{[^}]*_version/s);
120+
});
121+
122+
it('ignores enum and scalar definitions', () => {
123+
const schema = `
124+
enum Status { ACTIVE INACTIVE }
125+
scalar MyScalar
126+
type Todo @model { id: ID! }
127+
`;
128+
const result = injectSyncFields(schema);
129+
expect(result.modifiedModels).toEqual(['Todo']);
130+
expect(result.manyToManyRelations).toEqual([]);
131+
});
132+
133+
it('tracks @manyToMany relations by relationName and enumerates source models', () => {
134+
const schema = `
135+
type Card @model {
136+
id: ID!
137+
title: String!
138+
labels: [Label] @manyToMany(relationName: "CardLabel")
139+
}
140+
type Label @model {
141+
id: ID!
142+
name: String!
143+
cards: [Card] @manyToMany(relationName: "CardLabel")
144+
}
145+
`;
146+
const result = injectSyncFields(schema);
147+
expect(result.modifiedModels.sort()).toEqual(['Card', 'Label']);
148+
expect(result.manyToManyRelations).toHaveLength(1);
149+
expect(result.manyToManyRelations[0].relationName).toBe('CardLabel');
150+
expect(result.manyToManyRelations[0].sourceModels).toEqual(['Card', 'Label']);
151+
expect(hasAllSyncFields(result.updated, 'Card')).toBe(true);
152+
expect(hasAllSyncFields(result.updated, 'Label')).toBe(true);
153+
expect(result.updated).not.toMatch(/type\s+CardLabel\b/);
154+
});
155+
156+
it('tracks multiple distinct @manyToMany relations', () => {
157+
const schema = `
158+
type User @model { id: ID! }
159+
type Post @model {
160+
id: ID!
161+
tags: [Tag] @manyToMany(relationName: "PostTag")
162+
collaborators: [User] @manyToMany(relationName: "PostCollaborator")
163+
}
164+
type Tag @model {
165+
id: ID!
166+
posts: [Post] @manyToMany(relationName: "PostTag")
167+
}
168+
`;
169+
const result = injectSyncFields(schema);
170+
expect(result.manyToManyRelations.map((r) => r.relationName)).toEqual(['PostCollaborator', 'PostTag']);
171+
expect(result.manyToManyRelations.find((r) => r.relationName === 'PostTag')?.sourceModels).toEqual(['Post', 'Tag']);
172+
expect(result.manyToManyRelations.find((r) => r.relationName === 'PostCollaborator')?.sourceModels).toEqual(['Post']);
173+
});
174+
175+
it('preserves @auth, @hasMany, @belongsTo, and @index directives on other fields', () => {
176+
const schema = `
177+
type Board @model
178+
@auth(rules: [{ allow: owner, ownerField: "owner", identityClaim: "email" }]) {
179+
id: ID!
180+
name: String!
181+
owner: String
182+
workspaceID: ID! @index(name: "byWorkspace")
183+
columns: [Column] @hasMany(indexName: "byBoard", fields: ["id"])
184+
}
185+
type Column @model {
186+
id: ID!
187+
name: String!
188+
boardID: ID! @index(name: "byBoard")
189+
board: Board @belongsTo(fields: ["boardID"])
190+
}
191+
`;
192+
const result = injectSyncFields(schema);
193+
expect(result.modifiedModels.sort()).toEqual(['Board', 'Column']);
194+
expect(result.updated).toMatch(/@auth\(rules:/);
195+
expect(result.updated).toMatch(/@hasMany\(indexName: "byBoard"/);
196+
expect(result.updated).toMatch(/@belongsTo\(fields:/);
197+
expect(result.updated).toMatch(/@index\(name: "byBoard"\)/);
198+
expect(hasAllSyncFields(result.updated, 'Board')).toBe(true);
199+
expect(hasAllSyncFields(result.updated, 'Column')).toBe(true);
200+
});
201+
202+
it('uses correct scalar types: Int, Boolean, AWSTimestamp', () => {
203+
const schema = 'type Todo @model { id: ID! }';
204+
const result = injectSyncFields(schema);
205+
expect(result.updated).toMatch(/_version:\s*Int\b/);
206+
expect(result.updated).toMatch(/_deleted:\s*Boolean\b/);
207+
expect(result.updated).toMatch(/_lastChangedAt:\s*AWSTimestamp\b/);
208+
});
209+
210+
it('returns empty lists for a schema with no @model types', () => {
211+
const schema = `
212+
type NotAModel {
213+
id: ID!
214+
}
215+
enum Status {
216+
ACTIVE
217+
INACTIVE
218+
}
219+
`;
220+
const result = injectSyncFields(schema);
221+
expect(result.modifiedModels).toEqual([]);
222+
expect(result.manyToManyRelations).toEqual([]);
223+
});
224+
225+
it('throws on syntactically invalid SDL (caller expected to catch)', () => {
226+
expect(() => injectSyncFields('type Broken @model { id: ID!')).toThrow();
227+
});
228+
});
229+
230+
describe('preserveSyncFieldsOnDisable', () => {
231+
let tmpDir: string;
232+
233+
beforeEach(async () => {
234+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'preserve-sync-fields-test-'));
235+
});
236+
237+
afterEach(async () => {
238+
await fs.remove(tmpDir);
239+
});
240+
241+
it('rewrites schema.graphql and writes a one-time backup', async () => {
242+
const schemaPath = path.join(tmpDir, 'schema.graphql');
243+
const original = 'type Todo @model {\n id: ID!\n title: String!\n}\n';
244+
await fs.writeFile(schemaPath, original);
245+
246+
await preserveSyncFieldsOnDisable(tmpDir);
247+
248+
const backup = await fs.readFile(path.join(tmpDir, SCHEMA_BACKUP_FILENAME), 'utf8');
249+
expect(backup).toBe(original);
250+
251+
const updated = await fs.readFile(schemaPath, 'utf8');
252+
expect(hasAllSyncFields(updated, 'Todo')).toBe(true);
253+
});
254+
255+
it('does not overwrite an existing backup on a second run', async () => {
256+
const schemaPath = path.join(tmpDir, 'schema.graphql');
257+
const backupPath = path.join(tmpDir, SCHEMA_BACKUP_FILENAME);
258+
await fs.writeFile(schemaPath, 'type Todo @model {\n id: ID!\n}\n');
259+
await fs.writeFile(backupPath, 'PREEXISTING BACKUP CONTENTS');
260+
261+
await preserveSyncFieldsOnDisable(tmpDir);
262+
263+
const backup = await fs.readFile(backupPath, 'utf8');
264+
expect(backup).toBe('PREEXISTING BACKUP CONTENTS');
265+
});
266+
267+
it('soft-fails when schema.graphql is missing (no throw)', async () => {
268+
await expect(preserveSyncFieldsOnDisable(tmpDir)).resolves.toBeUndefined();
269+
expect(await fs.pathExists(path.join(tmpDir, SCHEMA_BACKUP_FILENAME))).toBe(false);
270+
});
271+
272+
it('prints the full migration checklist to printer.warn and an injection summary to printer.info', async () => {
273+
const schemaPath = path.join(tmpDir, 'schema.graphql');
274+
await fs.writeFile(schemaPath, 'type Todo @model {\n id: ID!\n}\n');
275+
276+
await preserveSyncFieldsOnDisable(tmpDir);
277+
278+
const warnMessages = mockedPrinter.warn.mock.calls.map((args) => String(args[0])).join('\n');
279+
const infoMessages = mockedPrinter.info.mock.calls.map((args) => String(args[0])).join('\n');
280+
expect(warnMessages).toContain('DataStore → AppSync migration checklist');
281+
expect(warnMessages).toContain('delete<Model> mutations become HARD deletes');
282+
expect(warnMessages).toContain('sync<Model> queries and observeQuery subscriptions no longer exist');
283+
expect(warnMessages).toContain(MIGRATION_GUIDE_URL);
284+
expect(infoMessages).toContain('Injected _version');
285+
expect(infoMessages).toContain('• Todo');
286+
});
287+
288+
it('prints the "no changes needed" info when every @model already has the fields', async () => {
289+
const schemaPath = path.join(tmpDir, 'schema.graphql');
290+
await fs.writeFile(
291+
schemaPath,
292+
'type Todo @model {\n id: ID!\n _version: Int\n _deleted: Boolean\n _lastChangedAt: AWSTimestamp\n}\n',
293+
);
294+
295+
await preserveSyncFieldsOnDisable(tmpDir);
296+
297+
const infoMessages = mockedPrinter.info.mock.calls.map((args) => String(args[0])).join('\n');
298+
expect(infoMessages).toContain('already declare _version');
299+
expect(await fs.pathExists(path.join(tmpDir, SCHEMA_BACKUP_FILENAME))).toBe(false);
300+
});
301+
});

packages/amplify-category-api/src/provider-utils/awscloudformation/cfn-api-artifact-handler.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { authConfigHasApiKey, checkIfAuthExists, getAppSyncAuthConfig, getAppSyn
3030
import { appSyncAuthTypeToAuthConfig } from './utils/auth-config-to-app-sync-auth-type-bi-di-mapper';
3131
import { printApiKeyWarnings } from './utils/print-api-key-warnings';
3232
import { conflictResolutionToResolverConfig } from './utils/resolver-config-to-conflict-resolution-bi-di-mapper';
33+
import { preserveSyncFieldsOnDisable } from './utils/preserve-sync-fields';
3334

3435
// keep in sync with ServiceName in amplify-category-function, but probably it will not change
3536
const FunctionServiceNameLambdaFunction = 'Lambda';
@@ -135,6 +136,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler {
135136
// Because we rely on an in-place update for 'NEW' lambda conflictResolution types, we
136137
// execute this behavior before the call to `updateAppsyncCLIInputs`.
137138
if (updates.conflictResolution) {
139+
await this.maybePreserveSyncFieldsOnDisable(updates, resourceDir);
138140
updates.conflictResolution = await this.createResolverResources(updates.conflictResolution);
139141
await writeResolverConfig(updates.conflictResolution, resourceDir);
140142
}
@@ -179,6 +181,23 @@ class CfnApiArtifactHandler implements ApiArtifactHandler {
179181
fs.writeFileSync(resourceDir, schema);
180182
};
181183

184+
/**
185+
* For headless DataStore-disable, inject the sync fields before the
186+
* transformer strips them. The interactive walkthrough has its own prompt
187+
* for this and calls `preserveSyncFieldsOnDisable` directly.
188+
*/
189+
private maybePreserveSyncFieldsOnDisable = async (updates: AppSyncServiceModification, resourceDir: string): Promise<void> => {
190+
if (!this.context.input?.options?.headless) return;
191+
if (!updates.conflictResolution) return;
192+
const payloadRequestsDisable =
193+
!updates.conflictResolution.defaultResolutionStrategy && _.isEmpty(updates.conflictResolution.perModelResolutionStrategy);
194+
const priorTransformerConfig = await readTransformerConfiguration(resourceDir);
195+
const priorlyEnabled = !_.isEmpty(priorTransformerConfig?.ResolverConfig);
196+
if (payloadRequestsDisable && priorlyEnabled) {
197+
await preserveSyncFieldsOnDisable(resourceDir);
198+
}
199+
};
200+
182201
private getResourceDir = (apiName: string): string => pathManager.getResourceDirectoryPath(undefined, category, apiName);
183202

184203
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type

packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { authConfigToAppSyncAuthType } from '../utils/auth-config-to-app-sync-au
3030
import { checkAppsyncApiResourceMigration } from '../utils/check-appsync-api-migration';
3131
import { defineGlobalSandboxMode } from '../utils/global-sandbox-mode';
3232
import { resolverConfigToConflictResolution } from '../utils/resolver-config-to-conflict-resolution-bi-di-mapper';
33+
import { preserveSyncFieldsOnDisable, MIGRATION_GUIDE_URL } from '../utils/preserve-sync-fields';
3334

3435
const serviceName = 'AppSync';
3536
const elasticContainerServiceName = 'ElasticContainer';
@@ -340,7 +341,13 @@ export const serviceApiInputWalkthrough = async (context: $TSContext, serviceMet
340341
};
341342
};
342343

343-
const updateApiInputWalkthrough = async (context: $TSContext, project: Record<string, any>, resolverConfig, modelTypes) => {
344+
const updateApiInputWalkthrough = async (
345+
context: $TSContext,
346+
project: Record<string, any>,
347+
resolverConfig,
348+
modelTypes,
349+
resourceDir: string,
350+
) => {
344351
let authConfig;
345352
let defaultAuthType;
346353
const updateChoices = [
@@ -379,6 +386,17 @@ const updateApiInputWalkthrough = async (context: $TSContext, project: Record<st
379386
resolverConfig = await askResolverConflictHandlerQuestion(context, modelTypes);
380387
} else if (updateOption === 'DISABLE_CONFLICT') {
381388
resolverConfig = {};
389+
const shouldPreserveSyncFields = await prompter.yesOrNo(
390+
'Preserve _version, _deleted, and _lastChangedAt fields on each @model? ' +
391+
'(Recommended — keeps existing DataStore client code working after conflict detection is disabled)',
392+
true,
393+
);
394+
if (shouldPreserveSyncFields) {
395+
await preserveSyncFieldsOnDisable(resourceDir);
396+
} else {
397+
printer.info('Skipped sync field preservation. Clients that still send _version / _deleted / _lastChangedAt will break.');
398+
printer.info(`Migration guide: ${MIGRATION_GUIDE_URL}`);
399+
}
382400
} else if (updateOption === 'AUTH_MODE') {
383401
({ authConfig, defaultAuthType } = await askDefaultAuthQuestion(context));
384402
authConfig = await askAdditionalAuthQuestions(context, authConfig, defaultAuthType);
@@ -483,7 +501,7 @@ export const updateWalkthrough = async (context: $TSContext): Promise<UpdateApiR
483501
});
484502
}
485503

486-
({ authConfig, resolverConfig } = await updateApiInputWalkthrough(context, project, resolverConfig, modelTypes));
504+
({ authConfig, resolverConfig } = await updateApiInputWalkthrough(context, project, resolverConfig, modelTypes, resourceDir));
487505

488506
return {
489507
version: 1,

0 commit comments

Comments
 (0)