datocms-plugin-record-bin 2.0.0 → 3.0.0
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/README.md +113 -11
- package/build/assets/index-BnrW9Ts8.js +15 -0
- package/build/assets/index-aWCW2c0n.css +1 -0
- package/build/index.html +13 -1
- package/index.html +12 -0
- package/package.json +24 -18
- package/src/entrypoints/BinOutlet.tsx +262 -37
- package/src/entrypoints/ConfigScreen.tsx +939 -38
- package/src/entrypoints/ErrorModal.tsx +86 -2
- package/src/index.tsx +73 -28
- package/src/react-app-env.d.ts +1 -1
- package/src/types/types.ts +36 -8
- package/src/utils/binCleanup.test.ts +107 -0
- package/src/utils/binCleanup.ts +71 -23
- package/src/utils/debugLogger.ts +27 -0
- package/src/utils/deployProviders.test.ts +33 -0
- package/src/utils/deployProviders.ts +28 -0
- package/src/utils/getDeploymentUrlFromParameters.test.ts +26 -0
- package/src/utils/getDeploymentUrlFromParameters.ts +21 -0
- package/src/utils/getRuntimeMode.test.ts +57 -0
- package/src/utils/getRuntimeMode.ts +23 -0
- package/src/utils/lambdaLessCapture.test.ts +218 -0
- package/src/utils/lambdaLessCapture.ts +160 -0
- package/src/utils/lambdaLessCleanup.test.ts +125 -0
- package/src/utils/lambdaLessCleanup.ts +69 -0
- package/src/utils/lambdaLessRestore.test.ts +248 -0
- package/src/utils/lambdaLessRestore.ts +159 -0
- package/src/utils/recordBinModel.ts +108 -0
- package/src/utils/recordBinPayload.test.ts +103 -0
- package/src/utils/recordBinPayload.ts +136 -0
- package/src/utils/recordBinWebhook.test.ts +253 -0
- package/src/utils/recordBinWebhook.ts +305 -0
- package/src/utils/render.tsx +17 -8
- package/src/utils/restoreError.test.ts +112 -0
- package/src/utils/restoreError.ts +221 -0
- package/src/utils/verifyLambdaHealth.test.ts +248 -0
- package/src/utils/verifyLambdaHealth.ts +422 -0
- package/vite.config.ts +11 -0
- package/build/asset-manifest.json +0 -13
- package/build/static/css/main.10f29737.css +0 -2
- package/build/static/css/main.10f29737.css.map +0 -1
- package/build/static/js/main.53795e3b.js +0 -3
- package/build/static/js/main.53795e3b.js.LICENSE.txt +0 -47
- package/build/static/js/main.53795e3b.js.map +0 -1
- package/src/entrypoints/InstallationModal.tsx +0 -107
- package/src/entrypoints/PreInstallConfig.tsx +0 -28
- package/src/utils/attemptVercelInitialization.ts +0 -16
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getRuntimeMode } from "./getRuntimeMode";
|
|
3
|
+
|
|
4
|
+
describe("getRuntimeMode", () => {
|
|
5
|
+
it("prefers explicit runtimeMode from plugin parameters", () => {
|
|
6
|
+
expect(
|
|
7
|
+
getRuntimeMode({
|
|
8
|
+
runtimeMode: "lambdaless",
|
|
9
|
+
deploymentURL: "https://record-bin.example.com",
|
|
10
|
+
})
|
|
11
|
+
).toBe("lambdaless");
|
|
12
|
+
|
|
13
|
+
expect(
|
|
14
|
+
getRuntimeMode({
|
|
15
|
+
runtimeMode: "lambda",
|
|
16
|
+
})
|
|
17
|
+
).toBe("lambda");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("accepts legacy boolean lambdaFullMode parameter", () => {
|
|
21
|
+
expect(
|
|
22
|
+
getRuntimeMode({
|
|
23
|
+
lambdaFullMode: true,
|
|
24
|
+
})
|
|
25
|
+
).toBe("lambda");
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
getRuntimeMode({
|
|
29
|
+
lambdaFullMode: false,
|
|
30
|
+
deploymentURL: "https://record-bin.example.com",
|
|
31
|
+
})
|
|
32
|
+
).toBe("lambdaless");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns lambda when deploymentURL exists", () => {
|
|
36
|
+
expect(
|
|
37
|
+
getRuntimeMode({
|
|
38
|
+
deploymentURL: "https://record-bin.example.com",
|
|
39
|
+
})
|
|
40
|
+
).toBe("lambda");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns lambda when vercelURL exists and deploymentURL is empty", () => {
|
|
44
|
+
expect(
|
|
45
|
+
getRuntimeMode({
|
|
46
|
+
deploymentURL: " ",
|
|
47
|
+
vercelURL: "https://record-bin.example.com",
|
|
48
|
+
})
|
|
49
|
+
).toBe("lambda");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns lambdaless when no URL is configured", () => {
|
|
53
|
+
expect(getRuntimeMode(undefined)).toBe("lambdaless");
|
|
54
|
+
expect(getRuntimeMode({})).toBe("lambdaless");
|
|
55
|
+
expect(getRuntimeMode({ deploymentURL: "" })).toBe("lambdaless");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getDeploymentUrlFromParameters } from "./getDeploymentUrlFromParameters";
|
|
2
|
+
|
|
3
|
+
type PluginParameters = Record<string, unknown> | undefined;
|
|
4
|
+
|
|
5
|
+
export type RuntimeMode = "lambda" | "lambdaless";
|
|
6
|
+
|
|
7
|
+
const isRuntimeMode = (value: unknown): value is RuntimeMode =>
|
|
8
|
+
value === "lambda" || value === "lambdaless";
|
|
9
|
+
|
|
10
|
+
export const getRuntimeMode = (parameters: PluginParameters): RuntimeMode => {
|
|
11
|
+
const configuredRuntimeMode = parameters?.runtimeMode;
|
|
12
|
+
if (isRuntimeMode(configuredRuntimeMode)) {
|
|
13
|
+
return configuredRuntimeMode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof parameters?.lambdaFullMode === "boolean") {
|
|
17
|
+
return parameters.lambdaFullMode ? "lambda" : "lambdaless";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return getDeploymentUrlFromParameters(parameters).trim()
|
|
21
|
+
? "lambda"
|
|
22
|
+
: "lambdaless";
|
|
23
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildClient, SchemaTypes } from "@datocms/cma-client-browser";
|
|
3
|
+
import { captureDeletedItemsWithoutLambda } from "./lambdaLessCapture";
|
|
4
|
+
|
|
5
|
+
vi.mock("@datocms/cma-client-browser", () => ({
|
|
6
|
+
buildClient: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
type ClientMock = {
|
|
10
|
+
itemTypes: {
|
|
11
|
+
find: ReturnType<typeof vi.fn>;
|
|
12
|
+
create: ReturnType<typeof vi.fn>;
|
|
13
|
+
update: ReturnType<typeof vi.fn>;
|
|
14
|
+
};
|
|
15
|
+
fields: {
|
|
16
|
+
create: ReturnType<typeof vi.fn>;
|
|
17
|
+
};
|
|
18
|
+
items: {
|
|
19
|
+
rawFind: ReturnType<typeof vi.fn>;
|
|
20
|
+
create: ReturnType<typeof vi.fn>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const createClientMock = (): ClientMock => ({
|
|
25
|
+
itemTypes: {
|
|
26
|
+
find: vi.fn(),
|
|
27
|
+
create: vi.fn(),
|
|
28
|
+
update: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
fields: {
|
|
31
|
+
create: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
items: {
|
|
34
|
+
rawFind: vi.fn(),
|
|
35
|
+
create: vi.fn(),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const createHookItem = (
|
|
40
|
+
id: string,
|
|
41
|
+
modelId: string
|
|
42
|
+
): SchemaTypes.Item =>
|
|
43
|
+
({
|
|
44
|
+
type: "item",
|
|
45
|
+
id,
|
|
46
|
+
relationships: {
|
|
47
|
+
item_type: {
|
|
48
|
+
data: {
|
|
49
|
+
type: "item_type",
|
|
50
|
+
id: modelId,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
attributes: {},
|
|
55
|
+
meta: {} as SchemaTypes.Item["meta"],
|
|
56
|
+
} as SchemaTypes.Item);
|
|
57
|
+
|
|
58
|
+
const createCtxMock = (
|
|
59
|
+
token?: string
|
|
60
|
+
): {
|
|
61
|
+
currentUserAccessToken: string | undefined;
|
|
62
|
+
environment: string;
|
|
63
|
+
plugin: { attributes: { parameters: Record<string, unknown> } };
|
|
64
|
+
notice: ReturnType<typeof vi.fn>;
|
|
65
|
+
} => ({
|
|
66
|
+
currentUserAccessToken: token,
|
|
67
|
+
environment: "main",
|
|
68
|
+
plugin: {
|
|
69
|
+
attributes: {
|
|
70
|
+
parameters: {},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
notice: vi.fn(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
vi.restoreAllMocks();
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("captureDeletedItemsWithoutLambda", () => {
|
|
82
|
+
it("captures deleted records using nested rawFind payload", async () => {
|
|
83
|
+
const clientMock = createClientMock();
|
|
84
|
+
clientMock.itemTypes.find.mockResolvedValue({ id: "record-bin-model-id" });
|
|
85
|
+
clientMock.items.rawFind.mockResolvedValue({
|
|
86
|
+
data: {
|
|
87
|
+
type: "item",
|
|
88
|
+
id: "item-1",
|
|
89
|
+
relationships: {
|
|
90
|
+
item_type: {
|
|
91
|
+
data: {
|
|
92
|
+
type: "item_type",
|
|
93
|
+
id: "blog-model-id",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
attributes: {
|
|
98
|
+
title: "Post title",
|
|
99
|
+
},
|
|
100
|
+
meta: {},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
clientMock.items.create.mockResolvedValue({ id: "trash-1" });
|
|
104
|
+
|
|
105
|
+
vi.mocked(buildClient).mockReturnValue(
|
|
106
|
+
clientMock as unknown as ReturnType<typeof buildClient>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const ctx = createCtxMock("token");
|
|
110
|
+
|
|
111
|
+
const result = await captureDeletedItemsWithoutLambda(
|
|
112
|
+
[createHookItem("item-1", "blog-model-id")],
|
|
113
|
+
ctx as never
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(result).toEqual({
|
|
117
|
+
capturedCount: 1,
|
|
118
|
+
failedItemIds: [],
|
|
119
|
+
skippedRecordBinItems: 0,
|
|
120
|
+
});
|
|
121
|
+
expect(clientMock.items.rawFind).toHaveBeenCalledWith("item-1", {
|
|
122
|
+
nested: true,
|
|
123
|
+
});
|
|
124
|
+
expect(clientMock.items.create).toHaveBeenCalledTimes(1);
|
|
125
|
+
const requestPayload = clientMock.items.create.mock.calls[0][0];
|
|
126
|
+
expect(requestPayload.model).toBe("blog-model-id");
|
|
127
|
+
expect(requestPayload.record_body).toEqual(expect.any(String));
|
|
128
|
+
expect(JSON.parse(requestPayload.record_body).event_type).toBe(
|
|
129
|
+
"to_be_restored"
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("skips records belonging to record_bin model", async () => {
|
|
134
|
+
const clientMock = createClientMock();
|
|
135
|
+
clientMock.itemTypes.find.mockResolvedValue({ id: "record-bin-model-id" });
|
|
136
|
+
|
|
137
|
+
vi.mocked(buildClient).mockReturnValue(
|
|
138
|
+
clientMock as unknown as ReturnType<typeof buildClient>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const ctx = createCtxMock("token");
|
|
142
|
+
|
|
143
|
+
const result = await captureDeletedItemsWithoutLambda(
|
|
144
|
+
[createHookItem("trash-item", "record-bin-model-id")],
|
|
145
|
+
ctx as never
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(result).toEqual({
|
|
149
|
+
capturedCount: 0,
|
|
150
|
+
failedItemIds: [],
|
|
151
|
+
skippedRecordBinItems: 1,
|
|
152
|
+
});
|
|
153
|
+
expect(clientMock.items.rawFind).not.toHaveBeenCalled();
|
|
154
|
+
expect(clientMock.items.create).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("aggregates failures and keeps deletion fail-open", async () => {
|
|
158
|
+
const clientMock = createClientMock();
|
|
159
|
+
clientMock.itemTypes.find.mockResolvedValue({ id: "record-bin-model-id" });
|
|
160
|
+
clientMock.items.rawFind
|
|
161
|
+
.mockRejectedValueOnce(new Error("fetch failed"))
|
|
162
|
+
.mockResolvedValueOnce({
|
|
163
|
+
data: {
|
|
164
|
+
type: "item",
|
|
165
|
+
id: "item-2",
|
|
166
|
+
relationships: {
|
|
167
|
+
item_type: {
|
|
168
|
+
data: {
|
|
169
|
+
type: "item_type",
|
|
170
|
+
id: "page-model-id",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
attributes: {
|
|
175
|
+
title: "Page title",
|
|
176
|
+
},
|
|
177
|
+
meta: {},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
clientMock.items.create.mockResolvedValue({ id: "trash-2" });
|
|
181
|
+
|
|
182
|
+
vi.mocked(buildClient).mockReturnValue(
|
|
183
|
+
clientMock as unknown as ReturnType<typeof buildClient>
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const ctx = createCtxMock("token");
|
|
187
|
+
|
|
188
|
+
const result = await captureDeletedItemsWithoutLambda(
|
|
189
|
+
[
|
|
190
|
+
createHookItem("item-1", "post-model-id"),
|
|
191
|
+
createHookItem("item-2", "page-model-id"),
|
|
192
|
+
],
|
|
193
|
+
ctx as never
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(result.capturedCount).toBe(1);
|
|
197
|
+
expect(result.failedItemIds).toEqual(["item-1"]);
|
|
198
|
+
expect(ctx.notice).toHaveBeenCalledWith(
|
|
199
|
+
"Record Bin could not archive 1 deleted record(s). Deletion still completed."
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("warns and returns when access token is missing", async () => {
|
|
204
|
+
const ctx = createCtxMock();
|
|
205
|
+
|
|
206
|
+
const result = await captureDeletedItemsWithoutLambda(
|
|
207
|
+
[createHookItem("item-1", "blog-model-id")],
|
|
208
|
+
ctx as never
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(result.capturedCount).toBe(0);
|
|
212
|
+
expect(result.failedItemIds).toEqual(["item-1"]);
|
|
213
|
+
expect(ctx.notice).toHaveBeenCalledWith(
|
|
214
|
+
"Record Bin could not archive deleted records because currentUserAccessToken is missing."
|
|
215
|
+
);
|
|
216
|
+
expect(buildClient).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { buildClient, SchemaTypes } from "@datocms/cma-client-browser";
|
|
2
|
+
import { OnBeforeItemsDestroyCtx } from "datocms-plugin-sdk";
|
|
3
|
+
import { createDebugLogger, isDebugEnabled } from "./debugLogger";
|
|
4
|
+
import { ensureRecordBinModel } from "./recordBinModel";
|
|
5
|
+
import {
|
|
6
|
+
buildRecordBinCompatiblePayload,
|
|
7
|
+
extractEntityAttributes,
|
|
8
|
+
extractEntityModelId,
|
|
9
|
+
} from "./recordBinPayload";
|
|
10
|
+
|
|
11
|
+
const buildTrashLabel = (
|
|
12
|
+
attributes: Record<string, unknown>,
|
|
13
|
+
modelID: string
|
|
14
|
+
): string => {
|
|
15
|
+
let titleValue = "No title record";
|
|
16
|
+
for (const attributeKey of Object.keys(attributes)) {
|
|
17
|
+
const attributeValue = attributes[attributeKey];
|
|
18
|
+
if (
|
|
19
|
+
typeof attributeValue === "string" &&
|
|
20
|
+
Number.isNaN(Number(attributeValue))
|
|
21
|
+
) {
|
|
22
|
+
titleValue = attributeValue;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return `${titleValue} | Model: ${modelID} | ${new Date().toDateString()}`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getHookItemModelId = (item: SchemaTypes.Item): string | undefined => {
|
|
31
|
+
const candidateModelId = item.relationships?.item_type?.data?.id;
|
|
32
|
+
return typeof candidateModelId === "string" ? candidateModelId : undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type LambdaLessCaptureResult = {
|
|
36
|
+
capturedCount: number;
|
|
37
|
+
failedItemIds: string[];
|
|
38
|
+
skippedRecordBinItems: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const captureDeletedItemsWithoutLambda = async (
|
|
42
|
+
items: SchemaTypes.Item[],
|
|
43
|
+
ctx: OnBeforeItemsDestroyCtx
|
|
44
|
+
): Promise<LambdaLessCaptureResult> => {
|
|
45
|
+
const debugLogger = createDebugLogger(
|
|
46
|
+
isDebugEnabled(ctx.plugin.attributes.parameters),
|
|
47
|
+
"lambdaLessCapture"
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const result: LambdaLessCaptureResult = {
|
|
51
|
+
capturedCount: 0,
|
|
52
|
+
failedItemIds: [],
|
|
53
|
+
skippedRecordBinItems: 0,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (!ctx.currentUserAccessToken) {
|
|
57
|
+
debugLogger.warn(
|
|
58
|
+
"Skipping Lambda-less capture because currentUserAccessToken is missing"
|
|
59
|
+
);
|
|
60
|
+
await ctx.notice(
|
|
61
|
+
"Record Bin could not archive deleted records because currentUserAccessToken is missing."
|
|
62
|
+
);
|
|
63
|
+
return {
|
|
64
|
+
...result,
|
|
65
|
+
failedItemIds: items
|
|
66
|
+
.map((item) => item.id)
|
|
67
|
+
.filter((itemId): itemId is string => Boolean(itemId)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const client = buildClient({
|
|
72
|
+
apiToken: ctx.currentUserAccessToken,
|
|
73
|
+
environment: ctx.environment,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let recordBinModelId = "";
|
|
77
|
+
try {
|
|
78
|
+
const recordBinModel = await ensureRecordBinModel(client);
|
|
79
|
+
recordBinModelId = recordBinModel.id;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
debugLogger.error("Could not ensure record_bin model before delete capture", error);
|
|
82
|
+
await ctx.notice(
|
|
83
|
+
"Record Bin could not archive deleted records because the record_bin model is unavailable."
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
...result,
|
|
87
|
+
failedItemIds: items
|
|
88
|
+
.map((item) => item.id)
|
|
89
|
+
.filter((itemId): itemId is string => Boolean(itemId)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
const itemId = item.id;
|
|
95
|
+
if (!itemId) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (getHookItemModelId(item) === recordBinModelId) {
|
|
100
|
+
result.skippedRecordBinItems += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const fullItemResponse = await client.items.rawFind(itemId, { nested: true });
|
|
106
|
+
const fullItemEntity = fullItemResponse.data as unknown as Record<
|
|
107
|
+
string,
|
|
108
|
+
unknown
|
|
109
|
+
>;
|
|
110
|
+
const deletedModelID =
|
|
111
|
+
extractEntityModelId(fullItemEntity) ?? getHookItemModelId(item);
|
|
112
|
+
if (!deletedModelID) {
|
|
113
|
+
throw new Error("Deleted record model id could not be determined.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (deletedModelID === recordBinModelId) {
|
|
117
|
+
result.skippedRecordBinItems += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const trashLabel = buildTrashLabel(
|
|
122
|
+
extractEntityAttributes(fullItemEntity),
|
|
123
|
+
deletedModelID
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const payload = buildRecordBinCompatiblePayload({
|
|
127
|
+
environment: ctx.environment,
|
|
128
|
+
entity: fullItemEntity,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await client.items.create({
|
|
132
|
+
item_type: {
|
|
133
|
+
type: "item_type",
|
|
134
|
+
id: recordBinModelId,
|
|
135
|
+
},
|
|
136
|
+
label: trashLabel,
|
|
137
|
+
model: deletedModelID,
|
|
138
|
+
record_body: JSON.stringify(payload),
|
|
139
|
+
date_of_deletion: new Date().toISOString(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
result.capturedCount += 1;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
result.failedItemIds.push(itemId);
|
|
145
|
+
debugLogger.warn("Could not archive deleted record", {
|
|
146
|
+
itemId,
|
|
147
|
+
error,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (result.failedItemIds.length > 0) {
|
|
153
|
+
await ctx.notice(
|
|
154
|
+
`Record Bin could not archive ${result.failedItemIds.length} deleted record(s). Deletion still completed.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
debugLogger.log("Lambda-less delete capture finished", result);
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildClient } from "@datocms/cma-client-browser";
|
|
3
|
+
import { cleanupRecordBinWithoutLambda } from "./lambdaLessCleanup";
|
|
4
|
+
|
|
5
|
+
vi.mock("@datocms/cma-client-browser", () => ({
|
|
6
|
+
buildClient: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
type ClientMock = {
|
|
10
|
+
itemTypes: {
|
|
11
|
+
find: ReturnType<typeof vi.fn>;
|
|
12
|
+
};
|
|
13
|
+
items: {
|
|
14
|
+
list: ReturnType<typeof vi.fn>;
|
|
15
|
+
bulkDestroy: ReturnType<typeof vi.fn>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const createClientMock = (): ClientMock => ({
|
|
20
|
+
itemTypes: {
|
|
21
|
+
find: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
items: {
|
|
24
|
+
list: vi.fn(),
|
|
25
|
+
bulkDestroy: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("cleanupRecordBinWithoutLambda", () => {
|
|
35
|
+
it("deletes aged records from record_bin model", async () => {
|
|
36
|
+
const clientMock = createClientMock();
|
|
37
|
+
clientMock.itemTypes.find.mockResolvedValue({ id: "record-bin-model-id" });
|
|
38
|
+
clientMock.items.list.mockResolvedValue([
|
|
39
|
+
{ id: "trash-1" },
|
|
40
|
+
{ id: "trash-2" },
|
|
41
|
+
]);
|
|
42
|
+
clientMock.items.bulkDestroy.mockResolvedValue({});
|
|
43
|
+
|
|
44
|
+
vi.mocked(buildClient).mockReturnValue(
|
|
45
|
+
clientMock as unknown as ReturnType<typeof buildClient>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const result = await cleanupRecordBinWithoutLambda({
|
|
49
|
+
currentUserAccessToken: "token",
|
|
50
|
+
environment: "main",
|
|
51
|
+
numberOfDays: 30,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
deletedCount: 2,
|
|
56
|
+
skipped: false,
|
|
57
|
+
});
|
|
58
|
+
expect(clientMock.items.list).toHaveBeenCalledWith({
|
|
59
|
+
filter: {
|
|
60
|
+
fields: {
|
|
61
|
+
dateOfDeletion: {
|
|
62
|
+
lte: expect.any(String),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
type: "record_bin",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
expect(clientMock.items.bulkDestroy).toHaveBeenCalledWith({
|
|
69
|
+
items: [
|
|
70
|
+
{
|
|
71
|
+
type: "item",
|
|
72
|
+
id: "trash-1",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: "item",
|
|
76
|
+
id: "trash-2",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns early when record_bin model does not exist", async () => {
|
|
83
|
+
const clientMock = createClientMock();
|
|
84
|
+
clientMock.itemTypes.find.mockRejectedValue(new Error("Not found"));
|
|
85
|
+
|
|
86
|
+
vi.mocked(buildClient).mockReturnValue(
|
|
87
|
+
clientMock as unknown as ReturnType<typeof buildClient>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const result = await cleanupRecordBinWithoutLambda({
|
|
91
|
+
currentUserAccessToken: "token",
|
|
92
|
+
environment: "main",
|
|
93
|
+
numberOfDays: 30,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual({
|
|
97
|
+
deletedCount: 0,
|
|
98
|
+
skipped: true,
|
|
99
|
+
});
|
|
100
|
+
expect(clientMock.items.list).not.toHaveBeenCalled();
|
|
101
|
+
expect(clientMock.items.bulkDestroy).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles empty cleanup sets", async () => {
|
|
105
|
+
const clientMock = createClientMock();
|
|
106
|
+
clientMock.itemTypes.find.mockResolvedValue({ id: "record-bin-model-id" });
|
|
107
|
+
clientMock.items.list.mockResolvedValue([]);
|
|
108
|
+
|
|
109
|
+
vi.mocked(buildClient).mockReturnValue(
|
|
110
|
+
clientMock as unknown as ReturnType<typeof buildClient>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const result = await cleanupRecordBinWithoutLambda({
|
|
114
|
+
currentUserAccessToken: "token",
|
|
115
|
+
environment: "main",
|
|
116
|
+
numberOfDays: 30,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual({
|
|
120
|
+
deletedCount: 0,
|
|
121
|
+
skipped: false,
|
|
122
|
+
});
|
|
123
|
+
expect(clientMock.items.bulkDestroy).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { buildClient } from "@datocms/cma-client-browser";
|
|
2
|
+
|
|
3
|
+
export type CleanupRecordBinWithoutLambdaInput = {
|
|
4
|
+
currentUserAccessToken: string | undefined;
|
|
5
|
+
environment: string;
|
|
6
|
+
numberOfDays: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CleanupRecordBinWithoutLambdaResult = {
|
|
10
|
+
deletedCount: number;
|
|
11
|
+
skipped: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const cleanupRecordBinWithoutLambda = async ({
|
|
15
|
+
currentUserAccessToken,
|
|
16
|
+
environment,
|
|
17
|
+
numberOfDays,
|
|
18
|
+
}: CleanupRecordBinWithoutLambdaInput): Promise<CleanupRecordBinWithoutLambdaResult> => {
|
|
19
|
+
if (!currentUserAccessToken) {
|
|
20
|
+
throw new Error("Missing currentUserAccessToken for Lambda-less cleanup.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = buildClient({
|
|
24
|
+
apiToken: currentUserAccessToken,
|
|
25
|
+
environment,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await client.itemTypes.find("record_bin");
|
|
30
|
+
} catch {
|
|
31
|
+
return {
|
|
32
|
+
deletedCount: 0,
|
|
33
|
+
skipped: true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cutOffDate = new Date();
|
|
38
|
+
cutOffDate.setDate(new Date().getDate() - numberOfDays);
|
|
39
|
+
|
|
40
|
+
const recordsToDelete = await client.items.list({
|
|
41
|
+
filter: {
|
|
42
|
+
fields: {
|
|
43
|
+
dateOfDeletion: {
|
|
44
|
+
lte: cutOffDate.toISOString(),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
type: "record_bin",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (recordsToDelete.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
deletedCount: 0,
|
|
54
|
+
skipped: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await client.items.bulkDestroy({
|
|
59
|
+
items: recordsToDelete.map((item) => ({
|
|
60
|
+
type: "item",
|
|
61
|
+
id: item.id,
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
deletedCount: recordsToDelete.length,
|
|
67
|
+
skipped: false,
|
|
68
|
+
};
|
|
69
|
+
};
|