@cocreate/dynamodb 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/.github/FUNDING.yml +3 -0
- package/.github/workflows/automated.yml +56 -0
- package/CHANGELOG.md +6 -0
- package/CONTRIBUTING.md +97 -0
- package/CoCreate.config.js +23 -0
- package/LICENSE +683 -0
- package/README.md +88 -0
- package/demo/index.html +19 -0
- package/docs/index.html +228 -0
- package/package.json +43 -0
- package/prettier.config.js +16 -0
- package/release.config.js +30 -0
- package/src/index.js +889 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
|
|
2
|
+
const {
|
|
3
|
+
DynamoDBDocumentClient,
|
|
4
|
+
ScanCommand,
|
|
5
|
+
GetCommand,
|
|
6
|
+
PutCommand,
|
|
7
|
+
UpdateCommand,
|
|
8
|
+
DeleteCommand,
|
|
9
|
+
TransactWriteCommand
|
|
10
|
+
} = require("@aws-sdk/lib-dynamodb");
|
|
11
|
+
const { CreateTableCommand, DeleteTableCommand, ListTablesCommand } = require("@aws-sdk/client-dynamodb");
|
|
12
|
+
const {
|
|
13
|
+
dotNotationToObject,
|
|
14
|
+
queryData,
|
|
15
|
+
searchData,
|
|
16
|
+
sortData,
|
|
17
|
+
isValidDate
|
|
18
|
+
} = require("@cocreate/utils");
|
|
19
|
+
|
|
20
|
+
const organizations = {};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dynamically resolves or caches an AWS DynamoDB Document Client instance.
|
|
24
|
+
* Normalizes AWS credentials out of your structured data.url or data.storageUrl object.
|
|
25
|
+
*/
|
|
26
|
+
async function dbClient(data) {
|
|
27
|
+
const storageUrl = data.storageUrl || data.url;
|
|
28
|
+
|
|
29
|
+
if (storageUrl) {
|
|
30
|
+
if (!organizations[data.organization_id]) {
|
|
31
|
+
organizations[data.organization_id] = {};
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
if (!organizations[data.organization_id][storageUrl]) {
|
|
35
|
+
const connectionPromise = (async () => {
|
|
36
|
+
let config = {};
|
|
37
|
+
|
|
38
|
+
// Resolve structured connection object (Recommended)
|
|
39
|
+
if (typeof storageUrl === "object" && storageUrl !== null) {
|
|
40
|
+
config = {
|
|
41
|
+
region: storageUrl.region || "us-east-1",
|
|
42
|
+
credentials: {
|
|
43
|
+
accessKeyId: storageUrl.accessKeyId || storageUrl.access_key_id,
|
|
44
|
+
secretAccessKey: storageUrl.secretAccessKey || storageUrl.secret_access_key
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
if (storageUrl.endpoint) {
|
|
48
|
+
config.endpoint = storageUrl.endpoint; // Support DynamoDB Local
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Handle string-fallback parses (e.g. dynamodb://region?accessKeyId=...&secretAccessKey=...)
|
|
52
|
+
else if (typeof storageUrl === "string" && storageUrl.startsWith("dynamodb://")) {
|
|
53
|
+
const urlParsed = new URL(storageUrl);
|
|
54
|
+
config = {
|
|
55
|
+
region: urlParsed.host || "us-east-1",
|
|
56
|
+
credentials: {
|
|
57
|
+
accessKeyId: urlParsed.searchParams.get("accessKeyId"),
|
|
58
|
+
secretAccessKey: urlParsed.searchParams.get("secretAccessKey")
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const baseClient = new DynamoDBClient(config);
|
|
64
|
+
|
|
65
|
+
// Wrap with the DocumentClient to handle JavaScript types natively (no need for raw DynamoDB marshalling)
|
|
66
|
+
const docClient = DynamoDBDocumentClient.from(baseClient, {
|
|
67
|
+
marshallOptions: {
|
|
68
|
+
removeUndefinedValues: true,
|
|
69
|
+
convertEmptyValues: true
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Verify client viability by executing a lightweight command
|
|
74
|
+
await docClient.send(new ListTablesCommand({ Limit: 1 }));
|
|
75
|
+
return docClient;
|
|
76
|
+
})();
|
|
77
|
+
|
|
78
|
+
organizations[data.organization_id][storageUrl] = connectionPromise;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return await organizations[data.organization_id][storageUrl];
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`${data.organization_id}: DynamoDB failed to connect`);
|
|
84
|
+
errorHandler(data, error);
|
|
85
|
+
return { status: false };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
errorHandler(data, "missing DynamoDB Connection URL/Credentials");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Global Event Listener: orgDeleted
|
|
95
|
+
* Cleans up active DynamoDB Client instances and destroys connection configurations.
|
|
96
|
+
*/
|
|
97
|
+
process.on("orgDeleted", (organization_id) => {
|
|
98
|
+
const orgPools = organizations[organization_id];
|
|
99
|
+
if (orgPools) {
|
|
100
|
+
console.log(`[DynamoDB Utilities] Cleanup Scheduled: Initiating 5-second grace period for Org: ${organization_id}`);
|
|
101
|
+
setTimeout(async () => {
|
|
102
|
+
const currentPools = organizations[organization_id];
|
|
103
|
+
if (!currentPools) return;
|
|
104
|
+
|
|
105
|
+
console.log(`[DynamoDB Utilities] Cleanup Executing: Destroying active clients for Org: ${organization_id}`);
|
|
106
|
+
const clientPromises = Object.values(currentPools);
|
|
107
|
+
|
|
108
|
+
for (const clientPromise of clientPromises) {
|
|
109
|
+
try {
|
|
110
|
+
const client = await clientPromise;
|
|
111
|
+
if (client && typeof client.destroy === "function") {
|
|
112
|
+
client.destroy();
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[DynamoDB Utilities] Error closing DynamoDB client for Org ${organization_id}:`, err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
delete organizations[organization_id];
|
|
120
|
+
console.log(`[DynamoDB Utilities] Memory Cleared: Registry freed for Org: ${organization_id}`);
|
|
121
|
+
}, 5000);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Universal router matching your standard pipeline signature.
|
|
127
|
+
*/
|
|
128
|
+
function send(data) {
|
|
129
|
+
let [type, method] = data.method.split(".");
|
|
130
|
+
if (type === "database") return database(method, data);
|
|
131
|
+
if (type === "array") return array(method, data);
|
|
132
|
+
if (type === "object") return object(method, data);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* "database" operations mapped to DynamoDB logical grouping names.
|
|
137
|
+
* DynamoDB is serverless and works globally on an AWS Region basis, so we emulate schemas.
|
|
138
|
+
*/
|
|
139
|
+
function database(method, data) {
|
|
140
|
+
const storageName = data.storageName || data.provider || "dynamodb";
|
|
141
|
+
return new Promise(async (resolve) => {
|
|
142
|
+
let type = "database";
|
|
143
|
+
let databaseArray = [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const db = await dbClient(data);
|
|
147
|
+
if (!db || db.status === false) return resolve(data);
|
|
148
|
+
|
|
149
|
+
if (method === "read") {
|
|
150
|
+
// We treat the current target region or designated config name as our active "database" catalog
|
|
151
|
+
const dbName = data.database || "default";
|
|
152
|
+
const dbObj = { name: dbName };
|
|
153
|
+
|
|
154
|
+
process.emit("usage", {
|
|
155
|
+
type: "egress",
|
|
156
|
+
data: { api: "listDatabases", context: dbName },
|
|
157
|
+
organization_id: data.organization_id
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (data.$filter && data.$filter.query) {
|
|
161
|
+
let isFilter = queryData(dbObj, data.$filter.query);
|
|
162
|
+
if (isFilter) {
|
|
163
|
+
databaseArray.push({ database: dbObj, storage: storageName });
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
databaseArray.push({ database: dbObj, storage: storageName });
|
|
167
|
+
}
|
|
168
|
+
resolve(createData(data, databaseArray, type));
|
|
169
|
+
}
|
|
170
|
+
if (method === "delete") {
|
|
171
|
+
// To drop a database partition, we scan and drop all tables belonging to the organization namespace
|
|
172
|
+
const listRes = await db.send(new ListTablesCommand({}));
|
|
173
|
+
const prefix = `${data.organization_id}_`;
|
|
174
|
+
|
|
175
|
+
for (let tName of listRes.TableNames || []) {
|
|
176
|
+
if (tName.startsWith(prefix)) {
|
|
177
|
+
process.emit("usage", {
|
|
178
|
+
type: "egress",
|
|
179
|
+
data: { api: "deleteTable", table: tName },
|
|
180
|
+
organization_id: data.organization_id
|
|
181
|
+
});
|
|
182
|
+
await db.send(new DeleteTableCommand({ TableName: tName }));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
resolve({ status: true });
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
errorHandler(data, error);
|
|
189
|
+
console.log(method, "error", error);
|
|
190
|
+
resolve(data);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* "array" operations mapped to AWS DynamoDB Tables.
|
|
197
|
+
* To achieve strict isolation, we prefix table names: "orgId_arrayName"
|
|
198
|
+
*/
|
|
199
|
+
function array(method, data) {
|
|
200
|
+
const storageName = data.storageName || data.provider || "dynamodb";
|
|
201
|
+
return new Promise(async (resolve) => {
|
|
202
|
+
let type = "array";
|
|
203
|
+
let arrayArray = [];
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const db = await dbClient(data);
|
|
207
|
+
if (!db || db.status === false) return resolve(data);
|
|
208
|
+
|
|
209
|
+
if (data.request) data.array = data.request;
|
|
210
|
+
|
|
211
|
+
let databases = data.database;
|
|
212
|
+
if (!Array.isArray(databases)) databases = [databases];
|
|
213
|
+
|
|
214
|
+
let databasesLength = databases.length;
|
|
215
|
+
for (let database of databases) {
|
|
216
|
+
const prefix = `${data.organization_id}_`;
|
|
217
|
+
|
|
218
|
+
if (method === "read") {
|
|
219
|
+
process.emit("usage", {
|
|
220
|
+
type: "egress",
|
|
221
|
+
data: { api: "listTables" },
|
|
222
|
+
organization_id: data.organization_id
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const listRes = await db.send(new ListTablesCommand({}));
|
|
226
|
+
for (let tName of listRes.TableNames || []) {
|
|
227
|
+
if (tName.startsWith(prefix)) {
|
|
228
|
+
const cleanName = tName.substring(prefix.length);
|
|
229
|
+
const tableObj = { name: cleanName };
|
|
230
|
+
|
|
231
|
+
if (data.$filter && data.$filter.query) {
|
|
232
|
+
let isFilter = queryData(tableObj, data.$filter.query);
|
|
233
|
+
if (isFilter) {
|
|
234
|
+
arrayArray.push({ name: cleanName, database, storage: storageName });
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
arrayArray.push({ name: cleanName, database, storage: storageName });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
databasesLength -= 1;
|
|
243
|
+
if (!databasesLength) {
|
|
244
|
+
data = createData(data, arrayArray, type);
|
|
245
|
+
resolve(data);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
let arrays = data.array;
|
|
249
|
+
if (method === "update") arrays = Object.entries(data.array);
|
|
250
|
+
if (!Array.isArray(arrays)) arrays = [arrays];
|
|
251
|
+
|
|
252
|
+
let arraysLength = arrays.length;
|
|
253
|
+
for (let array of arrays) {
|
|
254
|
+
if (method === "create") {
|
|
255
|
+
const realTableName = `${prefix}${array}`;
|
|
256
|
+
|
|
257
|
+
process.emit("usage", {
|
|
258
|
+
type: "egress",
|
|
259
|
+
data: { api: "createTable", table: realTableName },
|
|
260
|
+
organization_id: data.organization_id
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// DynamoDB requires a defined partition key scheme. We map '_id' as the primary hash key.
|
|
264
|
+
await db.send(new CreateTableCommand({
|
|
265
|
+
TableName: realTableName,
|
|
266
|
+
KeySchema: [{ AttributeName: "_id", KeyType: "HASH" }],
|
|
267
|
+
AttributeDefinitions: [{ AttributeName: "_id", AttributeType: "S" }],
|
|
268
|
+
BillingMode: "PAY_PER_REQUEST" // Serverless/On-Demand pricing
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
arrayArray.push({ name: array, database, storage: storageName });
|
|
272
|
+
|
|
273
|
+
arraysLength -= 1;
|
|
274
|
+
if (!arraysLength) databasesLength -= 1;
|
|
275
|
+
if (!databasesLength && !arraysLength) {
|
|
276
|
+
data = createData(data, arrayArray, type);
|
|
277
|
+
resolve(data);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
if (method === "update") {
|
|
281
|
+
let [oldName, newName] = array;
|
|
282
|
+
const oldRealName = `${prefix}${oldName}`;
|
|
283
|
+
const newRealName = `${prefix}${newName}`;
|
|
284
|
+
|
|
285
|
+
process.emit("usage", {
|
|
286
|
+
type: "egress",
|
|
287
|
+
data: { api: "renameTableEmulation", from: oldRealName, to: newRealName },
|
|
288
|
+
organization_id: data.organization_id
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// AWS DynamoDB does not natively support table renaming. We emulate this by creating
|
|
292
|
+
// the new table and migrating all scanned items into the new workspace.
|
|
293
|
+
await db.send(new CreateTableCommand({
|
|
294
|
+
TableName: newRealName,
|
|
295
|
+
KeySchema: [{ AttributeName: "_id", KeyType: "HASH" }],
|
|
296
|
+
AttributeDefinitions: [{ AttributeName: "_id", AttributeType: "S" }],
|
|
297
|
+
BillingMode: "PAY_PER_REQUEST"
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
// Scan items out of old table and bulk-insert them
|
|
301
|
+
const scanRes = await db.send(new ScanCommand({ TableName: oldRealName }));
|
|
302
|
+
for (let item of scanRes.Items || []) {
|
|
303
|
+
await db.send(new PutCommand({ TableName: newRealName, Item: item }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Drop old table cleanly
|
|
307
|
+
await db.send(new DeleteTableCommand({ TableName: oldRealName }));
|
|
308
|
+
|
|
309
|
+
arrayArray.push({ name: newName, oldName: oldName, database, storage: storageName });
|
|
310
|
+
|
|
311
|
+
arraysLength -= 1;
|
|
312
|
+
if (!arraysLength) databasesLength -= 1;
|
|
313
|
+
if (!databasesLength && !arraysLength) {
|
|
314
|
+
data = createData(data, arrayArray, type);
|
|
315
|
+
resolve(data);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (method === "delete") {
|
|
320
|
+
const realTableName = `${prefix}${array}`;
|
|
321
|
+
process.emit("usage", {
|
|
322
|
+
type: "egress",
|
|
323
|
+
data: { api: "deleteTable", table: realTableName },
|
|
324
|
+
organization_id: data.organization_id
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await db.send(new DeleteTableCommand({ TableName: realTableName }));
|
|
328
|
+
|
|
329
|
+
arrayArray.push({ name: array, database, storage: storageName });
|
|
330
|
+
|
|
331
|
+
arraysLength -= 1;
|
|
332
|
+
if (!arraysLength) databasesLength -= 1;
|
|
333
|
+
if (!databasesLength && !arraysLength) {
|
|
334
|
+
data = createData(data, arrayArray, type);
|
|
335
|
+
resolve(data);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
errorHandler(data, error);
|
|
344
|
+
console.log(method, "error", error);
|
|
345
|
+
resolve(data);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* "object" operations mapped to AWS DynamoDB Table Items.
|
|
352
|
+
*/
|
|
353
|
+
function object(method, data) {
|
|
354
|
+
const storageName = data.storageName || data.provider || "dynamodb";
|
|
355
|
+
return new Promise(async (resolve) => {
|
|
356
|
+
try {
|
|
357
|
+
const db = await dbClient(data);
|
|
358
|
+
if (!db || db.status === false) return resolve(data);
|
|
359
|
+
|
|
360
|
+
let type = "object";
|
|
361
|
+
let documents = [];
|
|
362
|
+
|
|
363
|
+
if (!data["timeStamp"]) {
|
|
364
|
+
data["timeStamp"] = new Date().toISOString();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let databases = data.database;
|
|
368
|
+
if (!Array.isArray(databases)) databases = [databases];
|
|
369
|
+
|
|
370
|
+
for (let database of databases) {
|
|
371
|
+
let arrays = data.array;
|
|
372
|
+
if (!Array.isArray(arrays)) arrays = [arrays];
|
|
373
|
+
|
|
374
|
+
for (let array of arrays) {
|
|
375
|
+
const realTableName = `${data.organization_id}_${array}`;
|
|
376
|
+
const reference = {
|
|
377
|
+
$storage: storageName,
|
|
378
|
+
$database: database,
|
|
379
|
+
$array: array
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
if (!data[type]) data[type] = [];
|
|
383
|
+
else if (typeof data[type] === "string")
|
|
384
|
+
data[type] = [{ _id: data[type] }];
|
|
385
|
+
else if (!Array.isArray(data[type]))
|
|
386
|
+
data[type] = [data[type]];
|
|
387
|
+
|
|
388
|
+
let isFilter = !!data.$filter;
|
|
389
|
+
if ((isFilter && !data[type].length) || data.isFilter) {
|
|
390
|
+
data[type].splice(0, 0, { isFilter: "isEmptyObjectFilter" });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < data[type].length; i++) {
|
|
394
|
+
let $storage = data[type][i].$storage || [];
|
|
395
|
+
let $database = data[type][i].$database || [];
|
|
396
|
+
let $array = data[type][i].$array || [];
|
|
397
|
+
|
|
398
|
+
if (!Array.isArray($storage)) $storage = [data[type][i].$storage];
|
|
399
|
+
if (!Array.isArray($database)) $database = [data[type][i].$database];
|
|
400
|
+
if (!Array.isArray($array)) $array = [data[type][i].$array];
|
|
401
|
+
|
|
402
|
+
if (!$storage.includes(storageName)) $storage.push(storageName);
|
|
403
|
+
if (!$database.includes(database)) $database.push(database);
|
|
404
|
+
if (!$array.includes(array)) $array.push(array);
|
|
405
|
+
|
|
406
|
+
delete data[type][i].$storage;
|
|
407
|
+
delete data[type][i].$database;
|
|
408
|
+
delete data[type][i].$array;
|
|
409
|
+
|
|
410
|
+
let queryFilter = isFilter ? data.$filter : (data[type][i].$filter || null);
|
|
411
|
+
|
|
412
|
+
if (method === "create") {
|
|
413
|
+
data[type][i] = replaceArray(data[type][i]);
|
|
414
|
+
data[type][i] = dotNotationToObject(data[type][i]);
|
|
415
|
+
|
|
416
|
+
const id = data[type][i]._id || `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
417
|
+
const orgId = data.organization_id;
|
|
418
|
+
const created = {
|
|
419
|
+
on: new Date(data.timeStamp).toISOString(),
|
|
420
|
+
by: data.user_id || data.clientId
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const recordPayload = { ...data[type][i] };
|
|
424
|
+
delete recordPayload._id;
|
|
425
|
+
delete recordPayload.organization_id;
|
|
426
|
+
|
|
427
|
+
const finalItem = {
|
|
428
|
+
_id: id,
|
|
429
|
+
organization_id: orgId,
|
|
430
|
+
created,
|
|
431
|
+
modified: null,
|
|
432
|
+
...recordPayload
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
process.emit("usage", {
|
|
436
|
+
type: "egress",
|
|
437
|
+
data: { api: "putItem", table: realTableName, id },
|
|
438
|
+
organization_id: data.organization_id
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await db.send(new PutCommand({
|
|
442
|
+
TableName: realTableName,
|
|
443
|
+
Item: finalItem
|
|
444
|
+
}));
|
|
445
|
+
|
|
446
|
+
data[type][i] = { ...finalItem, $storage, $database, $array };
|
|
447
|
+
}
|
|
448
|
+
else if (method === "read") {
|
|
449
|
+
if (data[type][i]._id && !isFilter) {
|
|
450
|
+
process.emit("usage", {
|
|
451
|
+
type: "egress",
|
|
452
|
+
data: { api: "getItem", table: realTableName, id: data[type][i]._id },
|
|
453
|
+
organization_id: data.organization_id
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const getRes = await db.send(new GetCommand({
|
|
457
|
+
TableName: realTableName,
|
|
458
|
+
Key: { _id: data[type][i]._id }
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
if (getRes.Item) {
|
|
462
|
+
let dbRecord = getRes.Item;
|
|
463
|
+
|
|
464
|
+
// Dynamic offline timings sync
|
|
465
|
+
if ($storage.length && data[type][i].modified && data[type][i].modified.on) {
|
|
466
|
+
let clientTime = new Date(data[type][i].modified.on);
|
|
467
|
+
let dbTime = new Date(dbRecord.modified ? dbRecord.modified.on : 0);
|
|
468
|
+
|
|
469
|
+
if (clientTime > dbTime) {
|
|
470
|
+
const updatedRecord = { ...dbRecord, ...data[type][i] };
|
|
471
|
+
const savedRecord = await executeUpdateTransaction(db, realTableName, data[type][i]._id, updatedRecord, data);
|
|
472
|
+
data[type][i] = { ...savedRecord, $storage, $database, $array };
|
|
473
|
+
} else {
|
|
474
|
+
data[type][i] = { ...data[type][i], ...dbRecord, $storage, $database, $array };
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
data[type][i] = { ...data[type][i], ...dbRecord, $storage, $database, $array };
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
data[type].splice(i, 1);
|
|
481
|
+
i--;
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
process.emit("usage", {
|
|
485
|
+
type: "egress",
|
|
486
|
+
data: { api: "scanTable", table: realTableName },
|
|
487
|
+
organization_id: data.organization_id
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Compile query filters dynamically
|
|
491
|
+
let scanParams = compileFilterToDynamo(realTableName, queryFilter, data.organization_id);
|
|
492
|
+
const scanRes = await db.send(new ScanCommand(scanParams));
|
|
493
|
+
|
|
494
|
+
let rows = scanRes.Items || [];
|
|
495
|
+
|
|
496
|
+
// Handle in-memory sorting/limit offset fallbacks for unindexed scan sets
|
|
497
|
+
if (data.$filter && data.$filter.sort) {
|
|
498
|
+
rows = sortData(rows, data.$filter.sort);
|
|
499
|
+
}
|
|
500
|
+
if (data.$filter && typeof data.$filter.index === "number") {
|
|
501
|
+
rows = rows.slice(data.$filter.index);
|
|
502
|
+
}
|
|
503
|
+
if (data.$filter && typeof data.$filter.limit === "number") {
|
|
504
|
+
rows = rows.slice(0, data.$filter.limit);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (let parsedRow of rows) {
|
|
508
|
+
if (data.$filter && data.$filter.search) {
|
|
509
|
+
let isMatch = searchData(parsedRow, data.$filter.search);
|
|
510
|
+
if (!isMatch) continue;
|
|
511
|
+
}
|
|
512
|
+
documents.push({
|
|
513
|
+
...parsedRow,
|
|
514
|
+
...reference
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (method === "update") {
|
|
520
|
+
const updatePayload = { ...data[type][i] };
|
|
521
|
+
delete updatePayload._id;
|
|
522
|
+
delete updatePayload.$filter;
|
|
523
|
+
|
|
524
|
+
if (data[type][i]._id) {
|
|
525
|
+
const updatedDoc = await executeUpdateTransaction(db, realTableName, data[type][i]._id, updatePayload, data);
|
|
526
|
+
if (updatedDoc) {
|
|
527
|
+
data[type][i] = { ...updatedDoc, $storage, $database, $array };
|
|
528
|
+
}
|
|
529
|
+
} else if (isFilter) {
|
|
530
|
+
let scanParams = compileFilterToDynamo(realTableName, queryFilter, data.organization_id);
|
|
531
|
+
process.emit("usage", {
|
|
532
|
+
type: "egress",
|
|
533
|
+
data: { api: "scanForUpdate", table: realTableName },
|
|
534
|
+
organization_id: data.organization_id
|
|
535
|
+
});
|
|
536
|
+
const scanRes = await db.send(new ScanCommand(scanParams));
|
|
537
|
+
|
|
538
|
+
for (let item of scanRes.Items || []) {
|
|
539
|
+
const updatedDoc = await executeUpdateTransaction(db, realTableName, item._id, updatePayload, data);
|
|
540
|
+
if (updatedDoc) {
|
|
541
|
+
documents.push({ ...updatedDoc, ...reference });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else if (method === "delete") {
|
|
547
|
+
if (data[type][i]._id) {
|
|
548
|
+
process.emit("usage", {
|
|
549
|
+
type: "egress",
|
|
550
|
+
data: { api: "deleteItem", table: realTableName, id: data[type][i]._id },
|
|
551
|
+
organization_id: data.organization_id
|
|
552
|
+
});
|
|
553
|
+
await db.send(new DeleteCommand({
|
|
554
|
+
TableName: realTableName,
|
|
555
|
+
Key: { _id: data[type][i]._id }
|
|
556
|
+
}));
|
|
557
|
+
data[type][i] = { _id: data[type][i]._id, $storage, $database, $array };
|
|
558
|
+
} else if (isFilter) {
|
|
559
|
+
let scanParams = compileFilterToDynamo(realTableName, queryFilter, data.organization_id);
|
|
560
|
+
process.emit("usage", {
|
|
561
|
+
type: "egress",
|
|
562
|
+
data: { api: "scanForDelete", table: realTableName },
|
|
563
|
+
organization_id: data.organization_id
|
|
564
|
+
});
|
|
565
|
+
const scanRes = await db.send(new ScanCommand(scanParams));
|
|
566
|
+
|
|
567
|
+
for (let item of scanRes.Items || []) {
|
|
568
|
+
await db.send(new DeleteCommand({
|
|
569
|
+
TableName: realTableName,
|
|
570
|
+
Key: { _id: item._id }
|
|
571
|
+
}));
|
|
572
|
+
documents.push({ _id: item._id, ...reference });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
data = createData(data, documents, type);
|
|
581
|
+
resolve(data);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
errorHandler(data, error);
|
|
584
|
+
console.log(method, "error", error);
|
|
585
|
+
resolve(data);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Executes a transactional item write lock pipeline simulating isolation behaviors inside DynamoDB.
|
|
592
|
+
*/
|
|
593
|
+
async function executeUpdateTransaction(db, realTableName, itemId, rawUpdateInput, requestData) {
|
|
594
|
+
try {
|
|
595
|
+
// DynamoDB implements transactions via TransactWriteItems. We first read the item, apply changes
|
|
596
|
+
// in code, and perform a conditional update check to prevent race condition write-collisions.
|
|
597
|
+
const getRes = await db.send(new GetCommand({
|
|
598
|
+
TableName: realTableName,
|
|
599
|
+
Key: { _id: itemId }
|
|
600
|
+
}));
|
|
601
|
+
|
|
602
|
+
let currentRecord = getRes.Item || null;
|
|
603
|
+
|
|
604
|
+
if (!currentRecord) {
|
|
605
|
+
if (requestData.upsert || rawUpdateInput.upsert || rawUpdateInput.$upsert) {
|
|
606
|
+
currentRecord = { _id: itemId, organization_id: requestData.organization_id };
|
|
607
|
+
} else {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const modified = {
|
|
613
|
+
on: new Date(requestData.timeStamp).toISOString(),
|
|
614
|
+
by: requestData.user_id || requestData.clientId
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const updatedPayload = applyMongoUpdate(currentRecord, rawUpdateInput);
|
|
618
|
+
|
|
619
|
+
const savePayload = { ...updatedPayload };
|
|
620
|
+
delete savePayload._id;
|
|
621
|
+
delete savePayload.organization_id;
|
|
622
|
+
delete savePayload.created;
|
|
623
|
+
delete savePayload.modified;
|
|
624
|
+
|
|
625
|
+
const recordToWrite = {
|
|
626
|
+
_id: itemId,
|
|
627
|
+
organization_id: requestData.organization_id,
|
|
628
|
+
created: currentRecord.created || modified,
|
|
629
|
+
modified,
|
|
630
|
+
...savePayload
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
process.emit("usage", {
|
|
634
|
+
type: "egress",
|
|
635
|
+
data: { api: "transactWriteItem", table: realTableName, id: itemId },
|
|
636
|
+
organization_id: requestData.organization_id
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Commit using AWS SDK's atomic validation transaction
|
|
640
|
+
await db.send(new TransactWriteCommand({
|
|
641
|
+
TransactItems: [
|
|
642
|
+
{
|
|
643
|
+
Put: {
|
|
644
|
+
TableName: realTableName,
|
|
645
|
+
Item: recordToWrite
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
]
|
|
649
|
+
}));
|
|
650
|
+
|
|
651
|
+
return recordToWrite;
|
|
652
|
+
} catch (error) {
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Applies deep MongoDB modification operators ($set, $unset, $push, $pull, $addToSet, $inc)
|
|
659
|
+
* recursively on a target record.
|
|
660
|
+
*/
|
|
661
|
+
function applyMongoUpdate(record, updatePayload) {
|
|
662
|
+
let target = { ...record };
|
|
663
|
+
|
|
664
|
+
Object.keys(updatePayload).forEach((rawKey) => {
|
|
665
|
+
if (rawKey.startsWith("$")) return;
|
|
666
|
+
let cleanKey = rawKey.replace(/\[(\d+)\]/g, ".$1");
|
|
667
|
+
setNestedValue(target, cleanKey, updatePayload[rawKey]);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
if (updatePayload.$set) {
|
|
671
|
+
Object.keys(updatePayload.$set).forEach((key) => {
|
|
672
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
673
|
+
setNestedValue(target, cleanKey, updatePayload.$set[key]);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (updatePayload.$unset) {
|
|
678
|
+
Object.keys(updatePayload.$unset).forEach((key) => {
|
|
679
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
680
|
+
deleteNestedValue(target, cleanKey);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (updatePayload.$inc) {
|
|
685
|
+
Object.keys(updatePayload.$inc).forEach((key) => {
|
|
686
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
687
|
+
let currentVal = getNestedValue(target, cleanKey) || 0;
|
|
688
|
+
setNestedValue(target, cleanKey, Number(currentVal) + Number(updatePayload.$inc[key]));
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (updatePayload.$push) {
|
|
693
|
+
Object.keys(updatePayload.$push).forEach((key) => {
|
|
694
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
695
|
+
let arr = getNestedValue(target, cleanKey);
|
|
696
|
+
if (!Array.isArray(arr)) arr = [];
|
|
697
|
+
|
|
698
|
+
const pushValue = updatePayload.$push[key];
|
|
699
|
+
if (pushValue && pushValue.$each) {
|
|
700
|
+
let valuesToPush = pushValue.$each;
|
|
701
|
+
let position = typeof pushValue.$position === "number" ? pushValue.$position : arr.length;
|
|
702
|
+
arr.splice(position, 0, ...valuesToPush);
|
|
703
|
+
} else {
|
|
704
|
+
arr.push(pushValue);
|
|
705
|
+
}
|
|
706
|
+
setNestedValue(target, cleanKey, arr);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (updatePayload.$pull) {
|
|
711
|
+
Object.keys(updatePayload.$pull).forEach((key) => {
|
|
712
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
713
|
+
let arr = getNestedValue(target, cleanKey);
|
|
714
|
+
if (Array.isArray(arr)) {
|
|
715
|
+
const filterVal = updatePayload.$pull[key];
|
|
716
|
+
arr = arr.filter(item => {
|
|
717
|
+
if (typeof filterVal === "object" && filterVal !== null) {
|
|
718
|
+
return !queryData(item, filterVal);
|
|
719
|
+
}
|
|
720
|
+
return item !== filterVal;
|
|
721
|
+
});
|
|
722
|
+
setNestedValue(target, cleanKey, arr);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (updatePayload.$addToSet) {
|
|
728
|
+
Object.keys(updatePayload.$addToSet).forEach((key) => {
|
|
729
|
+
let cleanKey = key.replace(/\[(\d+)\]/g, ".$1");
|
|
730
|
+
let arr = getNestedValue(target, cleanKey);
|
|
731
|
+
if (!Array.isArray(arr)) arr = [];
|
|
732
|
+
|
|
733
|
+
const addValue = updatePayload.$addToSet[key];
|
|
734
|
+
if (addValue && addValue.$each) {
|
|
735
|
+
addValue.$each.forEach(item => {
|
|
736
|
+
if (!arr.includes(item)) arr.push(item);
|
|
737
|
+
});
|
|
738
|
+
} else {
|
|
739
|
+
if (!arr.includes(addValue)) arr.push(addValue);
|
|
740
|
+
}
|
|
741
|
+
setNestedValue(target, cleanKey, arr);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return target;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Translates CoCreate Mongo-style filters dynamically into AWS DynamoDB query schemas.
|
|
750
|
+
*/
|
|
751
|
+
function compileFilterToDynamo(tableName, filter, organizationId) {
|
|
752
|
+
let params = {
|
|
753
|
+
TableName: tableName,
|
|
754
|
+
FilterExpression: "organization_id = :org_id",
|
|
755
|
+
ExpressionAttributeValues: {
|
|
756
|
+
":org_id": organizationId
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
if (filter && filter.query) {
|
|
761
|
+
let filterExpressions = ["organization_id = :org_id"];
|
|
762
|
+
let expressionVals = { ":org_id": organizationId };
|
|
763
|
+
let expressionNames = {};
|
|
764
|
+
let count = 0;
|
|
765
|
+
|
|
766
|
+
for (let key in filter.query) {
|
|
767
|
+
if (["$or", "$and", "organization_id"].includes(key)) continue;
|
|
768
|
+
|
|
769
|
+
let value = filter.query[key];
|
|
770
|
+
const valPlaceholder = `:val_${count}`;
|
|
771
|
+
const namePlaceholder = `#attr_${count}`;
|
|
772
|
+
|
|
773
|
+
expressionNames[namePlaceholder] = key;
|
|
774
|
+
|
|
775
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
776
|
+
for (let op in value) {
|
|
777
|
+
let operand = value[op];
|
|
778
|
+
let specificValPlaceholder = `${valPlaceholder}_${op.replace("$", "")}`;
|
|
779
|
+
expressionVals[specificValPlaceholder] = operand;
|
|
780
|
+
|
|
781
|
+
if (op === "$gt") filterExpressions.push(`${namePlaceholder} > ${specificValPlaceholder}`);
|
|
782
|
+
else if (op === "$gte") filterExpressions.push(`${namePlaceholder} >= ${specificValPlaceholder}`);
|
|
783
|
+
else if (op === "$lt") filterExpressions.push(`${namePlaceholder} < ${specificValPlaceholder}`);
|
|
784
|
+
else if (op === "$lte") filterExpressions.push(`${namePlaceholder} <= ${specificValPlaceholder}`);
|
|
785
|
+
else if (op === "$ne") filterExpressions.push(`${namePlaceholder} <> ${specificValPlaceholder}`);
|
|
786
|
+
else if (op === "$in") {
|
|
787
|
+
delete expressionVals[specificValPlaceholder];
|
|
788
|
+
const placeholders = operand.map((v, index) => {
|
|
789
|
+
const inPlaceholder = `${valPlaceholder}_in_${index}`;
|
|
790
|
+
expressionVals[inPlaceholder] = v;
|
|
791
|
+
return inPlaceholder;
|
|
792
|
+
});
|
|
793
|
+
filterExpressions.push(`${namePlaceholder} IN (${placeholders.join(", ")})`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
expressionVals[valPlaceholder] = value;
|
|
798
|
+
filterExpressions.push(`${namePlaceholder} = ${valPlaceholder}`);
|
|
799
|
+
}
|
|
800
|
+
count++;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
params.FilterExpression = filterExpressions.join(" AND ");
|
|
804
|
+
params.ExpressionAttributeValues = expressionVals;
|
|
805
|
+
if (Object.keys(expressionNames).length > 0) {
|
|
806
|
+
params.ExpressionAttributeNames = expressionNames;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return params;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function replaceArray(data = {}) {
|
|
814
|
+
let object = {};
|
|
815
|
+
Object.keys(data).forEach((key) => {
|
|
816
|
+
object[key.replace(/\[(\d+)\]/g, ".$1")] = data[key];
|
|
817
|
+
});
|
|
818
|
+
return object;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function getNestedValue(obj, path) {
|
|
822
|
+
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function setNestedValue(obj, path, value) {
|
|
826
|
+
const parts = path.split(".");
|
|
827
|
+
const last = parts.pop();
|
|
828
|
+
const target = parts.reduce((acc, part) => acc[part] = acc[part] || {}, obj);
|
|
829
|
+
target[last] = value;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function deleteNestedValue(obj, path) {
|
|
833
|
+
const parts = path.split(".");
|
|
834
|
+
const last = parts.pop();
|
|
835
|
+
const target = parts.reduce((acc, part) => acc && acc[part], obj);
|
|
836
|
+
if (target) delete target[last];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function createData(data, array, type) {
|
|
840
|
+
if (data[type] && data[type][0] && data[type][0].isFilter === "isEmptyObjectFilter") {
|
|
841
|
+
data[type].shift();
|
|
842
|
+
data.isFilter = true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
let key = type !== "object" ? "name" : "_id";
|
|
846
|
+
|
|
847
|
+
if (!Array.isArray(data[type])) {
|
|
848
|
+
console.log("data[type] is not an array", type);
|
|
849
|
+
} else {
|
|
850
|
+
for (let i = 0; i < array.length; i++) {
|
|
851
|
+
const matchIndex = data[type].findIndex((item) => item[key] === array[i][key]);
|
|
852
|
+
if (matchIndex !== -1) {
|
|
853
|
+
for (let $type of ["$storage", "$database", "$array"]) {
|
|
854
|
+
if (!data[type][matchIndex][$type]) data[type][matchIndex][$type] = [];
|
|
855
|
+
if (!Array.isArray(data[type][matchIndex][$type])) {
|
|
856
|
+
data[type][matchIndex][$type] = [data[type][matchIndex][$type]];
|
|
857
|
+
}
|
|
858
|
+
if (!data[type][matchIndex][$type].includes(array[i][$type])) {
|
|
859
|
+
data[type][matchIndex][$type].push(array[i][$type]);
|
|
860
|
+
}
|
|
861
|
+
delete array[i][$type];
|
|
862
|
+
}
|
|
863
|
+
data[type][matchIndex] = { ...data[type][matchIndex], ...array[i] };
|
|
864
|
+
} else {
|
|
865
|
+
data[type].push(array[i]);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return data;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function errorHandler(data, error, database, array) {
|
|
873
|
+
let errorMessage = typeof error === "object" && error.message ? error.message : error;
|
|
874
|
+
let errorObject = {
|
|
875
|
+
message: errorMessage,
|
|
876
|
+
storage: "dynamodb"
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
if (database) errorObject.database = database;
|
|
880
|
+
if (array) errorObject.array = array;
|
|
881
|
+
|
|
882
|
+
if (Array.isArray(data.error)) {
|
|
883
|
+
data.error.push(errorObject);
|
|
884
|
+
} else {
|
|
885
|
+
data.error = [errorObject];
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
module.exports = { send };
|