@ftschopp/dynatable-core 1.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/CHANGELOG.md +62 -0
- package/README.md +17 -0
- package/dist/builders/batch-get/create-batch-get-builder.d.ts +18 -0
- package/dist/builders/batch-get/create-batch-get-builder.d.ts.map +1 -0
- package/dist/builders/batch-get/create-batch-get-builder.js +71 -0
- package/dist/builders/batch-get/index.d.ts +3 -0
- package/dist/builders/batch-get/index.d.ts.map +1 -0
- package/dist/builders/batch-get/index.js +18 -0
- package/dist/builders/batch-get/types.d.ts +25 -0
- package/dist/builders/batch-get/types.d.ts.map +1 -0
- package/dist/builders/batch-get/types.js +2 -0
- package/dist/builders/batch-write/create-batch-write-builder.d.ts +12 -0
- package/dist/builders/batch-write/create-batch-write-builder.d.ts.map +1 -0
- package/dist/builders/batch-write/create-batch-write-builder.js +35 -0
- package/dist/builders/batch-write/index.d.ts +3 -0
- package/dist/builders/batch-write/index.d.ts.map +1 -0
- package/dist/builders/batch-write/index.js +18 -0
- package/dist/builders/batch-write/types.d.ts +30 -0
- package/dist/builders/batch-write/types.d.ts.map +1 -0
- package/dist/builders/batch-write/types.js +2 -0
- package/dist/builders/delete/create-delete-builder.d.ts +9 -0
- package/dist/builders/delete/create-delete-builder.d.ts.map +1 -0
- package/dist/builders/delete/create-delete-builder.js +72 -0
- package/dist/builders/delete/index.d.ts +3 -0
- package/dist/builders/delete/index.d.ts.map +1 -0
- package/dist/builders/delete/index.js +18 -0
- package/dist/builders/delete/types.d.ts +11 -0
- package/dist/builders/delete/types.d.ts.map +1 -0
- package/dist/builders/delete/types.js +2 -0
- package/dist/builders/get/create-get-builder.d.ts +12 -0
- package/dist/builders/get/create-get-builder.d.ts.map +1 -0
- package/dist/builders/get/create-get-builder.js +95 -0
- package/dist/builders/get/index.d.ts +3 -0
- package/dist/builders/get/index.d.ts.map +1 -0
- package/dist/builders/get/index.js +18 -0
- package/dist/builders/get/types.d.ts +28 -0
- package/dist/builders/get/types.d.ts.map +1 -0
- package/dist/builders/get/types.js +2 -0
- package/dist/builders/index.d.ts +12 -0
- package/dist/builders/index.d.ts.map +1 -0
- package/dist/builders/index.js +29 -0
- package/dist/builders/put/create-put-builder.d.ts +9 -0
- package/dist/builders/put/create-put-builder.d.ts.map +1 -0
- package/dist/builders/put/create-put-builder.js +104 -0
- package/dist/builders/put/index.d.ts +3 -0
- package/dist/builders/put/index.d.ts.map +1 -0
- package/dist/builders/put/index.js +18 -0
- package/dist/builders/put/types.d.ts +17 -0
- package/dist/builders/put/types.d.ts.map +1 -0
- package/dist/builders/put/types.js +2 -0
- package/dist/builders/query/create-query-builder.d.ts +9 -0
- package/dist/builders/query/create-query-builder.d.ts.map +1 -0
- package/dist/builders/query/create-query-builder.js +283 -0
- package/dist/builders/query/index.d.ts +3 -0
- package/dist/builders/query/index.d.ts.map +1 -0
- package/dist/builders/query/index.js +18 -0
- package/dist/builders/query/types.d.ts +79 -0
- package/dist/builders/query/types.d.ts.map +1 -0
- package/dist/builders/query/types.js +2 -0
- package/dist/builders/scan/create-scan-builder.d.ts +12 -0
- package/dist/builders/scan/create-scan-builder.d.ts.map +1 -0
- package/dist/builders/scan/create-scan-builder.js +103 -0
- package/dist/builders/scan/index.d.ts +3 -0
- package/dist/builders/scan/index.d.ts.map +1 -0
- package/dist/builders/scan/index.js +18 -0
- package/dist/builders/scan/types.d.ts +43 -0
- package/dist/builders/scan/types.d.ts.map +1 -0
- package/dist/builders/scan/types.js +2 -0
- package/dist/builders/shared/conditions.d.ts +18 -0
- package/dist/builders/shared/conditions.d.ts.map +1 -0
- package/dist/builders/shared/conditions.js +48 -0
- package/dist/builders/shared/index.d.ts +4 -0
- package/dist/builders/shared/index.d.ts.map +1 -0
- package/dist/builders/shared/index.js +19 -0
- package/dist/builders/shared/operators.d.ts +12 -0
- package/dist/builders/shared/operators.d.ts.map +1 -0
- package/dist/builders/shared/operators.js +197 -0
- package/dist/builders/shared/types.d.ts +77 -0
- package/dist/builders/shared/types.d.ts.map +1 -0
- package/dist/builders/shared/types.js +6 -0
- package/dist/builders/transact-get/create-transact-get-builder.d.ts +18 -0
- package/dist/builders/transact-get/create-transact-get-builder.d.ts.map +1 -0
- package/dist/builders/transact-get/create-transact-get-builder.js +60 -0
- package/dist/builders/transact-get/index.d.ts +3 -0
- package/dist/builders/transact-get/index.d.ts.map +1 -0
- package/dist/builders/transact-get/index.js +18 -0
- package/dist/builders/transact-get/types.d.ts +28 -0
- package/dist/builders/transact-get/types.d.ts.map +1 -0
- package/dist/builders/transact-get/types.js +2 -0
- package/dist/builders/transact-write/create-transact-write-builder.d.ts +18 -0
- package/dist/builders/transact-write/create-transact-write-builder.d.ts.map +1 -0
- package/dist/builders/transact-write/create-transact-write-builder.js +97 -0
- package/dist/builders/transact-write/index.d.ts +3 -0
- package/dist/builders/transact-write/index.d.ts.map +1 -0
- package/dist/builders/transact-write/index.js +18 -0
- package/dist/builders/transact-write/types.d.ts +34 -0
- package/dist/builders/transact-write/types.d.ts.map +1 -0
- package/dist/builders/transact-write/types.js +2 -0
- package/dist/builders/update/create-update-builder.d.ts +14 -0
- package/dist/builders/update/create-update-builder.d.ts.map +1 -0
- package/dist/builders/update/create-update-builder.js +180 -0
- package/dist/builders/update/index.d.ts +3 -0
- package/dist/builders/update/index.d.ts.map +1 -0
- package/dist/builders/update/index.js +18 -0
- package/dist/builders/update/types.d.ts +35 -0
- package/dist/builders/update/types.d.ts.map +1 -0
- package/dist/builders/update/types.js +2 -0
- package/dist/core/types.d.ts +224 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/entity.d.ts +73 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +161 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/table.d.ts +70 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +69 -0
- package/dist/utils/dynamodb-logger.d.ts +118 -0
- package/dist/utils/dynamodb-logger.d.ts.map +1 -0
- package/dist/utils/dynamodb-logger.js +125 -0
- package/dist/utils/model-utils.d.ts +24 -0
- package/dist/utils/model-utils.d.ts.map +1 -0
- package/dist/utils/model-utils.js +88 -0
- package/dist/utils/zod-utils.d.ts +13 -0
- package/dist/utils/zod-utils.d.ts.map +1 -0
- package/dist/utils/zod-utils.js +35 -0
- package/eslint.config.mjs +4 -0
- package/jest.config.js +11 -0
- package/package.json +36 -0
- package/src/builders/README.md +272 -0
- package/src/builders/batch-get/README.md +98 -0
- package/src/builders/batch-get/create-batch-get-builder.test.ts +165 -0
- package/src/builders/batch-get/create-batch-get-builder.ts +106 -0
- package/src/builders/batch-get/index.ts +2 -0
- package/src/builders/batch-get/types.ts +29 -0
- package/src/builders/batch-write/README.md +204 -0
- package/src/builders/batch-write/create-batch-write-builder.test.ts +173 -0
- package/src/builders/batch-write/create-batch-write-builder.ts +49 -0
- package/src/builders/batch-write/index.ts +2 -0
- package/src/builders/batch-write/types.ts +33 -0
- package/src/builders/delete/create-delete-builder.test.ts +294 -0
- package/src/builders/delete/create-delete-builder.ts +100 -0
- package/src/builders/delete/index.ts +2 -0
- package/src/builders/delete/types.ts +11 -0
- package/src/builders/get/create-get-builder.test.ts +272 -0
- package/src/builders/get/create-get-builder.ts +140 -0
- package/src/builders/get/index.ts +2 -0
- package/src/builders/get/types.ts +30 -0
- package/src/builders/index.ts +14 -0
- package/src/builders/put/create-put-builder.test.ts +213 -0
- package/src/builders/put/create-put-builder.ts +151 -0
- package/src/builders/put/index.ts +2 -0
- package/src/builders/put/types.ts +18 -0
- package/src/builders/query/create-query-builder.test.ts +230 -0
- package/src/builders/query/create-query-builder.ts +353 -0
- package/src/builders/query/index.ts +2 -0
- package/src/builders/query/types.ts +95 -0
- package/src/builders/scan/create-scan-builder.test.ts +260 -0
- package/src/builders/scan/create-scan-builder.ts +217 -0
- package/src/builders/scan/index.ts +2 -0
- package/src/builders/scan/types.ts +49 -0
- package/src/builders/shared/conditions.ts +58 -0
- package/src/builders/shared/index.ts +3 -0
- package/src/builders/shared/operators.test.ts +270 -0
- package/src/builders/shared/operators.ts +200 -0
- package/src/builders/shared/types.ts +100 -0
- package/src/builders/transact-get/README.md +167 -0
- package/src/builders/transact-get/create-transact-get-builder.test.ts +239 -0
- package/src/builders/transact-get/create-transact-get-builder.ts +67 -0
- package/src/builders/transact-get/index.ts +2 -0
- package/src/builders/transact-get/types.ts +31 -0
- package/src/builders/transact-write/README.md +166 -0
- package/src/builders/transact-write/create-transact-write-builder.test.ts +288 -0
- package/src/builders/transact-write/create-transact-write-builder.ts +118 -0
- package/src/builders/transact-write/index.ts +2 -0
- package/src/builders/transact-write/types.ts +33 -0
- package/src/builders/update/create-update-builder.test.ts +333 -0
- package/src/builders/update/create-update-builder.ts +284 -0
- package/src/builders/update/index.ts +2 -0
- package/src/builders/update/types.ts +42 -0
- package/src/core/types.test.ts +506 -0
- package/src/core/types.ts +290 -0
- package/src/entity.ts +337 -0
- package/src/index.ts +22 -0
- package/src/table.ts +109 -0
- package/src/utils/dynamodb-logger.test.ts +246 -0
- package/src/utils/dynamodb-logger.ts +175 -0
- package/src/utils/model-utils.test.ts +232 -0
- package/src/utils/model-utils.ts +101 -0
- package/src/utils/zod-utils.test.ts +272 -0
- package/src/utils/zod-utils.ts +36 -0
- package/tests/integration/instagram-clone.integration.test.ts +966 -0
- package/tests/integration/pagination-timestamps.integration.test.ts +375 -0
- package/tests/integration/transactions.integration.test.ts +529 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { applyPostDefaults } from './model-utils';
|
|
3
|
+
import { ModelDefinition } from '../core/types';
|
|
4
|
+
|
|
5
|
+
describe('applyPostDefaults - Timestamps', () => {
|
|
6
|
+
const testModel: ModelDefinition = {
|
|
7
|
+
key: {
|
|
8
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
9
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
10
|
+
},
|
|
11
|
+
attributes: {
|
|
12
|
+
username: { type: String, required: true },
|
|
13
|
+
name: { type: String },
|
|
14
|
+
email: { type: String },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.useFakeTimers();
|
|
20
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00.000Z'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.useRealTimers();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('Timestamps disabled (default)', () => {
|
|
28
|
+
test('should not add timestamps when disabled', () => {
|
|
29
|
+
const input = { username: 'alice', name: 'Alice' };
|
|
30
|
+
const result = applyPostDefaults(testModel, input, { timestamps: false });
|
|
31
|
+
|
|
32
|
+
expect(result.createdAt).toBeUndefined();
|
|
33
|
+
expect(result.updatedAt).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('should not add timestamps when option not provided', () => {
|
|
37
|
+
const input = { username: 'alice', name: 'Alice' };
|
|
38
|
+
const result = applyPostDefaults(testModel, input);
|
|
39
|
+
|
|
40
|
+
expect(result.createdAt).toBeUndefined();
|
|
41
|
+
expect(result.updatedAt).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('Timestamps enabled for new items', () => {
|
|
46
|
+
test('should add createdAt and updatedAt for new items', () => {
|
|
47
|
+
const input = { username: 'alice', name: 'Alice' };
|
|
48
|
+
const result = applyPostDefaults(testModel, input, {
|
|
49
|
+
timestamps: true,
|
|
50
|
+
isUpdate: false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.createdAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
54
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('should not override existing createdAt', () => {
|
|
58
|
+
const existingCreatedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
59
|
+
const input = {
|
|
60
|
+
username: 'alice',
|
|
61
|
+
name: 'Alice',
|
|
62
|
+
createdAt: existingCreatedAt,
|
|
63
|
+
};
|
|
64
|
+
const result = applyPostDefaults(testModel, input, {
|
|
65
|
+
timestamps: true,
|
|
66
|
+
isUpdate: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.createdAt).toEqual(existingCreatedAt);
|
|
70
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('should work with other defaults', () => {
|
|
74
|
+
const modelWithDefaults: ModelDefinition = {
|
|
75
|
+
key: {
|
|
76
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
77
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
78
|
+
},
|
|
79
|
+
attributes: {
|
|
80
|
+
username: { type: String, required: true },
|
|
81
|
+
name: { type: String },
|
|
82
|
+
status: { type: String, default: 'active' },
|
|
83
|
+
score: { type: Number, default: 0 },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const input = { username: 'alice', name: 'Alice' };
|
|
88
|
+
const result = applyPostDefaults(modelWithDefaults, input, {
|
|
89
|
+
timestamps: true,
|
|
90
|
+
isUpdate: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.status).toBe('active');
|
|
94
|
+
expect(result.score).toBe(0);
|
|
95
|
+
expect(result.createdAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
96
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('Timestamps enabled for updates', () => {
|
|
101
|
+
test('should only add updatedAt for updates', () => {
|
|
102
|
+
const input = { username: 'alice', name: 'Alice Updated' };
|
|
103
|
+
const result = applyPostDefaults(testModel, input, {
|
|
104
|
+
timestamps: true,
|
|
105
|
+
isUpdate: true,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.createdAt).toBeUndefined();
|
|
109
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('should update updatedAt even if already present', () => {
|
|
113
|
+
const oldUpdatedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
114
|
+
const input = {
|
|
115
|
+
username: 'alice',
|
|
116
|
+
name: 'Alice Updated',
|
|
117
|
+
updatedAt: oldUpdatedAt,
|
|
118
|
+
};
|
|
119
|
+
const result = applyPostDefaults(testModel, input, {
|
|
120
|
+
timestamps: true,
|
|
121
|
+
isUpdate: true,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
125
|
+
expect(result.updatedAt).not.toEqual(oldUpdatedAt);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('should preserve existing createdAt on update', () => {
|
|
129
|
+
const existingCreatedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
130
|
+
const input = {
|
|
131
|
+
username: 'alice',
|
|
132
|
+
name: 'Alice Updated',
|
|
133
|
+
createdAt: existingCreatedAt,
|
|
134
|
+
};
|
|
135
|
+
const result = applyPostDefaults(testModel, input, {
|
|
136
|
+
timestamps: true,
|
|
137
|
+
isUpdate: true,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result.createdAt).toEqual(existingCreatedAt);
|
|
141
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('Generated values with timestamps', () => {
|
|
146
|
+
test('should work with ULID generation', () => {
|
|
147
|
+
const modelWithUlid: ModelDefinition = {
|
|
148
|
+
key: {
|
|
149
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
150
|
+
SK: { type: String, value: 'USER#${id}' },
|
|
151
|
+
},
|
|
152
|
+
attributes: {
|
|
153
|
+
id: { type: String, generate: 'ulid' },
|
|
154
|
+
username: { type: String, required: true },
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const input = { username: 'alice' };
|
|
159
|
+
const result = applyPostDefaults(modelWithUlid, input, {
|
|
160
|
+
timestamps: true,
|
|
161
|
+
isUpdate: false,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.id).toBeDefined();
|
|
165
|
+
expect(typeof result.id).toBe('string');
|
|
166
|
+
expect(result.id.length).toBeGreaterThan(0);
|
|
167
|
+
expect(result.createdAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
168
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('should work with UUID generation', () => {
|
|
172
|
+
const modelWithUuid: ModelDefinition = {
|
|
173
|
+
key: {
|
|
174
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
175
|
+
SK: { type: String, value: 'USER#${id}' },
|
|
176
|
+
},
|
|
177
|
+
attributes: {
|
|
178
|
+
id: { type: String, generate: 'uuid' },
|
|
179
|
+
username: { type: String, required: true },
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const input = { username: 'alice' };
|
|
184
|
+
const result = applyPostDefaults(modelWithUuid, input, {
|
|
185
|
+
timestamps: true,
|
|
186
|
+
isUpdate: false,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(result.id).toBeDefined();
|
|
190
|
+
expect(typeof result.id).toBe('string');
|
|
191
|
+
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
192
|
+
expect(result.createdAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
193
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('Edge cases', () => {
|
|
198
|
+
test('should handle empty input', () => {
|
|
199
|
+
const input = {};
|
|
200
|
+
const result = applyPostDefaults(testModel, input, {
|
|
201
|
+
timestamps: true,
|
|
202
|
+
isUpdate: false,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(result.createdAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
206
|
+
expect(result.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('should handle multiple consecutive calls with different times', () => {
|
|
210
|
+
const input1 = { username: 'alice' };
|
|
211
|
+
const input2 = { username: 'bob' };
|
|
212
|
+
|
|
213
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00.000Z'));
|
|
214
|
+
const result1 = applyPostDefaults(testModel, input1, {
|
|
215
|
+
timestamps: true,
|
|
216
|
+
isUpdate: false,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
jest.setSystemTime(new Date('2024-01-15T11:00:00.000Z'));
|
|
220
|
+
const result2 = applyPostDefaults(testModel, input2, {
|
|
221
|
+
timestamps: true,
|
|
222
|
+
isUpdate: false,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result1.createdAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
226
|
+
expect(result1.updatedAt).toEqual('2024-01-15T10:00:00.000Z');
|
|
227
|
+
|
|
228
|
+
expect(result2.createdAt).toEqual('2024-01-15T11:00:00.000Z');
|
|
229
|
+
expect(result2.updatedAt).toEqual('2024-01-15T11:00:00.000Z');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
import { InferModel, KeyDefinition, ModelDefinition } from '@/core/types';
|
|
4
|
+
import { ulid } from 'ulid';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts all variable names from a template string
|
|
8
|
+
* @param template - Template string with ${variable} syntax
|
|
9
|
+
* @returns Array of variable names found in the template
|
|
10
|
+
*/
|
|
11
|
+
export const extractTemplateVars = (template: string): string[] => {
|
|
12
|
+
const matches = template.matchAll(/\${(.*?)}/g);
|
|
13
|
+
return Array.from(matches, (m) => m[1]).filter((v): v is string => v !== undefined);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves a string template with provided data
|
|
18
|
+
* @throws Error if any required template variable is missing from data
|
|
19
|
+
*/
|
|
20
|
+
export const resolveTemplate = (template: string, data: Record<string, any>): string => {
|
|
21
|
+
const missing: string[] = [];
|
|
22
|
+
|
|
23
|
+
const result = template.replace(/\${(.*?)}/g, (_, key) => {
|
|
24
|
+
const value = data[key];
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
missing.push(key);
|
|
27
|
+
return `\${${key}}`; // Keep placeholder for error message
|
|
28
|
+
}
|
|
29
|
+
return String(value);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (missing.length > 0) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Missing required field(s) for key template: ${missing.join(', ')}. ` +
|
|
35
|
+
`Template: "${template}"`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves all keys or indexes for a model
|
|
44
|
+
*/
|
|
45
|
+
export const resolveKeys = <M extends ModelDefinition>(
|
|
46
|
+
model: M,
|
|
47
|
+
input: Record<string, any>,
|
|
48
|
+
type: 'key' | 'index' | 'both' = 'key'
|
|
49
|
+
): Record<string, string> => {
|
|
50
|
+
const out: Record<string, string> = {};
|
|
51
|
+
const process = (keys?: Record<string, KeyDefinition>) => {
|
|
52
|
+
if (!keys) return;
|
|
53
|
+
for (const [k, def] of Object.entries(keys)) {
|
|
54
|
+
out[k] = resolveTemplate(def.value, input);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (type === 'key' || type === 'both') process(model.key);
|
|
58
|
+
if (type === 'index' || type === 'both') process(model.index);
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Applies default and generated values to validated input
|
|
64
|
+
*/
|
|
65
|
+
export const applyPostDefaults = <M extends ModelDefinition>(
|
|
66
|
+
model: M,
|
|
67
|
+
validatedItem: Record<string, any>,
|
|
68
|
+
options?: { isUpdate?: boolean; timestamps?: boolean }
|
|
69
|
+
): InferModel<M> => {
|
|
70
|
+
const result: Record<string, any> = { ...validatedItem };
|
|
71
|
+
|
|
72
|
+
for (const [key, attr] of Object.entries(model.attributes)) {
|
|
73
|
+
if (result[key] === undefined) {
|
|
74
|
+
if (attr.generate === 'ulid') {
|
|
75
|
+
result[key] = ulid();
|
|
76
|
+
} else if (attr.generate === 'uuid') {
|
|
77
|
+
result[key] = crypto.randomUUID();
|
|
78
|
+
} else if (attr.default !== undefined) {
|
|
79
|
+
result[key] = typeof attr.default === 'function' ? attr.default() : attr.default;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Apply timestamps if enabled
|
|
85
|
+
const enableTimestamps = options?.timestamps ?? false;
|
|
86
|
+
const isUpdate = options?.isUpdate ?? false;
|
|
87
|
+
|
|
88
|
+
if (enableTimestamps) {
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
|
|
91
|
+
// Only set createdAt on new items (not updates)
|
|
92
|
+
if (!isUpdate && result.createdAt === undefined) {
|
|
93
|
+
result.createdAt = now;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Always set updatedAt
|
|
97
|
+
result.updatedAt = now;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result as InferModel<M>;
|
|
101
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { modelToZod, typeToZod } from './zod-utils';
|
|
2
|
+
import type { AttributeDefinition, ModelDefinition } from '../core/types';
|
|
3
|
+
|
|
4
|
+
describe('zod-utils', () => {
|
|
5
|
+
describe('typeToZod', () => {
|
|
6
|
+
it('should convert String type to z.string()', () => {
|
|
7
|
+
const attr: AttributeDefinition = {
|
|
8
|
+
type: String,
|
|
9
|
+
required: true,
|
|
10
|
+
};
|
|
11
|
+
const zodType = typeToZod(attr);
|
|
12
|
+
|
|
13
|
+
expect(() => zodType.parse('hello')).not.toThrow();
|
|
14
|
+
expect(() => zodType.parse(123)).toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should convert Number type to z.number()', () => {
|
|
18
|
+
const attr: AttributeDefinition = {
|
|
19
|
+
type: Number,
|
|
20
|
+
required: true,
|
|
21
|
+
};
|
|
22
|
+
const zodType = typeToZod(attr);
|
|
23
|
+
|
|
24
|
+
expect(() => zodType.parse(42)).not.toThrow();
|
|
25
|
+
expect(() => zodType.parse('42')).toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should convert Boolean type to z.boolean()', () => {
|
|
29
|
+
const attr: AttributeDefinition = {
|
|
30
|
+
type: Boolean,
|
|
31
|
+
required: true,
|
|
32
|
+
};
|
|
33
|
+
const zodType = typeToZod(attr);
|
|
34
|
+
|
|
35
|
+
expect(() => zodType.parse(true)).not.toThrow();
|
|
36
|
+
expect(() => zodType.parse(false)).not.toThrow();
|
|
37
|
+
expect(() => zodType.parse('true')).toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should convert Date type to z.date()', () => {
|
|
41
|
+
const attr: AttributeDefinition = {
|
|
42
|
+
type: Date,
|
|
43
|
+
required: true,
|
|
44
|
+
};
|
|
45
|
+
const zodType = typeToZod(attr);
|
|
46
|
+
|
|
47
|
+
const now = new Date();
|
|
48
|
+
expect(() => zodType.parse(now)).not.toThrow();
|
|
49
|
+
expect(() => zodType.parse('2024-01-01')).toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should make optional fields when required is false', () => {
|
|
53
|
+
const attr: AttributeDefinition = {
|
|
54
|
+
type: String,
|
|
55
|
+
required: false,
|
|
56
|
+
};
|
|
57
|
+
const zodType = typeToZod(attr);
|
|
58
|
+
|
|
59
|
+
expect(() => zodType.parse('hello')).not.toThrow();
|
|
60
|
+
expect(() => zodType.parse(undefined)).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should make required fields when required is true', () => {
|
|
64
|
+
const attr: AttributeDefinition = {
|
|
65
|
+
type: String,
|
|
66
|
+
required: true,
|
|
67
|
+
};
|
|
68
|
+
const zodType = typeToZod(attr);
|
|
69
|
+
|
|
70
|
+
expect(() => zodType.parse('hello')).not.toThrow();
|
|
71
|
+
expect(() => zodType.parse(undefined)).toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return z.unknown() for unknown types', () => {
|
|
75
|
+
const attr: AttributeDefinition = {
|
|
76
|
+
type: Array as any,
|
|
77
|
+
required: true,
|
|
78
|
+
};
|
|
79
|
+
const zodType = typeToZod(attr);
|
|
80
|
+
|
|
81
|
+
// z.unknown() accepts any value
|
|
82
|
+
expect(() => zodType.parse('anything')).not.toThrow();
|
|
83
|
+
expect(() => zodType.parse(123)).not.toThrow();
|
|
84
|
+
expect(() => zodType.parse([])).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('modelToZod', () => {
|
|
89
|
+
it('should convert a simple model to Zod schema', () => {
|
|
90
|
+
const model: ModelDefinition = {
|
|
91
|
+
key: {
|
|
92
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
93
|
+
SK: { type: String, value: 'PROFILE' },
|
|
94
|
+
},
|
|
95
|
+
attributes: {
|
|
96
|
+
id: { type: String, required: true },
|
|
97
|
+
name: { type: String, required: true },
|
|
98
|
+
age: { type: Number, required: false },
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const zodSchema = modelToZod(model);
|
|
103
|
+
|
|
104
|
+
const validData = {
|
|
105
|
+
id: 'user-123',
|
|
106
|
+
name: 'John Doe',
|
|
107
|
+
age: 30,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
expect(() => zodSchema.parse(validData)).not.toThrow();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should allow optional fields to be missing', () => {
|
|
114
|
+
const model: ModelDefinition = {
|
|
115
|
+
key: {
|
|
116
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
117
|
+
SK: { type: String, value: 'PROFILE' },
|
|
118
|
+
},
|
|
119
|
+
attributes: {
|
|
120
|
+
id: { type: String, required: true },
|
|
121
|
+
email: { type: String, required: false },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const zodSchema = modelToZod(model);
|
|
126
|
+
|
|
127
|
+
const dataWithoutOptional = {
|
|
128
|
+
id: 'user-123',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
expect(() => zodSchema.parse(dataWithoutOptional)).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should reject missing required fields', () => {
|
|
135
|
+
const model: ModelDefinition = {
|
|
136
|
+
key: {
|
|
137
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
138
|
+
SK: { type: String, value: 'PROFILE' },
|
|
139
|
+
},
|
|
140
|
+
attributes: {
|
|
141
|
+
id: { type: String, required: true },
|
|
142
|
+
name: { type: String, required: true },
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const zodSchema = modelToZod(model);
|
|
147
|
+
|
|
148
|
+
const invalidData = {
|
|
149
|
+
id: 'user-123',
|
|
150
|
+
// name is missing
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(() => zodSchema.parse(invalidData)).toThrow();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should use passthrough to allow additional fields', () => {
|
|
157
|
+
const model: ModelDefinition = {
|
|
158
|
+
key: {
|
|
159
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
160
|
+
SK: { type: String, value: 'PROFILE' },
|
|
161
|
+
},
|
|
162
|
+
attributes: {
|
|
163
|
+
id: { type: String, required: true },
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const zodSchema = modelToZod(model);
|
|
168
|
+
|
|
169
|
+
const dataWithExtraFields = {
|
|
170
|
+
id: 'user-123',
|
|
171
|
+
createdAt: new Date(),
|
|
172
|
+
updatedAt: new Date(),
|
|
173
|
+
someOtherField: 'value',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Should not throw because of passthrough mode
|
|
177
|
+
expect(() => zodSchema.parse(dataWithExtraFields)).not.toThrow();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should handle complex models with multiple types', () => {
|
|
181
|
+
const model: ModelDefinition = {
|
|
182
|
+
key: {
|
|
183
|
+
PK: { type: String, value: 'PRODUCT#${id}' },
|
|
184
|
+
SK: { type: String, value: 'DETAILS' },
|
|
185
|
+
},
|
|
186
|
+
attributes: {
|
|
187
|
+
id: { type: String, required: true },
|
|
188
|
+
name: { type: String, required: true },
|
|
189
|
+
price: { type: Number, required: true },
|
|
190
|
+
available: { type: Boolean, required: false },
|
|
191
|
+
releaseDate: { type: Date, required: false },
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const zodSchema = modelToZod(model);
|
|
196
|
+
|
|
197
|
+
const validProduct = {
|
|
198
|
+
id: 'prod-123',
|
|
199
|
+
name: 'Laptop',
|
|
200
|
+
price: 999.99,
|
|
201
|
+
available: true,
|
|
202
|
+
releaseDate: new Date('2024-01-01'),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
expect(() => zodSchema.parse(validProduct)).not.toThrow();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should reject wrong types', () => {
|
|
209
|
+
const model: ModelDefinition = {
|
|
210
|
+
key: {
|
|
211
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
212
|
+
SK: { type: String, value: 'PROFILE' },
|
|
213
|
+
},
|
|
214
|
+
attributes: {
|
|
215
|
+
id: { type: String, required: true },
|
|
216
|
+
age: { type: Number, required: true },
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const zodSchema = modelToZod(model);
|
|
221
|
+
|
|
222
|
+
const invalidData = {
|
|
223
|
+
id: 'user-123',
|
|
224
|
+
age: '30', // Wrong type: string instead of number
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
expect(() => zodSchema.parse(invalidData)).toThrow();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should handle empty models', () => {
|
|
231
|
+
const model: ModelDefinition = {
|
|
232
|
+
key: {
|
|
233
|
+
PK: { type: String, value: 'EMPTY#${id}' },
|
|
234
|
+
SK: { type: String, value: 'ITEM' },
|
|
235
|
+
},
|
|
236
|
+
attributes: {},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const zodSchema = modelToZod(model);
|
|
240
|
+
|
|
241
|
+
expect(() => zodSchema.parse({})).not.toThrow();
|
|
242
|
+
// Passthrough allows any fields
|
|
243
|
+
expect(() => zodSchema.parse({ anyField: 'value' })).not.toThrow();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should validate parsed data correctly', () => {
|
|
247
|
+
const model: ModelDefinition = {
|
|
248
|
+
key: {
|
|
249
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
250
|
+
SK: { type: String, value: 'PROFILE' },
|
|
251
|
+
},
|
|
252
|
+
attributes: {
|
|
253
|
+
id: { type: String, required: true },
|
|
254
|
+
count: { type: Number, required: true },
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const zodSchema = modelToZod(model);
|
|
259
|
+
|
|
260
|
+
const data = {
|
|
261
|
+
id: 'user-123',
|
|
262
|
+
count: 42,
|
|
263
|
+
extra: 'allowed',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const parsed = zodSchema.parse(data);
|
|
267
|
+
expect(parsed.id).toBe('user-123');
|
|
268
|
+
expect(parsed.count).toBe(42);
|
|
269
|
+
expect(parsed.extra).toBe('allowed');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// -------------------- Zod Conversion --------------------
|
|
3
|
+
|
|
4
|
+
import { AttributeDefinition, ModelDefinition } from '@/core/types';
|
|
5
|
+
import { z, ZodObject, ZodType } from 'zod';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts an attribute definition to a Zod schema type
|
|
9
|
+
*/
|
|
10
|
+
export const typeToZod = (attr: AttributeDefinition): ZodType => {
|
|
11
|
+
const zodType: ZodType =
|
|
12
|
+
attr.type === String
|
|
13
|
+
? z.string()
|
|
14
|
+
: attr.type === Number
|
|
15
|
+
? z.number()
|
|
16
|
+
: attr.type === Boolean
|
|
17
|
+
? z.boolean()
|
|
18
|
+
: attr.type === Date
|
|
19
|
+
? z.date()
|
|
20
|
+
: z.unknown();
|
|
21
|
+
|
|
22
|
+
return attr.required ? zodType : zodType.optional();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Converts a model definition to a Zod object schema
|
|
27
|
+
* Uses passthrough mode to allow additional fields like createdAt/updatedAt
|
|
28
|
+
* that are added by applyPostDefaults when timestamps are enabled
|
|
29
|
+
*/
|
|
30
|
+
export const modelToZod = (model: ModelDefinition): ZodObject<any> => {
|
|
31
|
+
const shape: Record<string, ZodType> = {};
|
|
32
|
+
for (const [key, attr] of Object.entries(model.attributes)) {
|
|
33
|
+
shape[key] = typeToZod(attr);
|
|
34
|
+
}
|
|
35
|
+
return z.object(shape).passthrough();
|
|
36
|
+
};
|