@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.
- package/bin/copyDb.ts +19 -7
- package/components/deploymentRecorder.ts +102 -11
- package/components/operations.js +4 -0
- package/config/configUtils.ts +66 -11
- package/config/harperConfigEnvVars.ts +114 -17
- package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
- package/dist/bin/copyDb.js +20 -7
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +6 -0
- package/dist/components/deploymentRecorder.js +94 -12
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/operations.js +4 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +6 -0
- package/dist/config/configUtils.js +65 -9
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/harperConfigEnvVars.js +108 -16
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
- package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +2 -0
- package/dist/resources/DatabaseTransaction.js +6 -5
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/Table.d.ts +2 -1
- package/dist/resources/Table.js +66 -16
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/auditStore.js +27 -1
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/search.d.ts +1 -1
- package/dist/resources/search.js +7 -3
- package/dist/resources/search.js.map +1 -1
- package/dist/server/threads/manageThreads.js +8 -0
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/sqlEngine/parser/normalizer.js +37 -9
- package/dist/sqlEngine/parser/normalizer.js.map +1 -1
- package/dist/utility/environment/environmentManager.d.ts +11 -0
- package/dist/utility/environment/environmentManager.js +154 -0
- package/dist/utility/environment/environmentManager.js.map +1 -1
- package/dist/utility/lmdb/environmentUtility.js +2 -1
- package/dist/utility/lmdb/environmentUtility.js.map +1 -1
- package/npm-shrinkwrap.json +13 -7
- package/package.json +2 -2
- package/resources/DESIGN.md +1 -0
- package/resources/DatabaseTransaction.ts +7 -5
- package/resources/Table.ts +77 -16
- package/resources/auditStore.ts +32 -1
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
- package/resources/search.ts +8 -3
- package/server/threads/manageThreads.js +8 -0
- package/studio/web/assets/{Chat-aApwhRmz.js → Chat-BMUjPklt.js} +5 -5
- package/studio/web/assets/{Chat-aApwhRmz.js.map → Chat-BMUjPklt.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-sC0H91n9.js → FloatingChat-Cbghuxe5.js} +5 -5
- package/studio/web/assets/{FloatingChat-sC0H91n9.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
- package/studio/web/assets/{apiToken-DZ8JhHOI.js → apiToken-CkgTQAqc.js} +2 -2
- package/studio/web/assets/{apiToken-DZ8JhHOI.js.map → apiToken-CkgTQAqc.js.map} +1 -1
- package/studio/web/assets/{applications-9SEVLIO5.js → applications-WvF4NOTN.js} +13 -13
- package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
- package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
- package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
- package/studio/web/assets/index-CDeqVx71.css +1 -0
- package/studio/web/assets/index-Sevt3MVx.js +819 -0
- package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
- package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
- package/studio/web/assets/{index.lazy-CN1zq4I4.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
- package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
- package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
- package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
- package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
- package/studio/web/assets/{notifications-BFAF07xr.js → notifications-Ap84YyQp.js} +2 -2
- package/studio/web/assets/{notifications-BFAF07xr.js.map → notifications-Ap84YyQp.js.map} +1 -1
- package/studio/web/assets/notifications-vehqyOLM.js +2 -0
- package/studio/web/assets/{notifications-CKlYVvVN.js.map → notifications-vehqyOLM.js.map} +1 -1
- package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
- package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
- package/studio/web/assets/{profile-A1zhEdFG.js → profile-DzDpRLHY.js} +2 -2
- package/studio/web/assets/{profile-A1zhEdFG.js.map → profile-DzDpRLHY.js.map} +1 -1
- package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
- package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
- package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
- package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
- package/studio/web/assets/register-BDy01-q3.js +3 -0
- package/studio/web/assets/register-BDy01-q3.js.map +1 -0
- package/studio/web/assets/register-CQHRwNP4.js +3 -0
- package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
- package/studio/web/assets/{setComponentFile-SEtBt_GV.js → setComponentFile-D0aPcI3W.js} +2 -2
- package/studio/web/assets/{setComponentFile-SEtBt_GV.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
- package/studio/web/assets/setup-I15FAF9H.js +3 -0
- package/studio/web/assets/{setup-BisINqdH.js.map → setup-I15FAF9H.js.map} +1 -1
- package/studio/web/assets/status-BF1eJshJ.js +62 -0
- package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
- package/studio/web/assets/{swagger-ui-react-CAi_s1PC.js → swagger-ui-react-DwSPXXhl.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-CAi_s1PC.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
- package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
- package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
- package/studio/web/assets/{tsMode-DT74tlkM.js → tsMode-Bu51A-VG.js} +2 -2
- package/studio/web/assets/{tsMode-DT74tlkM.js.map → tsMode-Bu51A-VG.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-CGRGc1n7.js → useEntityRestURL-DrV1zMrN.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-CGRGc1n7.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
- package/studio/web/index.html +6 -7
- package/utility/environment/environmentManager.ts +152 -0
- package/utility/lmdb/environmentUtility.ts +2 -1
- package/studio/web/assets/applications-9SEVLIO5.js.map +0 -1
- package/studio/web/assets/index-C8Mv9kHV.css +0 -1
- package/studio/web/assets/index-Dy3uDGXb.js +0 -809
- package/studio/web/assets/index-Dy3uDGXb.js.map +0 -1
- package/studio/web/assets/index.lazy-CN1zq4I4.js +0 -14
- package/studio/web/assets/notifications-CKlYVvVN.js +0 -2
- package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
- package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
- package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
- package/studio/web/assets/setup-BisINqdH.js +0 -3
- package/studio/web/assets/status-BuQoCc7l.js +0 -62
- package/studio/web/assets/status-BuQoCc7l.js.map +0 -1
- 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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
package/components/operations.js
CHANGED
|
@@ -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
|
|
package/config/configUtils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
504
|
-
(server as any).config =
|
|
505
|
-
flatConfigObj =
|
|
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 =
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
566
|
-
if (!
|
|
567
|
-
state.originalValues
|
|
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
|
-
|
|
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
|
|
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 (!(
|
|
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 (
|
|
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
|
}
|