s3db.js 10.0.13 → 10.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "10.0.13",
3
+ "version": "10.0.14",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -1092,14 +1092,7 @@ export class EventualConsistencyPlugin extends Plugin {
1092
1092
  }
1093
1093
 
1094
1094
  try {
1095
- // Get the current record value first
1096
- const [recordOk, recordErr, record] = await tryFn(() =>
1097
- this.targetResource.get(originalId)
1098
- );
1099
-
1100
- const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
1101
-
1102
- // Get all transactions for this record
1095
+ // Get all unapplied transactions for this record
1103
1096
  const [ok, err, transactions] = await tryFn(() =>
1104
1097
  this.transactionResource.query({
1105
1098
  originalId,
@@ -1108,6 +1101,12 @@ export class EventualConsistencyPlugin extends Plugin {
1108
1101
  );
1109
1102
 
1110
1103
  if (!ok || !transactions || transactions.length === 0) {
1104
+ // No pending transactions - try to get current value from record
1105
+ const [recordOk, recordErr, record] = await tryFn(() =>
1106
+ this.targetResource.get(originalId)
1107
+ );
1108
+ const currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
1109
+
1111
1110
  if (this.config.verbose) {
1112
1111
  console.log(
1113
1112
  `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
@@ -1117,20 +1116,169 @@ export class EventualConsistencyPlugin extends Plugin {
1117
1116
  return currentValue;
1118
1117
  }
1119
1118
 
1119
+ // Get the LAST APPLIED VALUE from transactions (not from record - avoids S3 eventual consistency issues)
1120
+ // This is the source of truth for the current value
1121
+ const [appliedOk, appliedErr, appliedTransactions] = await tryFn(() =>
1122
+ this.transactionResource.query({
1123
+ originalId,
1124
+ applied: true
1125
+ })
1126
+ );
1127
+
1128
+ let currentValue = 0;
1129
+
1130
+ if (appliedOk && appliedTransactions && appliedTransactions.length > 0) {
1131
+ // Check if record exists - if deleted, ignore old applied transactions
1132
+ const [recordExistsOk, recordExistsErr, recordExists] = await tryFn(() =>
1133
+ this.targetResource.get(originalId)
1134
+ );
1135
+
1136
+ if (!recordExistsOk || !recordExists) {
1137
+ // Record was deleted - ignore applied transactions and start fresh
1138
+ // This prevents old values from being carried over after deletion
1139
+ if (this.config.verbose) {
1140
+ console.log(
1141
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1142
+ `Record ${originalId} doesn't exist, deleting ${appliedTransactions.length} old applied transactions`
1143
+ );
1144
+ }
1145
+
1146
+ // Delete old applied transactions to prevent them from being used when record is recreated
1147
+ const { results, errors } = await PromisePool
1148
+ .for(appliedTransactions)
1149
+ .withConcurrency(10)
1150
+ .process(async (txn) => {
1151
+ const [deleted] = await tryFn(() => this.transactionResource.delete(txn.id));
1152
+ return deleted;
1153
+ });
1154
+
1155
+ if (this.config.verbose && errors && errors.length > 0) {
1156
+ console.warn(
1157
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1158
+ `Failed to delete ${errors.length} old applied transactions`
1159
+ );
1160
+ }
1161
+
1162
+ currentValue = 0;
1163
+ } else {
1164
+ // Record exists - use applied transactions to calculate current value
1165
+ // Sort by timestamp to get chronological order
1166
+ appliedTransactions.sort((a, b) =>
1167
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
1168
+ );
1169
+
1170
+ // Check if there's a 'set' operation in applied transactions
1171
+ const hasSetInApplied = appliedTransactions.some(t => t.operation === 'set');
1172
+
1173
+ if (!hasSetInApplied) {
1174
+ // No 'set' operation in applied transactions means we're missing the base value
1175
+ // This can only happen if:
1176
+ // 1. Record had an initial value before first transaction
1177
+ // 2. First consolidation didn't create an anchor transaction (legacy behavior)
1178
+ // Solution: Get the current record value and create an anchor transaction now
1179
+ const recordValue = recordExists[this.config.field] || 0;
1180
+
1181
+ // Calculate what the base value was by subtracting all applied deltas
1182
+ let appliedDelta = 0;
1183
+ for (const t of appliedTransactions) {
1184
+ if (t.operation === 'add') appliedDelta += t.value;
1185
+ else if (t.operation === 'sub') appliedDelta -= t.value;
1186
+ }
1187
+
1188
+ const baseValue = recordValue - appliedDelta;
1189
+
1190
+ // Create and save anchor transaction with the base value
1191
+ // Only create if baseValue is non-zero AND we don't already have an anchor transaction
1192
+ const hasExistingAnchor = appliedTransactions.some(t => t.source === 'anchor');
1193
+ if (baseValue !== 0 && !hasExistingAnchor) {
1194
+ // Use the timestamp of the first applied transaction for cohort info
1195
+ const firstTransactionDate = new Date(appliedTransactions[0].timestamp);
1196
+ const cohortInfo = this.getCohortInfo(firstTransactionDate);
1197
+ const anchorTransaction = {
1198
+ id: idGenerator(),
1199
+ originalId: originalId,
1200
+ field: this.config.field,
1201
+ value: baseValue,
1202
+ operation: 'set',
1203
+ timestamp: new Date(firstTransactionDate.getTime() - 1).toISOString(), // 1ms before first txn to ensure it's first
1204
+ cohortDate: cohortInfo.date,
1205
+ cohortHour: cohortInfo.hour,
1206
+ cohortMonth: cohortInfo.month,
1207
+ source: 'anchor',
1208
+ applied: true
1209
+ };
1210
+
1211
+ await this.transactionResource.insert(anchorTransaction);
1212
+
1213
+ // Prepend to applied transactions for this consolidation
1214
+ appliedTransactions.unshift(anchorTransaction);
1215
+ }
1216
+ }
1217
+
1218
+ // Apply reducer to get the last consolidated value
1219
+ currentValue = this.config.reducer(appliedTransactions);
1220
+ }
1221
+ } else {
1222
+ // No applied transactions - this is the FIRST consolidation
1223
+ // Try to get initial value from record
1224
+ const [recordOk, recordErr, record] = await tryFn(() =>
1225
+ this.targetResource.get(originalId)
1226
+ );
1227
+ currentValue = (recordOk && record) ? (record[this.config.field] || 0) : 0;
1228
+
1229
+ // If there's an initial value, create and save an anchor transaction
1230
+ // This ensures all future consolidations have a reliable base value
1231
+ if (currentValue !== 0) {
1232
+ // Use timestamp of the first pending transaction (or current time if none)
1233
+ let anchorTimestamp;
1234
+ if (transactions && transactions.length > 0) {
1235
+ const firstPendingDate = new Date(transactions[0].timestamp);
1236
+ anchorTimestamp = new Date(firstPendingDate.getTime() - 1).toISOString();
1237
+ } else {
1238
+ anchorTimestamp = new Date().toISOString();
1239
+ }
1240
+
1241
+ const cohortInfo = this.getCohortInfo(new Date(anchorTimestamp));
1242
+ const anchorTransaction = {
1243
+ id: idGenerator(),
1244
+ originalId: originalId,
1245
+ field: this.config.field,
1246
+ value: currentValue,
1247
+ operation: 'set',
1248
+ timestamp: anchorTimestamp,
1249
+ cohortDate: cohortInfo.date,
1250
+ cohortHour: cohortInfo.hour,
1251
+ cohortMonth: cohortInfo.month,
1252
+ source: 'anchor',
1253
+ applied: true
1254
+ };
1255
+
1256
+ await this.transactionResource.insert(anchorTransaction);
1257
+
1258
+ if (this.config.verbose) {
1259
+ console.log(
1260
+ `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1261
+ `Created anchor transaction for ${originalId} with base value ${currentValue}`
1262
+ );
1263
+ }
1264
+ }
1265
+ }
1266
+
1120
1267
  if (this.config.verbose) {
1121
1268
  console.log(
1122
1269
  `[EventualConsistency] ${this.config.resource}.${this.config.field} - ` +
1123
1270
  `Consolidating ${originalId}: ${transactions.length} pending transactions ` +
1124
- `(current: ${currentValue})`
1271
+ `(current: ${currentValue} from ${appliedOk && appliedTransactions?.length > 0 ? 'applied transactions' : 'record'})`
1125
1272
  );
1126
1273
  }
1127
1274
 
1128
- // Sort transactions by timestamp
1275
+ // Sort pending transactions by timestamp
1129
1276
  transactions.sort((a, b) =>
1130
1277
  new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
1131
1278
  );
1132
1279
 
1133
- // If there's a current value and no 'set' operations, prepend a synthetic set transaction
1280
+ // If there's a current value and no 'set' operations in pending transactions,
1281
+ // prepend a synthetic set transaction to preserve the current value
1134
1282
  const hasSetOperation = transactions.some(t => t.operation === 'set');
1135
1283
  if (currentValue !== 0 && !hasSetOperation) {
1136
1284
  transactions.unshift(this._createSyntheticSetTransaction(currentValue));