@harperfast/harper 5.2.4 → 5.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bin/restart.ts +48 -23
  2. package/bin/restartExitWatchdog.ts +99 -0
  3. package/bin/run.ts +16 -10
  4. package/components/status/crossThread.ts +69 -44
  5. package/components/status/types.ts +1 -0
  6. package/dist/bin/restart.js +39 -21
  7. package/dist/bin/restart.js.map +1 -1
  8. package/dist/bin/restartExitWatchdog.d.ts +5 -0
  9. package/dist/bin/restartExitWatchdog.js +100 -0
  10. package/dist/bin/restartExitWatchdog.js.map +1 -0
  11. package/dist/bin/run.d.ts +2 -0
  12. package/dist/bin/run.js +17 -10
  13. package/dist/bin/run.js.map +1 -1
  14. package/dist/components/status/crossThread.d.ts +1 -0
  15. package/dist/components/status/crossThread.js +57 -41
  16. package/dist/components/status/crossThread.js.map +1 -1
  17. package/dist/components/status/types.d.ts +1 -0
  18. package/dist/resources/DatabaseTransaction.d.ts +46 -1
  19. package/dist/resources/DatabaseTransaction.js +173 -9
  20. package/dist/resources/DatabaseTransaction.js.map +1 -1
  21. package/dist/resources/LMDBTransaction.d.ts +2 -0
  22. package/dist/resources/LMDBTransaction.js +7 -1
  23. package/dist/resources/LMDBTransaction.js.map +1 -1
  24. package/dist/resources/Resource.js +5 -4
  25. package/dist/resources/Resource.js.map +1 -1
  26. package/dist/resources/ResourceInterface.d.ts +20 -0
  27. package/dist/resources/ResourceInterface.js.map +1 -1
  28. package/dist/resources/Table.js +56 -9
  29. package/dist/resources/Table.js.map +1 -1
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +11 -0
  31. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +153 -51
  32. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  33. package/dist/resources/transaction.js +4 -2
  34. package/dist/resources/transaction.js.map +1 -1
  35. package/dist/server/itc/serverHandlers.js +2 -1
  36. package/dist/server/itc/serverHandlers.js.map +1 -1
  37. package/dist/server/threads/manageThreads.d.ts +2 -0
  38. package/dist/server/threads/manageThreads.js +37 -5
  39. package/dist/server/threads/manageThreads.js.map +1 -1
  40. package/dist/utility/processManagement/processManagement.js +21 -3
  41. package/dist/utility/processManagement/processManagement.js.map +1 -1
  42. package/npm-shrinkwrap.json +2 -2
  43. package/package.json +1 -1
  44. package/resources/DESIGN.md +19 -18
  45. package/resources/DatabaseTransaction.ts +187 -17
  46. package/resources/LMDBTransaction.ts +7 -1
  47. package/resources/Resource.ts +6 -5
  48. package/resources/ResourceInterface.ts +20 -0
  49. package/resources/Table.ts +60 -8
  50. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +153 -54
  51. package/resources/transaction.ts +5 -3
  52. package/schema.graphql +8 -3
  53. package/server/DESIGN.md +6 -0
  54. package/server/itc/serverHandlers.js +2 -1
  55. package/server/threads/manageThreads.js +34 -5
  56. package/studio/web/assets/{Chat-BvOlYIhe.js → Chat-BNHY84-Z.js} +1 -1
  57. package/studio/web/assets/{FloatingChat-fCJiJCH2.js → FloatingChat-BbztUci0.js} +3 -3
  58. package/studio/web/assets/{apiToken-bKwPv0uN.js → apiToken-D0x_S8Wq.js} +1 -1
  59. package/studio/web/assets/{applications-BZ47njJ_.js → applications-BU83fSEG.js} +1 -1
  60. package/studio/web/assets/{index-DF1fSnXV.js → index-BQB9R8Ug.js} +4 -4
  61. package/studio/web/assets/{index.lazy-DWt8ubrr.js → index.lazy-CtErm5q7.js} +3 -3
  62. package/studio/web/assets/{notifications-B5ZBOni1.js → notifications-B8rDagJM.js} +1 -1
  63. package/studio/web/assets/{notifications-gVx0lA8H.js → notifications-CPwYKDDH.js} +1 -1
  64. package/studio/web/assets/{profile-D7g0x28A.js → profile-D0hB1xdm.js} +1 -1
  65. package/studio/web/assets/{regions-DRkhlkiD.js → regions-y_jxsBpN.js} +1 -1
  66. package/studio/web/assets/{setComponentFile-DQkkobG5.js → setComponentFile-DSHbURTI.js} +1 -1
  67. package/studio/web/assets/{setup-nf-EOheW.js → setup-J5SrM1Kp.js} +1 -1
  68. package/studio/web/assets/{status-B2tOM3RF.js → status-IoJkDzlx.js} +1 -1
  69. package/studio/web/assets/{swagger-ui-react-UktCouQ0.js → swagger-ui-react-BlgSS25O.js} +1 -1
  70. package/studio/web/assets/{useEntityRestURL-alqAn3dT.js → useEntityRestURL-qfahJnvr.js} +1 -1
  71. package/studio/web/index.html +1 -1
  72. package/utility/processManagement/processManagement.js +19 -3
