@harperfast/harper-pro 5.0.17 → 5.0.18

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 (30) hide show
  1. package/core/resources/RecordEncoder.ts +15 -12
  2. package/core/resources/RocksTransactionLogStore.ts +51 -0
  3. package/core/resources/Table.ts +17 -15
  4. package/core/resources/auditStore.ts +97 -5
  5. package/core/resources/databases.ts +67 -7
  6. package/dist/cloneNode/cloneNode.js +13 -8
  7. package/dist/cloneNode/cloneNode.js.map +1 -1
  8. package/dist/core/resources/RecordEncoder.js +1 -1
  9. package/dist/core/resources/RecordEncoder.js.map +1 -1
  10. package/dist/core/resources/RocksTransactionLogStore.js.map +1 -1
  11. package/dist/core/resources/Table.js +17 -17
  12. package/dist/core/resources/Table.js.map +1 -1
  13. package/dist/core/resources/auditStore.js +82 -5
  14. package/dist/core/resources/auditStore.js.map +1 -1
  15. package/dist/core/resources/databases.js +68 -5
  16. package/dist/core/resources/databases.js.map +1 -1
  17. package/dist/replication/replicationConnection.js +63 -18
  18. package/dist/replication/replicationConnection.js.map +1 -1
  19. package/npm-shrinkwrap.json +2 -2
  20. package/package.json +1 -1
  21. package/replication/replicationConnection.ts +66 -20
  22. package/studio/web/assets/{index-DhLu-DHX.js → index-Tv7e9k8K.js} +5 -5
  23. package/studio/web/assets/{index-DhLu-DHX.js.map → index-Tv7e9k8K.js.map} +1 -1
  24. package/studio/web/assets/{index.lazy-DBjOisCz.js → index.lazy-De4JGuec.js} +2 -2
  25. package/studio/web/assets/{index.lazy-DBjOisCz.js.map → index.lazy-De4JGuec.js.map} +1 -1
  26. package/studio/web/assets/{profile-DSL-499E.js → profile-voeNsl4C.js} +2 -2
  27. package/studio/web/assets/{profile-DSL-499E.js.map → profile-voeNsl4C.js.map} +1 -1
  28. package/studio/web/assets/{status-BRW5QtzY.js → status-110CCE-v.js} +2 -2
  29. package/studio/web/assets/{status-BRW5QtzY.js.map → status-110CCE-v.js.map} +1 -1
  30. package/studio/web/index.html +1 -1
@@ -206,6 +206,11 @@ export class NodeReplicationConnection extends EventEmitter {
206
206
  retries = 0;
207
207
  isConnected = true; // we start out assuming we will be connected
208
208
  isFinished = false;
209
+ // Set when this connection should never reconnect: user-driven unsubscribe(), or the
210
+ // empty-subscription delayed close inside replicateOverWS. Distinct from `isFinished`,
211
+ // which is the post-close terminal marker. Anything else (protocol errors, peer
212
+ // DISCONNECT, etc.) leaves this false so the close handler schedules a retry.
213
+ intentionallyUnsubscribed = false;
209
214
  nodeSubscriptions?: NodeSubscription[];
210
215
  latency = 0;
211
216
  replicateTablesByDefault: boolean;
@@ -248,18 +253,36 @@ export class NodeReplicationConnection extends EventEmitter {
248
253
  });
249
254
  }
250
255
  this.isConnected = true;
251
- session = replicateOverWS(
252
- this.socket,
253
- {
254
- database: this.databaseName,
255
- subscription: this.subscription,
256
- url: this.url,
257
- connection: this,
258
- isSubscriptionConnection: this.nodeSubscriptions !== undefined,
259
- },
260
- { replicates: true } // pre-authorized, but should only make publish: true if we are allowing reverse subscriptions
261
- );
262
- this.sessionResolve(session);
256
+ try {
257
+ session = replicateOverWS(
258
+ this.socket,
259
+ {
260
+ database: this.databaseName,
261
+ subscription: this.subscription,
262
+ url: this.url,
263
+ connection: this,
264
+ isSubscriptionConnection: this.nodeSubscriptions !== undefined,
265
+ },
266
+ { replicates: true } // pre-authorized, but should only make publish: true if we are allowing reverse subscriptions
267
+ );
268
+ this.sessionResolve(session);
269
+ } catch (error) {
270
+ // replicateOverWS does a fair amount of synchronous setup (setDatabase, audit
271
+ // store wiring, ping bookkeeping) and any of it can throw — most worryingly,
272
+ // audit decoder corruption surfaced via setDatabase or the immediate getRange.
273
+ // Without this guard the throw escapes the WS 'open' listener as an
274
+ // uncaughtException, the socket stays open, sessionResolve is never called, no
275
+ // 'close' fires, and the retry timer in the close handler never gets scheduled —
276
+ // leaving the (peer, db) pair stuck with `connected: false` on main but no further
277
+ // activity until a process restart. Terminating the socket forces the close
278
+ // handler to run, which now retries.
279
+ logger.error?.(
280
+ `Error setting up replication session to ${this.url} (db: "${this.databaseName}"), terminating to retry`,
281
+ error
282
+ );
283
+ this.sessionReject(error);
284
+ this.socket.terminate();
285
+ }
263
286
  });
