@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.
Files changed (137) hide show
  1. package/bin/cliOperations.ts +76 -12
  2. package/bin/run.ts +10 -0
  3. package/bin/status.ts +1 -1
  4. package/components/Application.ts +146 -83
  5. package/components/Scope.ts +4 -0
  6. package/components/componentLoader.ts +7 -0
  7. package/components/operations.js +21 -1
  8. package/config/configUtils.ts +139 -5
  9. package/config-app.schema.json +70 -0
  10. package/dist/bin/cliOperations.js +76 -12
  11. package/dist/bin/cliOperations.js.map +1 -1
  12. package/dist/bin/run.js +9 -0
  13. package/dist/bin/run.js.map +1 -1
  14. package/dist/bin/status.js +1 -1
  15. package/dist/bin/status.js.map +1 -1
  16. package/dist/components/Application.d.ts +12 -5
  17. package/dist/components/Application.js +121 -60
  18. package/dist/components/Application.js.map +1 -1
  19. package/dist/components/Scope.d.ts +1 -0
  20. package/dist/components/Scope.js +4 -0
  21. package/dist/components/Scope.js.map +1 -1
  22. package/dist/components/componentLoader.js +7 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/operations.js +20 -1
  25. package/dist/components/operations.js.map +1 -1
  26. package/dist/config/configUtils.d.ts +31 -0
  27. package/dist/config/configUtils.js +127 -5
  28. package/dist/config/configUtils.js.map +1 -1
  29. package/dist/resources/DatabaseTransaction.d.ts +12 -0
  30. package/dist/resources/DatabaseTransaction.js +97 -0
  31. package/dist/resources/DatabaseTransaction.js.map +1 -1
  32. package/dist/resources/RequestTarget.js +13 -3
  33. package/dist/resources/RequestTarget.js.map +1 -1
  34. package/dist/resources/Resource.js +16 -0
  35. package/dist/resources/Resource.js.map +1 -1
  36. package/dist/resources/Table.js +35 -2
  37. package/dist/resources/Table.js.map +1 -1
  38. package/dist/resources/analytics/metadata.d.ts +3 -0
  39. package/dist/resources/analytics/metadata.js +3 -0
  40. package/dist/resources/analytics/metadata.js.map +1 -1
  41. package/dist/resources/analytics/write.js +22 -0
  42. package/dist/resources/analytics/write.js.map +1 -1
  43. package/dist/resources/defineResource.js +20 -7
  44. package/dist/resources/defineResource.js.map +1 -1
  45. package/dist/resources/jsResource.d.ts +24 -0
  46. package/dist/resources/jsResource.js +58 -2
  47. package/dist/resources/jsResource.js.map +1 -1
  48. package/dist/resources/openApi.js +45 -20
  49. package/dist/resources/openApi.js.map +1 -1
  50. package/dist/resources/scheduler/CronExpression.d.ts +71 -0
  51. package/dist/resources/scheduler/CronExpression.js +367 -0
  52. package/dist/resources/scheduler/CronExpression.js.map +1 -0
  53. package/dist/resources/scheduler/engine.d.ts +91 -0
  54. package/dist/resources/scheduler/engine.js +767 -0
  55. package/dist/resources/scheduler/engine.js.map +1 -0
  56. package/dist/resources/scheduler/scheduler.d.ts +33 -0
  57. package/dist/resources/scheduler/scheduler.js +200 -0
  58. package/dist/resources/scheduler/scheduler.js.map +1 -0
  59. package/dist/security/auth.js +1 -0
  60. package/dist/security/auth.js.map +1 -1
  61. package/dist/security/jsLoader.js +8 -0
  62. package/dist/security/jsLoader.js.map +1 -1
  63. package/dist/security/keys.d.ts +32 -0
  64. package/dist/security/keys.js +147 -0
  65. package/dist/security/keys.js.map +1 -1
  66. package/dist/server/REST.js +67 -1
  67. package/dist/server/REST.js.map +1 -1
  68. package/dist/server/Server.d.ts +6 -0
  69. package/dist/server/Server.js.map +1 -1
  70. package/dist/server/http.d.ts +2 -0
  71. package/dist/server/http.js +139 -14
  72. package/dist/server/http.js.map +1 -1
  73. package/dist/server/operationsServer.js +3 -3
  74. package/dist/server/operationsServer.js.map +1 -1
  75. package/dist/server/serverHelpers/progressEmitter.js +5 -1
  76. package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
  77. package/dist/server/threads/threadServer.js +9 -5
  78. package/dist/server/threads/threadServer.js.map +1 -1
  79. package/dist/utility/common_utils.js +25 -0
  80. package/dist/utility/common_utils.js.map +1 -1
  81. package/dist/utility/install/installer.d.ts +9 -1
  82. package/dist/utility/install/installer.js +21 -0
  83. package/dist/utility/install/installer.js.map +1 -1
  84. package/dist/validation/configValidator.js +3 -0
  85. package/dist/validation/configValidator.js.map +1 -1
  86. package/npm-shrinkwrap.json +272 -230
  87. package/package.json +3 -3
  88. package/resources/DESIGN.md +1 -1
  89. package/resources/DatabaseTransaction.ts +95 -0
  90. package/resources/RequestTarget.ts +12 -3
  91. package/resources/Resource.ts +18 -0
  92. package/resources/Table.ts +34 -3
  93. package/resources/analytics/metadata.ts +3 -0
  94. package/resources/analytics/write.ts +23 -0
  95. package/resources/defineResource.ts +17 -4
  96. package/resources/jsResource.ts +61 -2
  97. package/resources/openApi.ts +44 -19
  98. package/resources/scheduler/CronExpression.ts +394 -0
  99. package/resources/scheduler/engine.ts +812 -0
  100. package/resources/scheduler/scheduler.ts +236 -0
  101. package/security/auth.ts +1 -0
  102. package/security/jsLoader.ts +8 -0
  103. package/security/keys.ts +152 -0
  104. package/server/REST.ts +70 -1
  105. package/server/Server.ts +6 -0
  106. package/server/http.ts +122 -15
  107. package/server/operationsServer.ts +5 -3
  108. package/server/serverHelpers/progressEmitter.ts +5 -1
  109. package/server/threads/threadServer.js +9 -5
  110. package/studio/web/assets/{Chat-BZks8dVF.js → Chat-DHP4XpID.js} +2 -2
  111. package/studio/web/assets/{Chat-BZks8dVF.js.map → Chat-DHP4XpID.js.map} +1 -1
  112. package/studio/web/assets/{FloatingChat-Dic8paVO.js → FloatingChat-CJ7PssCv.js} +4 -4
  113. package/studio/web/assets/{FloatingChat-Dic8paVO.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
  114. package/studio/web/assets/{applications-uOXkeUIN.js → applications-DxXiGpsR.js} +2 -2
  115. package/studio/web/assets/{applications-uOXkeUIN.js.map → applications-DxXiGpsR.js.map} +1 -1
  116. package/studio/web/assets/{index-i-2wrKhv.js → index-BdbBanDP.js} +6 -6
  117. package/studio/web/assets/{index-i-2wrKhv.js.map → index-BdbBanDP.js.map} +1 -1
  118. package/studio/web/assets/{index.lazy-Csk8eCoB.js → index.lazy-B2eH28zD.js} +4 -4
  119. package/studio/web/assets/{index.lazy-Csk8eCoB.js.map → index.lazy-B2eH28zD.js.map} +1 -1
  120. package/studio/web/assets/{profile-Sb3mGDl6.js → profile-DK5hgucv.js} +2 -2
  121. package/studio/web/assets/{profile-Sb3mGDl6.js.map → profile-DK5hgucv.js.map} +1 -1
  122. package/studio/web/assets/{setComponentFile-BgZcaPJ2.js → setComponentFile-BVDWRYxx.js} +2 -2
  123. package/studio/web/assets/{setComponentFile-BgZcaPJ2.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
  124. package/studio/web/assets/{setup-DKtlLgmT.js → setup-DJ9BInoK.js} +2 -2
  125. package/studio/web/assets/{setup-DKtlLgmT.js.map → setup-DJ9BInoK.js.map} +1 -1
  126. package/studio/web/assets/{status-B45iLeug.js → status-B_qzmgfD.js} +2 -2
  127. package/studio/web/assets/{status-B45iLeug.js.map → status-B_qzmgfD.js.map} +1 -1
  128. package/studio/web/assets/{swagger-ui-react-Csu4026e.js → swagger-ui-react-DOL5jCqg.js} +2 -2
  129. package/studio/web/assets/{swagger-ui-react-Csu4026e.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
  130. package/studio/web/assets/{tsMode-DVgxUr_l.js → tsMode-DpxUxfTW.js} +2 -2
  131. package/studio/web/assets/{tsMode-DVgxUr_l.js.map → tsMode-DpxUxfTW.js.map} +1 -1
  132. package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js → useEntityRestURL-CU_lY6XW.js} +2 -2
  133. package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
  134. package/studio/web/index.html +1 -1
  135. package/utility/common_utils.ts +26 -0
  136. package/utility/install/installer.ts +26 -1
  137. 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.1",
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.1080.0",
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.4.0",
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",
@@ -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 this.id undefined
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() {
@@ -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
@@ -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 === undefined && !target.toString()) {
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
- const expiresAt: number =
2085
- options?.expiresAt ?? context?.expiresAt ?? (expirationMs ? expirationMs + Date.now() : -1);
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 issues: ValidationIssue[] = [];
556
- if (queryFragment) coerceAndValidateQuery(queryFragment, target, issues);
557
- if (hasBody && bodyFragment) rest[0] = validateBody(bodyFragment, rest[0], issues);
558
- if (issues.length) throw new ValidationError(issues);
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
  };
@@ -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
- scope.handleEntry(async function handleResourceEntry(entryEvent) {
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 !== 'add') {
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) {
@@ -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
- // Parameterised routes (e.g. `/widget/:id/action/:action`) live outside the resource Map; emit them as templated
315
- // paths with `{param}` path parameters so they appear in the OpenAPI document like any other endpoint.
316
- for (const route of resources.paramRoutes ?? []) {
317
- const entry = route.entry;
318
- if (!entry?.path || entry.Resource?.isError) continue;
319
- // @hidden: drop the Resource from the OpenAPI document entirely.
320
- if (entry.Resource?.hidden === true) continue;
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) continue;
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) {