@@ -16,6 +16,8 @@ import type { Entry } from './RecordEncoder.ts';
16
16
  import { toBufferKey } from 'ordered-binary';
17
17
 
18
18
  const trackedTxns = new Set<DatabaseTransaction>();
19
+ // Read options for a rotated generation's native transactions; shared because they never vary.
20
+ const SNAPSHOT_FREE = Object.freeze({ disableSnapshot: true });
19
21
  // Logical transactions the monitor supervises for their WRITES, kept apart from trackedTxns because the
20
22
  // two have different units and different consumers: trackedTxns is per-link, bounds a read snapshot, and
21
23
  // is what the read-queue-depth metric counts, while this holds one entry per logical transaction — the
@@ -259,7 +261,9 @@ export type TransactionWrite = {
259
261
  before?: () => void | Promise<void>;
260
262
  beforeIntermediate?: () => void | Promise<void>;
261
263
  commit?: (txnTime: number, existingEntry: Partial<Entry>, retry: boolean, transaction: any) => MaybePromise<void>;
262
- validate?: (txnTime: number) => void;
264
+ // Once a write has been taken over, the transaction committing it is not the one that staged it, and
265
+ // overload accounting, the replay marker and a no-op write's removal all belong to the committer.
266
+ validate?: (txnTime: number, committedBy: DatabaseTransaction) => void;
263
267
  fullUpdate?: boolean;
264
268
  saved?: boolean;
265
269
  deferSave?: boolean;
@@ -276,6 +280,10 @@ export type TransactionWrite = {
276
280
  // sticky: a non-isRetry staging of this write appended its audit entry (set in save(); the retry
277
281
  // dedup guards in the commit handler read it to ignore the write's own orphaned entry)
278
282
  appendedAuditEntry?: boolean;
283
+ // the transaction holding this write in its `writes` (set in addWrite). A deferred write's save() is
284
+ // only its trigger, so it can be triggered after the context has moved on to another transaction;
285
+ // this is who commits it when the transaction current at that point is not a scope (#2292).
286
+ stagedIn?: DatabaseTransaction;
279
287
  // the preceding write to the same store and key in this transaction, if any (linked in addWrite)
280
288
  priorWrite?: TransactionWrite;
281
289
  // set only by a write that BOTH reads priorStagedWrite() and publishes stagedEntry; addWrite orders
@@ -331,6 +339,15 @@ type RocksTransactionWithRetry = RocksTransaction & { isRetry?: boolean };
331
339
 
332
340
  export class DatabaseTransaction implements Transaction {
333
341
  #context: Context;
342
+ // Whether a resources/transaction.ts scope owns this instance — i.e. a final commit or abort is
343
+ // guaranteed to follow. Only such a transaction may be rotated to a new generation by a mid-scope
344
+ // commit (see rotateAfterMidScopeCommit); anything else must commit each later write immediately,
345
+ // because nothing would commit staged ones. Settable only at construction, so it cannot be turned on
346
+ // for a transaction that is already attached to a context and running.
347
+ #scopeOwned: boolean;
348
+ constructor(options?: { scopeOwned?: boolean }) {
349
+ this.#scopeOwned = options?.scopeOwned === true;
350
+ }
334
351
  writes: TransactionWrite[] = []; // the set of writes to commit if the conditions are met
335
352
  // the last staged write per store and key, used to chain repeat writes to the same key (linkWrite)
336
353
  declare writesByKey?: Map<any, Map<unknown, TransactionWrite>>;
@@ -350,6 +367,11 @@ export class DatabaseTransaction implements Transaction {
350
367
  timeoutBudget = 0;
351
368
  // save() only stages here; ImmediateTransaction overrides it to commit, which addWrite must not defer
352
369
  saveCommits = false;
370
+ // True where save() puts the write into this transaction's native handle, which is what lets a scope
371
+ // take over a write staged in another transaction's `writes` (Table.ts's #saveOperation).
372
+ // LMDBTransaction's save() is a no-op — its commit applies `writes` — so there a write can only be
373
+ // committed by the transaction that holds it.
374
+ stagesWriteOnSave = true;
353
375
  validated = 0;
354
376
  timestamp = 0;
355
377
  retries = 0;
@@ -391,6 +413,11 @@ export class DatabaseTransaction implements Transaction {
391
413
  // Set once the retained read handle's write intents have been released (see commit()'s
392
414
  // outstanding-iterators branch), so a retry round cannot re-fire the release.
393
415
  declare writesAbandoned?: boolean;
416
+ // Set once a mid-scope commit has rotated this instance to a new generation: every native
417
+ // transaction it opens from then on reads WITHOUT a snapshot. Committing mid-scope is how a handler
418
+ // asks to stop reading a pinned snapshot, so re-pinning one for the rest of the scope would take
419
+ // back what it asked for.
420
+ snapshotFree = false;
394
421
 
395
422
  getReadTxn(disableSnapshot?: boolean): ReadTransaction {
396
423
  this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
@@ -417,7 +444,12 @@ export class DatabaseTransaction implements Transaction {
417
444
  // snapshot that blocks compaction. Only applied when creating the transaction fresh; an
418
445
  // already-open transaction keeps whatever snapshot mode it was created with.
419
446
  // `coordinatedRetry` signals IsBusy write conflicts as RETRY_NOW rather than ERR_BUSY.
420
- this.attachOwnedTransaction(new RocksTransaction(this.db.store, { coordinatedRetry: true, disableSnapshot }));
447
+ this.attachOwnedTransaction(
448
+ new RocksTransaction(this.db.store, {
449
+ coordinatedRetry: true,
450
+ disableSnapshot: disableSnapshot || this.snapshotFree,
451
+ })
452
+ );
421
453
 
422
454
  if (this.timestamp) {
423
455
  this.transaction.setTimestamp(this.timestamp);
@@ -569,11 +601,47 @@ export class DatabaseTransaction implements Transaction {
569
601
  writesForStore.set(keyId, operation);
570
602
  }
571
603
 
604
+ /**
605
+ * Drop a staged write from this transaction, and from its per-key chain, so the transaction taking it
606
+ * over becomes its only owner (Table.ts's #saveOperation).
607
+ */
608
+ detachWrite(operation: TransactionWrite): void {
609
+ const index = this.writes.indexOf(operation);
610
+ if (index > -1) this.writes[index] = null;
611
+ if (operation.key === undefined) return;
612
+ const writesForStore = this.writesByKey?.get(operation.store);
613
+ if (!writesForStore) return;
614
+ const keyId = writeKeyId(operation.key);
615
+ // Membership, not `stagedIn`, which every commit handler clears and so cannot tell a takeover from a
616
+ // write done in place; a prior already taken over must not become this transaction's basis again.
617
+ let prior = operation.priorWrite;
618
+ while (prior && !this.writes.includes(prior)) prior = prior.priorWrite;
619
+ const tail = writesForStore.get(keyId);
620
+ if (tail === operation) {
621
+ if (prior) writesForStore.set(keyId, prior);
622
+ else writesForStore.delete(keyId);
623
+ return;
624
+ }
625
+ // A successor left chained to it would take its merge basis and index diff from a record another
626
+ // transaction owns and may roll back (harper#1968's failure class).
627
+ for (let successor = tail; successor; successor = successor.priorWrite) {
628
+ if (successor.priorWrite === operation) {
629
+ successor.priorWrite = prior;
630
+ return;
631
+ }
632
+ }
633
+ }
634
+
572
635
  /**
573
636
  * Discard the staged write set (committed or aborted); the per-key chain must go with it so a
574
637
  * reused transaction never bases a write on a previous batch's staged state.
575
638
  */
576
639
  clearWrites(): void {
640
+ // A deferred write's `stagedIn` must not outlive this transaction's ability to commit it: save() can
641
+ // fire after a commit or abort, and routing it back here would revive a write this transaction
642
+ // already rolled back — whose blobs abort() has reclaimed. Cleared here, save() resolves the
643
+ // context's current transaction as it did before `stagedIn` existed.
644
+ for (const write of this.writes) if (write?.stagedIn === this) write.stagedIn = undefined;
577
645
  this.writes = [];
578
646
  this.writesByKey = undefined;
579
647
  }
@@ -708,6 +776,7 @@ export class DatabaseTransaction implements Transaction {
708
776
  this.writeTimeout = this.timeout;
709
777
  this.linkWrite(operation);
710
778
  this.writes.push(operation);
779
+ operation.stagedIn = this;
711
780
  // Hold this write back while any earlier same-key write has not run — out of staging order both
712
781
  // diff against the pre-transaction record (harper#2211, DESIGN.md). The whole chain, not just the
713
782
  // immediate link: an eager non-chaining write in between would otherwise launder the deferral.
@@ -743,7 +812,10 @@ export class DatabaseTransaction implements Transaction {
743
812
  if (!transaction && this.open === TRANSACTION_STATE.OPEN) transaction = this.transaction;
744
813
  let immediateCommit = false;
745
814
  if (!transaction) {
746
- transaction = new RocksTransaction(operation.store.store as RocksStore);
815
+ transaction = new RocksTransaction(
816
+ operation.store.store as RocksStore,
817
+ this.snapshotFree ? SNAPSHOT_FREE : undefined
818
+ );
747
819
  if (operation.store.rootStore !== this.db.rootStore) {
748
820
  harperLogger.warn?.('Created new transaction in save, but the store does match existing store', transaction.id);
749
821
  }
@@ -783,7 +855,7 @@ export class DatabaseTransaction implements Transaction {
783
855
  if (!operation.saved) {
784
856
  operation.saved = true;
785
857
  // immediately execute in this transaction
786
- if ((operation.validate?.(txnTime) as any) === false) {
858
+ if ((operation.validate?.(txnTime, this) as any) === false) {
787
859
  operation.commit = () => {}; // noop if we try again
788
860
  return;
789
861
  }
@@ -831,6 +903,13 @@ export class DatabaseTransaction implements Transaction {
831
903
  // check just in case we got any more transactions while we were waiting, if so just recursively continue to finish the additional writes now
832
904
  return this.commit(options);
833
905
  }
906
+ // The save loop above can be what opened this transaction's native handle — save() attaches
907
+ // one when it had none, which is every ImmediateTransaction commit since its getReadTxn
908
+ // opens none — leaving the local captured before the loop empty while that handle holds
909
+ // every staged write, for the detach below to drop uncommitted (issue #2288). Only when
910
+ // the local is empty: a truthy one is what the loop staged into, and the retained-handle
911
+ // and replay branches below deliberately commit a handle other than this.transaction.
912
+ if (!transaction) transaction = this.transaction;
834
913
  this.open = TRANSACTION_STATE.CLOSED;
835
914
  // RocksTransaction.commit() resolves with RETRY_NOW_VALUE (a number) under
836
915
  // coordinatedRetry, or void on a normal commit/abort.
@@ -1024,15 +1103,27 @@ export class DatabaseTransaction implements Transaction {
1024
1103
  // now reset transactions tracking; this transaction be reused and committed again
1025
1104
  this.retries = 0; // reset per-native-transaction retry counter so a reused DatabaseTransaction's next batch starts fresh
1026
1105
  this.clearWrites();
1106
+ if (options.doneWriting) this.endScopeOwnership();
1027
1107
  this.releaseContext(!!options.doneWriting);
1028
- this.next = null;
1029
1108
  let txnTime = this.timestamp;
1030
1109
  this.timestamp = 0; // reset the timestamp as well
1031
- return Promise.all(completions).then(() => {
1032
- return {
1033
- txnTime,
1034
- };
1035
- });
1110
+ return Promise.all(completions).then(
1111
+ () => {
1112
+ // Only once the chained store's commit has settled, as on the synchronous path: a
1113
+ // partially failed mid-scope commit must not leave the scope resumable.
1114
+ this.completeMidScopeCommit(options);
1115
+ return {
1116
+ txnTime,
1117
+ };
1118
+ },
1119
+ (error) => {
1120
+ // As on the synchronous path: a completion that failed (a chained store's commit,
1121
+ // a replication confirmation) leaves this commit partly landed, so ownership goes
1122
+ // with it rather than letting a later commit rotate on top.
1123
+ this.endScopeOwnership();
1124
+ throw error;
1125
+ }
1126
+ );
1036
1127
  },
1037
1128
  (error) => {
1038
1129
  // Coordinated transactions surface conflicts as RETRY_NOW (handled in the
@@ -1110,6 +1201,9 @@ export class DatabaseTransaction implements Transaction {
1110
1201
  // back-reference here too, or transaction.ts's onComplete() (which has no
1111
1202
  // rejection handler of its own) would leave a long-lived context pinning this
1112
1203
  // CLOSED wrapper forever.
1204
+ // A failed commit must never be followed by a resumed segment: this generation is
1205
+ // finished and its durability is unknown, so ownership goes with it.
1206
+ this.endScopeOwnership();
1113
1207
  this.releaseContext(!!options.doneWriting);
1114
1208
  throw error;
1115
1209
  }
@@ -1121,6 +1215,7 @@ export class DatabaseTransaction implements Transaction {
1121
1215
  cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
1122
1216
  }
1123
1217
  this.clearWrites();
1218
+ if (options.doneWriting) this.endScopeOwnership();
1124
1219
  this.releaseContext(!!options.doneWriting);
1125
1220
  const txnResolution: CommitResolution = {
1126
1221
  txnTime: this.timestamp,
@@ -1129,16 +1224,39 @@ export class DatabaseTransaction implements Transaction {
1129
1224
  // now run any other transactions
1130
1225
  options.timestamp = this.timestamp;
1131
1226
  // as above: the next store must not inherit this store's explicit native transaction
1132
- const nextResolution = this.next?.commit(
1133
- options.transaction ? { ...options, transaction: undefined } : options
1134
- );
1227
+ let nextResolution;
1228
+ try {
1229
+ nextResolution = this.next?.commit(options.transaction ? { ...options, transaction: undefined } : options);
1230
+ } catch (error) {
1231
+ // A synchronous throw reaches neither rejection handler below, and the head has already
1232
+ // committed — surrender ownership here too, or the scope stays resumable on top of a
1233
+ // half-landed multi-store commit.
1234
+ this.endScopeOwnership();
1235
+ throw error;
1236
+ }
1135
1237
  if ((nextResolution as any)?.then)
1136
- return (nextResolution as any)?.then((nextResolution) => ({
1137
- txnTime: this.timestamp,
1138
- next: nextResolution,
1139
- }));
1238
+ return (nextResolution as any)?.then(
1239
+ (nextResolution) => {
1240
+ // Only once the chained store's own commit has SETTLED: rotating first would leave the
1241
+ // scope resumable after a partially failed mid-scope commit.
1242
+ this.completeMidScopeCommit(options);
1243
+ return {
1244
+ txnTime: this.timestamp,
1245
+ next: nextResolution,
1246
+ };
1247
+ },
1248
+ (error) => {
1249
+ // A chained store's commit failed, so this multi-store commit half-landed. Surrender
1250
+ // ownership as the head's own failure branch does: a handler that catches this and
1251
+ // commits again must not rotate on top of it, and must not have the failed link
1252
+ // dropped from the chain before its abort can clean up its blobs.
1253
+ this.endScopeOwnership();
1254
+ throw error;
1255
+ }
1256
+ );
1140
1257
  txnResolution.next = nextResolution as any;
1141
1258
  }
1259
+ this.completeMidScopeCommit(options);
1142
1260
  return txnResolution;
1143
1261
  },
1144
1262
  (error) => {
@@ -1147,6 +1265,44 @@ export class DatabaseTransaction implements Transaction {
1147
1265
  }
1148
1266
  );
1149
1267
  }
1268
+ /**
1269
+ * A successful commit that is NOT the scope's final one leaves the scope still running and still
1270
+ * responsible for a commit. Rotate to a fresh OPEN generation so the rest of the scope's writes
1271
+ * stage into it and are committed — or rolled back — as one unit, instead of each committing itself
1272
+ * the moment it is made. Every dispatch path keeps its plain `open === OPEN` check; CLOSED never
1273
+ * gains a second meaning.
1274
+ *
1275
+ * Deliberately not rotated when: the scope is finished (`doneWriting`), nothing owns this instance,
1276
+ * a timeout poisoned it, or a commit failed — a failed or uncertain commit must never be followed by
1277
+ * a resumed segment that can commit on its own. Nor when read iterators still hold the native
1278
+ * handle: that handle belongs to them until they drain, so there is nothing to rotate into and those
1279
+ * writes keep today's immediate-commit path.
1280
+ */
1281
+ /**
1282
+ * Finish a commit: the chain goes with it, then the scope may rotate. A link left attached and CLOSED
1283
+ * would be reused by txnForContext for the next write to that database and commit itself, surviving a
1284
+ * rollback of the rotated head — the cross-store leftover this rotation exists to prevent. Every
1285
+ * commit path must run this, and none may do one half without the other.
1286
+ */
1287
+ /** Both scope flags leave together, so no exit can clear one and keep the other. */
1288
+ private endScopeOwnership(): void {
1289
+ this.#scopeOwned = false;
1290
+ this.snapshotFree = false;
1291
+ }
1292
+
1293
+ private completeMidScopeCommit(options: CommitOptions): void {
1294
+ this.next = null;
1295
+ this.rotateAfterMidScopeCommit(options);
1296
+ }
1297
+
1298
+ /** See completeMidScopeCommit, which is the only caller and carries the reasoning. */
1299
+ private rotateAfterMidScopeCommit(options: CommitOptions): void {
1300
+ if (options.doneWriting || this.timedOut || this.transaction || !this.#scopeOwned) return;
1301
+ this.open = TRANSACTION_STATE.OPEN;
1302
+ this.snapshotFree = true;
1303
+ this.writesAbandoned = false;
1304
+ }
1305
+
1150
1306
  abort(): void {
1151
1307
  while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
1152
1308
  // Defensively release any native handle whose reference bookkeeping was already consumed.
@@ -1159,6 +1315,7 @@ export class DatabaseTransaction implements Transaction {
1159
1315
  cleanupUnusedBlobs(write.savedBlobs, collectRetainedFileIds(write.store.getEntry(write.key)?.value));
1160
1316
  }
1161
1317
  } finally {
1318
+ this.endScopeOwnership(); // the scope is over; nothing may rotate this instance again
1162
1319
  this.clearWrites();
1163
1320
  // A timeout-poisoned abort (abortDueToTimeout()) is the one abort that is NOT "reuse-free":
1164
1321
  // Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (instead of
@@ -1382,6 +1539,19 @@ export function isReleasedTransaction(value: unknown): boolean {
1382
1539
  return value === RELEASED_TRANSACTION;
1383
1540
  }
1384
1541
 
1542
+ /**
1543
+ * Whether this transaction can be joined as the atomic scope resources/transaction.ts promises. OPEN is
1544
+ * not sufficient: an ImmediateTransaction commits every write as it is made, so a caller that joined one
1545
+ * would get per-write autocommit with no final commit or abort to roll back to. txnForContext installs
1546
+ * one in a context slot that is empty or holds the released placeholder, where it reports OPEN with
1547
+ * nothing owning a commit for it (#2292). Ownership itself is deliberately not the test — a context
1548
+ * pre-seeded with an externally driven DatabaseTransaction (replayLogs.ts) still owns the writes it is
1549
+ * given, and its own commit/abort still governs them.
1550
+ */
1551
+ export function isJoinableScope(transaction: DatabaseTransaction | null | undefined): boolean {
1552
+ return transaction?.open === TRANSACTION_STATE.OPEN && !transaction.saveCommits;
1553
+ }
1554
+
1385
1555
  let timer;
1386
1556
 
1387
1557
  /**
@@ -25,6 +25,7 @@ export function replicationConfirmation(callback) {
25
25
  }
26
26
 
27
27
  export class LMDBTransaction extends DatabaseTransaction {
28
+ stagesWriteOnSave = false;
28
29
  #context: Context;
29
30
  writes: TransactionWrite[] = []; // the set of writes to commit if the conditions are met
30
31
  validated = 0;
@@ -97,6 +98,9 @@ export class LMDBTransaction extends DatabaseTransaction {
97
98
  const immediateTxn = new ImmediateTransaction(this.db);
98
99
  immediateTxn.addWrite(operation);
99
100
  const result = immediateTxn.commit({});
101
+ // Nothing may be sent back to this throwaway: the write is already committed, and its
102
+ // durability is the promise below.
103
+ operation.stagedIn = undefined;
100
104
  if (result?.then) {
101
105
  operation.promise = result;
102
106
  } else {
@@ -107,6 +111,7 @@ export class LMDBTransaction extends DatabaseTransaction {
107
111
 
108
112
  this.linkWrite(operation);
109
113
  this.writes.push(operation); // standard path, add to current transaction
114
+ operation.stagedIn = this;
110
115
  }
111
116
 
112
117
  removeWrite(operation: TransactionWrite) {
@@ -133,7 +138,7 @@ export class LMDBTransaction extends DatabaseTransaction {
133
138
  this.validated = this.writes.length;
134
139
  for (let i = start; i < this.validated; i++) {
135
140
  const write = this.writes[i];
136
- write?.validate?.(this.timestamp);
141
+ write?.validate?.(this.timestamp, this);
137
142
  }
138
143
  let hasBefore;
139
144
  for (let i = start; i < this.validated; i++) {
@@ -342,6 +347,7 @@ export class LMDBTransaction extends DatabaseTransaction {
342
347
  }
343
348
 
344
349
  export class ImmediateTransaction extends LMDBTransaction {
350
+ saveCommits = true;
345
351
  constructor(db: RootDatabaseKind) {
346
352
  super();
347
353
  this.db = db;
@@ -12,8 +12,8 @@ import {
12
12
  import { randomUUID } from 'crypto';
13
13
  import {
14
14
  DatabaseTransaction,
15
+ isJoinableScope,
15
16
  isReleasedTransaction,
16
- TRANSACTION_STATE,
17
17
  type Transaction,
18
18
  } from './DatabaseTransaction.ts';
19
19
  import { IterableEventQueue } from './IterableEventQueue.ts';
@@ -769,9 +769,10 @@ function transactional(
769
769
  if (isCollection) resourceOptions.isCollection = true;
770
770
  } else resourceOptions = options;
771
771
  const loadAsInstance = this.loadAsInstance;
772
- // Only join an existing transaction if it is still genuinely OPEN (mirrors the reuse check
773
- // resources/transaction.ts's transaction() helper already applies to itself). A `context`
774
- // object can carry a *stale* `.transaction` left over from an earlier, unrelated call that
772
+ // Only join an existing transaction that can actually be a scope (isJoinableScope: OPEN, and it
773
+ // stages its writes rather than committing each one — the same gate resources/transaction.ts's
774
+ // transaction() helper applies to itself). Beyond that, a `context` object can carry a *stale*
775
+ // `.transaction` left over from an earlier, unrelated call that
775
776
  // already ran to completion: ambient contexts obtained via contextStorage.getStore() are
776
777
  // no longer guaranteed to be fresh, one-shot objects now that processLocalTransaction (#1591/
777
778
  // #1592) installs one shared, long-lived context for the lifetime of an entire operation
@@ -795,7 +796,7 @@ function transactional(
795
796
  // starting fresh) makes the write throw transactionOpenTooLongError via addWrite()/commit()'s
796
797
  // poison check, correctly propagating the abort to the caller. See
797
798
  // integrationTests/resources/txn-overtime-atomicity.test.ts.
798
- if (context?.transaction?.open === TRANSACTION_STATE.OPEN || context?.transaction?.timedOut) {
799
+ if (isJoinableScope(context?.transaction) || context?.transaction?.timedOut) {
799
800
  // we are already in a transaction (or it was poisoned by a timeout abort and must fail), proceed
800
801
  const resource = this.getResource(query, context, resourceOptions);
801
802
  return resource.then
@@ -75,6 +75,26 @@ export interface Context {
75
75
  * so code that commits mid-handler can keep using its context. LMDBTransaction does not release,
76
76
  * so there the completed transaction itself stays in the slot — also safe to call, but retained.
77
77
  * `null` was the previous released marker and is still accepted defensively.
78
+ *
79
+ * A read that resolves a transaction without going through the static-API wrappers (an instance load,
80
+ * for one) replaces a released or never-set slot with an ImmediateTransaction, which commits every
81
+ * write as it is made. `transaction()` and the static API start their own scope rather than joining
82
+ * that, so an explicit `transaction()` is atomic on a released slot as it is on a fresh one.
83
+ *
84
+ * A transaction its own handler commits mid-scope is rotated to a fresh open generation, so the rest
85
+ * of that scope's writes are committed — or rolled back — with the scope's final commit rather than
86
+ * each committing itself immediately. Two cases keep the older per-write behavior: a commit made
87
+ * while a read iterator still holds the transaction's handle (that handle is the iterator's until it
88
+ * drains), and a commit that failed. LMDB has always behaved this way; the RocksDB path now matches.
89
+ *
90
+ * The engines still differ on reads. A rotated RocksDB generation is snapshot-free, so the rest of
91
+ * the scope keeps seeing other writers' committed data — which is what committing mid-scope asks for.
92
+ * LMDB cannot open a snapshot-free read transaction, so there the scope keeps its snapshot.
93
+ *
94
+ * Because the writes after a mid-scope commit stage rather than commit one at a time, a single
95
+ * mid-scope commit does not bound how much a long handler holds in memory. A handler streaming a
96
+ * large volume should keep committing (a checkpoint every N records), which commits each batch and
97
+ * rotates again.
78
98
  */
79
99
  transaction?: DatabaseTransaction | null;
80
100
  /** If the operation that will be performed with this context should check user authorization */
@@ -36,6 +36,7 @@ import {
36
36
  DatabaseTransaction,
37
37
  ImmediateTransaction,
38
38
  priorStagedWrite,
39
+ isJoinableScope,
39
40
  isReleasedTransaction,
40
41
  TRANSACTION_STATE,
41
42
  writeKeyId,
@@ -1873,7 +1874,28 @@ export function makeTable(options) {
1873
1874
  }
1874
1875
  #saveOperation(operation: any) {
1875
1876
  const transaction = txnForContext(this.getContext());
1876
- if (transaction.save) return transaction.save(operation) || operation.promise || operation.result;
1877
+ const holder = operation.stagedIn;
1878
+ // never-drop-on-conflict lives on the transaction and would not travel with the write, so an
1879
+ // apply or a replay keeps it (harper-pro#348)
1880
+ const holderOwnsPolicy = holder?.sourceApply || holder?.isReplay;
1881
+ // stagesWriteOnSave: LMDBTransaction's addWrite never runs the write (its commit applies
1882
+ // `writes`), so handing it one is a dead end
1883
+ if (
1884
+ holder &&
1885
+ holder !== transaction &&
1886
+ !holderOwnsPolicy &&
1887
+ transaction.stagesWriteOnSave &&
1888
+ isJoinableScope(transaction)
1889
+ ) {
1890
+ holder.detachWrite(operation);
1891
+ // The basis chain belongs to the holder: derived from a write this scope cannot commit, the
1892
+ // merge and index diff would be relative to a record that may never land.
1893
+ operation.priorWrite = undefined;
1894
+ operation.deferSave = false;
1895
+ return when(transaction.addWrite(operation), () => operation.promise ?? operation.result);
1896
+ }
1897
+ const owner = holder ?? transaction;
1898
+ if (owner.save) return owner.save(operation) || operation.promise || operation.result;
1877
1899
  }
1878
1900
 
1879
1901
  addTo(property: any, value: any) {
@@ -2314,11 +2336,11 @@ export function makeTable(options) {
2314
2336
  nodeName: (context as any)?.nodeName,
2315
2337
  fullUpdate,
2316
2338
  deferSave: true,
2317
- validate: (txnTime) => {
2339
+ validate: (txnTime, committedBy = transaction) => {
2318
2340
  if (!recordUpdate) recordUpdate = this.#changes;
2319
2341
  if (fullUpdate || (recordUpdate && hasChanges(this.#changes === recordUpdate ? this : recordUpdate))) {
2320
2342
  if (!(context as any)?.source) {
2321
- transaction.checkOverloaded();
2343
+ committedBy.checkOverloaded();
2322
2344
  // A record must be a plain object. Reject primitive, string/number, bare-binary,
2323
2345
  // and bare-array roots — e.g. a raw Buffer from an application/octet-stream PUT, a
2324
2346
  // JSON string/number body, or a top-level JSON array. Such roots carry no primary
@@ -2356,7 +2378,7 @@ export function makeTable(options) {
2356
2378
  // by replayLogs). Records were valid when originally written; post-crash schema
2357
2379
  // evolution (e.g. newly required fields) must not prevent replaying them
2358
2380
  // (harper#1316, facet b).
2359
- if (!transaction.isReplay) this.validate(recordUpdate, !fullUpdate);
2381
+ if (!committedBy.isReplay) this.validate(recordUpdate, !fullUpdate);
2360
2382
  if (updatedTimeProperty) {
2361
2383
  recordUpdate[updatedTimeProperty.name] =
2362
2384
  updatedTimeProperty.type === 'Date'
@@ -2391,7 +2413,7 @@ export function makeTable(options) {
2391
2413
  // TODO: else freeze after we have applied the changes
2392
2414
  }
2393
2415
  } else {
2394
- (transaction as any).removeWrite?.(write);
2416
+ (committedBy as any).removeWrite?.(write);
2395
2417
  return false;
2396
2418
  }
2397
2419
  },
@@ -2434,6 +2456,7 @@ export function makeTable(options) {
2434
2456
  let incrementalUpdateToApply: boolean;
2435
2457
 
2436
2458
  this.#savingOperation = null;
2459
+ write.stagedIn = undefined; // nothing may pin this write's transaction past its commit
2437
2460
  let omitLocalRecord = false;
2438
2461
  // we use optimistic locking to only commit if the existing record state still holds true.
2439
2462
  // this is superior to using an async transaction since it doesn't require JS execution
@@ -5645,10 +5668,33 @@ export function makeTable(options) {
5645
5668
  // See if this is a transaction for our database and if so, use it
5646
5669
  if (transaction.db?.path === primaryStore.path) return transaction;
5647
5670
  // try the next one:
5648
- const nextTxn = transaction.next;
5671
+ let nextTxn = transaction.next;
5672
+ // A self-committing link is CLOSED once it has committed, and a further write through it
5673
+ // commits on a native handle nothing awaits (#2323). Spent — closed, handle detached, none of
5674
+ // its OWN writes left (hasPendingWrites walks successors, which is not this question) — it
5675
+ // holds nothing, so drop it. A run of them can be spent, hence the loop. A timeout-poisoned
5676
+ // link is kept: reusing it is what makes the rest of the operation fail atomically (#1411).
5677
+ while (
5678
+ nextTxn?.saveCommits &&
5679
+ nextTxn.open !== TRANSACTION_STATE.OPEN &&
5680
+ !nextTxn.timedOut &&
5681
+ !nextTxn.transaction &&
5682
+ !nextTxn.writes.some((write) => write)
5683
+ ) {
5684
+ transaction.next = nextTxn.next;
5685
+ nextTxn = transaction.next;
5686
+ }
5649
5687
  if (!nextTxn) {
5650
5688
  // no next one, then add our database
5651
- transaction.next = isRocksDB ? new DatabaseTransaction() : new LMDBTransaction();
5689
+ // A staging link under a self-committing head is committed only if the head's own database
5690
+ // is written again and cascades the chain, so a handler writing this one last loses it (#2292).
5691
+ transaction.next = transaction.saveCommits
5692
+ ? ((isRocksDB
5693
+ ? new ImmediateTransaction(primaryStore as any)
5694
+ : new ImmediateLMDBTransaction(primaryStore as any)) as any)
5695
+ : isRocksDB
5696
+ ? new DatabaseTransaction()
5697
+ : new LMDBTransaction();
5652
5698
  // The chain root, so a link that only ever receives a blind write is supervised by the
5653
5699
  // long-transaction monitor as part of its logical transaction rather than as its own
5654
5700
  // timeout root (issue #2231).
@@ -5667,8 +5713,14 @@ export function makeTable(options) {
5667
5713
  // commit is tracked with its own identity (DatabaseTransaction.ts's trackOutstandingCommit),
5668
5714
  // so a wedged second-store commit is named just as precisely as a wedged first one.
5669
5715
  transaction.next.startedFrom = transaction.startedFrom;
5670
- if (transaction.open === TRANSACTION_STATE.CLOSED) {
5716
+ // A second database joined after a mid-scope commit belongs to the same snapshot-free
5717
+ // generation as the head, or its reads would re-pin what the commit just unpinned.
5718
+ transaction.next.snapshotFree = transaction.snapshotFree;
5719
+ if (transaction.open === TRANSACTION_STATE.CLOSED && !transaction.next.saveCommits) {
5671
5720
  // if the current transaction is already closed, we need to retain that state on new databases we work with
5721
+ // Never onto a self-committing link: CLOSED is what routes its first write through the
5722
+ // commit re-entry that drops the native commit promise (#2323), and it commits per write
5723
+ // regardless of this state.
5672
5724
  transaction.next.open = TRANSACTION_STATE.CLOSED;
5673
5725
  }
5674
5726
  transaction = transaction.next;