@harperfast/harper 5.2.0-alpha.4 → 5.2.0-alpha.6

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 (173) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dist/agent/agent.d.ts +24 -3
  28. package/dist/agent/agent.js +172 -10
  29. package/dist/agent/agent.js.map +1 -1
  30. package/dist/agent/bestPractices.d.ts +24 -0
  31. package/dist/agent/bestPractices.js +60 -0
  32. package/dist/agent/bestPractices.js.map +1 -0
  33. package/dist/agent/mcpTools.d.ts +24 -0
  34. package/dist/agent/mcpTools.js +115 -0
  35. package/dist/agent/mcpTools.js.map +1 -0
  36. package/dist/agent/operations.js +10 -1
  37. package/dist/agent/operations.js.map +1 -1
  38. package/dist/agent/registryTools.d.ts +45 -0
  39. package/dist/agent/registryTools.js +113 -0
  40. package/dist/agent/registryTools.js.map +1 -0
  41. package/dist/agent/session.js +16 -7
  42. package/dist/agent/session.js.map +1 -1
  43. package/dist/agent/tools/fsTools.js +70 -28
  44. package/dist/agent/tools/fsTools.js.map +1 -1
  45. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  46. package/dist/agent/tools/inspectorTool.js +428 -0
  47. package/dist/agent/tools/inspectorTool.js.map +1 -0
  48. package/dist/agent/toolset.d.ts +16 -6
  49. package/dist/agent/toolset.js +17 -7
  50. package/dist/agent/toolset.js.map +1 -1
  51. package/dist/agent/types.d.ts +2 -0
  52. package/dist/components/Application.d.ts +73 -18
  53. package/dist/components/Application.js +475 -64
  54. package/dist/components/Application.js.map +1 -1
  55. package/dist/components/OptionsWatcher.d.ts +1 -1
  56. package/dist/components/OptionsWatcher.js +65 -2
  57. package/dist/components/OptionsWatcher.js.map +1 -1
  58. package/dist/components/Scope.d.ts +8 -6
  59. package/dist/components/Scope.js +22 -6
  60. package/dist/components/Scope.js.map +1 -1
  61. package/dist/components/componentLoader.js +5 -1
  62. package/dist/components/componentLoader.js.map +1 -1
  63. package/dist/components/componentSecrets.d.ts +33 -7
  64. package/dist/components/componentSecrets.js +473 -59
  65. package/dist/components/componentSecrets.js.map +1 -1
  66. package/dist/components/deploymentRecorder.d.ts +2 -2
  67. package/dist/components/deploymentRecorder.js +1 -1
  68. package/dist/components/deploymentRecorder.js.map +1 -1
  69. package/dist/components/gitCredentialHelper.d.ts +1 -0
  70. package/dist/components/gitCredentialHelper.js +113 -0
  71. package/dist/components/gitCredentialHelper.js.map +1 -0
  72. package/dist/components/gitCredentialServer.d.ts +33 -0
  73. package/dist/components/gitCredentialServer.js +236 -0
  74. package/dist/components/gitCredentialServer.js.map +1 -0
  75. package/dist/components/mcp/tools/application.d.ts +19 -0
  76. package/dist/components/mcp/tools/application.js +180 -22
  77. package/dist/components/mcp/tools/application.js.map +1 -1
  78. package/dist/components/mcp/tools/operations.d.ts +13 -0
  79. package/dist/components/mcp/tools/operations.js +1 -0
  80. package/dist/components/mcp/tools/operations.js.map +1 -1
  81. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  82. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  83. package/dist/components/operations.js +23 -21
  84. package/dist/components/operations.js.map +1 -1
  85. package/dist/components/operationsValidation.js +84 -21
  86. package/dist/components/operationsValidation.js.map +1 -1
  87. package/dist/components/secretOperations.d.ts +33 -11
  88. package/dist/components/secretOperations.js +90 -26
  89. package/dist/components/secretOperations.js.map +1 -1
  90. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  91. package/dist/config/harperConfigEnvVars.js +95 -5
  92. package/dist/config/harperConfigEnvVars.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/resources/Resource.d.ts +19 -0
  97. package/dist/resources/Resource.js +93 -2
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/Table.d.ts +9 -0
  100. package/dist/resources/Table.js +256 -50
  101. package/dist/resources/Table.js.map +1 -1
  102. package/dist/resources/analytics/read.js +28 -25
  103. package/dist/resources/analytics/read.js.map +1 -1
  104. package/dist/resources/defineResource.d.ts +180 -0
  105. package/dist/resources/defineResource.js +505 -0
  106. package/dist/resources/defineResource.js.map +1 -0
  107. package/dist/resources/defineTable.d.ts +221 -0
  108. package/dist/resources/defineTable.js +227 -0
  109. package/dist/resources/defineTable.js.map +1 -0
  110. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  111. package/dist/resources/jsonSchemaTypes.js +12 -4
  112. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  113. package/dist/resources/openApi.js +69 -13
  114. package/dist/resources/openApi.js.map +1 -1
  115. package/dist/resources/search.js +6 -8
  116. package/dist/resources/search.js.map +1 -1
  117. package/dist/server/graphqlQuerying.js +4 -2
  118. package/dist/server/graphqlQuerying.js.map +1 -1
  119. package/dist/server/http.d.ts +12 -0
  120. package/dist/server/http.js +35 -15
  121. package/dist/server/http.js.map +1 -1
  122. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  123. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +23 -0
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/utility/errors/hdbError.d.ts +21 -0
  127. package/dist/utility/errors/hdbError.js +23 -1
  128. package/dist/utility/errors/hdbError.js.map +1 -1
  129. package/dist/validation/configValidator.js +22 -6
  130. package/dist/validation/configValidator.js.map +1 -1
  131. package/index.ts +33 -0
  132. package/npm-shrinkwrap.json +9197 -15236
  133. package/package.json +5 -3
  134. package/resources/DESIGN.md +42 -15
  135. package/resources/Resource.ts +99 -2
  136. package/resources/Table.ts +275 -71
  137. package/resources/analytics/read.ts +30 -25
  138. package/resources/defineResource.ts +651 -0
  139. package/resources/defineTable.ts +407 -0
  140. package/resources/jsonSchemaTypes.ts +12 -4
  141. package/resources/openApi.ts +68 -16
  142. package/resources/search.ts +5 -8
  143. package/server/graphqlQuerying.ts +4 -2
  144. package/server/http.ts +40 -16
  145. package/server/serverHelpers/serverUtilities.ts +19 -6
  146. package/server/serverHelpers/uwsServer.ts +24 -0
  147. package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
  148. package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
  149. package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
  150. package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
  151. package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
  152. package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
  153. package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
  154. package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
  155. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
  156. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
  157. package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
  158. package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
  159. package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
  160. package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
  161. package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
  162. package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
  163. package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
  164. package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
  165. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
  166. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
  167. package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
  168. package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
  169. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
  170. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
  171. package/studio/web/index.html +1 -1
  172. package/utility/errors/hdbError.ts +32 -0
  173. package/validation/configValidator.ts +23 -6
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-alpha.4",
4
+ "version": "5.2.0-alpha.6",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://harper.fast",
7
7
  "bugs": {
@@ -56,6 +56,7 @@
56
56
  "build:watch": "npm run build -- --watch --incremental",
57
57
  "typecheck": "tsc --project tsconfig.json",
58
58
  "typecheck:fast": "npx -y -p typescript@7.0.2 tsc --noEmit --project tsconfig.json",
59
+ "test:types": "tsc --project unitTests/types/tsconfig.json",
59
60
  "package": "./build-tools/build.sh",
60
61
  "lint": "oxlint --format stylish --deny-warnings .",
61
62
  "lint:required": "oxlint --format stylish --quiet .",
@@ -166,7 +167,7 @@
166
167
  },
