s3db.js 12.0.0 → 12.1.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/dist/s3db.cjs.js +454 -3833
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +9 -1
- package/dist/s3db.es.js +451 -3830
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +91 -57
- package/package.json +15 -15
- package/src/client.class.js +40 -5
- package/src/concerns/plugin-storage.js +67 -37
- package/src/plugins/api/index.js +5 -0
- package/src/plugins/api/server.js +4 -0
- package/src/plugins/plugin.class.js +5 -0
- package/src/plugins/relation.plugin.js +183 -47
- package/src/plugins/replicator.plugin.js +2 -1
- package/src/plugins/ttl.plugin.js +478 -303
- package/src/resource.class.js +309 -34
- package/src/s3db.d.ts +9 -1
- package/dist/s3db-cli.js +0 -55543
|
@@ -140,7 +140,10 @@ import {
|
|
|
140
140
|
* cache: true,
|
|
141
141
|
* batchSize: 100,
|
|
142
142
|
* preventN1: true,
|
|
143
|
-
* verbose: false
|
|
143
|
+
* verbose: false,
|
|
144
|
+
* fallbackLimit: null, // null = no limit (recommended), number = max records in fallback queries
|
|
145
|
+
* cascadeBatchSize: 10, // Parallel operations in cascade delete/update (default: 10)
|
|
146
|
+
* cascadeTransactions: false // Enable rollback on cascade failures (default: false)
|
|
144
147
|
* })
|
|
145
148
|
*
|
|
146
149
|
* === 💡 Usage Examples ===
|
|
@@ -283,6 +286,21 @@ class RelationPlugin extends Plugin {
|
|
|
283
286
|
this.preventN1 = config.preventN1 !== undefined ? config.preventN1 : true;
|
|
284
287
|
this.verbose = config.verbose || false;
|
|
285
288
|
|
|
289
|
+
// Fallback limit for non-partitioned queries
|
|
290
|
+
// null = no limit (load all records, slower but correct)
|
|
291
|
+
// number = max records to load (faster but may truncate)
|
|
292
|
+
// WARNING: Setting a limit may cause silent data loss if you have more related records!
|
|
293
|
+
this.fallbackLimit = config.fallbackLimit !== undefined ? config.fallbackLimit : null;
|
|
294
|
+
|
|
295
|
+
// Cascade batch size for parallel delete/update operations
|
|
296
|
+
// Higher = faster but more memory/connections (default: 10)
|
|
297
|
+
this.cascadeBatchSize = config.cascadeBatchSize || 10;
|
|
298
|
+
|
|
299
|
+
// Enable transaction/rollback support for cascade operations (default: false)
|
|
300
|
+
// When enabled, tracks all cascade operations and rolls back on failure
|
|
301
|
+
// Note: Best-effort rollback (S3 doesn't support true transactions)
|
|
302
|
+
this.cascadeTransactions = config.cascadeTransactions !== undefined ? config.cascadeTransactions : false;
|
|
303
|
+
|
|
286
304
|
// Track loaded relations per request to prevent N+1
|
|
287
305
|
this._loaderCache = new Map();
|
|
288
306
|
|
|
@@ -296,7 +314,8 @@ class RelationPlugin extends Plugin {
|
|
|
296
314
|
batchLoads: 0,
|
|
297
315
|
cascadeOperations: 0,
|
|
298
316
|
partitionCacheHits: 0,
|
|
299
|
-
deduplicatedQueries: 0
|
|
317
|
+
deduplicatedQueries: 0,
|
|
318
|
+
fallbackLimitWarnings: 0
|
|
300
319
|
};
|
|
301
320
|
}
|
|
302
321
|
|
|
@@ -647,13 +666,7 @@ class RelationPlugin extends Plugin {
|
|
|
647
666
|
);
|
|
648
667
|
} else {
|
|
649
668
|
// Fallback: Load all and filter (less efficient but works)
|
|
650
|
-
|
|
651
|
-
console.log(
|
|
652
|
-
`[RelationPlugin] No partition found for ${relatedResource.name}.${config.foreignKey}, using full scan`
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
const allRelated = await relatedResource.list({ limit: 10000 });
|
|
656
|
-
relatedRecords = allRelated.filter(r => localKeys.includes(r[config.foreignKey]));
|
|
669
|
+
relatedRecords = await this._fallbackLoad(relatedResource, config.foreignKey, localKeys);
|
|
657
670
|
}
|
|
658
671
|
|
|
659
672
|
// Create lookup map
|
|
@@ -707,13 +720,7 @@ class RelationPlugin extends Plugin {
|
|
|
707
720
|
);
|
|
708
721
|
} else {
|
|
709
722
|
// Fallback: Load all and filter (less efficient but works)
|
|
710
|
-
|
|
711
|
-
console.log(
|
|
712
|
-
`[RelationPlugin] No partition found for ${relatedResource.name}.${config.foreignKey}, using full scan`
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
const allRelated = await relatedResource.list({ limit: 10000 });
|
|
716
|
-
relatedRecords = allRelated.filter(r => localKeys.includes(r[config.foreignKey]));
|
|
723
|
+
relatedRecords = await this._fallbackLoad(relatedResource, config.foreignKey, localKeys);
|
|
717
724
|
}
|
|
718
725
|
|
|
719
726
|
// Create lookup map (one-to-many)
|
|
@@ -775,13 +782,7 @@ class RelationPlugin extends Plugin {
|
|
|
775
782
|
);
|
|
776
783
|
} else {
|
|
777
784
|
// Fallback: Load all and filter (less efficient but works)
|
|
778
|
-
|
|
779
|
-
console.log(
|
|
780
|
-
`[RelationPlugin] No partition found for ${relatedResource.name}.${config.localKey}, using full scan`
|
|
781
|
-
);
|
|
782
|
-
}
|
|
783
|
-
const allRelated = await relatedResource.list({ limit: 10000 });
|
|
784
|
-
return allRelated.filter(r => foreignKeys.includes(r[config.localKey]));
|
|
785
|
+
return await this._fallbackLoad(relatedResource, config.localKey, foreignKeys);
|
|
785
786
|
}
|
|
786
787
|
});
|
|
787
788
|
|
|
@@ -856,13 +857,7 @@ class RelationPlugin extends Plugin {
|
|
|
856
857
|
);
|
|
857
858
|
} else {
|
|
858
859
|
// Fallback: Load all and filter (less efficient but works)
|
|
859
|
-
|
|
860
|
-
console.log(
|
|
861
|
-
`[RelationPlugin] No partition found for ${junctionResource.name}.${config.foreignKey}, using full scan`
|
|
862
|
-
);
|
|
863
|
-
}
|
|
864
|
-
const allJunction = await junctionResource.list({ limit: 10000 });
|
|
865
|
-
junctionRecords = allJunction.filter(j => localKeys.includes(j[config.foreignKey]));
|
|
860
|
+
junctionRecords = await this._fallbackLoad(junctionResource, config.foreignKey, localKeys);
|
|
866
861
|
}
|
|
867
862
|
|
|
868
863
|
if (junctionRecords.length === 0) {
|
|
@@ -888,13 +883,7 @@ class RelationPlugin extends Plugin {
|
|
|
888
883
|
);
|
|
889
884
|
} else {
|
|
890
885
|
// Fallback: Load all and filter (less efficient but works)
|
|
891
|
-
|
|
892
|
-
console.log(
|
|
893
|
-
`[RelationPlugin] No partition found for ${relatedResource.name}.${config.localKey}, using full scan`
|
|
894
|
-
);
|
|
895
|
-
}
|
|
896
|
-
const allRelated = await relatedResource.list({ limit: 10000 });
|
|
897
|
-
relatedRecords = allRelated.filter(r => otherKeys.includes(r[config.localKey]));
|
|
886
|
+
relatedRecords = await this._fallbackLoad(relatedResource, config.localKey, otherKeys);
|
|
898
887
|
}
|
|
899
888
|
|
|
900
889
|
// Create maps
|
|
@@ -929,6 +918,60 @@ class RelationPlugin extends Plugin {
|
|
|
929
918
|
return records;
|
|
930
919
|
}
|
|
931
920
|
|
|
921
|
+
/**
|
|
922
|
+
* Batch process operations with controlled parallelism
|
|
923
|
+
* @private
|
|
924
|
+
*/
|
|
925
|
+
async _batchProcess(items, operation, batchSize = null) {
|
|
926
|
+
if (items.length === 0) return [];
|
|
927
|
+
|
|
928
|
+
const actualBatchSize = batchSize || this.cascadeBatchSize;
|
|
929
|
+
const results = [];
|
|
930
|
+
|
|
931
|
+
// Process in chunks to control parallelism
|
|
932
|
+
for (let i = 0; i < items.length; i += actualBatchSize) {
|
|
933
|
+
const chunk = items.slice(i, i + actualBatchSize);
|
|
934
|
+
const chunkPromises = chunk.map(item => operation(item));
|
|
935
|
+
const chunkResults = await Promise.all(chunkPromises);
|
|
936
|
+
results.push(...chunkResults);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return results;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Load records using fallback (full scan) when no partition is available
|
|
944
|
+
* Issues warnings when limit is reached to prevent silent data loss
|
|
945
|
+
* @private
|
|
946
|
+
*/
|
|
947
|
+
async _fallbackLoad(resource, fieldName, filterValues) {
|
|
948
|
+
const options = this.fallbackLimit !== null ? { limit: this.fallbackLimit } : {};
|
|
949
|
+
|
|
950
|
+
if (this.verbose) {
|
|
951
|
+
console.log(
|
|
952
|
+
`[RelationPlugin] No partition found for ${resource.name}.${fieldName}, using full scan` +
|
|
953
|
+
(this.fallbackLimit ? ` (limited to ${this.fallbackLimit} records)` : ' (no limit)')
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const allRecords = await resource.list(options);
|
|
958
|
+
const filteredRecords = allRecords.filter(r => filterValues.includes(r[fieldName]));
|
|
959
|
+
|
|
960
|
+
// WARNING: If we hit the limit, we may have missed some records!
|
|
961
|
+
if (this.fallbackLimit && allRecords.length >= this.fallbackLimit) {
|
|
962
|
+
this.stats.fallbackLimitWarnings++;
|
|
963
|
+
console.warn(
|
|
964
|
+
`[RelationPlugin] WARNING: Fallback query for ${resource.name}.${fieldName} hit the limit of ${this.fallbackLimit} records. ` +
|
|
965
|
+
`Some related records may be missing! Consider:\n` +
|
|
966
|
+
` 1. Adding a partition on field "${fieldName}" for better performance\n` +
|
|
967
|
+
` 2. Increasing fallbackLimit in plugin config (or set to null for no limit)\n` +
|
|
968
|
+
` Partition example: partitions: { by${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}: { fields: { ${fieldName}: 'string' } } }`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return filteredRecords;
|
|
973
|
+
}
|
|
974
|
+
|
|
932
975
|
/**
|
|
933
976
|
* Find partition by field name (for efficient relation loading)
|
|
934
977
|
* Uses cache to avoid repeated lookups
|
|
@@ -1028,6 +1071,7 @@ class RelationPlugin extends Plugin {
|
|
|
1028
1071
|
/**
|
|
1029
1072
|
* Cascade delete operation
|
|
1030
1073
|
* Uses partitions when available for efficient cascade
|
|
1074
|
+
* Supports transaction/rollback when enabled
|
|
1031
1075
|
* @private
|
|
1032
1076
|
*/
|
|
1033
1077
|
async _cascadeDelete(record, resource, relationName, config) {
|
|
@@ -1041,6 +1085,10 @@ class RelationPlugin extends Plugin {
|
|
|
1041
1085
|
});
|
|
1042
1086
|
}
|
|
1043
1087
|
|
|
1088
|
+
// Track deleted records for rollback (if transactions enabled)
|
|
1089
|
+
const deletedRecords = [];
|
|
1090
|
+
const junctionResource = config.type === 'belongsToMany' ? this.database.resource(config.through) : null;
|
|
1091
|
+
|
|
1044
1092
|
try {
|
|
1045
1093
|
if (config.type === 'hasMany') {
|
|
1046
1094
|
// Delete all related records - use partition if available
|
|
@@ -1065,13 +1113,20 @@ class RelationPlugin extends Plugin {
|
|
|
1065
1113
|
});
|
|
1066
1114
|
}
|
|
1067
1115
|
|
|
1068
|
-
for
|
|
1069
|
-
|
|
1116
|
+
// Track records for rollback if transactions enabled
|
|
1117
|
+
if (this.cascadeTransactions) {
|
|
1118
|
+
deletedRecords.push(...relatedRecords.map(r => ({ type: 'delete', resource: relatedResource, record: r })));
|
|
1070
1119
|
}
|
|
1071
1120
|
|
|
1121
|
+
// Batch delete for better performance (10-100x faster than sequential)
|
|
1122
|
+
await this._batchProcess(relatedRecords, async (related) => {
|
|
1123
|
+
return await relatedResource.delete(related.id);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1072
1126
|
if (this.verbose) {
|
|
1073
1127
|
console.log(
|
|
1074
|
-
`[RelationPlugin] Cascade deleted ${relatedRecords.length} ${config.resource} for ${resource.name}:${record.id}`
|
|
1128
|
+
`[RelationPlugin] Cascade deleted ${relatedRecords.length} ${config.resource} for ${resource.name}:${record.id} ` +
|
|
1129
|
+
`(batched in ${Math.ceil(relatedRecords.length / this.cascadeBatchSize)} chunks)`
|
|
1075
1130
|
);
|
|
1076
1131
|
}
|
|
1077
1132
|
} else if (config.type === 'hasOne') {
|
|
@@ -1093,6 +1148,10 @@ class RelationPlugin extends Plugin {
|
|
|
1093
1148
|
}
|
|
1094
1149
|
|
|
1095
1150
|
if (relatedRecords.length > 0) {
|
|
1151
|
+
// Track for rollback if transactions enabled
|
|
1152
|
+
if (this.cascadeTransactions) {
|
|
1153
|
+
deletedRecords.push({ type: 'delete', resource: relatedResource, record: relatedRecords[0] });
|
|
1154
|
+
}
|
|
1096
1155
|
await relatedResource.delete(relatedRecords[0].id);
|
|
1097
1156
|
}
|
|
1098
1157
|
} else if (config.type === 'belongsToMany') {
|
|
@@ -1120,18 +1179,51 @@ class RelationPlugin extends Plugin {
|
|
|
1120
1179
|
});
|
|
1121
1180
|
}
|
|
1122
1181
|
|
|
1123
|
-
for
|
|
1124
|
-
|
|
1182
|
+
// Track for rollback if transactions enabled
|
|
1183
|
+
if (this.cascadeTransactions) {
|
|
1184
|
+
deletedRecords.push(...junctionRecords.map(j => ({ type: 'delete', resource: junctionResource, record: j })));
|
|
1125
1185
|
}
|
|
1126
1186
|
|
|
1187
|
+
// Batch delete for better performance (10-100x faster than sequential)
|
|
1188
|
+
await this._batchProcess(junctionRecords, async (junction) => {
|
|
1189
|
+
return await junctionResource.delete(junction.id);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1127
1192
|
if (this.verbose) {
|
|
1128
1193
|
console.log(
|
|
1129
|
-
`[RelationPlugin] Cascade deleted ${junctionRecords.length} junction records from ${config.through}`
|
|
1194
|
+
`[RelationPlugin] Cascade deleted ${junctionRecords.length} junction records from ${config.through} ` +
|
|
1195
|
+
`(batched in ${Math.ceil(junctionRecords.length / this.cascadeBatchSize)} chunks)`
|
|
1130
1196
|
);
|
|
1131
1197
|
}
|
|
1132
1198
|
}
|
|
1133
1199
|
}
|
|
1134
1200
|
} catch (error) {
|
|
1201
|
+
// Attempt rollback if transactions enabled
|
|
1202
|
+
if (this.cascadeTransactions && deletedRecords.length > 0) {
|
|
1203
|
+
console.error(
|
|
1204
|
+
`[RelationPlugin] Cascade delete failed, attempting rollback of ${deletedRecords.length} records...`
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
const rollbackErrors = [];
|
|
1208
|
+
// Rollback in reverse order (LIFO)
|
|
1209
|
+
for (const { resource: res, record: rec } of deletedRecords.reverse()) {
|
|
1210
|
+
try {
|
|
1211
|
+
await res.insert(rec);
|
|
1212
|
+
} catch (rollbackError) {
|
|
1213
|
+
rollbackErrors.push({ record: rec.id, error: rollbackError.message });
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (rollbackErrors.length > 0) {
|
|
1218
|
+
console.error(
|
|
1219
|
+
`[RelationPlugin] Rollback partially failed for ${rollbackErrors.length} records:`,
|
|
1220
|
+
rollbackErrors
|
|
1221
|
+
);
|
|
1222
|
+
} else if (this.verbose) {
|
|
1223
|
+
console.log(`[RelationPlugin] Rollback successful, restored ${deletedRecords.length} records`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1135
1227
|
throw new CascadeError('delete', resource.name, record.id, error, {
|
|
1136
1228
|
relation: relationName,
|
|
1137
1229
|
relatedResource: config.resource
|
|
@@ -1142,6 +1234,7 @@ class RelationPlugin extends Plugin {
|
|
|
1142
1234
|
/**
|
|
1143
1235
|
* Cascade update operation (update foreign keys when local key changes)
|
|
1144
1236
|
* Uses partitions when available for efficient cascade
|
|
1237
|
+
* Supports transaction/rollback when enabled
|
|
1145
1238
|
* @private
|
|
1146
1239
|
*/
|
|
1147
1240
|
async _cascadeUpdate(record, changes, resource, relationName, config) {
|
|
@@ -1152,6 +1245,9 @@ class RelationPlugin extends Plugin {
|
|
|
1152
1245
|
return;
|
|
1153
1246
|
}
|
|
1154
1247
|
|
|
1248
|
+
// Track updated records for rollback (if transactions enabled)
|
|
1249
|
+
const updatedRecords = [];
|
|
1250
|
+
|
|
1155
1251
|
try {
|
|
1156
1252
|
const oldLocalKeyValue = record[config.localKey];
|
|
1157
1253
|
const newLocalKeyValue = changes[config.localKey];
|
|
@@ -1182,18 +1278,58 @@ class RelationPlugin extends Plugin {
|
|
|
1182
1278
|
});
|
|
1183
1279
|
}
|
|
1184
1280
|
|
|
1185
|
-
for
|
|
1186
|
-
|
|
1281
|
+
// Track old values for rollback if transactions enabled
|
|
1282
|
+
if (this.cascadeTransactions) {
|
|
1283
|
+
updatedRecords.push(...relatedRecords.map(r => ({
|
|
1284
|
+
type: 'update',
|
|
1285
|
+
resource: relatedResource,
|
|
1286
|
+
id: r.id,
|
|
1287
|
+
oldValue: r[config.foreignKey],
|
|
1288
|
+
newValue: newLocalKeyValue,
|
|
1289
|
+
field: config.foreignKey
|
|
1290
|
+
})));
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Batch update for better performance (10-100x faster than sequential)
|
|
1294
|
+
await this._batchProcess(relatedRecords, async (related) => {
|
|
1295
|
+
return await relatedResource.update(related.id, {
|
|
1187
1296
|
[config.foreignKey]: newLocalKeyValue
|
|
1188
1297
|
}, { skipCascade: true }); // Prevent infinite cascade loop
|
|
1189
|
-
}
|
|
1298
|
+
});
|
|
1190
1299
|
|
|
1191
1300
|
if (this.verbose) {
|
|
1192
1301
|
console.log(
|
|
1193
|
-
`[RelationPlugin] Cascade updated ${relatedRecords.length} ${config.resource} records`
|
|
1302
|
+
`[RelationPlugin] Cascade updated ${relatedRecords.length} ${config.resource} records ` +
|
|
1303
|
+
`(batched in ${Math.ceil(relatedRecords.length / this.cascadeBatchSize)} chunks)`
|
|
1194
1304
|
);
|
|
1195
1305
|
}
|
|
1196
1306
|
} catch (error) {
|
|
1307
|
+
// Attempt rollback if transactions enabled
|
|
1308
|
+
if (this.cascadeTransactions && updatedRecords.length > 0) {
|
|
1309
|
+
console.error(
|
|
1310
|
+
`[RelationPlugin] Cascade update failed, attempting rollback of ${updatedRecords.length} records...`
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
const rollbackErrors = [];
|
|
1314
|
+
// Rollback in reverse order (LIFO)
|
|
1315
|
+
for (const { resource: res, id, field, oldValue } of updatedRecords.reverse()) {
|
|
1316
|
+
try {
|
|
1317
|
+
await res.update(id, { [field]: oldValue }, { skipCascade: true });
|
|
1318
|
+
} catch (rollbackError) {
|
|
1319
|
+
rollbackErrors.push({ id, error: rollbackError.message });
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (rollbackErrors.length > 0) {
|
|
1324
|
+
console.error(
|
|
1325
|
+
`[RelationPlugin] Rollback partially failed for ${rollbackErrors.length} records:`,
|
|
1326
|
+
rollbackErrors
|
|
1327
|
+
);
|
|
1328
|
+
} else if (this.verbose) {
|
|
1329
|
+
console.log(`[RelationPlugin] Rollback successful, restored ${updatedRecords.length} records`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1197
1333
|
throw new CascadeError('update', resource.name, record.id, error, {
|
|
1198
1334
|
relation: relationName,
|
|
1199
1335
|
relatedResource: config.resource
|
|
@@ -589,7 +589,8 @@ export class ReplicatorPlugin extends Plugin {
|
|
|
589
589
|
if (!this.replicatorLog) return;
|
|
590
590
|
|
|
591
591
|
const [ok, err] = await tryFn(async () => {
|
|
592
|
-
|
|
592
|
+
// Use patch() for 40-60% performance improvement (truncate-data behavior)
|
|
593
|
+
await this.replicatorLog.patch(logId, {
|
|
593
594
|
...updates,
|
|
594
595
|
lastAttempt: new Date().toISOString()
|
|
595
596
|
});
|