datocms-plugin-record-bin 1.9.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.
Files changed (47) hide show
  1. package/README.md +113 -11
  2. package/build/assets/index-BnrW9Ts8.js +15 -0
  3. package/build/assets/index-aWCW2c0n.css +1 -0
  4. package/build/index.html +13 -1
  5. package/index.html +12 -0
  6. package/package.json +24 -18
  7. package/src/entrypoints/BinOutlet.tsx +262 -37
  8. package/src/entrypoints/ConfigScreen.tsx +939 -38
  9. package/src/entrypoints/ErrorModal.tsx +86 -2
  10. package/src/index.tsx +73 -28
  11. package/src/react-app-env.d.ts +1 -1
  12. package/src/types/types.ts +36 -8
  13. package/src/utils/binCleanup.test.ts +107 -0
  14. package/src/utils/binCleanup.ts +71 -23
  15. package/src/utils/debugLogger.ts +27 -0
  16. package/src/utils/deployProviders.test.ts +33 -0
  17. package/src/utils/deployProviders.ts +28 -0
  18. package/src/utils/getDeploymentUrlFromParameters.test.ts +26 -0
  19. package/src/utils/getDeploymentUrlFromParameters.ts +21 -0
  20. package/src/utils/getRuntimeMode.test.ts +57 -0
  21. package/src/utils/getRuntimeMode.ts +23 -0
  22. package/src/utils/lambdaLessCapture.test.ts +218 -0
  23. package/src/utils/lambdaLessCapture.ts +160 -0
  24. package/src/utils/lambdaLessCleanup.test.ts +125 -0
  25. package/src/utils/lambdaLessCleanup.ts +69 -0
  26. package/src/utils/lambdaLessRestore.test.ts +248 -0
  27. package/src/utils/lambdaLessRestore.ts +159 -0
  28. package/src/utils/recordBinModel.ts +108 -0
  29. package/src/utils/recordBinPayload.test.ts +103 -0
  30. package/src/utils/recordBinPayload.ts +136 -0
  31. package/src/utils/recordBinWebhook.test.ts +253 -0
  32. package/src/utils/recordBinWebhook.ts +305 -0
  33. package/src/utils/render.tsx +17 -8
  34. package/src/utils/restoreError.test.ts +112 -0
  35. package/src/utils/restoreError.ts +221 -0
  36. package/src/utils/verifyLambdaHealth.test.ts +248 -0
  37. package/src/utils/verifyLambdaHealth.ts +422 -0
  38. package/vite.config.ts +11 -0
  39. package/build/asset-manifest.json +0 -13
  40. package/build/static/css/main.10f29737.css +0 -2
  41. package/build/static/css/main.10f29737.css.map +0 -1
  42. package/build/static/js/main.53795e3b.js +0 -3
  43. package/build/static/js/main.53795e3b.js.LICENSE.txt +0 -47
  44. package/build/static/js/main.53795e3b.js.map +0 -1
  45. package/src/entrypoints/InstallationModal.tsx +0 -107
  46. package/src/entrypoints/PreInstallConfig.tsx +0 -28
  47. package/src/utils/attemptVercelInitialization.ts +0 -16