167
168
  "dependencies": {
168
169
  "@aws-sdk/client-s3": "^3.1012.0",
169
- "@aws-sdk/lib-storage": "3.1076.0",
170
+ "@aws-sdk/lib-storage": "3.1080.0",
170
171
  "@endo/static-module-record": "^1.1.2",
171
172
  "@fastify/autoload": "^6.3.1",
172
173
  "@fastify/compress": "^8.3.1",
@@ -174,6 +175,7 @@
174
175
  "@fastify/static": "^9.1.3",
175
176
  "@harperfast/extended-iterable": "^1.0.1",
176
177
  "@harperfast/rocksdb-js": "^2.4.0",
178
+ "@harperfast/skills": "^1.10.8",
177
179
  "@turf/area": "6.5.0",
178
180
  "@turf/boolean-contains": "6.5.0",
179
181
  "@turf/boolean-disjoint": "6.5.0",
@@ -198,7 +200,7 @@
198
200
  "fast-glob": "3.3.3",
199
201
  "fastify": "^5.8.2",
200
202
  "fastify-plugin": "^5.1.0",
201
- "fs-extra": "11.3.5",
203
+ "fs-extra": "11.3.6",
202
204
  "graphql": "^16.10.0",
203
205
  "graphql-http": "^1.22.4",
204
206
  "gunzip-maybe": "1.4.2",
@@ -25,6 +25,9 @@ See also: `../DESIGN.md` for cross-cutting non-obvious internals (RecordObject p
25
25
  | `auditStore.ts` | Append-only audit log records |
26
26
  | `nodeIdMapping.ts` | Maps node IDs ↔ timestamps for replication ordering |
27
27
  | `openApi.ts` | Generates OpenAPI/JSON Schema from `@export` schemas |
28
+ | `defineTable.ts` | Code-first table authoring (`defineTable` + `types`) — a TS front-end to the canonical `table()` model |
29
+ | `defineResource.ts` | Per-method request contract (`defineResource` / `Resource.withSchema`, `t`, `schemaOf`) — typed handlers + edge validation |
30
+ | `jsonSchemaTypes.ts` | Shared `JsonSchemaFragment` IR + `attributeToFragment` projector (one vocabulary for validation/OpenAPI/MCP) |
28
31
  | `analytics/` | Telemetry recording (separate from monitoring) |
29
32
 
30
33
  ---
@@ -75,21 +78,22 @@ One giant `makeTable()` factory that returns a `TableResource extends Resource`
75
78
 
76
79
  ## "Where is X" cheat sheet
77
80
 
78
- | Question | Where |
79
- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | How is a CRUD request authorized? | `Table.ts → #section: authz-hooks`; defaults in `Resource.ts` (`allowRead` etc.) |
81
- | Where does versioning / conflict resolution happen? | `Table.ts → _writeUpdate` (`#section: write-path-internals`) |
82
- | How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`) |
83
- | How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`) |
84
- | How is the response body shaped (select clause)? | `Table.ts → transformEntryForSelect` (`#section: search-query`) |
85
- | Where is record-level TTL evaluated? | `Table.ts → setTTLExpiration` (`#section: lifecycle-admin`); `Updatable.getExpiresAt` (`#section: setup-and-factory`) |
86
- | How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
87
- | How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
88
- | Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage) |
89
- | How does a query opt out of a read snapshot? | Pass `snapshot: false` on the search request (e.g. `get_analytics`). `Table.ts → search` calls `txn.useReadTxn(snapshot === false)`; on RocksDB `DatabaseTransaction.getReadTxn` then builds the read txn with `{ disableSnapshot: true }` so a long scan reads latest without pinning a snapshot. No-op on LMDB (`LMDBTransaction.useReadTxn`). |
90
- | How does a URL path map to a Resource? | `Resources.ts → getMatch` (exact/prefix fast path) then `matchParamRoute` (parameterised routes); see "Path routing" below |
91
- | How does HNSW keep the graph connected on delete? | `indexes/HierarchicalNavigableSmallWorld.ts → index()` delete path: zero-degree orphans reindexed via `needsReindexing`; severed multi-node islands detected and reconnected by `repairSeveredNeighbors` (#1712) |
92
- | How is a filter applied _during_ a vector search? | Predicate-aware traversal (#1241): `search.ts → executeConditions` composes companion AND conditions + a request `vectorFilter` + a static `allowReadRecord` RBAC hook into one `(primaryKey) => boolean` (`composeRecordFilter`) and passes it to `HierarchicalNavigableSmallWorld.search(cond, ctx, filter)`. The filter gates result admission at layer 0 only (routing ignores it, ACORN-style); a visit budget (`filterExpansion`) bounds the under-filled/selective case. Very selective _condition_ filters are instead diverted to the exact brute-force path by the query planner's `estimateCountAsSort` ordering. |
81
+ | Question | Where |
82
+ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
+ | How is a CRUD request authorized? | `Table.ts → #section: authz-hooks`; defaults in `Resource.ts` (`allowRead` etc.) |
84
+ | Where does versioning / conflict resolution happen? | `Table.ts → _writeUpdate` (`#section: write-path-internals`) |
85
+ | How does `search()` choose an index? | `Table.ts → search` (`#section: search-query`) |
86
+ | How are subscriptions replayed? | `Table.ts → subscribe` (`#section: pub-sub`) |
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`) |
89
+ | How are residencies enforced (replication)? | `Table.ts → #section: lifecycle-admin` (residency block: `getResidencyRecord`, `setResidency`, `setResidencyById`, `getResidency`) |
90
+ | How is the RecordObject prototype applied? | `RecordEncoder.ts` (see `../DESIGN.md`) |
91
+ | Where is the per-request transaction stored? | `transaction.ts` + `contextStorage` (AsyncLocalStorage) |
92
+ | How does a query opt out of a read snapshot? | Pass `snapshot: false` on the search request (e.g. `get_analytics`). `Table.ts → search` calls `txn.useReadTxn(snapshot === false)`; on RocksDB `DatabaseTransaction.getReadTxn` then builds the read txn with `{ disableSnapshot: true }` so a long scan reads latest without pinning a snapshot. No-op on LMDB (`LMDBTransaction.useReadTxn`). |
93
+ | How does a URL path map to a Resource? | `Resources.ts → getMatch` (exact/prefix fast path) then `matchParamRoute` (parameterised routes); see "Path routing" below |
94
+ | How does HNSW keep the graph connected on delete? | `indexes/HierarchicalNavigableSmallWorld.ts → index()` delete path: zero-degree orphans reindexed via `needsReindexing`; severed multi-node islands detected and reconnected by `repairSeveredNeighbors` (#1712) |
95
+ | How is a filter applied _during_ a vector search? | Predicate-aware traversal (#1241): `search.ts → executeConditions` composes companion AND conditions + a request `vectorFilter` + a record-scoped `allowRead` override into one `(primaryKey) => boolean` (`composeRecordFilter`) and passes it to `HierarchicalNavigableSmallWorld.search(cond, ctx, filter)`. The filter gates result admission at layer 0 only (routing ignores it, ACORN-style); a visit budget (`filterExpansion`) bounds the under-filled/selective case. Very selective _condition_ filters are instead diverted to the exact brute-force path by the query planner's `estimateCountAsSort` ordering. |
96
+ | How is row-level read access control enforced? | Unified `allowRead` (#1422 gap 2 / #1241): an application-OVERRIDDEN `allowRead` (detected via the `isDefaultAllowRead` marker on the framework defaults) is record-scoped — evaluated once per record with `this` = the (frozen) record during query execution, fail-closed on throw, dispatched via the resolved method (never `record.allowRead` lookup — data shadowing). The authorize wrapper (`Resource.ts → authorizeActionOnResource`) defers collection reads on tables (`supportsRowLevelAllowRead`) to this per-record path; single-record `get` keeps the entry check (record loaded, proxied reads work). Records also expose a non-enumerable `allowRead` delegate on the per-table `structPrototype`. |
93
97
 
94
98
  ---
95
99
 
@@ -128,6 +132,29 @@ Tests: `../unitTests/resources/paramRoutes.test.js` (unit) and `../integrationTe
128
132
 
129
133
  ---
130
134
 
135
+ ## Typed, discoverable resources (code-first schema + request contract)
136
+
137
+ Design record: the full RFC and its type-level design proofs live in the design PR (**HarperFast/harper#1503**); this section is the retained summary.
138
+
139
+ **The principle.** Harper strips TypeScript at runtime (`--conditions=typestrip`), which erases types and rules out metadata-emitting decorators. So **runtime metadata must be values, and TypeScript types are _derived_ from those values — never the reverse.** Everything here is erasable syntax; the values survive stripping, the types are inferred, nothing can drift. One shared IR — `JsonSchemaFragment` (`jsonSchemaTypes.ts`), produced by `attributeToFragment` — feeds validation, OpenAPI, and MCP, so those surfaces cannot silently disagree.
140
+
141
+ **Code-first tables (`defineTable.ts`).** `defineTable(name, shape, opts)` authors a table in TypeScript and eagerly registers it through the same `table()` factory GraphQL drives — the returned value _is_ the live table class (`Track.get/put/...` work, `new Track()`/`instanceof` hold). Fields come from the `types` vocabulary (getter flags: `string.indexed`, `id.primaryKey`, `date.createdTime`); per-verb shapes are inferred projections discoverable as members (`(typeof Track)['$insert' | '$upsert' | '$patch' | '$query' | '$record']`). Relationships use lazy thunks (`types.relation(() => Album, { from })`) so forward references/cycles resolve at query time; `relationOf`/`hasManyOf` are the escape hatch for a mutual pair whose eager const-inference would otherwise collapse to `any`.
142
+
143
+ **Per-method request contract (`defineResource.ts`).** Two front-ends, same runtime metadata:
144
+
145
+ - `defineResource(contract, impl)` — function form (an object of verb handlers).
146
+ - `Resource.withSchema(contract)` — class form; `extends` it and implement the declared verbs. It pins `static loadAsInstance = false` so instance verbs receive the converged `(target, data)` arg order the types assume (the default dispatch order is `(data, target)` — see `Resource.post/put/patch`).
147
+
148
+ A contract is `{ path, record?, get/post/put/patch/delete: { query?, body?, response? } }`. Handler types are **derived** from it: path params from a template-literal parse of `path`, query/body/response from the schema's inferred type. It is a **subset, not a fork** — a handler gets the SAME `RequestTarget`, structurally narrowed (`target.id: string`, `target.get('expand')` typed by the query schema), and the resource still registers/serves like a plain one. The narrowed types are justified by **runtime enforcement**: each declared verb validates/coerces `query`/`body` before dispatch and throws a structured 400 (`ValidationError`, per-field `{ path, code, message }[]`) — the same bargain `Table.validate` makes for tables.
149
+
150
+ **Vocabulary.** The built-in `t` (`t.string/number/integer/boolean/date/enum/array/object`) and `schemaOf<T>(source?)` both reduce to `JsonSchemaFragment`. A `defineTable` projection slots straight into a contract body/response — `schemaOf<(typeof Track)['$insert']>({ table: Track, projection: 'insert' })` derives the compile-time type from the projection and the runtime fragment from the table's attributes (via `projectTableFragment` → `attributeToFragment`). **Nullability:** non-nullable by default (a bare `t.string` rejects `null`); `.optional` allows absence, `.nullable` allows an explicit `null`; table-derived bodies mirror `Table.validate`'s policy (null rejected only when `nullable === false`).
151
+
152
+ **Downstream surfaces.** `openApi.ts` emits a contract's query params, request body, and response for parameterised routes; `components/mcp/tools/application.ts` drives the tool input/output schema off the contract and binds arbitrary path params + query (`applyContractInputs`), which lifts the generated-verb binding restriction for contract resources. `ValidationError` (`../utility/errors/hdbError.ts`) extends `ClientError` (400) so existing HTTP handling is unchanged; the structured issues ride on `.detail`/`.errors`.
153
+
154
+ Tests: `../unitTests/resources/defineResource.test.js`, `../unitTests/resources/defineTable-registration.test.js`, `../unitTests/resources/openApi-contract.test.js`, `../unitTests/components/mcp/tools/application-contract.test.js`.
155
+
156
+ ---
157
+
131
158
  ## Conventions
132
159
 
133
160
  - **Never** remove `transactional()` from a static method on `Resource` — it owns transaction context lifetime.
@@ -18,8 +18,10 @@ import { transaction, contextStorage } from './transaction.ts';
18
18
  import { parseQuery } from './search.ts';
19
19
  import { RequestTarget } from './RequestTarget.ts';
20
20
  import { when, promiseNormalize } from '../utility/when.ts';
21
+ import { logger } from '../utility/logging/logger.ts';
21
22
  import { registerLiveSubscription } from '../server/liveSubscriptionAuth.ts';
22
23
  import type { JsonSchemaFragment } from './jsonSchemaTypes.ts';
24
+ import { makeSchemaClass, type Contract, type SchemaClass } from './defineResource.ts';
23
25
 
24
26
  const EXTENSION_TYPES = {
25
27
  json: 'application/json',
@@ -59,6 +61,26 @@ export class Resource<Record extends object = any> implements ResourceInterface<
59
61
  static outputSchemas?: { [verb: string]: JsonSchemaFragment };
60
62
  static mcp?: { annotations?: { [verb: string]: { [key: string]: unknown } } };
61
63
  static hidden?: boolean;
64
+ /** The per-method request contract, when this resource was built via `withSchema`. */
65
+ static requestContract?: Contract;
66
+ /** Per-verb `{ query?, body? }` input schemas derived from the request contract, read by OpenAPI/MCP. */
67
+ static inputSchemas?: { [verb: string]: { query?: JsonSchemaFragment; body?: JsonSchemaFragment } };
68
+
69
+ /**
70
+ * Declare a per-method request contract and get back a Resource subclass typed by it. Extend the
71
+ * returned class and implement the declared verbs; handlers receive the SAME
72
+ * `RequestTarget`, structurally narrowed (path params typed, `target.get(param)` typed by the query
73
+ * schema). Each declared verb validates/coerces `query`/`body` before dispatch and returns a
74
+ * structured 400 on failure, which is what justifies the narrowed types. The contract also feeds
75
+ * OpenAPI and MCP from the one declaration.
76
+ */
77
+ static withSchema<Base extends new (...args: any[]) => any, const C extends Contract>(
78
+ this: Base,
79
+ contract: C
80
+ ): SchemaClass<Base, C> {
81
+ return makeSchemaClass(this, contract);
82
+ }
83
+
62
84
  constructor(identifier: Id, source: any) {
63
85
  this.#id = identifier;
64
86
  const context = source?.getContext ? (source.getContext() ?? null) : undefined;
@@ -283,6 +305,19 @@ export class Resource<Record extends object = any> implements ResourceInterface<
283
305
 
284
306
  static query = transactional(
285
307
  function (resource: any, query: RequestTarget, _request: Context, data: any) {
308
+ // On the table path, search(target) uses `data` (the request body carrying the conditions) as
309
+ // its sole target, while checkPermission was set on the URL `query`. Thread it across so
310
+ // Table.search sees the flag and enforces row-level allowRead — otherwise a QUERY on a table
311
+ // with an overridden allowRead returns the full unfiltered set (the deferral skips the entry
312
+ // check on the promise that search consumes checkPermission, which it never sees).
313
+ // checkPermission is framework-owned: `data` is the client-controlled QUERY body, so it must
314
+ // always be overwritten here (not just filled when nullish) — otherwise a client could send
315
+ // `checkPermission: false` in the body to disable Table.search's row-level allowRead guard.
316
+ if (resource.constructor.loadAsInstance !== false && data && typeof data === 'object') {
317
+ const checkPermission = (query as any)?.checkPermission;
318
+ if (checkPermission == null) delete (data as any).checkPermission;
319
+ else (data as any).checkPermission = checkPermission;
320
+ }
286
321
  return resource.search
287
322
  ? resource.constructor.loadAsInstance === false
288
323
  ? resource.search(query, data)
@@ -491,6 +526,26 @@ export class Resource<Record extends object = any> implements ResourceInterface<
491
526
 
492
527
  _assignPackageExport('Resource', Resource);
493
528
 
529
+ // Warn once per table when an ASYNC allowRead override is used on a row-level-capable table: it is
530
+ // evaluated only at the collection-scope entry check and gets NO per-record/per-event filtering, so
531
+ // the collection-permissive "open, filter per record" pattern would fail open. Row-level filtering
532
+ // requires a synchronous override.
533
+ const asyncAllowReadWarned = new Set<string>();
534
+ function warnAsyncAllowReadOnce(tableName: string | undefined): void {
535
+ const key = tableName ?? '<anonymous>';
536
+ if (asyncAllowReadWarned.has(key)) return;
537
+ asyncAllowReadWarned.add(key);
538
+ logger.warn?.(
539
+ `allowRead override on table "${key}" is async: it is evaluated only at request entry (collection scope) with NO per-record or per-event filtering. Row-level (record-scoped) authorization requires a synchronous allowRead — an async collection-permissive override would return unfiltered results.`
540
+ );
541
+ }
542
+
543
+ // Mark the built-in allowRead so the authorization flow can tell a framework default from an
544
+ // application override. An overridden allowRead on a table is evaluated per RECORD during query
545
+ // execution (#1422 gap 2 / #1241) — with `this` being each record — instead of once at collection
546
+ // entry where `this` has no record to inspect. Table.ts marks its table-level default the same way.
547
+ (Resource.prototype.allowRead as any).isDefaultAllowRead = true;
548
+
494
549
  export function snakeCase(camelCase: string) {
495
550
  return (
496
551
  camelCase[0].toLowerCase() +
@@ -733,6 +788,43 @@ function transactional(
733
788
  }
734
789
  if (checkPermission) {
735
790
  if (loadAsInstance !== false) {
791
+ // Does this table carry an APPLICATION-overridden allowRead (record-scoped, #1422 gap 2)?
792
+ // The framework defaults are marked isDefaultAllowRead; anything else is an app override.
793
+ // Row-level authorization (per-record query traversal, per-event delivery) is SYNC-only.
794
+ // A SYNC application override participates in it; an ASYNC override cannot (traversal
795
+ // and delivery can't await), so it is evaluated ONLY at the collection-scope entry
796
+ // check — a table/connection-level decision, the same as before this feature. It must
797
+ // therefore make a complete decision at collection scope; the collection-permissive
798
+ // "open, filter per record" pattern requires a synchronous override (warned below).
799
+ const overridden =
800
+ options.type === 'read' &&
801
+ (resource.constructor as any)?.supportsRowLevelAllowRead &&
802
+ !(resource.allowRead as any)?.isDefaultAllowRead;
803
+ const rowLevelOverride = overridden && (resource.allowRead as any)?.constructor?.name !== 'AsyncFunction';
804
+ if (rowLevelOverride) {
805
+ // Durable signal that this read was authorization-checked, for the subscription
806
+ // delivery filter (the entry check below clears query.checkPermission before
807
+ // Table.subscribe runs, and an anonymous-but-checked subscription has no user to key on).
808
+ (query as any).rowLevelAuthChecked = true;
809
+ } else if (overridden) {
810
+ warnAsyncAllowReadOnce((resource.constructor as any)?.name);
811
+ }
812
+ // Deferral (per-record enforcement instead of a meaningless collection-scope entry
813
+ // verdict): `get` with a non-null id is a true single-record read (entry check runs with
814
+ // the record loaded) and only defers when isCollection — mirroring the isSearchTarget
815
+ // routing. ALL search/query calls defer: a present id there is a starts_with/prefix SEED
816
+ // (multi-record scan), so an entry verdict would gate — or grant — the whole scan; both
817
+ // invoke instance search() directly, which consumes checkPermission and arms the guard.
818
+ // subscribe/connect are NOT deferred: the entry check is the connection grant, and an
819
+ // override there may be connection-level (e.g. an MQTT topic ACL that decides on
820
+ // context.topic, not record fields, and must run at subscribe time); a record-level
821
+ // override returns permissive at collection scope to open, and Table.subscribe then
822
+ // filters each delivered event per record (#1419).
823
+ if (rowLevelOverride && !isSubscribeAction && (options.method !== 'get' || query.isCollection)) {
824
+ return when(data, (data) => {
825
+ return runAction(data);
826
+ });
827
+ }
736
828
  // do permission checks, with allow methods
737
829
  let allowed;
738
830
  try {
@@ -802,8 +894,13 @@ function registerLiveSubscriptionForContext(subscription: any, resource: any, qu
802
894
  // and getCurrentUser() (which reads the resource's context) — evaluate against current state,
803
895
  // not the stale user captured at subscribe time.
804
896
  if (context) (context as any).user = fresh;
805
- // Re-run the same table/RBAC-level allowRead the subscription was granted with, against the
806
- // fresh user. No per-record evaluation this matches how access was originally granted.
897
+ // Re-run the SAME allowRead the subscribe entry check ran, against the fresh user — this
898
+ // mirrors the connection grant exactly. For a record-scoped override (#1419), that override
899
+ // gated the connection at collection scope (typically composing the table/RBAC grant via
900
+ // `super.allowRead`), and its per-record decisions are enforced separately during delivery;
901
+ // re-running it here re-verifies whatever it gated the connection on — a connection-level
902
+ // override (e.g. an MQTT topic ACL) re-checks its topic grant, and a record-scoped override
903
+ // that composes `super` re-checks the RBAC baseline. No per-record evaluation here.
807
904
  const reTarget: any = new RequestTarget();
808
905
  reTarget.id = capturedId;
809
906
  reTarget.isCollection = capturedIsCollection;