264
287
  this.socket.on('error', (error) => {
265
288
  if (error.code === 'SELF_SIGNED_CERT_IN_CHAIN') {
@@ -277,21 +300,27 @@ export class NodeReplicationConnection extends EventEmitter {
277
300
  this.sessionReject(error);
278
301
  });
279
302
  this.socket.on('close', (code, reasonBuffer) => {
280
- // if we get disconnected, notify subscriptions manager so we can reroute through another node
303
+ // Only treat the close as terminal when something explicitly marked it as a deliberate
304
+ // teardown (user unsubscribe or the empty-subscription delayed close). Protocol-level
305
+ // closes — peer DISCONNECT, unauthorized after open, node-name-mismatch, invalid
306
+ // sequence id, etc. — used to also set `isFinished` via replicateOverWS's close()
307
+ // helper, which left the connection silently dead and required hdb_nodes churn to
308
+ // recover. Those now fall through to the retry path below.
309
+ const intentional = this.intentionallyUnsubscribed;
281
310
  if (this.isConnected) {
282
311
  if (this.nodeSubscriptions) {
283
312
  disconnectedFromNode({
284
313
  name: this.nodeName,
285
314
  database: this.databaseName,
286
315
  url: this.url,
287
- finished: this.socket.isFinished,
316
+ finished: intentional,
288
317
  });
289
318
  }
290
319
  this.isConnected = false;
291
320
  }
292
321
  this.removeAllListeners('subscriptions-updated');
293
322
 
294
- if (this.socket.isFinished) {
323
+ if (intentional) {
295
324
  this.isFinished = true;
296
325
  session?.end();
297
326
  this.emit('finished');
@@ -327,7 +356,7 @@ export class NodeReplicationConnection extends EventEmitter {
327
356
  this.emit('subscriptions-updated', nodeSubscriptions);
328
357
  }
329
358
  unsubscribe() {
330
- this.socket.isFinished = true;
359
+ this.intentionallyUnsubscribed = true;
331
360
  this.socket.close(1008, 'No longer subscribed');
332
361
  }
333
362
 
@@ -1100,11 +1129,20 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1100
1129
  const table = tableEntry.table;
1101
1130
  const primaryStore = table.primaryStore;
1102
1131
  const encoder = primaryStore.encoder;
1132
+ // Force a reload the first time this connection touches each table:
1133
+ // `primaryStore.encoder` is a process-wide singleton, so its typedStructs
1134
+ // may have been populated to a stale length by prior activity on this
1135
+ // thread, and this connection's initial TABLE_FIXED_STRUCTURE must reflect
1136
+ // what's actually in LMDB right now. After this, HAS_STRUCTURE_UPDATE on
1137
+ // subsequent audit records keeps the encoder in sync, since every
1138
+ // typed-struct addition produces a flagged audit record.
1103
1139
  if (
1140
+ !tableEntry.structuresLoaded ||
1104
1141
  auditRecord.extendedType & HAS_STRUCTURE_UPDATE ||
1105
1142
  !encoder.typedStructs ||
1106
1143
  auditRecord.structureVersion > encoder.typedStructs.length + encoder.structures.length
1107
1144
  ) {
1145
+ tableEntry.structuresLoaded = true;
1108
1146
  // there is a structure update, we need to reload the structure from storage.
1109
1147
  // this is copied from msgpackr's struct, may want to expose as public method
1110
1148
  encoder._mergeStructures(encoder.getStructures());
@@ -1770,12 +1808,19 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
1770
1808
  logger.debug?.(connectionId, 'closed', code, reasonBuffer?.toString());
1771
1809
  });
1772
1810
 
1773
- function close(code?, reason?) {
1811
+ function close(code?, reason?, intentional?: boolean) {
1774
1812
  try {
1775
- ws.isFinished = true;
1813
+ // Only the deliberate "we are done with this connection" call sites pass intentional=true
1814
+ // (currently just the empty-subscription delayed close below). Everything else — auth
1815
+ // failures after open, peer-initiated DISCONNECT, schema/sequence errors — is a
1816
+ // transient protocol close that should reconnect, so we let the WS close event fall
1817
+ // through to NodeReplicationConnection's normal retry path instead of marking the
1818
+ // connection as finished or emitting 'finished' (which would remove it from the
1819
+ // worker's connections map).
1820
+ if (intentional && options.connection) options.connection.intentionallyUnsubscribed = true;
1776
1821
  logger.debug?.(connectionId, 'closing', remoteNodeName, databaseName, code, reason);
1777
1822
  ws.close(code, reason);
1778
- options.connection?.emit('finished'); // we want to synchronously indicate that the connection is finished, so it is not accidently reused
1823
+ if (intentional) options.connection?.emit('finished'); // synchronously indicate that the connection is finished, so it is not accidentally reused
1779
1824
  } catch (error) {
1780
1825
  logger.error?.(connectionId, 'Error closing connection', error);
1781
1826
  }
@@ -2110,7 +2155,8 @@ export function replicateOverWS(ws: WebSocket, options: any, authorization: Prom
2110
2155
  const scheduled = performance.now();
2111
2156
  delayedClose = setTimeout(() => {
2112
2157
  // if we have not received any messages in a while, we can close the connection
2113
- if (lastMessageTime <= scheduled) close(1008, 'Connection has no subscriptions and is no longer used');
2158
+ if (lastMessageTime <= scheduled)
2159
+ close(1008, 'Connection has no subscriptions and is no longer used', true);
2114
2160
  else scheduleClose();
2115
2161
  }, DELAY_CLOSE_TIME).unref();
2116
2162
  };