@harperfast/harper 5.2.1 → 5.2.2

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 (117) hide show
  1. package/bin/copyDb.ts +19 -7
  2. package/components/deploymentRecorder.ts +102 -11
  3. package/components/operations.js +4 -0
  4. package/config/configUtils.ts +66 -11
  5. package/config/harperConfigEnvVars.ts +114 -17
  6. package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  7. package/dist/bin/copyDb.js +20 -7
  8. package/dist/bin/copyDb.js.map +1 -1
  9. package/dist/components/deploymentRecorder.d.ts +6 -0
  10. package/dist/components/deploymentRecorder.js +94 -12
  11. package/dist/components/deploymentRecorder.js.map +1 -1
  12. package/dist/components/operations.js +4 -0
  13. package/dist/components/operations.js.map +1 -1
  14. package/dist/config/configUtils.d.ts +6 -0
  15. package/dist/config/configUtils.js +65 -9
  16. package/dist/config/configUtils.js.map +1 -1
  17. package/dist/config/harperConfigEnvVars.js +108 -16
  18. package/dist/config/harperConfigEnvVars.js.map +1 -1
  19. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  20. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
  21. package/dist/resources/DatabaseTransaction.d.ts +2 -0
  22. package/dist/resources/DatabaseTransaction.js +6 -5
  23. package/dist/resources/DatabaseTransaction.js.map +1 -1
  24. package/dist/resources/Table.d.ts +2 -1
  25. package/dist/resources/Table.js +66 -16
  26. package/dist/resources/Table.js.map +1 -1
  27. package/dist/resources/auditStore.js +27 -1
  28. package/dist/resources/auditStore.js.map +1 -1
  29. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
  31. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  32. package/dist/resources/search.d.ts +1 -1
  33. package/dist/resources/search.js +7 -3
  34. package/dist/resources/search.js.map +1 -1
  35. package/dist/server/threads/manageThreads.js +8 -0
  36. package/dist/server/threads/manageThreads.js.map +1 -1
  37. package/dist/sqlEngine/parser/normalizer.js +37 -9
  38. package/dist/sqlEngine/parser/normalizer.js.map +1 -1
  39. package/dist/utility/environment/environmentManager.d.ts +11 -0
  40. package/dist/utility/environment/environmentManager.js +154 -0
  41. package/dist/utility/environment/environmentManager.js.map +1 -1
  42. package/dist/utility/lmdb/environmentUtility.js +2 -1
  43. package/dist/utility/lmdb/environmentUtility.js.map +1 -1
  44. package/npm-shrinkwrap.json +13 -7
  45. package/package.json +2 -2
  46. package/resources/DESIGN.md +1 -0
  47. package/resources/DatabaseTransaction.ts +7 -5
  48. package/resources/Table.ts +77 -16
  49. package/resources/auditStore.ts +32 -1
  50. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
  51. package/resources/search.ts +8 -3
  52. package/server/threads/manageThreads.js +8 -0
  53. package/studio/web/assets/{Chat-aApwhRmz.js → Chat-BMUjPklt.js} +5 -5
  54. package/studio/web/assets/{Chat-aApwhRmz.js.map → Chat-BMUjPklt.js.map} +1 -1
  55. package/studio/web/assets/{FloatingChat-sC0H91n9.js → FloatingChat-Cbghuxe5.js} +5 -5
  56. package/studio/web/assets/{FloatingChat-sC0H91n9.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
  57. package/studio/web/assets/{apiToken-DZ8JhHOI.js → apiToken-CkgTQAqc.js} +2 -2
  58. package/studio/web/assets/{apiToken-DZ8JhHOI.js.map → apiToken-CkgTQAqc.js.map} +1 -1
  59. package/studio/web/assets/{applications-9SEVLIO5.js → applications-WvF4NOTN.js} +13 -13
  60. package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
  61. package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
  62. package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
  63. package/studio/web/assets/index-CDeqVx71.css +1 -0
  64. package/studio/web/assets/index-Sevt3MVx.js +819 -0
  65. package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
  66. package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
  67. package/studio/web/assets/{index.lazy-CN1zq4I4.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
  68. package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
  69. package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
  70. package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
  71. package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
  72. package/studio/web/assets/{notifications-BFAF07xr.js → notifications-Ap84YyQp.js} +2 -2
  73. package/studio/web/assets/{notifications-BFAF07xr.js.map → notifications-Ap84YyQp.js.map} +1 -1
  74. package/studio/web/assets/notifications-vehqyOLM.js +2 -0
  75. package/studio/web/assets/{notifications-CKlYVvVN.js.map → notifications-vehqyOLM.js.map} +1 -1
  76. package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
  77. package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
  78. package/studio/web/assets/{profile-A1zhEdFG.js → profile-DzDpRLHY.js} +2 -2
  79. package/studio/web/assets/{profile-A1zhEdFG.js.map → profile-DzDpRLHY.js.map} +1 -1
  80. package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
  81. package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
  82. package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
  83. package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
  84. package/studio/web/assets/register-BDy01-q3.js +3 -0
  85. package/studio/web/assets/register-BDy01-q3.js.map +1 -0
  86. package/studio/web/assets/register-CQHRwNP4.js +3 -0
  87. package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
  88. package/studio/web/assets/{setComponentFile-SEtBt_GV.js → setComponentFile-D0aPcI3W.js} +2 -2
  89. package/studio/web/assets/{setComponentFile-SEtBt_GV.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
  90. package/studio/web/assets/setup-I15FAF9H.js +3 -0
  91. package/studio/web/assets/{setup-BisINqdH.js.map → setup-I15FAF9H.js.map} +1 -1
  92. package/studio/web/assets/status-BF1eJshJ.js +62 -0
  93. package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
  94. package/studio/web/assets/{swagger-ui-react-CAi_s1PC.js → swagger-ui-react-DwSPXXhl.js} +2 -2
  95. package/studio/web/assets/{swagger-ui-react-CAi_s1PC.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
  96. package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
  97. package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
  98. package/studio/web/assets/{tsMode-DT74tlkM.js → tsMode-Bu51A-VG.js} +2 -2
  99. package/studio/web/assets/{tsMode-DT74tlkM.js.map → tsMode-Bu51A-VG.js.map} +1 -1
  100. package/studio/web/assets/{useEntityRestURL-CGRGc1n7.js → useEntityRestURL-DrV1zMrN.js} +2 -2
  101. package/studio/web/assets/{useEntityRestURL-CGRGc1n7.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
  102. package/studio/web/index.html +6 -7
  103. package/utility/environment/environmentManager.ts +152 -0
  104. package/utility/lmdb/environmentUtility.ts +2 -1
  105. package/studio/web/assets/applications-9SEVLIO5.js.map +0 -1
  106. package/studio/web/assets/index-C8Mv9kHV.css +0 -1
  107. package/studio/web/assets/index-Dy3uDGXb.js +0 -809
  108. package/studio/web/assets/index-Dy3uDGXb.js.map +0 -1
  109. package/studio/web/assets/index.lazy-CN1zq4I4.js +0 -14
  110. package/studio/web/assets/notifications-CKlYVvVN.js +0 -2
  111. package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
  112. package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
  113. package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
  114. package/studio/web/assets/setup-BisINqdH.js +0 -3
  115. package/studio/web/assets/status-BuQoCc7l.js +0 -62
  116. package/studio/web/assets/status-BuQoCc7l.js.map +0 -1
  117. package/studio/web/assets/textarea-DhLMZ3PA.js.map +0 -1
package/bin/copyDb.ts CHANGED
@@ -454,15 +454,22 @@ function countRecords(rawDbi): { records: number; unversioned: number } {
454
454
  * the RocksDB lock — run in-process (inspector) on a live instance, or offline.
455
455
  */
456
456
  export function verifyMigratedDatabase(databasePath: string): Record<string, { records: number; unversioned: number }> {
457
- const rootStore = RocksDatabase.open(databasePath, {});
458
- const dbisDb = RocksDatabase.open(databasePath, {
459
- name: INTERNAL_DBIS_NAME,
460
- sharedStructuresKey: Symbol.for('structures'),
461
- });
457
+ // Every open handle, so a failure at any point (e.g. the second open throwing on lock
458
+ // contention) cannot leak an earlier handle that would hold the RocksDB lock on the very
459
+ // diagnostic path operators use after a broken migration.
460
+ const handles: RocksDatabase[] = [];
462
461
  const report: Record<string, { records: number; unversioned: number }> = {};
463
462
  try {
463
+ handles.push(RocksDatabase.open(databasePath, {}));
464
+ const dbisDb = RocksDatabase.open(databasePath, {
465
+ name: INTERNAL_DBIS_NAME,
466
+ sharedStructuresKey: Symbol.for('structures'),
467
+ });
468
+ handles.push(dbisDb);
464
469
  for (const { key, value: attribute } of dbisDb.getRange({})) {
465
470
  if (typeof key === 'symbol' || !attribute?.isPrimaryKey) continue;
471
+ // per-table handles close per-iteration so a many-table sweep does not hold every CF
472
+ // handle open at once; only the two pre-loop opens need the leak-safety array
466
473
  const rawDbi = RocksDatabase.open(databasePath, { name: key, encoding: false });
467
474
  try {
468
475
  report[key] = countRecords(rawDbi);
@@ -471,8 +478,13 @@ export function verifyMigratedDatabase(databasePath: string): Record<string, { r
471
478
  }
472
479
  }
473
480
  } finally {
474
- dbisDb.close();
475
- rootStore.close();
481
+ for (const handle of handles.reverse()) {
482
+ try {
483
+ handle.close();
484
+ } catch (error) {
485
+ console.error('Error closing verification store', error);
486
+ }
487
+ }
476
488
  }
477
489
  return report;
478
490
  }
@@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { Readable, Transform, pipeline } from 'node:stream';
15
15
  import { databases } from '../resources/databases.ts';
16
+ import { transaction, contextStorage } from '../resources/transaction.ts';
16
17
  import { createBlob, isSaving, deleteBlob, BLOB_UNAVAILABLE_STATUS } from '../resources/blob.ts';
17
18
  import * as terms from '../utility/hdbTerms.ts';
18
19
  import type { CredentialReference } from './secretOperations.ts';
@@ -68,6 +69,39 @@ interface CreateOptions {
68
69
  // no-custody transient token.
69
70
  credentials?: CredentialReference[] | null;
70
71
  emitter?: ProgressEmitter;
72
+ // Open-transaction budget (ms) for the payload-ingest write, below. Mirrors the
73
+ // `deployment_timeout` operation parameter already used for the peer-side row/blob waits
74
+ // (DEFAULT_AWAIT_ROW_TIMEOUT_MS) so operators have one existing knob for all of a deploy's
75
+ // size-driven timeouts, rather than a new config surface for this one.
76
+ ingestTimeoutMs?: number;
77
+ }
78
+
79
+ /**
80
+ * Run `write()` in a fresh transaction while preserving audit attribution. Recorder writes must
81
+ * commit independently of a caller's transaction so an older staged row cannot overwrite them;
82
+ * authorization is enforced by the deploy operation rather than these internal writes.
83
+ *
84
+ * `timeoutBudget` is sticky across RocksDB `getReadTxn()` calls, so reads inside `write()` cannot
85
+ * silently restore the generic default. The transaction uses the larger of this budget and the
86
+ * configured global limit.
87
+ */
88
+ function withIsolatedTransaction<T>(write: () => Promise<T>, timeoutMs?: number): Promise<T> {
89
+ const ambient = contextStorage.getStore();
90
+ const context = {
91
+ user: ambient?.user,
92
+ originatingOperation: ambient?.originatingOperation,
93
+ session: ambient?.session,
94
+ signal: ambient?.signal,
95
+ };
96
+ return transaction(context, (txn) => {
97
+ if (timeoutMs != null) {
98
+ // TODO(harper#2057): only extends the RocksDB DatabaseTransaction head; on
99
+ // HARPER_STORAGE_ENGINE=lmdb, Table.txnForContext() chains a separate LMDBTransaction
100
+ // that this budget doesn't reach.
101
+ txn.timeoutBudget = timeoutMs;
102
+ }
103
+ return write();
104
+ });
71
105
  }
72
106
 
73
107
  export class DeploymentRecorder {
@@ -78,10 +112,13 @@ export class DeploymentRecorder {
78
112
  private pendingPut: Promise<void> | null = null;
79
113
  private dirty = false;
80
114
  private sealed = false;
115
+ private flushSuppressed = false;
116
+ private readonly ingestTimeoutMs?: number;
81
117
 
82
- private constructor(deploymentId: string, initial: Record<string, any>) {
118
+ private constructor(deploymentId: string, initial: Record<string, any>, ingestTimeoutMs?: number) {
83
119
  this.deploymentId = deploymentId;
84
120
  this.record = initial;
121
+ this.ingestTimeoutMs = ingestTimeoutMs;
85
122
  }
86
123
 
87
124
  static async create(options: CreateOptions): Promise<DeploymentRecorder> {
@@ -107,7 +144,7 @@ export class DeploymentRecorder {
107
144
  credentials: options.credentials ?? null,
108
145
  error: null,
109
146
  };
110
- const recorder = new DeploymentRecorder(deploymentId, record);
147
+ const recorder = new DeploymentRecorder(deploymentId, record, options.ingestTimeoutMs);
111
148
  await recorder.put();
112
149
  if (options.emitter) {
113
150
  recorder.subscribeTo(options.emitter);
@@ -158,9 +195,8 @@ export class DeploymentRecorder {
158
195
  // the record dirty; the chained continuation issues a follow-up put once the prior one
159
196
  // settles. This keeps event_log writes O(1) puts per burst rather than O(N) per event.
160
197
  private scheduleFlush(): void {
161
- if (this.sealed) {
162
- // Sealed: accumulate state in memory but don't write. finish() does the single
163
- // terminal write. See seal() for why. The emitter still emits live SSE events.
198
+ if (this.sealed || this.flushSuppressed) {
199
+ // Accumulate state until finish() or the active ingest writes it without a competing put.
164
200
  this.dirty = true;
165
201
  return;
166
202
  }
@@ -175,6 +211,7 @@ export class DeploymentRecorder {
175
211
  this.scheduleFlush();
176
212
  }
177
213
  });
214
+ this.pendingPut.catch((error) => logger.warn?.('Failed to persist deployment progress', error));
178
215
  }
179
216
 
180
217
  /**
@@ -191,6 +228,26 @@ export class DeploymentRecorder {
191
228
  * materialized by the time they reach us, so they take the simpler buffer path.
192
229
  */
193
230
  async ingestPayload(source: Readable | Buffer | string): Promise<void> {
231
+ this.flushSuppressed = true;
232
+ try {
233
+ while (this.pendingPut) {
234
+ try {
235
+ await this.pendingPut;
236
+ } catch {
237
+ /* already logged; the ingest write carries the current row state */
238
+ }
239
+ }
240
+ await this.ingestPayloadWithoutFlush(source);
241
+ } finally {
242
+ this.flushSuppressed = false;
243
+ if (this.dirty && !this.sealed) {
244
+ this.dirty = false;
245
+ this.scheduleFlush();
246
+ }
247
+ }
248
+ }
249
+
250
+ private async ingestPayloadWithoutFlush(source: Readable | Buffer | string): Promise<void> {
194
251
  const hash = createHash('sha256');
195
252
 
196
253
  // In-memory sources: the bytes are already resident, so hashing them and creating a
@@ -202,7 +259,10 @@ export class DeploymentRecorder {
202
259
  this.record.payload_blob = createBlob(buffer, { type: 'application/gzip' });
203
260
  this.record.payload_hash = hash.digest('hex');
204
261
  this.record.payload_size = buffer.length;
205
- await this.put();
262
+ // Same blob-durability gate as the streaming path below applies here too (the commit
263
+ // waits for the buffer to land on disk), so a large base64-in-JSON/CBOR body needs the
264
+ // same extended budget.
265
+ await this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
206
266
  return;
207
267
  }
208
268
 
@@ -263,6 +323,16 @@ export class DeploymentRecorder {
263
323
  });
264
324
  const blob = createBlob(tap, { type: 'application/gzip' });
265
325
  this.record.payload_blob = blob;
326
+ // This row's commit is gated on the blob's durable file write (resources/blob.ts,
327
+ // startPreCommitBlobsForRecord's local-write branch) so a peer never sees a row that
328
+ // references a not-yet-durable blob — the write transaction is legitimately held open for
329
+ // as long as the payload takes to land on disk. The deploy system is explicitly designed
330
+ // to carry arbitrarily large, uncapped components (see the module comment above), so that
331
+ // duration scales with payload size, not with anything bounded. Run this one put() under
332
+ // an extended open-time budget instead of the generic storage.maxTransactionOpenTime meant
333
+ // for ordinary short requests (a large-but-otherwise-healthy ingest tripped that generic
334
+ // guard on a loaded self-hosted runner — see the Large Deploy Payload Test regression).
335
+ //
266
336
  // Persisting the row encodes the blob, which synchronously starts the file write that
267
337
  // drains the tap, so `isSaving(blob)` is set as soon as put() is *called*. Await the put,
268
338
  // the source draining (tapDone), and the file flush (saving) together: handing all three
@@ -270,7 +340,7 @@ export class DeploymentRecorder {
270
340
  // a large upload mid-stream (which rejects both tapDone and the blob write) fails the
271
341
  // deploy loudly here instead of leaking an unhandledRejection. This also makes hash/size
272
342
  // correctness independent of the put()/store write timing and of isSaving() being defined.
273
- const putDone = this.put();
343
+ const putDone = this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
274
344
  const saving = isSaving(blob) ?? Promise.resolve();
275
345
  try {
276
346
  await Promise.all([putDone, tapDone, saving]);
@@ -288,7 +358,7 @@ export class DeploymentRecorder {
288
358
  // ensures the null-reference write lands after the original reference write
289
359
  // (so it wins) and the blob's file lock is released before we unlink it.
290
360
  await Promise.allSettled([putDone, saving]);
291
- await this.put();
361
+ await this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
292
362
  deleteBlob(blob);
293
363
  } catch (cleanupError) {
294
364
  logger.warn?.('Failed to clean up partial deploy payload blob', cleanupError);
@@ -301,7 +371,7 @@ export class DeploymentRecorder {
301
371
  this.record.payload_size = blob.size ?? byteCount;
302
372
  // Persist the now-known hash + size. The blob is already saved, so this re-put does
303
373
  // not re-stream — saveBlob short-circuits on the existing fileId.
304
- await this.put();
374
+ await this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
305
375
  }
306
376
 
307
377
  /**
@@ -436,14 +506,14 @@ export class DeploymentRecorder {
436
506
  return this.record;
437
507
  }
438
508
 
439
- private async put(): Promise<void> {
509
+ private async put(timeoutMs?: number): Promise<void> {
440
510
  const table = (databases as any).system?.[terms.SYSTEM_TABLE_NAMES.DEPLOYMENT_TABLE_NAME];
441
511
  if (!table) {
442
512
  // Table missing means the upgrade directive hasn't run yet (or the table got dropped).
443
513
  // We tolerate this — tracking is observability; the deploy itself must still succeed.
444
514
  return;
445
515
  }
446
- await table.put(this.record);
516
+ await withIsolatedTransaction(() => table.put(this.record), timeoutMs);
447
517
  }
448
518
  }
449
519
 
@@ -455,11 +525,32 @@ export class DeploymentRecorder {
455
525
  // via the `deployment_timeout` operation parameter.
456
526
  export const DEFAULT_AWAIT_ROW_TIMEOUT_MS = 120_000;
457
527
 
528
+ // Default open-time budget for the origin's payload-ingest write transaction (ingestPayload,
529
+ // below). Distinct from DEFAULT_AWAIT_ROW_TIMEOUT_MS (that one bounds a *replication* wait;
530
+ // this one bounds *local disk I/O* for a component the deploy system explicitly supports at
531
+ // arbitrarily large, uncapped sizes — see the module comment above and the large-payload
532
+ // regression test). 10 minutes comfortably covers a multi-GB ingest even on a slow or loaded
533
+ // disk while still being a bounded safety net, not a disabled one. Overridable via the same
534
+ // `deployment_timeout` operation parameter as the peer-side waits, so operators sizing for
535
+ // even larger components (or a known-slow disk) have one knob, not two.
536
+ export const DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS = 600_000;
537
+
458
538
  export function coerceTimeoutMs(value: unknown, fallback: number): number {
459
539
  const requested = Number(value);
460
540
  return Number.isFinite(requested) && requested >= 0 ? requested : fallback;
461
541
  }
462
542
 
543
+ // Floored at the default, never lowered by it: `deployment_timeout` also governs the
544
+ // peer-side row/blob wait (awaitDeploymentRow), where 0 means "poll once, don't wait" — a
545
+ // legitimate fast-fail setting for peers that must never also starve the origin's own disk
546
+ // write down to 0ms.
547
+ export function ingestTransactionTimeoutMs(deploymentTimeout: unknown): number {
548
+ return Math.max(
549
+ coerceTimeoutMs(deploymentTimeout, DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS),
550
+ DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS
551
+ );
552
+ }
553
+
463
554
  /**
464
555
  * Peer-side helper — wait for the hdb_deployment row to arrive via table replication,
465
556
  * then return it. The row is committed on origin before `replicateOperation` is
@@ -441,6 +441,10 @@ async function deployComponent(req) {
441
441
  // Reference form only — the rollback source for re-resolving the credential.
442
442
  credentials: credentialReferences.length ? credentialReferences : null,
443
443
  emitter,
444
+ // Reuse the same `deployment_timeout` knob operators already have for the
445
+ // peer-side row/blob waits to size the origin's payload-ingest write
446
+ // transaction too — see DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS in deploymentRecorder.
447
+ ingestTimeoutMs: req.deployment_timeout,
444
448
  });
445
449
  if (recorder) req._deploymentId = recorder.deploymentId;
446
450
 
@@ -49,6 +49,14 @@ let flatDefaultConfigObj;
49
49
  let flatConfigObj;
50
50
  let configObj;
51
51
 
52
+ // Canonical param names that live in CONFIG_PARAM_MAP but do NOT correspond to a path in the
53
+ // harper-config.yaml schema (BOOT_PROP_PARAMS is boot-props-file-only bookkeeping — see its own
54
+ // comment). Splitting one of these on '_' and writing it into the nested configObj tree would
55
+ // silently create a bogus top-level section (e.g. 'settings_path' -> configObj.settings.path),
56
+ // which componentLoader.ts treats as a real component to load, since it iterates every truthy
57
+ // top-level key of the root config.
58
+ const NON_NESTED_CONFIG_PARAMS = new Set<string>(Object.values(hdbTerms.BOOT_PROP_PARAMS));
59
+
52
60
  export function resolvePath(relativePath: string) {
53
61
  if (relativePath?.startsWith('~/')) {
54
62
  return path.join(hdbUtils.getHomeDir(), relativePath.slice(1));
@@ -96,7 +104,7 @@ const RENAME_RETRY_MAX_DELAY_MS = 500;
96
104
  // Never notified; exists only so Atomics.wait can time out (a synchronous, CPU-idle sleep).
97
105
  const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
98
106
 
99
- function atomicWriteFile(
107
+ export function atomicWriteFile(
100
108
  filePath,
101
109
  content,
102
110
  {
@@ -193,8 +201,7 @@ export function createConfigFile(args, skipFsValidation = false) {
193
201
  // Validates config doc and if required sets default values for some parameters.
194
202
  validateConfig(configDoc, skipFsValidation);
195
203
 
196
- const configObj = configDoc.toJSON();
197
- flatConfigObj = flattenConfig(configObj);
204
+ flatConfigObj = setActiveConfig(configDoc.toJSON());
198
205
 
199
206
  // Create new config file and write config doc to it.
200
207
  const hdbRoot = configDoc.getIn(['rootPath']) as string;
@@ -500,9 +507,9 @@ export function initConfig(force = false) {
500
507
 
501
508
  // Validates config doc and if required sets default values for some parameters.
502
509
  validateConfig(configDoc);
503
- const configObj = configDoc.toJSON();
504
- (server as any).config = configObj;
505
- flatConfigObj = flattenConfig(configObj);
510
+ const parsedConfig = configDoc.toJSON();
511
+ (server as any).config = parsedConfig;
512
+ flatConfigObj = setActiveConfig(parsedConfig);
506
513
 
507
514
  // If config has old version of logrotate enabled let user know it has been deprecated.
508
515
  if (flatConfigObj['logging_rotation_rotate']) {
@@ -690,6 +697,41 @@ export function updateConfigObject(param: string, value: any) {
690
697
  }
691
698
 
692
699
  flatConfigObj[configObjKey.toLowerCase()] = value;
700
+
701
+ // Keep the nested config tree in sync too: componentLoader (root components) and other
702
+ // nested-path readers derive behavior — e.g. a component's network port/protocol — from
703
+ // getConfigObj()'s tree, not the flattened map, so an override that only touched flatConfigObj
704
+ // was invisible to them. configObj is unset only before a live config has ever been installed
705
+ // (setActiveConfig), i.e. during install; don't auto-vivify it, since getConfigObj() reads its
706
+ // falsiness as "not yet initialized" and an empty object would short-circuit that lazy init
707
+ // permanently.
708
+ if (configObj != null && !NON_NESTED_CONFIG_PARAMS.has(configObjKey)) {
709
+ const pathSegments = configObjKey.split('_');
710
+ let node = configObj;
711
+ for (let i = 0; i < pathSegments.length - 1; i++) {
712
+ const segment = pathSegments[i];
713
+ if (node[segment] === undefined) {
714
+ // Auto-vivifying ancestors only to delete the leaf would leave empty sections behind,
715
+ // which componentLoader.ts would treat as components to load.
716
+ if (value === undefined) return;
717
+ node[segment] = {};
718
+ } else if (typeof node[segment] !== 'object' || node[segment] === null) {
719
+ // A legacy scalar shorthand for this key (e.g. `threads: 4`); descending would replace
720
+ // it with {} out from under other readers. Leaves the two views disagreeing on this
721
+ // param, so say so rather than diverging silently.
722
+ logger.trace(
723
+ `Config param '${configObjKey}' not mirrored into the nested config: '${segment}' holds a scalar value`
724
+ );
725
+ return;
726
+ }
727
+ node = node[segment];
728
+ }
729
+ // squashObj never writes an undefined value as an enumerable key either; root-config readers
730
+ // (e.g. bin/run.ts) iterate configObj's own keys and assume every one holds a real value.
731
+ const leaf = pathSegments[pathSegments.length - 1];
732
+ if (value === undefined) delete node[leaf];
733
+ else node[leaf] = value;
734
+ }
693
735
  }
694
736
 
695
737
  /**
@@ -855,7 +897,7 @@ export function updateConfigValue(
855
897
  }
856
898
  atomicWriteFile(configFileLocation, String(configDoc));
857
899
  if (update_config_obj) {
858
- flatConfigObj = flattenConfig(configDoc.toJSON());
900
+ flatConfigObj = setActiveConfig(configDoc.toJSON());
859
901
  }
860
902
  logger.trace(`Config parameter: ${param} updated with value: ${value}`);
861
903
  }
@@ -874,9 +916,25 @@ function backupConfigFile(configPath, hdbRoot) {
874
916
  }
875
917
  }
876
918
 
919
+ /**
920
+ * Flattens `obj` and installs it as the live config — the nested tree getConfigObj() hands out and
921
+ * updateConfigObject() mirrors overrides into. Only callers that own the live config may do this:
922
+ * flattenConfig() also runs on docs that are discarded moments later (the defaults doc, the
923
+ * user-supplied doc installer.ts reads for HDB_CONFIG), and no reader of configObj can tell one of
924
+ * those from the real thing.
925
+ * @param obj
926
+ * @returns the flattened config
927
+ */
928
+ function setActiveConfig(obj) {
929
+ const flatObj = flattenConfig(obj);
930
+ configObj = obj;
931
+ return flatObj;
932
+ }
933
+
877
934
  const PRESERVED_PROPERTIES = ['databases'];
878
935
  /**
879
936
  * Flattens the JSON version of Harper config with underscores separating each parent/child key.
937
+ * Does NOT install `obj` as the live config — see setActiveConfig() for that.
880
938
  * @param obj
881
939
  * @returns {null}
882
940
  */
@@ -885,10 +943,7 @@ export function flattenConfig(obj) {
885
943
  if (obj?.operationsApi?.network) obj.operationsApi.network = { ...obj.http, ...obj.operationsApi.network };
886
944
  if (obj?.operationsApi) obj.operationsApi.tls = { ...obj.tls, ...obj.operationsApi.tls };
887
945
 
888
- configObj = obj;
889
- const flatObj = squashObj(obj);
890
-
891
- return flatObj;
946
+ return squashObj(obj);
892
947
 
893
948
  function squashObj(obj) {
894
949
  let result = {};
@@ -28,6 +28,7 @@ import * as path from 'node:path';
28
28
  import * as crypto from 'node:crypto';
29
29
  import { cloneDeep } from 'lodash';
30
30
  import { getBackupDirPath } from './configHelpers.ts';
31
+ import { atomicWriteFile } from './configUtils.ts';
31
32
  import * as hdbTerms from '../utility/hdbTerms.ts';
32
33
 
33
34
  const STATE_FILE_NAME = '.harper-config-state.json';
@@ -78,6 +79,10 @@ interface ConfigState {
78
79
  version: string;
79
80
  sources: Record<string, ConfigSource>; // Maps config path to the source that set it
80
81
  originalValues: Record<string, any>; // Original values before env var override (for restoration)
82
+ // Paths the config file declared as empty objects before an env layer first populated
83
+ // them (#1618/#1726). Kept separate from originalValues so a marker can never mask, or
84
+ // be consumed as, a real leaf original at the same path.
85
+ emptyScopeOriginals: Record<string, true>;
81
86
  snapshots: {
82
87
  // Snapshots of what each env var currently specifies (for detecting changes)
83
88
  HARPER_DEFAULT_CONFIG?: { hash: string; config: ConfigObject };
@@ -410,21 +415,93 @@ function setNestedValue(obj: ConfigObject, path: string, value: any): void {
410
415
  }
411
416
 
412
417
  /**
413
- * Delete nested value by dot-notation path
418
+ * Delete nested value by dot-notation path, pruning ancestor objects the deletion
419
+ * emptied. Removal operates leaf-by-leaf on flattened paths, so deleting the last
420
+ * leaf under an entry would otherwise leave an `entry: {}` husk in the config file —
421
+ * invalid wherever validation requires fields, and sticky once persisted (#2067).
422
+ * Prunes only what this deletion emptied: an absent leaf deletes nothing and prunes
423
+ * nothing, so a deliberate empty scope (#1618/#1726) is never eaten by a no-op
424
+ * removal. Returns the pruned ancestor paths, deepest first.
414
425
  */
415
- function deleteNestedValue(obj: ConfigObject, path: string): void {
426
+ function deleteNestedValue(obj: ConfigObject, path: string): string[] {
416
427
  const keys = path.split('.');
428
+ const ancestors: ConfigObject[] = [];
417
429
  let current = obj;
418
430
 
419
431
  for (let i = 0; i < keys.length - 1; i++) {
420
432
  const key = keys[i];
421
433
  if (!isPlainObject(current[key])) {
422
- return; // Path doesn't exist
434
+ return []; // Path doesn't exist
423
435
  }
436
+ ancestors.push(current);
424
437
  current = current[key];
425
438
  }
426
439
 
427
- delete current[keys[keys.length - 1]];
440
+ const leafKey = keys[keys.length - 1];
441
+ // Own-property check: `in` walks the prototype chain, so a leaf named like an
442
+ // Object.prototype member (`constructor`, `toString`) would pass, no-op the
443
+ // delete, and let the prune loop eat a deliberate empty scope.
444
+ if (!Object.prototype.hasOwnProperty.call(current, leafKey)) {
445
+ return [];
446
+ }
447
+ delete current[leafKey];
448
+
449
+ const prunedPaths: string[] = [];
450
+ for (let i = ancestors.length - 1; i >= 0 && Object.keys(current).length === 0; i--) {
451
+ delete ancestors[i][keys[i]];
452
+ prunedPaths.push(keys.slice(0, i + 1).join('.'));
453
+ current = ancestors[i];
454
+ }
455
+ return prunedPaths;
456
+ }
457
+
458
+ /**
459
+ * If the deepest existing ancestor of `path` is an empty plain object, record it in
460
+ * emptyScopeOriginals before a layer populates it: a bare `name: {}` in the config
461
+ * file is user content (#1618/#1726) even while an env var temporarily fills it. At
462
+ * most one ancestor can be both existing and empty, so one marker suffices.
463
+ */
464
+ function recordEmptyAncestorOriginal(fileConfig: ConfigObject, state: ConfigState, path: string): void {
465
+ const keys = path.split('.');
466
+ let current = fileConfig;
467
+ for (let i = 0; i < keys.length - 1; i++) {
468
+ current = Object.prototype.hasOwnProperty.call(current, keys[i]) ? current[keys[i]] : undefined;
469
+ if (current === undefined) {
470
+ // An absent prefix cannot be a live file-declared empty scope, so markers at
471
+ // or under it are stale (the user deleted the scope while the env var held
472
+ // it) and must not resurrect it later. Only true absence qualifies: a scalar
473
+ // here is a higher layer occluding the scope, not the user deleting it, and
474
+ // emptiness is what an apply's first leaf creates right after recording.
475
+ const prefix = keys.slice(0, i + 1).join('.');
476
+ for (const markerPath of Object.keys(state.emptyScopeOriginals)) {
477
+ if (markerPath === prefix || markerPath.startsWith(prefix + '.')) {
478
+ delete state.emptyScopeOriginals[markerPath];
479
+ }
480
+ }
481
+ return;
482
+ }
483
+ if (!isPlainObject(current)) return;
484
+ if (Object.keys(current).length === 0) {
485
+ state.emptyScopeOriginals[keys.slice(0, i + 1).join('.')] = true;
486
+ return;
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Counterpart to recordEmptyAncestorOriginal: when a prune removed an ancestor the
493
+ * file originally declared as `{}`, put the empty scope back. Only paths the deletion
494
+ * actually pruned are candidates, so a scalar overwrite or an absent-leaf no-op can
495
+ * never resurrect a scope over live env-layer content.
496
+ */
497
+ function restorePrunedEmptyAncestor(fileConfig: ConfigObject, state: ConfigState, prunedPaths: string[]): void {
498
+ for (const prunedPath of prunedPaths) {
499
+ if (Object.prototype.hasOwnProperty.call(state.emptyScopeOriginals, prunedPath)) {
500
+ setNestedValue(fileConfig, prunedPath, {});
501
+ delete state.emptyScopeOriginals[prunedPath];
502
+ return;
503
+ }
504
+ }
428
505
  }
429
506
 
430
507
  /**
@@ -474,16 +551,23 @@ function loadConfigState(rootPath: string): ConfigState {
474
551
  version: '1.0',
475
552
  sources: {},
476
553
  originalValues: {},
554
+ emptyScopeOriginals: {},
477
555
  snapshots: {},
478
556
  };
479
557
  }
480
558
 
481
559
  try {
482
560
  const state = fs.readJsonSync(statePath) as ConfigState;
483
- // Ensure originalValues exists (for backwards compatibility with old state files)
561
+ // Ensure newer fields exist (for backwards compatibility with old state files)
484
562
  if (!state.originalValues) {
485
563
  state.originalValues = {};
486
564
  }
565
+ if (!state.emptyScopeOriginals) {
566
+ // Only the field is recoverable, not the information: a scope an env layer
567
+ // populated before this field existed has no marker and will not be restored
568
+ // on vacate (see DESIGN.md, env-config empty objects)
569
+ state.emptyScopeOriginals = {};
570
+ }
487
571
  return state;
488
572
  } catch (error) {
489
573
  // If state file is corrupted, start fresh
@@ -493,6 +577,7 @@ function loadConfigState(rootPath: string): ConfigState {
493
577
  version: '1.0',
494
578
  sources: {},
495
579
  originalValues: {},
580
+ emptyScopeOriginals: {},
496
581
  snapshots: {},
497
582
  };
498
583
  }
@@ -508,7 +593,9 @@ function saveConfigState(rootPath: string, state: ConfigState): void {
508
593
  // Ensure backup directory exists
509
594
  fs.ensureDirSync(backupDir);
510
595
 
511
- fs.writeJsonSync(statePath, state, { spaces: 2 });
596
+ // Atomic write: a torn state file resets to fresh on the next load, losing every
597
+ // restoration record — the blast radius is user config-file content
598
+ atomicWriteFile(statePath, JSON.stringify(state, null, 2) + '\n');
512
599
  }
513
600
 
514
601
  /**
@@ -553,7 +640,7 @@ function applyConfigLayer(
553
640
  const flatEnvConfig = flattenObject(envConfig);
554
641
 
555
642
  for (const [path, value] of Object.entries(flatEnvConfig)) {
556
- const currentSource = state.sources[path];
643
+ const currentSource = Object.prototype.hasOwnProperty.call(state.sources, path) ? state.sources[path] : undefined;
557
644
  const currentValue = getNestedValue(fileConfig, path);
558
645
 
559
646
  // Skip if this path has a source we should respect
@@ -562,9 +649,14 @@ function applyConfigLayer(
562
649
  }
563
650
 
564
651
  // Store original value if requested and this is first time overriding
565
- if (storeOriginals && !currentSource && currentValue !== undefined && currentValue !== null) {
566
- if (!(path in state.originalValues)) {
567
- state.originalValues[path] = currentValue;
652
+ if (storeOriginals) {
653
+ if (!currentSource && currentValue != null) {
654
+ if (!Object.prototype.hasOwnProperty.call(state.originalValues, path)) {
655
+ state.originalValues[path] = currentValue;
656
+ }
657
+ } else if (currentValue == null) {
658
+ // runs on re-assert too, so a hand-deleted scope clears its stale marker
659
+ recordEmptyAncestorOriginal(fileConfig, state, path);
568
660
  }
569
661
  }
570
662
 
@@ -599,13 +691,13 @@ function handleDeletions(
599
691
  (sourceName === 'HARPER_DEFAULT_CONFIG' ||
600
692
  sourceName === 'HARPER_CONFIG' ||
601
693
  sourceName === 'HARPER_SET_CONFIG') &&
602
- path in state.originalValues
694
+ Object.prototype.hasOwnProperty.call(state.originalValues, path)
603
695
  ) {
604
696
  setNestedValue(fileConfig, path, state.originalValues[path]);
605
697
  delete state.originalValues[path];
606
698
  } else {
607
699
  // For other sources or if no original value, delete
608
- deleteNestedValue(fileConfig, path);
700
+ restorePrunedEmptyAncestor(fileConfig, state, deleteNestedValue(fileConfig, path));
609
701
  }
610
702
  delete state.sources[path];
611
703
  }
@@ -619,7 +711,7 @@ function removeValuesWithSource(fileConfig: ConfigObject, state: ConfigState, so
619
711
  const pathsToRemove = Object.keys(state.sources).filter((path) => state.sources[path] === sourceName);
620
712
 
621
713
  for (const path of pathsToRemove) {
622
- deleteNestedValue(fileConfig, path);
714
+ restorePrunedEmptyAncestor(fileConfig, state, deleteNestedValue(fileConfig, path));
623
715
  delete state.sources[path];
624
716
  }
625
717
  }
@@ -690,7 +782,9 @@ function processEnvVar(
690
782
  // Runtime: Only update values we previously set
691
783
  const flatEnvConfig = flattenObject(parsedConfig);
692
784
  for (const [path, value] of Object.entries(flatEnvConfig)) {
693
- const currentSource = state.sources[path];
785
+ const currentSource = Object.prototype.hasOwnProperty.call(state.sources, path)
786
+ ? state.sources[path]
787
+ : undefined;
694
788
  const currentValue = getNestedValue(fileConfig, path);
695
789
 
696
790
  // Skip if path has a tracked source that's not HARPER_DEFAULT_CONFIG
@@ -702,12 +796,15 @@ function processEnvVar(
702
796
  if (!currentSource) {
703
797
  if (currentValue !== undefined && currentValue !== null) {
704
798
  // Value exists but we never set it - store as original but don't override
705
- if (!(path in state.originalValues)) {
799
+ if (!Object.prototype.hasOwnProperty.call(state.originalValues, path)) {
706
800
  state.originalValues[path] = currentValue;
707
801
  }
708
802
  continue;
709
803
  }
710
804
  }
805
+ if (currentValue == null) {
806
+ recordEmptyAncestorOriginal(fileConfig, state, path);
807
+ }
711
808
 
712
809
  // Set the value and track the source (directive leaves compose against current)
713
810
  setNestedValue(fileConfig, path, resolveLeafValue(currentValue, value, path));
@@ -749,13 +846,13 @@ function cleanupRemovedEnvVar(
749
846
  if (sourceName === 'HARPER_DEFAULT_CONFIG' || sourceName === 'HARPER_CONFIG' || sourceName === 'HARPER_SET_CONFIG') {
750
847
  const pathsToCleanup = Object.keys(state.sources).filter((path) => state.sources[path] === sourceName);
751
848
  for (const path of pathsToCleanup) {
752
- if (path in state.originalValues) {
849
+ if (Object.prototype.hasOwnProperty.call(state.originalValues, path)) {
753
850
  // Restore original value
754
851
  setNestedValue(fileConfig, path, state.originalValues[path]);
755
852
  delete state.originalValues[path];
756
853
  } else {
757
854
  // No original, just delete
758
- deleteNestedValue(fileConfig, path);
855
+ restorePrunedEmptyAncestor(fileConfig, state, deleteNestedValue(fileConfig, path));
759
856
  }
760
857
  delete state.sources[path];
761
858
  }