@harperfast/harper 5.2.0-beta.1 → 5.2.0-beta.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/cliOperations.ts +76 -12
- package/bin/run.ts +10 -0
- package/bin/status.ts +1 -1
- package/components/Application.ts +146 -83
- package/components/Scope.ts +4 -0
- package/components/componentLoader.ts +7 -0
- package/components/operations.js +21 -1
- package/config/configUtils.ts +139 -5
- package/config-app.schema.json +70 -0
- package/dist/bin/cliOperations.js +76 -12
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/run.js +9 -0
- package/dist/bin/run.js.map +1 -1
- package/dist/bin/status.js +1 -1
- package/dist/bin/status.js.map +1 -1
- package/dist/components/Application.d.ts +12 -5
- package/dist/components/Application.js +121 -60
- package/dist/components/Application.js.map +1 -1
- package/dist/components/Scope.d.ts +1 -0
- package/dist/components/Scope.js +4 -0
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +7 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/operations.js +20 -1
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +31 -0
- package/dist/config/configUtils.js +127 -5
- package/dist/config/configUtils.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +12 -0
- package/dist/resources/DatabaseTransaction.js +97 -0
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/RequestTarget.js +13 -3
- package/dist/resources/RequestTarget.js.map +1 -1
- package/dist/resources/Resource.js +16 -0
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.js +35 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/metadata.d.ts +3 -0
- package/dist/resources/analytics/metadata.js +3 -0
- package/dist/resources/analytics/metadata.js.map +1 -1
- package/dist/resources/analytics/write.js +22 -0
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/defineResource.js +20 -7
- package/dist/resources/defineResource.js.map +1 -1
- package/dist/resources/jsResource.d.ts +24 -0
- package/dist/resources/jsResource.js +58 -2
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/openApi.js +45 -20
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/scheduler/CronExpression.d.ts +71 -0
- package/dist/resources/scheduler/CronExpression.js +367 -0
- package/dist/resources/scheduler/CronExpression.js.map +1 -0
- package/dist/resources/scheduler/engine.d.ts +91 -0
- package/dist/resources/scheduler/engine.js +767 -0
- package/dist/resources/scheduler/engine.js.map +1 -0
- package/dist/resources/scheduler/scheduler.d.ts +33 -0
- package/dist/resources/scheduler/scheduler.js +200 -0
- package/dist/resources/scheduler/scheduler.js.map +1 -0
- package/dist/security/auth.js +1 -0
- package/dist/security/auth.js.map +1 -1
- package/dist/security/jsLoader.js +8 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/keys.d.ts +32 -0
- package/dist/security/keys.js +147 -0
- package/dist/security/keys.js.map +1 -1
- package/dist/server/REST.js +67 -1
- package/dist/server/REST.js.map +1 -1
- package/dist/server/Server.d.ts +6 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +139 -14
- package/dist/server/http.js.map +1 -1
- package/dist/server/operationsServer.js +3 -3
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/progressEmitter.js +5 -1
- package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
- package/dist/server/threads/threadServer.js +9 -5
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/common_utils.js +25 -0
- package/dist/utility/common_utils.js.map +1 -1
- package/dist/utility/install/installer.d.ts +9 -1
- package/dist/utility/install/installer.js +21 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/validation/configValidator.js +3 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/npm-shrinkwrap.json +272 -230
- package/package.json +3 -3
- package/resources/DESIGN.md +1 -1
- package/resources/DatabaseTransaction.ts +95 -0
- package/resources/RequestTarget.ts +12 -3
- package/resources/Resource.ts +18 -0
- package/resources/Table.ts +34 -3
- package/resources/analytics/metadata.ts +3 -0
- package/resources/analytics/write.ts +23 -0
- package/resources/defineResource.ts +17 -4
- package/resources/jsResource.ts +61 -2
- package/resources/openApi.ts +44 -19
- package/resources/scheduler/CronExpression.ts +394 -0
- package/resources/scheduler/engine.ts +812 -0
- package/resources/scheduler/scheduler.ts +236 -0
- package/security/auth.ts +1 -0
- package/security/jsLoader.ts +8 -0
- package/security/keys.ts +152 -0
- package/server/REST.ts +70 -1
- package/server/Server.ts +6 -0
- package/server/http.ts +122 -15
- package/server/operationsServer.ts +5 -3
- package/server/serverHelpers/progressEmitter.ts +5 -1
- package/server/threads/threadServer.js +9 -5
- package/studio/web/assets/{Chat-BZks8dVF.js → Chat-DHP4XpID.js} +2 -2
- package/studio/web/assets/{Chat-BZks8dVF.js.map → Chat-DHP4XpID.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-Dic8paVO.js → FloatingChat-CJ7PssCv.js} +4 -4
- package/studio/web/assets/{FloatingChat-Dic8paVO.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
- package/studio/web/assets/{applications-uOXkeUIN.js → applications-DxXiGpsR.js} +2 -2
- package/studio/web/assets/{applications-uOXkeUIN.js.map → applications-DxXiGpsR.js.map} +1 -1
- package/studio/web/assets/{index-i-2wrKhv.js → index-BdbBanDP.js} +6 -6
- package/studio/web/assets/{index-i-2wrKhv.js.map → index-BdbBanDP.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Csk8eCoB.js → index.lazy-B2eH28zD.js} +4 -4
- package/studio/web/assets/{index.lazy-Csk8eCoB.js.map → index.lazy-B2eH28zD.js.map} +1 -1
- package/studio/web/assets/{profile-Sb3mGDl6.js → profile-DK5hgucv.js} +2 -2
- package/studio/web/assets/{profile-Sb3mGDl6.js.map → profile-DK5hgucv.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-BgZcaPJ2.js → setComponentFile-BVDWRYxx.js} +2 -2
- package/studio/web/assets/{setComponentFile-BgZcaPJ2.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
- package/studio/web/assets/{setup-DKtlLgmT.js → setup-DJ9BInoK.js} +2 -2
- package/studio/web/assets/{setup-DKtlLgmT.js.map → setup-DJ9BInoK.js.map} +1 -1
- package/studio/web/assets/{status-B45iLeug.js → status-B_qzmgfD.js} +2 -2
- package/studio/web/assets/{status-B45iLeug.js.map → status-B_qzmgfD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-Csu4026e.js → swagger-ui-react-DOL5jCqg.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-Csu4026e.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
- package/studio/web/assets/{tsMode-DVgxUr_l.js → tsMode-DpxUxfTW.js} +2 -2
- package/studio/web/assets/{tsMode-DVgxUr_l.js.map → tsMode-DpxUxfTW.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js → useEntityRestURL-CU_lY6XW.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/common_utils.ts +26 -0
- package/utility/install/installer.ts +26 -1
- package/validation/configValidator.ts +3 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harperfast/harper",
|
|
3
3
|
"description": "Harper is an open-source Node.js performance platform that unifies database, cache, application, and messaging layers into one in-memory process.",
|
|
4
|
-
"version": "5.2.0-beta.
|
|
4
|
+
"version": "5.2.0-beta.2",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://harper.fast",
|
|
7
7
|
"bugs": {
|
|
@@ -167,14 +167,14 @@
|
|
|
167
167
|
},
|
|
168
168
|
"dependencies": {
|
|
169
169
|
"@aws-sdk/client-s3": "^3.1012.0",
|
|
170
|
-
"@aws-sdk/lib-storage": "3.
|
|
170
|
+
"@aws-sdk/lib-storage": "3.1086.0",
|
|
171
171
|
"@endo/static-module-record": "^1.1.2",
|
|
172
172
|
"@fastify/autoload": "^6.3.1",
|
|
173
173
|
"@fastify/compress": "^8.3.1",
|
|
174
174
|
"@fastify/cors": "^11.2.0",
|
|
175
175
|
"@fastify/static": "^9.1.3",
|
|
176
176
|
"@harperfast/extended-iterable": "^1.0.1",
|
|
177
|
-
"@harperfast/rocksdb-js": "^2.
|
|
177
|
+
"@harperfast/rocksdb-js": "^2.5.0",
|
|
178
178
|
"@harperfast/skills": "^1.10.8",
|
|
179
179
|
"@turf/area": "6.5.0",
|
|
180
180
|
"@turf/boolean-contains": "6.5.0",
|
package/resources/DESIGN.md
CHANGED
|
@@ -85,7 +85,7 @@ One giant `makeTable()` factory that returns a `TableResource extends Resource`
|
|
|
85
85
|
| How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`) |
|
|
86
86
|
| How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`) |
|
|
87
87
|
| How is the response body shaped (select clause)? | `Table.ts → transformEntryForSelect` (`#section: search-query`) |
|
|
88
|
-
| Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`)
|
|
88
|
+
| Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`). Stored expiry metadata is resolved in the `_writeUpdate` commit closure: `options.expiresAt ?? context.expiresAt ?? (record @expiresAt field, if finite & ≥ 0) ?? table default`. This metadata drives read-hiding + the cleanup sweep. The `@expiresAt` attribute is authoritative for **direct** put/patch only; cache/source fills persist via `recordUpdater` and derive expiry from `sourceContext.expiresAt` (source freshness / table default), not the field. |
|
|
89
89
|
| Why does `search()` hide a row that's past its TTL but not yet swept? | `Table.ts → transformEntryForSelect` unconditionally treats `entry.expiresAt < Date.now()` as gone (lazy eviction on read) — correct for a SELECT, but a mutation locating rows to overwrite needs the opposite: pass `target.includeExpired = true` (read by the SQL engine's `runUpdate`/`runDelete` via `SqlEngineContext.includeExpiredRows`) to treat such a row as a live match, matching the leniency a direct by-id `put`/`patch` already has (they skip this check entirely, since `Resource.patch`'s static options don't request `ensureLoaded`). |
|
|
90
90
|
| How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
|
|
91
91
|
| How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
|
|
@@ -27,6 +27,80 @@ const MAX_RETRIES = 40;
|
|
|
27
27
|
// cap (see the commit rejection handler), don't grow the delay unbounded.
|
|
28
28
|
const MAX_RETRY_DELAY_MS = 1000;
|
|
29
29
|
let outstandingCommit, outstandingCommitStart;
|
|
30
|
+
|
|
31
|
+
// The analytics module registers a recorder here at load (dependency inversion, mirroring
|
|
32
|
+
// `replicationConfirmation` below) so the storage layer doesn't statically import the analytics/server
|
|
33
|
+
// modules. Unset until analytics loads, and when analytics is disabled the recorder call is cheap.
|
|
34
|
+
let recordCommitLatencyMs: ((durationMs: number) => void) | undefined;
|
|
35
|
+
export function setCommitLatencyRecorder(recorder: ((durationMs: number) => void) | undefined) {
|
|
36
|
+
recordCommitLatencyMs = recorder;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Emit the submit→settle duration of a write commit as the `transaction-commit-time` distribution
|
|
40
|
+
// metric. Recorded on both fulfilment and rejection since a slow-then-failed commit still consumed
|
|
41
|
+
// queue time. The recorder is wrapped so it can never throw — a metrics failure must neither break the
|
|
42
|
+
// commit nor surface as an unhandled rejection on this floating `.then`. The thenable guard protects
|
|
43
|
+
// against a future caller passing a non-Promise `commitResolution` (today it is always the rocksdb-js
|
|
44
|
+
// async `Transaction.commit()` result, which is guaranteed to be a Promise).
|
|
45
|
+
function recordCommitLatency(commitResolution: Promise<void>, submittedAt: number) {
|
|
46
|
+
if (!recordCommitLatencyMs) return;
|
|
47
|
+
const record = () => {
|
|
48
|
+
try {
|
|
49
|
+
recordCommitLatencyMs(performance.now() - submittedAt);
|
|
50
|
+
} catch {
|
|
51
|
+
// analytics recording is best-effort and must never disturb the commit path
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
if (commitResolution && typeof (commitResolution as any).then === 'function') {
|
|
55
|
+
commitResolution.then(record, record);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Queue-depth gauges surfaced through the analytics pipeline (write-transaction-queue-depth /
|
|
60
|
+
// read-transaction-queue-depth). Per-thread state; the analytics aggregator sums across threads.
|
|
61
|
+
// `writeTxnQueueDepth` counts write commits handed to the storage engine but not yet resolved —
|
|
62
|
+
// this is the backlog that, when it drains too slowly, produces the "Outstanding write transactions
|
|
63
|
+
// have too long of queue" overload error. Read depth is derived from the live `trackedTxns` set
|
|
64
|
+
// (every tracked transaction holds an open read snapshot). We also retain a high-water mark per
|
|
65
|
+
// sampling window because the queue can fill and drain within a single (~1s) analytics period, so an
|
|
66
|
+
// instantaneous sample taken at emit time would routinely miss the spike operators need to see.
|
|
67
|
+
// RocksDB-write-path only: LMDB routes through the separate LMDBTransaction.commit()/getReadTxn()
|
|
68
|
+
// overrides (resources/LMDBTransaction.ts), which maintain their own unrelated `trackedTxns` set and
|
|
69
|
+
// do not call into this accounting.
|
|
70
|
+
let writeTxnQueueDepth = 0;
|
|
71
|
+
let writeTxnQueueDepthHighWater = 0;
|
|
72
|
+
let readTxnQueueDepthHighWater = 0;
|
|
73
|
+
|
|
74
|
+
function enterWriteQueue() {
|
|
75
|
+
if (++writeTxnQueueDepth > writeTxnQueueDepthHighWater) writeTxnQueueDepthHighWater = writeTxnQueueDepth;
|
|
76
|
+
}
|
|
77
|
+
function leaveWriteQueue() {
|
|
78
|
+
// Floor at zero: accounting is balanced by construction (every enterWriteQueue has exactly one
|
|
79
|
+
// matching settlement), but the guard is cheap insurance against a future call-site imbalance
|
|
80
|
+
// producing a negative depth that would corrupt every subsequent sample.
|
|
81
|
+
if (writeTxnQueueDepth > 0) writeTxnQueueDepth--;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the current write/read transaction queue depths for this thread along with the high-water
|
|
86
|
+
* mark observed since the previous call, then resets the high-water marks to the current depth so the
|
|
87
|
+
* next sampling window starts fresh. Consumed by the analytics writer (see analytics/write.ts).
|
|
88
|
+
*/
|
|
89
|
+
export function getTransactionQueueDepths() {
|
|
90
|
+
// `readTxnQueueDepthHighWater` is maintained at the single trackedTxns growth site, so it already
|
|
91
|
+
// dominates the current size here — no need to reconcile against `readDepth` before reporting.
|
|
92
|
+
const readDepth = trackedTxns.size;
|
|
93
|
+
const depths = {
|
|
94
|
+
writeDepth: writeTxnQueueDepth,
|
|
95
|
+
writeMaxDepth: writeTxnQueueDepthHighWater,
|
|
96
|
+
readDepth,
|
|
97
|
+
readMaxDepth: readTxnQueueDepthHighWater,
|
|
98
|
+
};
|
|
99
|
+
writeTxnQueueDepthHighWater = writeTxnQueueDepth;
|
|
100
|
+
readTxnQueueDepthHighWater = readDepth;
|
|
101
|
+
return depths;
|
|
102
|
+
}
|
|
103
|
+
|
|
30
104
|
let confirmReplication;
|
|
31
105
|
export function replicationConfirmation(callback) {
|
|
32
106
|
confirmReplication = callback;
|
|
@@ -161,6 +235,7 @@ export class DatabaseTransaction implements Transaction {
|
|
|
161
235
|
}
|
|
162
236
|
if ((this.transaction as any).openTimer) (this.transaction as any).openTimer = 0;
|
|
163
237
|
trackedTxns.add(this);
|
|
238
|
+
if (trackedTxns.size > readTxnQueueDepthHighWater) readTxnQueueDepthHighWater = trackedTxns.size;
|
|
164
239
|
return this.transaction;
|
|
165
240
|
}
|
|
166
241
|
|
|
@@ -329,6 +404,26 @@ export class DatabaseTransaction implements Transaction {
|
|
|
329
404
|
// cast to Promise<void> is safe — the sentinel never propagates
|
|
330
405
|
// past that branch.
|
|
331
406
|
commitResolution = transaction.commit() as Promise<void>;
|
|
407
|
+
// Record how long this commit stays outstanding (submit → settle) as a distribution
|
|
408
|
+
// metric. This is the same clock the overload check uses (outstandingCommitStart is
|
|
409
|
+
// stamped at submit), so a rising p99/p999 is the leading indicator for the
|
|
410
|
+
// "Outstanding write transactions have too long of queue" (503) rejection. A transient-
|
|
411
|
+
// conflict retry rejects this promise and issues a fresh commit(), and outstandingCommit
|
|
412
|
+
// re-arms per attempt, so recording per attempt matches the overload semantics.
|
|
413
|
+
recordCommitLatency(commitResolution, performance.now());
|
|
414
|
+
// Count this commit against the write queue depth until the storage engine
|
|
415
|
+
// resolves it. A transient-conflict retry rejects this promise and issues a
|
|
416
|
+
// fresh commit() (re-entering here), so the enter/leave stays balanced. leaveWriteQueue
|
|
417
|
+
// never throws, so the settled promise resolves and needs no rejection handling of its own.
|
|
418
|
+
// The thenable guard protects against a future caller passing a non-Promise
|
|
419
|
+
// `commitResolution` (today it is always rocksdb-js's async Transaction.commit()
|
|
420
|
+
// result, guaranteed to be a Promise).
|
|
421
|
+
enterWriteQueue();
|
|
422
|
+
if (commitResolution && typeof (commitResolution as any).then === 'function') {
|
|
423
|
+
commitResolution.then(leaveWriteQueue, leaveWriteQueue);
|
|
424
|
+
} else {
|
|
425
|
+
leaveWriteQueue();
|
|
426
|
+
}
|
|
332
427
|
} else {
|
|
333
428
|
try {
|
|
334
429
|
commitResolution = transaction.abort();
|
|
@@ -111,17 +111,26 @@ export class RequestTarget extends URLSearchParams {
|
|
|
111
111
|
if (path) {
|
|
112
112
|
// parse for properties and set the id
|
|
113
113
|
if (path.startsWith('/')) path = path.substring(1);
|
|
114
|
-
} else {
|
|
115
|
-
return; // leave
|
|
114
|
+
} else if (target === undefined) {
|
|
115
|
+
return; // constructed with no target at all (internal use) — leave id/isCollection unset
|
|
116
116
|
}
|
|
117
117
|
if (path) {
|
|
118
118
|
if (path.endsWith('/')) {
|
|
119
119
|
this.isCollection = true;
|
|
120
120
|
}
|
|
121
121
|
this.id = decodeURIComponent(path);
|
|
122
|
-
} else {
|
|
122
|
+
} else if (this.pathname === '/') {
|
|
123
|
+
// a bare trailing slash is the documented way to address a resource's collection
|
|
123
124
|
this.isCollection = true;
|
|
124
125
|
this.id = null;
|
|
126
|
+
} else {
|
|
127
|
+
// an exact resource-path match with nothing left to parse and no trailing slash
|
|
128
|
+
// (e.g. `/redirects` instead of the required `/redirects/`, harper#678): this is
|
|
129
|
+
// neither a valid collection request nor a specific record, so id/isCollection must
|
|
130
|
+
// still be well-defined (never left `undefined`) for dispatch to reject it cleanly
|
|
131
|
+
// instead of letting downstream code assume one of them is always set.
|
|
132
|
+
this.isCollection = false;
|
|
133
|
+
this.id = null;
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
toString() {
|
package/resources/Resource.ts
CHANGED
|
@@ -704,6 +704,24 @@ function transactional(
|
|
|
704
704
|
query.id = id;
|
|
705
705
|
}
|
|
706
706
|
isCollection = query.isCollection;
|
|
707
|
+
if (
|
|
708
|
+
options.method === 'post' &&
|
|
709
|
+
query.id === null &&
|
|
710
|
+
!isCollection &&
|
|
711
|
+
this.prototype.post === Resource.prototype.post
|
|
712
|
+
) {
|
|
713
|
+
// the matched path had nothing left to resolve into a collection or a specific record —
|
|
714
|
+
// i.e. it exactly matched a resource's base path without the required trailing slash
|
|
715
|
+
// (harper#678). This only matters for the base/default post() dispatch: it reads
|
|
716
|
+
// this.#isCollection (set from this same query) and falls back to missingMethod() for
|
|
717
|
+
// this state anyway, so rejecting early here just gives a clearer, purpose-built message.
|
|
718
|
+
// A resource with its own post() override (e.g. a component doing a bulk import via
|
|
719
|
+
// POST to its collection root, like the redirector template's Redirect.post()) is
|
|
720
|
+
// trusted to handle a null-id/non-collection target itself — it may not use id/isCollection
|
|
721
|
+
// at all, and forcing the trailing slash on it would break a currently-supported no-slash
|
|
722
|
+
// bulk-POST convention. See harper#678's regression on PR #1807.
|
|
723
|
+
throw new ClientError(`A trailing slash is required to POST to the ${this.name} collection`, 404);
|
|
724
|
+
}
|
|
707
725
|
let resourceOptions;
|
|
708
726
|
if (!context) {
|
|
709
727
|
// try to get the context from the async context if possible
|
package/resources/Table.ts
CHANGED
|
@@ -1244,7 +1244,7 @@ export function makeTable(options) {
|
|
|
1244
1244
|
// go back to the static search method so it gets a chance to override
|
|
1245
1245
|
return constructor.search(target, this.getContext());
|
|
1246
1246
|
}
|
|
1247
|
-
if (target && target.id
|
|
1247
|
+
if (target && target.id == null && !target.toString()) {
|
|
1248
1248
|
const description = {
|
|
1249
1249
|
// basically a describe call
|
|
1250
1250
|
records: './', // an href to the records themselves
|
|
@@ -2081,8 +2081,10 @@ export function makeTable(options) {
|
|
|
2081
2081
|
const type = fullUpdate ? 'put' : 'patch';
|
|
2082
2082
|
let residencyId: number | undefined;
|
|
2083
2083
|
if (options?.residencyId != undefined) residencyId = options.residencyId;
|
|
2084
|
-
|
|
2085
|
-
|
|
2084
|
+
// options/context expiresAt are the most specific overrides; a record @expiresAt field
|
|
2085
|
+
// (resolved below, once recordToStore is merged) overrides the table default in both
|
|
2086
|
+
// directions; the table default is the final fallback. -1 means no expiration.
|
|
2087
|
+
let expiresAt: number | undefined = options?.expiresAt ?? context?.expiresAt;
|
|
2086
2088
|
const additionalAuditRefs: Array<{ version: number; nodeId: number }> = []; // track additional audit refs to store
|
|
2087
2089
|
// Bulk base-copy snapshot apply: store current-state directly with no audit/transaction-log entry
|
|
2088
2090
|
// and no out-of-order resequencing/dedup (the source of the O(n) keyed-lookup spin in
|
|
@@ -2499,6 +2501,35 @@ export function makeTable(options) {
|
|
|
2499
2501
|
}
|
|
2500
2502
|
residencyId = getResidencyId(residency);
|
|
2501
2503
|
}
|
|
2504
|
+
if (expiresAt == undefined) {
|
|
2505
|
+
// A schema @expiresAt attribute makes the record field authoritative over the table
|
|
2506
|
+
// default, in both directions: stamp it into the stored expiry metadata that governs
|
|
2507
|
+
// read-hiding and the cleanup sweep, not just the separate index-pruning sweep (which
|
|
2508
|
+
// only removes already-past records and so can never extend past the table default).
|
|
2509
|
+
// Read from recordToStore so the metadata matches exactly what the pruning sweep later
|
|
2510
|
+
// reads back. Falls back to the table default when the field is unset or not a timestamp.
|
|
2511
|
+
const fieldExpiresAt = expiresAtProperty ? recordToStore?.[expiresAtProperty.name] : undefined;
|
|
2512
|
+
// Coerce only genuine timestamp shapes: a number/bigint epoch, a Date, or a numeric/ISO
|
|
2513
|
+
// string. Booleans, empty/whitespace strings, and null/undefined fall through to NaN so a
|
|
2514
|
+
// nonsensical field value uses the table default rather than expiring the record at epoch 0.
|
|
2515
|
+
let fieldExpiresAtMs = NaN;
|
|
2516
|
+
if (typeof fieldExpiresAt === 'number' || typeof fieldExpiresAt === 'bigint')
|
|
2517
|
+
fieldExpiresAtMs = Number(fieldExpiresAt);
|
|
2518
|
+
else if (fieldExpiresAt instanceof Date) fieldExpiresAtMs = fieldExpiresAt.getTime();
|
|
2519
|
+
else if (typeof fieldExpiresAt === 'string' && fieldExpiresAt.trim() !== '') {
|
|
2520
|
+
const numeric = Number(fieldExpiresAt);
|
|
2521
|
+
fieldExpiresAtMs = Number.isFinite(numeric) ? numeric : Date.parse(fieldExpiresAt);
|
|
2522
|
+
}
|
|
2523
|
+
// Only a finite, non-negative epoch counts: negatives collide with the -1 "no expiration"
|
|
2524
|
+
// sentinel (the encoder omits HAS_EXPIRATION for <0, but the field sweep would still evict a
|
|
2525
|
+
// negative field value), so treat a negative/NaN field as unset and use the table default.
|
|
2526
|
+
expiresAt =
|
|
2527
|
+
Number.isFinite(fieldExpiresAtMs) && fieldExpiresAtMs >= 0
|
|
2528
|
+
? fieldExpiresAtMs
|
|
2529
|
+
: expirationMs
|
|
2530
|
+
? expirationMs + Date.now()
|
|
2531
|
+
: -1;
|
|
2532
|
+
}
|
|
2502
2533
|
if (!fullUpdate) {
|
|
2503
2534
|
// we use our own data as the basis for the audit record, which will include information about the incremental updates, even if it was overwritten by CRDT resolution
|
|
2504
2535
|
auditRecordToStore = recordUpdate;
|
|
@@ -8,6 +8,9 @@ export const METRIC = {
|
|
|
8
8
|
NODE_STORAGE: 'node-storage',
|
|
9
9
|
ROCKSDB_STATS: 'rocksdb-stats',
|
|
10
10
|
ROCKSDB_TXNLOG_STATS: 'rocksdb-txnlog-stats',
|
|
11
|
+
TRANSACTION_COMMIT_TIME: 'transaction-commit-time',
|
|
12
|
+
WRITE_TRANSACTION_QUEUE_DEPTH: 'write-transaction-queue-depth',
|
|
13
|
+
READ_TRANSACTION_QUEUE_DEPTH: 'read-transaction-queue-depth',
|
|
11
14
|
} as const;
|
|
12
15
|
|
|
13
16
|
export type BuiltInMetricName = (typeof METRIC)[keyof typeof METRIC];
|
|
@@ -14,6 +14,7 @@ import { server } from '../../server/Server.ts';
|
|
|
14
14
|
import * as fs from 'node:fs';
|
|
15
15
|
import { getAnalyticsHostnameTable, nodeIds, stableNodeId } from './hostnames.ts';
|
|
16
16
|
import { METRIC } from './metadata.ts';
|
|
17
|
+
import { getTransactionQueueDepths, setCommitLatencyRecorder } from '../DatabaseTransaction.ts';
|
|
17
18
|
import { RocksDatabase, type TransactionLogStats } from '@harperfast/rocksdb-js';
|
|
18
19
|
|
|
19
20
|
const log = forComponent('analytics').conditional;
|
|
@@ -130,6 +131,9 @@ export function recordAction(value: Value, metric: string, path?: string, method
|
|
|
130
131
|
|
|
131
132
|
server.recordAnalytics = recordAction;
|
|
132
133
|
|
|
134
|
+
// Let the storage layer emit write-commit latency without statically depending on this module.
|
|
135
|
+
setCommitLatencyRecorder((durationMs) => recordAction(durationMs, METRIC.TRANSACTION_COMMIT_TIME));
|
|
136
|
+
|
|
133
137
|
export function recordActionBinary(value, metric, path?, method?, type?) {
|
|
134
138
|
recordAction(Boolean(value), metric, path, method, type);
|
|
135
139
|
}
|
|
@@ -213,6 +217,25 @@ function sendAnalytics() {
|
|
|
213
217
|
byThread: true,
|
|
214
218
|
...memoryUsage,
|
|
215
219
|
});
|
|
220
|
+
// Transaction queue depth gauges. `depth` is the instantaneous depth at emit time; `maxDepth` is
|
|
221
|
+
// the high-water mark over this sampling window (the queue can fill and drain within a single
|
|
222
|
+
// period, so the instantaneous sample alone would miss short spikes). Reported per-thread and
|
|
223
|
+
// summed across threads by the aggregator, mirroring the `memory` gauge above.
|
|
224
|
+
const queueDepths = getTransactionQueueDepths();
|
|
225
|
+
metrics.push({
|
|
226
|
+
metric: METRIC.WRITE_TRANSACTION_QUEUE_DEPTH,
|
|
227
|
+
threadId,
|
|
228
|
+
byThread: true,
|
|
229
|
+
depth: queueDepths.writeDepth,
|
|
230
|
+
maxDepth: queueDepths.writeMaxDepth,
|
|
231
|
+
});
|
|
232
|
+
metrics.push({
|
|
233
|
+
metric: METRIC.READ_TRANSACTION_QUEUE_DEPTH,
|
|
234
|
+
threadId,
|
|
235
|
+
byThread: true,
|
|
236
|
+
depth: queueDepths.readDepth,
|
|
237
|
+
maxDepth: queueDepths.readMaxDepth,
|
|
238
|
+
});
|
|
216
239
|
for (const listener of analyticsListeners) {
|
|
217
240
|
listener(metrics);
|
|
218
241
|
}
|
|
@@ -550,12 +550,25 @@ function applyContractMetadata(carrier: any, contract: Contract): void {
|
|
|
550
550
|
*/
|
|
551
551
|
function wrapStaticVerb(original: Function, compiled: CompiledVerb): Function {
|
|
552
552
|
const { queryFragment, bodyFragment, hasBody } = compiled;
|
|
553
|
+
function validateAndDispatch(this: any, target: any, rest: any[], body: any) {
|
|
554
|
+
const issues: ValidationIssue[] = [];
|
|
555
|
+
if (queryFragment) coerceAndValidateQuery(queryFragment, target, issues);
|
|
556
|
+
if (hasBody && bodyFragment) rest[0] = validateBody(bodyFragment, body, issues);
|
|
557
|
+
if (issues.length) throw new ValidationError(issues);
|
|
558
|
+
return original.call(this, target, ...rest);
|
|
559
|
+
}
|
|
553
560
|
return function (this: any, target: any, ...rest: any[]) {
|
|
554
561
|
if (target instanceof URLSearchParams) {
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
562
|
+
const body = rest[0];
|
|
563
|
+
// REST.ts's streaming deserializer hands the body over as a still-pending promise
|
|
564
|
+
// (`request.data`); await it before validating, or it reads as an empty object and every
|
|
565
|
+
// declared-required field is (incorrectly) reported missing. A plain value (as passed by a
|
|
566
|
+
// programmatic call or a unit test) validates synchronously, preserving the synchronous-throw
|
|
567
|
+
// contract those callers rely on.
|
|
568
|
+
if (hasBody && bodyFragment && body && typeof body.then === 'function') {
|
|
569
|
+
return body.then((resolvedBody: any) => validateAndDispatch.call(this, target, rest, resolvedBody));
|
|
570
|
+
}
|
|
571
|
+
return validateAndDispatch.call(this, target, rest, body);
|
|
559
572
|
}
|
|
560
573
|
return original.call(this, target, ...rest);
|
|
561
574
|
};
|
package/resources/jsResource.ts
CHANGED
|
@@ -71,9 +71,39 @@ export class ResourceLoadError extends Error {
|
|
|
71
71
|
*
|
|
72
72
|
* Thus, this plugin only handle files as they are added (`add` event). All other events result in a restart request.
|
|
73
73
|
*
|
|
74
|
+
* A redeploy tears down and reinstalls the component's files while this scope's watcher is paused
|
|
75
|
+
* (see `Scope`/`EntryHandler` deploy lifecycle); on resume the fresh chokidar scan re-emits every
|
|
76
|
+
* existing file as `'add'` — including ones whose contents just changed. Treating those as plain
|
|
77
|
+
* adds would silently re-run against the stale module cache and never flag a restart (harper#1817).
|
|
78
|
+
* So we track which files this scope has already loaded: a re-`add` of a known file is a redeploy of
|
|
79
|
+
* loaded code we cannot hot-swap, and is handled like a `change` — request a restart. A first-time
|
|
80
|
+
* `add` (initial load, or a genuinely new file added at runtime) still loads without a restart.
|
|
81
|
+
*
|
|
82
|
+
* A redeploy that *deletes* a loaded file is a different shape of the same problem: the fresh
|
|
83
|
+
* chokidar scan only reports what's currently on disk, so a file that's gone produces no event at
|
|
84
|
+
* all — no re-`add`, no `unlink` — and the modified-file handling above never sees it. Left
|
|
85
|
+
* unhandled, the deleted resource stays registered and active in memory (harper#1817 follow-up). So
|
|
86
|
+
* we also track which files the post-redeploy scan pass reports, and once that scan's `ready` fires,
|
|
87
|
+
* diff it against everything this scope has ever loaded: anything missing was deleted, and is
|
|
88
|
+
* handled the same way as a modified file — request a restart.
|
|
89
|
+
*
|
|
90
|
+
* That diff must only run for an actual redeploy rescan, not every time `EntryHandler` emits
|
|
91
|
+
* `ready` — it also refires after each ordinary runtime add/change once that file's read settles
|
|
92
|
+
* (its initial-scan-complete latch never resets outside a full rescan), and diffing against that
|
|
93
|
+
* would falsely treat every other already-loaded file as deleted. So the diff window is gated by
|
|
94
|
+
* the scope's own `deploy:start`/`deploy:end` bracket (see `Scope`): `deploy:start` pauses the
|
|
95
|
+
* watcher and opens the window (and is where we reset the scan-file tracking, since no file events
|
|
96
|
+
* can land while paused), and the first `ready` afterward — the resumed watcher's fresh scan
|
|
97
|
+
* completing — closes it and runs the diff.
|
|
74
98
|
*/
|
|
75
99
|
export async function handleApplication(scope: Scope) {
|
|
76
|
-
|
|
100
|
+
const loadedResourceFiles = new Set<string>();
|
|
101
|
+
// Files reported as `add` since the most recent `deploy:start`, populated only while
|
|
102
|
+
// `awaitingPostRedeployScan` is true — see the gating note above.
|
|
103
|
+
let currentScanFiles = new Set<string>();
|
|
104
|
+
let awaitingPostRedeployScan = false;
|
|
105
|
+
|
|
106
|
+
const entryHandler = scope.handleEntry(async function handleResourceEntry(entryEvent) {
|
|
77
107
|
if (entryEvent.entryType !== 'file') {
|
|
78
108
|
scope.logger.warn(
|
|
79
109
|
`jsResource plugin cannot handle entry type ${entryEvent.entryType}. Modify the 'files' option in ${scope.configFilePath} to only include files.`
|
|
@@ -81,7 +111,13 @@ export async function handleApplication(scope: Scope) {
|
|
|
81
111
|
return;
|
|
82
112
|
}
|
|
83
113
|
|
|
84
|
-
if (entryEvent.eventType
|
|
114
|
+
if (awaitingPostRedeployScan && entryEvent.eventType === 'add') {
|
|
115
|
+
// Recorded unconditionally — before the loaded/re-add branch below — so the post-scan
|
|
116
|
+
// deletion diff sees every file this scan reported, whether newly loaded or already known.
|
|
117
|
+
currentScanFiles.add(entryEvent.absolutePath);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (entryEvent.eventType !== 'add' || loadedResourceFiles.has(entryEvent.absolutePath)) {
|
|
85
121
|
scope.requestRestart();
|
|
86
122
|
return;
|
|
87
123
|
}
|
|
@@ -97,6 +133,9 @@ export async function handleApplication(scope: Scope) {
|
|
|
97
133
|
scope.logger.debug?.(`Registered root resource: ${path}`);
|
|
98
134
|
}
|
|
99
135
|
recurseForResources(scope, resourceModule, root);
|
|
136
|
+
// Record the load so a later re-`add` of this same file (a redeploy re-scan) is treated
|
|
137
|
+
// as a change and requests a restart rather than silently re-serving stale cached code.
|
|
138
|
+
loadedResourceFiles.add(entryEvent.absolutePath);
|
|
100
139
|
// A JS resource that extends an exported @table is the one carrying author opt-ins
|
|
101
140
|
// (`static mcpTools`/`mcpPrompts`), and it registers here — after the schema-derived
|
|
102
141
|
// table class and after the MCP component's boot scan. Signal so listing surfaces
|
|
@@ -107,6 +146,26 @@ export async function handleApplication(scope: Scope) {
|
|
|
107
146
|
throw new ResourceLoadError(entryEvent.absolutePath, error);
|
|
108
147
|
}
|
|
109
148
|
});
|
|
149
|
+
|
|
150
|
+
// Optional chaining: a mock/test scope may not implement EventEmitter, and Scope#handleEntry
|
|
151
|
+
// itself can return undefined (e.g. MissingDefaultFilesOptionError). In real use `scope` is
|
|
152
|
+
// always an EventEmitter and `entryHandler` is always the EntryHandler backing this watcher.
|
|
153
|
+
scope.on?.('deploy:start', () => {
|
|
154
|
+
awaitingPostRedeployScan = true;
|
|
155
|
+
currentScanFiles = new Set();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
entryHandler?.on?.('ready', () => {
|
|
159
|
+
if (!awaitingPostRedeployScan) return;
|
|
160
|
+
awaitingPostRedeployScan = false;
|
|
161
|
+
for (const loadedFile of loadedResourceFiles) {
|
|
162
|
+
if (!currentScanFiles.has(loadedFile)) {
|
|
163
|
+
// Known file that the just-completed scan never reported — deleted during the redeploy.
|
|
164
|
+
loadedResourceFiles.delete(loadedFile);
|
|
165
|
+
scope.requestRestart();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
110
169
|
}
|
|
111
170
|
|
|
112
171
|
function recurseForResources(scope: Scope, resourceModule: any, prefix: string) {
|
package/resources/openApi.ts
CHANGED
|
@@ -102,6 +102,10 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
|
|
|
102
102
|
// @hidden type-level: drop the Resource from the OpenAPI document entirely.
|
|
103
103
|
// Data remains queryable through Harper's other interfaces under RBAC.
|
|
104
104
|
if (resource.Resource.hidden === true) continue;
|
|
105
|
+
// request-contract resources (`defineResource`/`Resource.withSchema`) at a static (non-parameterised)
|
|
106
|
+
// path are emitted below, alongside their parameterised counterparts, off the declared contract —
|
|
107
|
+
// not the table-CRUD assumptions this loop makes (primary key, generated schema component, etc).
|
|
108
|
+
if (resource.Resource.requestContract) continue;
|
|
105
109
|
|
|
106
110
|
const { path } = resource;
|
|
107
111
|
const strippedPath = path.split('/').pop(); // strip any namespace from path
|
|
@@ -311,26 +315,15 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
|
|
|
311
315
|
}
|
|
312
316
|
}
|
|
313
317
|
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (entry
|
|
318
|
+
// Emit OpenAPI paths for a request-contract resource (`defineResource`/`Resource.withSchema`), whether
|
|
319
|
+
// declared at a static path (`/OrderIntake`) or a parameterised one (`/widget/:id`) — each verb's
|
|
320
|
+
// query/body/response is driven off the shared `inputSchemas`/`outputSchemas` the contract compiled,
|
|
321
|
+
// the same `JsonSchemaFragment` IR that drives runtime validation/coercion, so the spec matches what
|
|
322
|
+
// the server actually enforces (rather than the table-CRUD generation above).
|
|
323
|
+
const emitContractRoutes = (url: string, entry: { Resource: any }, pathParams: any[]) => {
|
|
324
|
+
if (!entry?.Resource) return;
|
|
321
325
|
const { prototype } = entry.Resource;
|
|
322
|
-
if (!prototype)
|
|
323
|
-
|
|
324
|
-
const { template, params } = routePatternToTemplate(route.segments);
|
|
325
|
-
const url = `/${template}`;
|
|
326
|
-
const pathParams = params.map((param) => {
|
|
327
|
-
const parameter = new Parameter(param.name, 'path', { type: 'string' });
|
|
328
|
-
parameter.required = true;
|
|
329
|
-
parameter.description = param.wildcard
|
|
330
|
-
? 'captures the remaining path segments'
|
|
331
|
-
: `value bound from the :${param.name} path segment`;
|
|
332
|
-
return parameter;
|
|
333
|
-
});
|
|
326
|
+
if (!prototype) return;
|
|
334
327
|
|
|
335
328
|
const tableDoc: string | undefined = entry.Resource.description;
|
|
336
329
|
const withDoc = (sentence: string) => (tableDoc ? `${tableDoc} ${sentence}` : sentence);
|
|
@@ -408,6 +401,38 @@ export function generateJsonApi(resources: Resources, serverHttpURL: string) {
|
|
|
408
401
|
}
|
|
409
402
|
);
|
|
410
403
|
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Request-contract resources declared at a static (non-parameterised) path (e.g. `defineResource({path:
|
|
407
|
+
// '/OrderIntake', ...})`) — skipped by the table-CRUD loop above, emitted here off the declared contract.
|
|
408
|
+
for (const [, entry] of resources) {
|
|
409
|
+
if (!entry) continue;
|
|
410
|
+
if (!entry.path || !entry.Resource?.requestContract || entry.Resource.isError || entry.Resource.hidden === true)
|
|
411
|
+
continue;
|
|
412
|
+
const path = entry.path.startsWith('/') ? entry.path : '/' + entry.path;
|
|
413
|
+
emitContractRoutes(path, entry, []);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Parameterised routes (e.g. `/widget/:id/action/:action`) live outside the resource Map; emit them as templated
|
|
417
|
+
// paths with `{param}` path parameters so they appear in the OpenAPI document like any other endpoint.
|
|
418
|
+
for (const route of resources.paramRoutes ?? []) {
|
|
419
|
+
const entry = route.entry;
|
|
420
|
+
if (!entry?.path || entry.Resource?.isError) continue;
|
|
421
|
+
// @hidden: drop the Resource from the OpenAPI document entirely.
|
|
422
|
+
if (entry.Resource?.hidden === true) continue;
|
|
423
|
+
|
|
424
|
+
const { template, params } = routePatternToTemplate(route.segments);
|
|
425
|
+
const url = `/${template}`;
|
|
426
|
+
const pathParams = params.map((param) => {
|
|
427
|
+
const parameter = new Parameter(param.name, 'path', { type: 'string' });
|
|
428
|
+
parameter.required = true;
|
|
429
|
+
parameter.description = param.wildcard
|
|
430
|
+
? 'captures the remaining path segments'
|
|
431
|
+
: `value bound from the :${param.name} path segment`;
|
|
432
|
+
return parameter;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
emitContractRoutes(url, entry, pathParams);
|
|
411
436
|
}
|
|
412
437
|
|
|
413
438
|
for (const [, value] of resources.allTypes) {
|