@@ -0,0 +1,248 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { buildClient } from "@datocms/cma-client-browser";
3
+ import {
4
+ isLambdaLessRestoreError,
5
+ restoreRecordWithoutLambda,
6
+ } from "./lambdaLessRestore";
7
+
8
+ vi.mock("@datocms/cma-client-browser", () => ({
9
+ buildClient: vi.fn(),
10
+ }));
11
+
12
+ type ClientMock = {
13
+ items: {
14
+ rawCreate: ReturnType<typeof vi.fn>;
15
+ destroy: ReturnType<typeof vi.fn>;
16
+ };
17
+ };
18
+
19
+ const createClientMock = (): ClientMock => ({
20
+ items: {
21
+ rawCreate: vi.fn(),
22
+ destroy: vi.fn(),
23
+ },
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ describe("restoreRecordWithoutLambda", () => {
32
+ it("restores a record, sanitizes payload and removes trash item", async () => {
33
+ const clientMock = createClientMock();
34
+ clientMock.items.rawCreate.mockResolvedValue({
35
+ data: {
36
+ id: "restored-item-id",
37
+ relationships: {
38
+ item_type: {
39
+ data: {
40
+ id: "restored-model-id",
41
+ },
42
+ },
43
+ },
44
+ },
45
+ });
46
+ clientMock.items.destroy.mockResolvedValue({});
47
+
48
+ vi.mocked(buildClient).mockReturnValue(
49
+ clientMock as unknown as ReturnType<typeof buildClient>
50
+ );
51
+
52
+ const recordBody = {
53
+ event_type: "to_be_restored",
54
+ environment: "main",
55
+ entity: {
56
+ type: "item",
57
+ id: "deleted-item-id",
58
+ attributes: {
59
+ title: "Old title",
60
+ created_at: "2024-01-01T00:00:00.000Z",
61
+ updated_at: "2024-01-02T00:00:00.000Z",
62
+ content: [
63
+ {
64
+ id: "block-id",
65
+ type: "item",
66
+ attributes: {
67
+ text: "Nested block",
68
+ },
69
+ relationships: {
70
+ item_type: {
71
+ data: {
72
+ type: "item_type",
73
+ id: "block-model-id",
74
+ },
75
+ },
76
+ },
77
+ },
78
+ ],
79
+ },
80
+ relationships: {
81
+ item_type: {
82
+ data: {
83
+ type: "item_type",
84
+ id: "article-model-id",
85
+ },
86
+ },
87
+ creator: {
88
+ data: {
89
+ type: "user",
90
+ id: "user-id",
91
+ },
92
+ },
93
+ },
94
+ meta: {
95
+ created_at: "2024-01-01T00:00:00.000Z",
96
+ first_published_at: null,
97
+ status: "published",
98
+ },
99
+ },
100
+ };
101
+
102
+ const result = await restoreRecordWithoutLambda({
103
+ currentUserAccessToken: "token",
104
+ fallbackEnvironment: "main",
105
+ recordBody,
106
+ trashRecordID: "trash-id",
107
+ });
108
+
109
+ expect(result).toEqual({
110
+ restoredRecord: {
111
+ id: "restored-item-id",
112
+ modelID: "restored-model-id",
113
+ },
114
+ });
115
+
116
+ const createPayload = clientMock.items.rawCreate.mock.calls[0][0];
117
+ expect(createPayload.data.id).toBeUndefined();
118
+ expect(createPayload.data.attributes.created_at).toBeUndefined();
119
+ expect(createPayload.data.attributes.updated_at).toBeUndefined();
120
+ expect(createPayload.data.relationships.creator).toBeUndefined();
121
+ expect(createPayload.data.meta).toEqual({
122
+ created_at: "2024-01-01T00:00:00.000Z",
123
+ first_published_at: null,
124
+ });
125
+ expect(createPayload.data.attributes.content[0].id).toBeUndefined();
126
+ expect(
127
+ createPayload.data.attributes.content[0].relationships.item_type.data.id
128
+ ).toBe("block-model-id");
129
+ expect(clientMock.items.destroy).toHaveBeenCalledWith("trash-id");
130
+ });
131
+
132
+ it("supports raw entity payloads without webhook envelope", async () => {
133
+ const clientMock = createClientMock();
134
+ clientMock.items.rawCreate.mockResolvedValue({
135
+ data: {
136
+ id: "restored-item-id",
137
+ relationships: {
138
+ item_type: {
139
+ data: {
140
+ id: "restored-model-id",
141
+ },
142
+ },
143
+ },
144
+ },
145
+ });
146
+ clientMock.items.destroy.mockResolvedValue({});
147
+
148
+ vi.mocked(buildClient).mockReturnValue(
149
+ clientMock as unknown as ReturnType<typeof buildClient>
150
+ );
151
+
152
+ await restoreRecordWithoutLambda({
153
+ currentUserAccessToken: "token",
154
+ fallbackEnvironment: "fallback-env",
155
+ recordBody: {
156
+ type: "item",
157
+ id: "item-id",
158
+ attributes: {
159
+ title: "Standalone payload",
160
+ },
161
+ relationships: {
162
+ item_type: {
163
+ data: {
164
+ type: "item_type",
165
+ id: "model-id",
166
+ },
167
+ },
168
+ },
169
+ meta: {
170
+ created_at: "2024-01-01T00:00:00.000Z",
171
+ first_published_at: null,
172
+ },
173
+ },
174
+ trashRecordID: "trash-id",
175
+ });
176
+
177
+ expect(buildClient).toHaveBeenCalledWith({
178
+ apiToken: "token",
179
+ environment: "fallback-env",
180
+ });
181
+ });
182
+
183
+ it("throws LambdaLessRestoreError with lambda-like payload on rawCreate errors", async () => {
184
+ const clientMock = createClientMock();
185
+ clientMock.items.rawCreate.mockRejectedValue({
186
+ errors: [
187
+ {
188
+ attributes: {
189
+ code: "VALIDATION_INVALID",
190
+ details: {
191
+ code: "INVALID_FIELD",
192
+ field: "title",
193
+ },
194
+ },
195
+ },
196
+ ],
197
+ });
198
+
199
+ vi.mocked(buildClient).mockReturnValue(
200
+ clientMock as unknown as ReturnType<typeof buildClient>
201
+ );
202
+
203
+ await expect(
204
+ restoreRecordWithoutLambda({
205
+ currentUserAccessToken: "token",
206
+ fallbackEnvironment: "main",
207
+ recordBody: {
208
+ event_type: "to_be_restored",
209
+ environment: "main",
210
+ entity: {
211
+ type: "item",
212
+ id: "item-id",
213
+ attributes: {
214
+ title: "Broken payload",
215
+ },
216
+ relationships: {
217
+ item_type: {
218
+ data: {
219
+ type: "item_type",
220
+ id: "model-id",
221
+ },
222
+ },
223
+ },
224
+ meta: {
225
+ created_at: "2024-01-01T00:00:00.000Z",
226
+ first_published_at: null,
227
+ },
228
+ },
229
+ },
230
+ trashRecordID: "trash-id",
231
+ })
232
+ ).rejects.toSatisfy((error: unknown) => {
233
+ if (!isLambdaLessRestoreError(error)) {
234
+ return false;
235
+ }
236
+
237
+ expect(error.restorationError.simplifiedError.code).toBe(
238
+ "VALIDATION_INVALID"
239
+ );
240
+ expect(error.restorationError.fullErrorPayload).toContain(
241
+ "VALIDATION_INVALID"
242
+ );
243
+ return true;
244
+ });
245
+
246
+ expect(clientMock.items.destroy).not.toHaveBeenCalled();
247
+ });
248
+ });
@@ -0,0 +1,159 @@
1
+ import { buildClient } from "@datocms/cma-client-browser";
2
+ import { errorObject } from "../types/types";
3
+ import { normalizeRecordBinPayload } from "./recordBinPayload";
4
+ import { buildRestoreErrorPayload } from "./restoreError";
5
+
6
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
7
+ Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+
9
+ const recursivelyDeleteAllBlockIDs = (
10
+ recursiveObject: unknown,
11
+ previousKey: string
12
+ ) => {
13
+ if (Array.isArray(recursiveObject)) {
14
+ for (const arrayItem of recursiveObject) {
15
+ recursivelyDeleteAllBlockIDs(arrayItem, previousKey);
16
+ }
17
+ return;
18
+ }
19
+
20
+ if (!isRecord(recursiveObject)) {
21
+ return;
22
+ }
23
+
24
+ if (
25
+ Object.prototype.hasOwnProperty.call(recursiveObject, "id") &&
26
+ previousKey !== "data"
27
+ ) {
28
+ delete recursiveObject.id;
29
+ }
30
+
31
+ for (const key of Object.keys(recursiveObject)) {
32
+ const child = recursiveObject[key];
33
+ if (typeof child === "object" && child !== null) {
34
+ recursivelyDeleteAllBlockIDs(child, key);
35
+ }
36
+ }
37
+ };
38
+
39
+ const deepCloneRecord = (
40
+ value: Record<string, unknown>
41
+ ): Record<string, unknown> => JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
42
+
43
+ const sanitizeEntityForCreation = (
44
+ entity: Record<string, unknown>
45
+ ): Record<string, unknown> => {
46
+ const requestBody = deepCloneRecord(entity);
47
+ delete requestBody.id;
48
+
49
+ const attributes = isRecord(requestBody.attributes)
50
+ ? requestBody.attributes
51
+ : {};
52
+ delete attributes.created_at;
53
+ delete attributes.updated_at;
54
+ requestBody.attributes = attributes;
55
+
56
+ const relationships = isRecord(requestBody.relationships)
57
+ ? requestBody.relationships
58
+ : {};
59
+ delete relationships.creator;
60
+ requestBody.relationships = relationships;
61
+
62
+ const meta = isRecord(requestBody.meta) ? requestBody.meta : {};
63
+ requestBody.meta = {
64
+ created_at: meta.created_at,
65
+ first_published_at: meta.first_published_at,
66
+ };
67
+
68
+ recursivelyDeleteAllBlockIDs(requestBody, "");
69
+ return requestBody;
70
+ };
71
+
72
+ export class LambdaLessRestoreError extends Error {
73
+ readonly restorationError: errorObject;
74
+
75
+ constructor(message: string, restorationError: errorObject) {
76
+ super(message);
77
+ this.name = "LambdaLessRestoreError";
78
+ this.restorationError = restorationError;
79
+ }
80
+ }
81
+
82
+ export const isLambdaLessRestoreError = (
83
+ error: unknown
84
+ ): error is LambdaLessRestoreError => error instanceof LambdaLessRestoreError;
85
+
86
+ export type RestoreRecordWithoutLambdaInput = {
87
+ currentUserAccessToken: string | undefined;
88
+ fallbackEnvironment: string;
89
+ recordBody: unknown;
90
+ trashRecordID: string;
91
+ };
92
+
93
+ export type RestoreRecordWithoutLambdaResult = {
94
+ restoredRecord: {
95
+ id: string;
96
+ modelID: string;
97
+ };
98
+ };
99
+
100
+ export const restoreRecordWithoutLambda = async ({
101
+ currentUserAccessToken,
102
+ fallbackEnvironment,
103
+ recordBody,
104
+ trashRecordID,
105
+ }: RestoreRecordWithoutLambdaInput): Promise<RestoreRecordWithoutLambdaResult> => {
106
+ if (!currentUserAccessToken) {
107
+ throw new Error("Missing currentUserAccessToken for Lambda-less restore.");
108
+ }
109
+
110
+ const normalizedPayload = normalizeRecordBinPayload(
111
+ recordBody,
112
+ fallbackEnvironment
113
+ );
114
+ const requestBody = sanitizeEntityForCreation(normalizedPayload.entity);
115
+
116
+ const client = buildClient({
117
+ apiToken: currentUserAccessToken,
118
+ environment: normalizedPayload.environment,
119
+ });
120
+
121
+ let restoredRecordResponse:
122
+ | {
123
+ data: {
124
+ id: string;
125
+ relationships: {
126
+ item_type: {
127
+ data: {
128
+ id: string;
129
+ };
130
+ };
131
+ };
132
+ };
133
+ }
134
+ | undefined;
135
+
136
+ try {
137
+ restoredRecordResponse = await client.items.rawCreate({
138
+ data: requestBody as never,
139
+ });
140
+ } catch (error) {
141
+ throw new LambdaLessRestoreError(
142
+ "The record could not be restored!",
143
+ buildRestoreErrorPayload(error)
144
+ );
145
+ }
146
+
147
+ await client.items.destroy(trashRecordID);
148
+
149
+ const restoredRecordId = restoredRecordResponse.data.id;
150
+ const restoredModelId =
151
+ restoredRecordResponse.data.relationships.item_type.data.id;
152
+
153
+ return {
154
+ restoredRecord: {
155
+ id: restoredRecordId,
156
+ modelID: restoredModelId,
157
+ },
158
+ };
159
+ };
@@ -0,0 +1,108 @@
1
+ import { buildClient } from "@datocms/cma-client-browser";
2
+
3
+ type CmaClient = ReturnType<typeof buildClient>;
4
+
5
+ type RecordBinModel = {
6
+ id: string;
7
+ };
8
+
9
+ const extractModelId = (value: unknown): string | undefined => {
10
+ if (!value || typeof value !== "object") {
11
+ return undefined;
12
+ }
13
+
14
+ const candidate = value as Record<string, unknown>;
15
+ return typeof candidate.id === "string" ? candidate.id : undefined;
16
+ };
17
+
18
+ const findExistingRecordBinModel = async (
19
+ client: CmaClient
20
+ ): Promise<RecordBinModel | undefined> => {
21
+ try {
22
+ const existingModel = await client.itemTypes.find("record_bin");
23
+ const existingModelId = extractModelId(existingModel);
24
+ if (!existingModelId) {
25
+ return undefined;
26
+ }
27
+
28
+ return { id: existingModelId };
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ };
33
+
34
+ export const ensureRecordBinModel = async (
35
+ client: CmaClient
36
+ ): Promise<RecordBinModel> => {
37
+ const existingModel = await findExistingRecordBinModel(client);
38
+ if (existingModel) {
39
+ return existingModel;
40
+ }
41
+
42
+ let createdModelId: string | undefined;
43
+
44
+ try {
45
+ const createdModel = await client.itemTypes.create({
46
+ name: "🗑 Record Bin",
47
+ api_key: "record_bin",
48
+ collection_appearance: "table",
49
+ });
50
+ createdModelId = extractModelId(createdModel);
51
+ } catch (error) {
52
+ const modelCreatedInParallel = await findExistingRecordBinModel(client);
53
+ if (modelCreatedInParallel) {
54
+ return modelCreatedInParallel;
55
+ }
56
+
57
+ throw error;
58
+ }
59
+
60
+ if (!createdModelId) {
61
+ throw new Error("Record Bin model creation returned an invalid model id.");
62
+ }
63
+
64
+ const labelField = await client.fields.create(createdModelId, {
65
+ label: "Label",
66
+ field_type: "string",
67
+ api_key: "label",
68
+ position: 1,
69
+ });
70
+
71
+ await client.fields.create(createdModelId, {
72
+ label: "Model",
73
+ field_type: "string",
74
+ api_key: "model",
75
+ position: 2,
76
+ });
77
+
78
+ await client.fields.create(createdModelId, {
79
+ label: "Date of deletion",
80
+ field_type: "date_time",
81
+ api_key: "date_of_deletion",
82
+ position: 3,
83
+ });
84
+
85
+ await client.fields.create(createdModelId, {
86
+ label: "Record body",
87
+ field_type: "json",
88
+ api_key: "record_body",
89
+ position: 4,
90
+ });
91
+
92
+ const labelFieldId = extractModelId(labelField);
93
+ if (!labelFieldId) {
94
+ throw new Error("Record Bin label field creation returned an invalid field id.");
95
+ }
96
+
97
+ await client.itemTypes.update(createdModelId, {
98
+ title_field: {
99
+ type: "field",
100
+ id: labelFieldId,
101
+ },
102
+ collection_appearance: "table",
103
+ });
104
+
105
+ return {
106
+ id: createdModelId,
107
+ };
108
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildRecordBinCompatiblePayload,
4
+ extractEntityModelId,
5
+ normalizeRecordBinPayload,
6
+ } from "./recordBinPayload";
7
+
8
+ const entityFixture = {
9
+ type: "item",
10
+ id: "item-1",
11
+ relationships: {
12
+ item_type: {
13
+ data: {
14
+ type: "item_type",
15
+ id: "model-1",
16
+ },
17
+ },
18
+ },
19
+ attributes: {
20
+ title: "Hello world",
21
+ },
22
+ meta: {
23
+ created_at: "2024-01-01T00:00:00.000Z",
24
+ first_published_at: null,
25
+ },
26
+ };
27
+
28
+ describe("buildRecordBinCompatiblePayload", () => {
29
+ it("builds a webhook-compatible envelope", () => {
30
+ const payload = buildRecordBinCompatiblePayload({
31
+ environment: "main",
32
+ entity: entityFixture,
33
+ capturedAt: "2026-02-25T00:00:00.000Z",
34
+ });
35
+
36
+ expect(payload).toEqual({
37
+ event_type: "to_be_restored",
38
+ entity_type: "item",
39
+ environment: "main",
40
+ entity: entityFixture,
41
+ event_triggered_at: "2026-02-25T00:00:00.000Z",
42
+ related_entities: [],
43
+ __record_bin: {
44
+ source: "onBeforeItemsDestroy",
45
+ version: "2026-02-25",
46
+ },
47
+ });
48
+ });
49
+ });
50
+
51
+ describe("normalizeRecordBinPayload", () => {
52
+ it("normalizes synthetic payload envelopes", () => {
53
+ const normalized = normalizeRecordBinPayload(
54
+ buildRecordBinCompatiblePayload({
55
+ environment: "sandbox",
56
+ entity: entityFixture,
57
+ }),
58
+ "main"
59
+ );
60
+
61
+ expect(normalized.environment).toBe("sandbox");
62
+ expect(normalized.entity).toEqual(entityFixture);
63
+ expect(normalized.eventType).toBe("to_be_restored");
64
+ });
65
+
66
+ it("normalizes legacy webhook payload envelopes", () => {
67
+ const normalized = normalizeRecordBinPayload(
68
+ {
69
+ event_type: "delete",
70
+ environment: "staging",
71
+ entity: entityFixture,
72
+ },
73
+ "main"
74
+ );
75
+
76
+ expect(normalized.environment).toBe("staging");
77
+ expect(normalized.entity).toEqual(entityFixture);
78
+ expect(normalized.eventType).toBe("delete");
79
+ });
80
+
81
+ it("accepts raw entity payloads and applies fallback environment", () => {
82
+ const normalized = normalizeRecordBinPayload(entityFixture, "main");
83
+
84
+ expect(normalized.environment).toBe("main");
85
+ expect(normalized.entity).toEqual(entityFixture);
86
+ expect(normalized.eventType).toBeUndefined();
87
+ });
88
+
89
+ it("throws for malformed payloads", () => {
90
+ expect(() => normalizeRecordBinPayload("not-json", "main")).toThrow(
91
+ /Unexpected token/
92
+ );
93
+ expect(() => normalizeRecordBinPayload({ foo: "bar" }, "main")).toThrow(
94
+ /entity payload/
95
+ );
96
+ });
97
+ });
98
+
99
+ describe("extractEntityModelId", () => {
100
+ it("extracts the model id from raw entities", () => {
101
+ expect(extractEntityModelId(entityFixture)).toBe("model-1");
102
+ });
103
+ });