@harperfast/template-vue-ts-studio 1.9.2 → 1.10.1

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 (26) hide show
  1. package/.agents/skills/harper-best-practices/AGENTS.md +916 -338
  2. package/.agents/skills/harper-best-practices/rules/caching.md +68 -62
  3. package/.agents/skills/harper-best-practices/rules/custom-resources.md +144 -23
  4. package/.agents/skills/harper-best-practices/rules/defining-relationships.md +152 -22
  5. package/.agents/skills/harper-best-practices/rules/extending-tables.md +90 -21
  6. package/.agents/skills/harper-best-practices/rules/handling-binary-data.md +103 -23
  7. package/.agents/skills/harper-best-practices/rules/programmatic-table-requests.md +143 -91
  8. package/.agents/skills/harper-best-practices/rules/schema-design-tooling.md +87 -64
  9. package/.agents/skills/harper-best-practices/rules/serving-web-content.md +28 -0
  10. package/.agents/skills/harper-best-practices/rules/using-blob-datatype.md +97 -16
  11. package/.agents/skills/harper-best-practices/rules/vector-indexing.md +59 -29
  12. package/.agents/skills/harper-best-practices/rules.manifest.yaml +109 -7
  13. package/agent/skills/harper-best-practices/AGENTS.md +916 -338
  14. package/agent/skills/harper-best-practices/rules/caching.md +68 -62
  15. package/agent/skills/harper-best-practices/rules/custom-resources.md +144 -23
  16. package/agent/skills/harper-best-practices/rules/defining-relationships.md +152 -22
  17. package/agent/skills/harper-best-practices/rules/extending-tables.md +90 -21
  18. package/agent/skills/harper-best-practices/rules/handling-binary-data.md +103 -23
  19. package/agent/skills/harper-best-practices/rules/programmatic-table-requests.md +143 -91
  20. package/agent/skills/harper-best-practices/rules/schema-design-tooling.md +87 -64
  21. package/agent/skills/harper-best-practices/rules/serving-web-content.md +28 -0
  22. package/agent/skills/harper-best-practices/rules/using-blob-datatype.md +97 -16
  23. package/agent/skills/harper-best-practices/rules/vector-indexing.md +59 -29
  24. package/agent/skills/harper-best-practices/rules.manifest.yaml +109 -7
  25. package/package.json +1 -1
  26. package/skills-lock.json +1 -1
@@ -40,7 +40,7 @@ type ExamplePerson @table @export {
40
40
  }
41
41
  ```
42
42
 
43
- ### 1.2 Schema Design and Tooling
43
+ ### 1.2 Schema Design and GraphQL Tooling
44
44
 
45
45
  Instructions for the agent to follow when designing Harper schemas, applying core directives, and configuring GraphQL tooling.
46
46
 
@@ -50,7 +50,18 @@ Apply this rule when creating or modifying Harper schema files, configuring `gra
50
50
 
51
51
  #### How It Works
52
52
 
53
- 1. **Create a GraphQL schema file** with Harper-specific directives. Name it (e.g., `schema.graphql`) and place it in your component directory.
53
+ 1. **Create a schema file** using standard GraphQL type definitions with Harper-specific directives. Name it (e.g., `schema.graphql`) and place it in your component directory.
54
+
55
+ 2. **Register the schema** in the component's `config.yaml` using the `graphqlSchema` plugin:
56
+
57
+ ```yaml
58
+ graphqlSchema:
59
+ files: 'schema.graphql'
60
+ ```
61
+
62
+ Both plugins and applications can specify schemas.
63
+
64
+ 3. **Mark types as tables** with `@table`. The type name becomes the table name by default:
54
65
 
55
66
  ```graphql
56
67
  type Dog @table {
@@ -59,23 +70,36 @@ Apply this rule when creating or modifying Harper schema files, configuring `gra
59
70
  breed: String
60
71
  age: Int
61
72
  }
73
+ ```
74
+
75
+ 4. **Designate a primary key** with `@primaryKey` on exactly one field per type. Primary keys must be unique; duplicate-key inserts are rejected. If no primary key is provided on insert, Harper auto-generates one:
76
+ - `String` or `ID` → UUID string
77
+ - `Int`, `Long`, or `Any` → auto-incrementing integer
62
78
 
79
+ Use `Long` or `Any` for auto-generated numeric keys; `Int` is 32-bit and may be insufficient for large tables.
80
+
81
+ 5. **Add secondary indexes** with `@indexed` on any field that will be used for filtering in REST queries, SQL, or NoSQL operations:
82
+
83
+ ```graphql
63
84
  type Breed @table {
64
85
  id: Long @primaryKey
65
86
  name: String @indexed
66
87
  }
67
88
  ```
68
89
 
69
- 2. **Register the schema in `config.yaml`** using the `graphqlSchema` plugin key:
90
+ If the field value is an array, each element is individually indexed. Null values are indexed by default.
70
91
 
71
- ```yaml
72
- graphqlSchema:
73
- files: 'schema.graphql'
92
+ 6. **Expose a table as an external endpoint** with `@export`. The optional `name` parameter sets the URL path segment:
93
+
94
+ ```graphql
95
+ type MyTable @table @export(name: "my-table") {
96
+ id: Long @primaryKey
97
+ }
74
98
  ```
75
99
 
76
- Both plugins and applications can specify schemas this way.
100
+ Without `name`, the type name is used as the path segment.
77
101
 
78
- 3. **Mark every table type with `@table`**. The type name becomes the table name by default. Use optional arguments to override behavior:
102
+ 7. **Configure `@table` arguments** as needed for database placement, expiration, eviction, and replication:
79
103
 
80
104
  | Argument | Type | Default | Description |
81
105
  | -------------- | --------- | ----------------------------- | ------------------------------------------------------------- |
@@ -86,147 +110,271 @@ Apply this rule when creating or modifying Harper schema files, configuring `gra
86
110
  | `scanInterval` | `Int` | `(expiration + eviction) / 4` | Seconds between eviction scans |
87
111
  | `replicate` | `Boolean` | `true` | Enable replication of this table |
88
112
 
89
- 4. **Designate a primary key on every table** using `@primaryKey`. Primary keys must be unique; duplicate-key inserts are rejected. If no key is provided on insert, Harper auto-generates one:
90
- - `String` or `ID` UUID string
91
- - `Int`, `Long`, or `Any` auto-incrementing integer
113
+ 8. **Apply additional field directives** where needed:
114
+ - `@createdTime` auto-assigns Unix epoch milliseconds on record creation
115
+ - `@updatedTime` auto-assigns Unix epoch milliseconds on each update
116
+ - `@embed(source: "fieldName", model: "modelName")` — computes an embedding vector on write; attribute type must be `[Float]`
117
+ - `@hidden` — suppresses the field from MCP tool descriptors and OpenAPI documents (not an access-control mechanism)
118
+
119
+ 9. **Restrict extra properties** with `@sealed` if records must not include properties beyond those declared:
120
+ ```graphql
121
+ type StrictRecord @table @sealed {
122
+ id: Long @primaryKey
123
+ name: String
124
+ }
125
+ ```
92
126
 
93
- Prefer `Long` or `Any` for auto-generated numeric keys; `Int` is 32-bit and may be insufficient for large tables.
127
+ #### Examples
128
+
129
+ **Minimal two-table schema:**
130
+
131
+ ```graphql
132
+ type Dog @table {
133
+ id: Long @primaryKey
134
+ name: String
135
+ breed: String
136
+ age: Int
137
+ }
138
+
139
+ type Breed @table {
140
+ id: Long @primaryKey
141
+ name: String @indexed
142
+ }
143
+ ```
144
+
145
+ **Table with expiration, eviction, and custom scan interval:**
146
+
147
+ ```graphql
148
+ # Expire after 5 minutes, evict after 1 hour, scan every 10 minutes
149
+ type WeatherCache @table(expiration: 300, eviction: 3300, scanInterval: 600) {
150
+ id: ID @primaryKey
151
+ temperature: Float
152
+ }
153
+ ```
154
+
155
+ **Exported table with timestamps and a hidden field:**
156
+
157
+ ```graphql
158
+ type Customer @table @export(name: "customers") {
159
+ id: Long @primaryKey
160
+ name: String @indexed
161
+ createdAt: Long @createdTime
162
+ updatedAt: Long @updatedTime
163
+
164
+ """
165
+ Internal — do not surface to external consumers.
166
+ """
167
+ creditScore: Int @hidden
168
+ }
169
+ ```
94
170
 
95
- 5. **Index fields that need fast querying** with `@indexed`. This is required for filtering by that attribute in REST queries, SQL, or NoSQL operations. If the field value is an array, each element is individually indexed.
171
+ **Multiple `@table` argument combinations:**
172
+
173
+ ```graphql
174
+ # Override table name
175
+ type Product @table(table: "products") {
176
+ id: Long @primaryKey
177
+ }
178
+
179
+ # Place in a specific database
180
+ type Order @table(database: "commerce") {
181
+ id: Long @primaryKey
182
+ }
183
+
184
+ # Auto-expire records after 1 hour
185
+ type Session @table(expiration: 3600) {
186
+ id: Long @primaryKey
187
+ userId: String
188
+ }
189
+
190
+ # Disable replication
191
+ type LocalRecord @table(replicate: false) {
192
+ id: Long @primaryKey
193
+ value: String
194
+ }
195
+
196
+ # Combine multiple arguments
197
+ type Event @table(database: "analytics", expiration: 86400) {
198
+ id: Long @primaryKey
199
+ name: String @indexed
200
+ }
201
+ ```
202
+
203
+ #### Notes
204
+
205
+ - All tables default to the `"data"` database. When designing plugins or applications, use unique database names to avoid table naming collisions.
206
+ - Schemas are flexible by default — records may include properties beyond those declared. Use `@sealed` to prevent this.
207
+ - `expiration` marks a record stale; `eviction` controls how long after expiration the record is physically removed. Eviction does not remove records from secondary indexes — Harper fetches the full record on demand if an evicted record matches a query.
208
+ - `scanInterval` is clock-aligned to the server's local timezone, not startup-aligned. The eviction schedule is deterministic across restarts.
209
+ - If replication is disabled on a table and later re-enabled, writes made during the disabled period are not replicated retroactively.
210
+ - `@hidden` (on types or fields) is a metadata-visibility directive only. Use `attribute_permissions` on roles to enforce data access control.
211
+ - Disabling replication (`replicate: false`) and re-enabling it later will not catch up on writes made while replication was disabled.
212
+
213
+ ### 1.3 Defining Relationships Between Tables in Harper
214
+
215
+ Instructions for the agent to follow when defining and querying relationships between tables in Harper using the `@relationship` directive.
216
+
217
+ #### When to Use
218
+
219
+ Apply this rule whenever a schema requires linking two tables via a foreign key — for example, modeling shows and networks, products and brands, or orders and items. Use it when queries need to filter or select nested related records using dot-syntax.
220
+
221
+ #### How It Works
222
+
223
+ 1. **Use `@relationship(from: attribute)` for many-to-one or many-to-many**: Place this on a field in the current table when the foreign key lives in this table and references the primary key of the target table.
96
224
 
97
225
  ```graphql
98
- type Product @table {
226
+ type RealityShow @table @export {
227
+ id: Long @primaryKey
228
+ networkId: Long @indexed
229
+ network: Network @relationship(from: networkId)
230
+ title: String @indexed
231
+ }
232
+
233
+ type Network @table @export {
99
234
  id: Long @primaryKey
100
- category: String @indexed
101
- price: Float @indexed
235
+ name: String @indexed
102
236
  }
103
237
  ```
104
238
 
105
- 6. **Expose a table as an external resource endpoint** with `@export`. This makes the table accessible via REST, MQTT, and other interfaces. The optional `name` parameter sets the URL path segment; without it, the type name is used.
239
+ For a many-to-many relationship, make the foreign key an array:
106
240
 
107
241
  ```graphql
108
- type MyTable @table @export(name: "my-table") {
242
+ type RealityShow @table @export {
109
243
  id: Long @primaryKey
244
+ networkIds: [Long] @indexed
245
+ networks: [Network] @relationship(from: networkIds)
110
246
  }
111
247
  ```
112
248
 
113
- 7. **Restrict extra properties** with `@sealed` when records must not include attributes beyond those declared. By default, Harper allows additional properties.
249
+ 2. **Use `@relationship(to: attribute)` for one-to-many or many-to-many**: Place this on a field in the current table when the foreign key lives in the target table and references the primary key of this table. The result type must be an array.
114
250
 
115
251
  ```graphql
116
- type StrictRecord @table @sealed {
252
+ type Network @table @export {
117
253
  id: Long @primaryKey
254
+ name: String @indexed
255
+ shows: [RealityShow] @relationship(to: networkId)
256
+ }
257
+ ```
258
+
259
+ 3. **Use `@relationship(from: attribute, to: attribute)` for foreign key to foreign key joins**: Specify both `from` and `to` when neither side uses the primary key. This is useful for joining on non-primary-key attributes.
260
+
261
+ ```graphql
262
+ type OrderItem @table @export {
263
+ id: Long @primaryKey
264
+ orderId: Long @indexed
265
+ productSku: Long @indexed
266
+ product: Product @relationship(from: productSku, to: sku)
267
+ }
268
+
269
+ type Product @table @export {
270
+ id: Long @primaryKey
271
+ sku: Long @indexed
118
272
  name: String
119
273
  }
120
274
  ```
121
275
 
122
- 8. **Configure expiration, eviction, and scan behavior** together when building caching tables. These three arguments control the full record lifecycle:
123
- - `expiration` — record becomes stale; next request triggers a source fetch
124
- - `eviction` — additional time after `expiration` before physical removal
125
- - `scanInterval` — how often Harper scans for records to evict; clock-aligned, not startup-aligned
276
+ 4. **Query across relationships using dot-syntax**: Filter by related table attributes using chained dot notation. This behaves as an INNER JOIN.
277
+
278
+ ```
279
+ GET /RealityShow?network.name=Bravo
280
+ GET /Product/?brand.name=Microsoft
281
+ GET /Brand/?products.name=Keyboard
282
+ ```
283
+
284
+ 5. **Select nested relationship fields with `select()`**: Relationship attributes are not included by default. Use `select()` to include them in results. When selecting without a filter on the related table, this acts as a LEFT JOIN — the relationship property is omitted if the foreign key is null or references a non-existent record.
285
+
286
+ ```
287
+ GET /Product/?brand.name=Microsoft&select(name,brand)
288
+ GET /Product/?brand.name=Microsoft&select(name,brand{name})
289
+ GET /Product/?name=Keyboard&select(name,brand{name,id})
290
+ ```
126
291
 
127
292
  #### Examples
128
293
 
129
- **Caching table with tuned expiration:**
294
+ **Many-to-one relationship** a show belongs to a network:
130
295
 
131
296
  ```graphql
132
- # Expire after 5 minutes, evict after 1 hour, scan every 10 minutes
133
- type WeatherCache @table(expiration: 300, eviction: 3300, scanInterval: 600) {
134
- id: ID @primaryKey
135
- temperature: Float
297
+ type RealityShow @table @export {
298
+ id: Long @primaryKey
299
+ networkId: Long @indexed
300
+ network: Network @relationship(from: networkId)
301
+ title: String @indexed
136
302
  }
137
- ```
138
303
 
139
- **Table in a named database with expiration and an indexed field:**
140
-
141
- ```graphql
142
- type Event @table(database: "analytics", expiration: 86400) {
304
+ type Network @table @export {
143
305
  id: Long @primaryKey
144
306
  name: String @indexed
145
307
  }
146
308
  ```
147
309
 
148
- **Session cache with auto-expiry:**
310
+ Query:
311
+
312
+ ```
313
+ GET /RealityShow?network.name=Bravo
314
+ ```
315
+
316
+ **One-to-many relationship** — a network has many shows:
149
317
 
150
318
  ```graphql
151
- type Session @table(expiration: 3600) {
319
+ type Network @table @export {
152
320
  id: Long @primaryKey
153
- userId: String
321
+ name: String @indexed
322
+ shows: [RealityShow] @relationship(to: networkId)
154
323
  }
155
324
  ```
156
325
 
157
- **Table with audit timestamps:**
326
+ **Many-to-many with array foreign keys** — a product has multiple resellers:
158
327
 
159
328
  ```graphql
160
- type Order @table @export(name: "orders") {
329
+ type Product @table @export {
161
330
  id: Long @primaryKey
162
- createdAt: Long @createdTime
163
- updatedAt: Long @updatedTime
164
- status: String @indexed
331
+ name: String
332
+ resellerIds: [Long] @indexed
333
+ resellers: [Reseller] @relationship(from: resellerIds)
165
334
  }
166
335
  ```
167
336
 
168
- **Overriding the table name and disabling replication:**
337
+ Query with nested select:
338
+
339
+ ```
340
+ GET /Product/?resellers.name=Cool Shop&select(id,name,resellers{name,id})
341
+ ```
342
+
343
+ **Foreign key to foreign key join** — order item joined on SKU:
169
344
 
170
345
  ```graphql
171
- type Product @table(table: "products") {
346
+ type OrderItem @table @export {
172
347
  id: Long @primaryKey
173
- name: String
348
+ orderId: Long @indexed
349
+ productSku: Long @indexed
350
+ product: Product @relationship(from: productSku, to: sku)
174
351
  }
175
352
 
176
- type LocalRecord @table(replicate: false) {
353
+ type Product @table @export {
177
354
  id: Long @primaryKey
178
- value: String
355
+ sku: Long @indexed
356
+ name: String
179
357
  }
180
358
  ```
181
359
 
182
360
  #### Notes
183
361
 
184
- - Use unique `database` names in plugins and applications to avoid table naming collisions, since all tables default to the `"data"` database.
185
- - Eviction removes non-indexed record data but does **not** remove a record from its secondary indexes. Indexes remain functional for evicted records; Harper fetches the full record from the source on demand when a query matches an evicted record.
186
- - `scanInterval` is clock-aligned to the server's local timezone. The server's startup time does not affect when eviction runs.
187
- - If replication is disabled on a table and later re-enabled, it will not catch up on writes made while replication was disabled.
188
- - Null values are indexed by `@indexed` fields, enabling queries such as `GET /Product/?category=null`.
189
-
190
- ### 1.3 Defining Relationships
191
-
192
- Instructions for the agent to follow when defining relationships between Harper tables.
193
-
194
- #### When to Use
195
-
196
- Use this skill when you need to link data across different tables, enabling automatic joins and efficient related-data fetching via REST APIs.
197
-
198
- #### How It Works
199
-
200
- 1. **Identify the Relationship Type**: Determine if it's one-to-one, many-to-one, or one-to-many.
201
- 2. **Use the `@relationship` Directive**: Apply it to a field in your GraphQL schema.
202
- - **Many-to-One (Current table holds FK)**: Use `from`.
203
- ```graphql
204
- type Book @table @export {
205
- authorId: ID
206
- author: Author @relationship(from: "authorId")
207
- }
208
- ```
209
- - **One-to-Many (Related table holds FK)**: Use `to` and an array type.
210
- ```graphql
211
- type Author @table @export {
212
- books: [Book] @relationship(to: "authorId")
213
- }
214
- ```
215
- 3. **Query with Relationships**: Use dot syntax in REST API calls for filtering or the `select()` operator for including related data.
216
- - Example Filter: `GET /Book/?author.name=Harper`
217
- - Example Select: `GET /Author/?select(name,books(title))`
362
+ - The `@relationship` directive requires the referenced attribute to be `@indexed` on the foreign key side.
363
+ - Self-referential relationships are supported, enabling parent-child hierarchies within a single table.
364
+ - The array order of foreign key values (e.g., `resellerIds`) is preserved when resolving many-to-many relationships.
365
+ - When using `select()` without a filter on the related table, the join behaves as a LEFT JOIN missing or null foreign keys result in the relationship property being omitted rather than causing an error.
218
366
 
219
367
  ### 1.4 Vector Indexing
220
368
 
221
- Instructions for the agent to follow when enabling and querying vector indexes for similarity search in Harper using the HNSW algorithm.
369
+ Instructions for the agent to enable HNSW vector indexes on table fields and query them for similarity search in Harper.
222
370
 
223
371
  #### When to Use
224
372
 
225
- Apply this rule when adding a vector index to a Harper table schema or writing similarity search queries against high-dimensional vector fields. Use it whenever you need approximate nearest-neighbor search, distance-threshold filtering, or distance-scored results.
373
+ Apply this rule when adding a vector similarity search capability to a Harper table for example, storing text embeddings and querying for nearest neighbors, filtering by distance threshold, or tuning index construction and search parameters. Use it alongside [adding-tables-with-schemas.md](adding-tables-with-schemas.md) when defining the schema that hosts the vector field.
226
374
 
227
375
  #### How It Works
228
376
 
229
- 1. **Declare the vector index on a `[Float]` field**: Add `@indexed(type: "HNSW")` to any `[Float]` attribute in a `@table` type. See [adding-tables-with-schemas.md](adding-tables-with-schemas.md) for general schema setup.
377
+ 1. **Declare the vector index on a field**: Add `@indexed(type: "HNSW")` to a `[Float]` field inside a `@table` type. This creates an HNSW (Hierarchical Navigable Small World) index for approximate nearest-neighbor search.
230
378
 
231
379
  ```graphql
232
380
  type Document @table {
@@ -235,7 +383,7 @@ Apply this rule when adding a vector index to a Harper table schema or writing s
235
383
  }
236
384
  ```
237
385
 
238
- 2. **Query by nearest neighbors using `sort`**: Call `Document.search()` with a `sort` object containing `attribute` (the indexed field name) and `target` (the query vector). Include `limit` to cap results.
386
+ 2. **Query by nearest neighbors using `sort`**: Call `.search()` with a `sort` descriptor that specifies the indexed `attribute` and a `target` vector. Use `limit` to cap results.
239
387
 
240
388
  ```javascript
241
389
  let results = Document.search({
@@ -254,7 +402,7 @@ Apply this rule when adding a vector index to a Harper table schema or writing s
254
402
  });
255
403
  ```
256
404
 
257
- 4. **Filter by distance threshold**: To return only records within a similarity cutoff (without ranking), place `target` directly on the condition alongside `comparator` and `value`. Omit `sort`.
405
+ 4. **Filter by distance threshold**: To return only records within a similarity cutoff (without ranking), place `target` directly on the condition alongside `comparator` and `value`. This bounds result quality rather than ranking by similarity.
258
406
 
259
407
  ```javascript
260
408
  let results = Document.search({
@@ -267,7 +415,7 @@ Apply this rule when adding a vector index to a Harper table schema or writing s
267
415
  });
268
416
  ```
269
417
 
270
- 5. **Include computed distance in results**: Use the special `$distance` field in `select` to return the distance from the target vector. Works with both `sort`-based and `conditions`-based queries.
418
+ 5. **Include computed distance in results**: Use the special `$distance` field in `select` to return the distance from the target vector. Available in both `sort`-based and threshold-based queries.
271
419
 
272
420
  ```javascript
273
421
  let results = Document.search({
@@ -277,43 +425,59 @@ Apply this rule when adding a vector index to a Harper table schema or writing s
277
425
  });
278
426
  ```
279
427
 
280
- 6. **Tune HNSW parameters**: Pass additional parameters to `@indexed(type: "HNSW", ...)` to control index quality and performance.
428
+ 6. **Tune per-query search options**: Pass `distance` and `ef` directly on the `sort` descriptor to override index defaults for a single query.
429
+
430
+ ```javascript
431
+ let results = Document.search({
432
+ sort: { attribute: 'textEmbeddings', target: searchVector, distance: 'dotProduct', ef: 200 },
433
+ limit: 5,
434
+ });
435
+ ```
281
436
 
282
- | Parameter | Default | Description |
283
- | ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------- |
284
- | `distance` | `"cosine"` | Distance function: `"euclidean"` or `"cosine"` (negative cosine similarity) |
285
- | `efConstruction` | `100` | Max nodes explored during index construction. Higher = better recall, lower = better performance |
286
- | `M` | `16` | Preferred connections per graph layer. Higher = more space, better recall for high-dimensional data |
287
- | `optimizeRouting` | `0.5` | Heuristic aggressiveness for omitting redundant connections (0 = off, 1 = most aggressive) |
288
- | `mL` | computed from `M` | Normalization factor for level generation |
289
- | `efSearchConstruction` | `50` | Max nodes explored during search |
437
+ - `distance` overrides the distance function for this query: `"cosine"`, `"euclidean"`, or `"dotProduct"`.
438
+ - `ef` overrides the search exploration budget. Higher values improve recall at the cost of latency.
439
+
440
+ 7. **Configure HNSW index parameters**: Pass parameters directly in the `@indexed` directive. Structural parameters (`distance`, `M`, `efConstruction`, `quantization`) trigger an index rebuild when changed; `efConstructionSearch` does not.
441
+
442
+ ```graphql
443
+ type Document @table {
444
+ id: Long @primaryKey
445
+ textEmbeddings: [Float]
446
+ @indexed(type: "HNSW", distance: "euclidean", optimizeRouting: 0, efConstructionSearch: 100)
447
+ }
448
+ ```
449
+
450
+ 8. **Enable vector quantization**: Use `quantization: "int8"` to store vectors as 8-bit integers, reducing index size and memory usage. Harper re-ranks nearest-neighbor `sort` results against full-precision vectors automatically.
451
+
452
+ ```graphql
453
+ type Document @table {
454
+ id: Long @primaryKey
455
+ textEmbeddings: [Float] @indexed(type: "HNSW", quantization: "int8")
456
+ }
457
+ ```
290
458
 
291
459
  #### Examples
292
460
 
293
- **Schema with custom HNSW parameters:**
461
+ Full schema with custom HNSW parameters and a nearest-neighbor query with distance output:
294
462
 
295
463
  ```graphql
296
464
  type Document @table {
297
465
  id: Long @primaryKey
298
466
  textEmbeddings: [Float]
299
- @indexed(type: "HNSW", distance: "euclidean", optimizeRouting: 0, efSearchConstruction: 100)
467
+ @indexed(type: "HNSW", distance: "euclidean", optimizeRouting: 0, efConstructionSearch: 100)
300
468
  }
301
469
  ```
302
470
 
303
- **Nearest-neighbor search with distance score:**
304
-
305
471
  ```javascript
472
+ // Nearest-neighbor search with distance scores
306
473
  let results = Document.search({
307
474
  select: ['name', '$distance'],
308
475
  sort: { attribute: 'textEmbeddings', target: searchVector },
309
476
  limit: 5,
310
477
  });
311
- ```
312
-
313
- **Distance-threshold filter (no ranking):**
314
478
 
315
- ```javascript
316
- let results = Document.search({
479
+ // Distance-threshold query (no ranking)
480
+ let closeMatches = Document.search({
317
481
  conditions: {
318
482
  attribute: 'textEmbeddings',
319
483
  comparator: 'lt',
@@ -325,81 +489,243 @@ let results = Document.search({
325
489
 
326
490
  #### Notes
327
491
 
328
- - The default `distance` function is `cosine`. Pass `distance: "euclidean"` to switch.
329
- - `efConstruction` controls index build quality; raising it improves recall at the cost of build time.
492
+ ##### HNSW Parameters
493
+
494
+ | Parameter | Default | Description |
495
+ | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------ |
496
+ | `distance` | `"cosine"` | Distance function: `"cosine"`, `"euclidean"`, or `"dotProduct"` |
497
+ | `efConstruction` | `100` | Max nodes explored during index construction. Higher = better recall, lower = better performance |
498
+ | `M` | `16` | Preferred connections per graph layer. Higher = more space, better recall for high-dimensional data |
499
+ | `optimizeRouting` | `0.5` | Heuristic aggressiveness for omitting redundant connections (0 = off, 1 = most aggressive) |
500
+ | `mL` | computed from `M` | Normalization factor for level generation |
501
+ | `efConstructionSearch` | auto-scaled | Max nodes explored during search. When unset, auto-scales with index size; setting it fixes the budget |
502
+ | `quantization` | — | `"int8"` stores vectors quantized to int8 |
503
+
504
+ - The `distance` option on a per-query `sort` descriptor accepts `"cosine"`, `"euclidean"`, or `"dotProduct"`.
505
+ - When no `ef` is passed and `efConstructionSearch` (or `efConstruction`) is not explicitly set on the index, the search budget auto-scales with index size.
506
+ - `efConstruction` seeds the initial value of `efConstructionSearch`; setting either one fixes the search budget.
507
+ - The correct parameter name is `efConstructionSearch` (not `efSearchConstruction`).
330
508
  - `$distance` is available in both `sort`-based ranking and `conditions`-based threshold queries.
331
- - Use the threshold (`conditions` + `target`) form when you want to bound result quality by a similarity cutoff rather than ranking by similarity.
509
+ - For `quantization: "int8"`, distance-threshold (`lt`/`le`) queries filter on approximate distance; `sort` queries re-rank against full-precision vectors.
332
510
 
333
- ### 1.5 Using Blob Datatype
511
+ ### 1.5 Using the Blob Data Type
334
512
 
335
- Instructions for the agent to follow when working with the Blob data type in Harper.
513
+ Instructions for the agent to follow when storing and retrieving large binary content using the `Blob` data type in Harper.
336
514
 
337
515
  #### When to Use
338
516
 
339
- Use this skill when you need to store unstructured or large binary data (media, documents) that is too large for standard JSON fields. Blobs provide efficient storage and integrated streaming support.
517
+ Apply this rule when a schema field needs to store large binary content such as images, video, audio, or large HTML typically content larger than 20KB. Use `Blob` instead of `Bytes` when streaming support and out-of-record storage are required. See [handling-binary-data.md](handling-binary-data.md) for broader binary data guidance.
340
518
 
341
519
  #### How It Works
342
520
 
343
- 1. **Define Blob Fields**: In your GraphQL schema, use the `Blob` type:
521
+ 1. **Declare a `Blob` field in your schema**: Add a field typed as `Blob` to your `@table` type.
522
+
344
523
  ```graphql
345
524
  type MyTable @table {
346
- id: ID @primaryKey
525
+ id: Any! @primaryKey
347
526
  data: Blob
348
527
  }
349
528
  ```
350
- 2. **Create and Store Blobs**: Use `createBlob()` from Harper's globals to wrap Buffers or Streams:
529
+
530
+ 2. **Create and store a blob with `createBlob()`**: Pass a buffer or stream to `createBlob()`, then `put` the record.
531
+
532
+ ```javascript
533
+ let blob = createBlob(largeBuffer);
534
+ await MyTable.put({ id: 'my-record', data: blob });
535
+ ```
536
+
537
+ 3. **Retrieve blob data using standard Web API methods**: The `Blob` type implements the Web API `Blob` interface. Use `.bytes()`, `.text()`, `.arrayBuffer()`, `.stream()`, or `.slice()` as needed.
538
+
351
539
  ```javascript
352
- import { tables } from 'harper';
353
- const blob = createBlob(largeBuffer);
354
- await tables.MyTable.put('my-id', { data: blob });
540
+ let record = await MyTable.get('my-record');
541
+ let buffer = await record.data.bytes(); // ArrayBuffer
542
+ let text = await record.data.text(); // string
543
+ let stream = record.data.stream(); // ReadableStream
355
544
  ```
356
- 3. **Use Streaming (Optional)**: For very large files, pass a stream to `createBlob()` to avoid loading the entire file into memory.
357
- 4. **Read Blob Data**: Retrieve the record and use `.bytes()` or streaming interfaces on the blob field:
545
+
546
+ 4. **Use `saveBeforeCommit` when full write must precede commit**: By default, `Blob` is not ACID-compliant — a record can reference a blob before it is fully written. Set `saveBeforeCommit: true` to block the transaction until the blob is fully saved.
547
+
358
548
  ```javascript
359
- const record = await tables.MyTable.get('my-id');
360
- const buffer = await record.data.bytes();
549
+ let blob = createBlob(stream, { saveBeforeCommit: true });
550
+ await MyTable.put({ id: 'my-record', data: blob });
551
+ // put() resolves only after blob is fully written and record is committed
361
552
  ```
362
- 5. **Ensure Write Completion**: Use `saveBeforeCommit: true` in `createBlob` options if you need the blob fully written before the record is committed.
363
- 6. **Handle Errors**: Attach error listeners to the blob object to handle streaming failures.
553
+
554
+ 5. **Register an error handler when returning a blob via REST**: Interrupted streams must be handled explicitly.
555
+
556
+ ```javascript
557
+ export class MyEndpoint extends MyTable {
558
+ static async get(target) {
559
+ const record = super.get(target);
560
+ let blob = record.data;
561
+ blob.on('error', () => {
562
+ MyTable.invalidate(target);
563
+ });
564
+ return { status: 200, headers: {}, body: blob };
565
+ }
566
+ }
567
+ ```
568
+
569
+ 6. **Rely on automatic coercion where applicable**: When a field is typed as `Blob` in the schema, any string or buffer assigned via `put`, `patch`, or `publish` is automatically coerced to a `Blob` — no manual `createBlob()` call is needed in those cases.
570
+
571
+ ##### `BlobOptions` reference
572
+
573
+ Pass an options object as the second argument to `createBlob()`.
574
+
575
+ | Option | Type | Default | Description |
576
+ | ------------------ | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
577
+ | `type` | `string` | `undefined` | MIME type to associate with the blob (e.g., `image/jpeg`). Readable via `blob.type` and used when serving HTTP. |
578
+ | `size` | `number` | `undefined` | Size of the data in bytes, if known ahead of time. Otherwise inferred from a buffer or determined as a stream completes. |
579
+ | `saveBeforeCommit` | `boolean` | `false` | Wait until the blob is fully written before the transaction commits. |
580
+ | `compress` | `boolean` | `false` | Compress the stored data with deflate. |
581
+ | `flush` | `boolean` | `false` | Flush the file to disk after writing, before the `createBlob` promise chain resolves. |
582
+
583
+ #### Examples
584
+
585
+ **Store an image with a MIME type:**
586
+
587
+ ```javascript
588
+ let blob = createBlob(imageBuffer, { type: 'image/jpeg' });
589
+ await Photo.put({ id, data: blob });
590
+ ```
591
+
592
+ **Stream a blob in as it streams out (low-latency passthrough):**
593
+
594
+ ```javascript
595
+ let blob = createBlob(incomingStream);
596
+ // blob exists, but data is still streaming to storage
597
+ await MyTable.put({ id: 'my-record', data: blob });
598
+
599
+ let record = await MyTable.get('my-record');
600
+ // blob data is accessible as it arrives
601
+ let outgoingStream = record.data.stream();
602
+ ```
603
+
604
+ **Guarantee full write before commit using `saveBeforeCommit`:**
605
+
606
+ ```javascript
607
+ let blob = createBlob(stream, { saveBeforeCommit: true });
608
+ await MyTable.put({ id: 'my-record', data: blob });
609
+ ```
610
+
611
+ #### Notes
612
+
613
+ - `Blob` stores data separately from the record. If you need the binary data to be a true, ACID-committed part of the record, use a `Bytes` field instead.
614
+ - All standard Web API `Blob` methods — `.text()`, `.arrayBuffer()`, `.stream()`, `.slice()`, and `.bytes()` — are available on retrieved blob fields.
615
+ - Without `saveBeforeCommit: true`, blobs are **not** ACID-compliant by default; a record can reference a blob before it is fully written to storage.
364
616
 
365
617
  ### 1.6 Handling Binary Data
366
618
 
367
- Instructions for the agent to follow when handling binary data in Harper.
619
+ Instructions for the agent to follow when storing and serving binary data (images, audio, arbitrary content types) in Harper.
368
620
 
369
621
  #### When to Use
370
622
 
371
- Use this skill when you need to store binary files (images, audio, etc.) in the database or serve them back to clients via REST endpoints.
623
+ Apply this rule when a Harper resource needs to accept, store, or serve binary payloads such as images, audio files, or calendar data. Use it when REST clients send `base64`-encoded data inside JSON, when raw binary is uploaded via `PUT`/`POST`, or when a resource must stream binary back to the client with the correct `Content-Type`.
372
624
 
373
625
  #### How It Works
374
626
 
375
- 1. **Store Binary Data**: In your resource's `post` or `put` method, convert incoming data to Buffers and then to Blobs using `createBlob` from Harper's globals. Include the MIME type if available:
627
+ 1. **Accept base64-encoded binary from JSON clients**: Decode the incoming `base64` string with `Buffer.from` and wrap it using `createBlob`, recording the MIME type. Override `post` in your resource class:
376
628
 
377
629
  ```typescript
378
- async post(target, record) {
379
- if (record.data) {
380
- record.data = createBlob(Buffer.from(record.data, record.encoding || 'base64'), {
381
- type: record.contentType || 'application/octet-stream',
382
- });
383
- }
384
- return super.post(target, record);
630
+ import { type RequestTargetOrId, tables, createBlob } from 'harper';
631
+
632
+ export class Photo extends tables.Photo {
633
+ static async post(target: RequestTargetOrId, record: any) {
634
+ if (record.data) {
635
+ record.data = createBlob(Buffer.from(record.data, record.encoding || 'base64'), {
636
+ type: record.contentType || 'application/octet-stream',
637
+ });
638
+ }
639
+ return super.post(target, record);
640
+ }
385
641
  }
386
642
  ```
387
643
 
388
- 2. **Serve Binary Data**: In your resource's `get` method, return a response object with the appropriate `Content-Type` and the binary data in the `body`:
644
+ 2. **Serve binary from a resource**: Override `get` to return a response object with the blob's MIME type in the `Content-Type` header and the blob as the body. Harper streams it to the client:
645
+
389
646
  ```typescript
390
- async get(target) {
391
- const record = await super.get(target);
392
- if (record?.data) {
393
- return {
394
- status: 200,
395
- headers: { 'Content-Type': record.data.type || 'application/octet-stream' },
396
- body: record.data,
397
- };
398
- }
399
- return record;
647
+ export class Photo extends tables.Photo {
648
+ static async get(target: RequestTargetOrId) {
649
+ const record = await super.get(target);
650
+ if (record?.data) {
651
+ return {
652
+ status: 200,
653
+ headers: { 'Content-Type': record.data.type || 'application/octet-stream' },
654
+ body: record.data,
655
+ };
656
+ }
657
+ return record;
658
+ }
400
659
  }
401
660
  ```
402
- 3. **Use the Blob Type**: Ensure your GraphQL schema uses the `Blob` scalar for binary fields.
661
+
662
+ 3. **Upload raw binary with a non-standard content type**: Make a `PUT` or `POST` with any non-standard `Content-Type` header. Harper automatically stores the body as a record with `contentType` and `data` properties:
663
+
664
+ ```http
665
+ PUT /my-resource/33
666
+ Content-Type: text/calendar
667
+
668
+ BEGIN:VCALENDAR
669
+ VERSION:2.0
670
+ ...
671
+ ```
672
+
673
+ Harper stores this as:
674
+
675
+ ```json
676
+ { "contentType": "text/calendar", "data": "BEGIN:VCALENDAR\nVERSION:2.0\n..." }
677
+ ```
678
+
679
+ Retrieving that record returns the response with the stored `Content-Type` and body. If the content type is not from the `text` family, the data is treated as binary (a Node.js `Buffer`).
680
+
681
+ 4. **Upload binary to a specific property**: Use `application/octet-stream` (or any image/binary MIME type) and target a sub-path to store binary directly on a property:
682
+
683
+ ```http
684
+ PUT /my-resource/33/image
685
+ Content-Type: image/gif
686
+
687
+ ...image data...
688
+ ```
689
+
690
+ #### Examples
691
+
692
+ **End-to-end: accept base64 JSON, store as blob, serve as binary**
693
+
694
+ ```typescript
695
+ import { type RequestTargetOrId, tables, createBlob } from 'harper';
696
+
697
+ export class Photo extends tables.Photo {
698
+ // Accept base64-encoded uploads in JSON
699
+ static async post(target: RequestTargetOrId, record: any) {
700
+ if (record.data) {
701
+ record.data = createBlob(Buffer.from(record.data, record.encoding || 'base64'), {
702
+ type: record.contentType || 'application/octet-stream',
703
+ });
704
+ }
705
+ return super.post(target, record);
706
+ }
707
+
708
+ // Stream the blob back with the correct Content-Type
709
+ static async get(target: RequestTargetOrId) {
710
+ const record = await super.get(target);
711
+ if (record?.data) {
712
+ return {
713
+ status: 200,
714
+ headers: { 'Content-Type': record.data.type || 'application/octet-stream' },
715
+ body: record.data,
716
+ };
717
+ }
718
+ return record;
719
+ }
720
+ }
721
+ ```
722
+
723
+ #### Notes
724
+
725
+ - `createBlob` takes a `Buffer` as its first argument and an options object with a `type` property for the MIME type. See [using-blob-datatype.md](using-blob-datatype.md) for full details on the blob data type.
726
+ - Always fall back to `application/octet-stream` when no MIME type is known, both when creating and when serving blobs.
727
+ - When Harper retrieves a record that has both `contentType` and `data` properties, it automatically sets the response `Content-Type` and body — no custom `get` override is required for that case unless you need additional logic.
728
+ - Non-`text` content types cause `data` to be stored and returned as a Node.js `Buffer`.
403
729
 
404
730
  ## 2. API & Communication
405
731
 
@@ -1021,209 +1347,427 @@ export class RefreshJWT extends Resource {
1021
1347
 
1022
1348
  ### 3.1 Custom Resources
1023
1349
 
1024
- Instructions for the agent to follow when creating custom resources in Harper.
1350
+ Instructions for the agent to follow when defining custom REST endpoints with JavaScript or TypeScript in Harper.
1025
1351
 
1026
1352
  #### When to Use
1027
1353
 
1028
- Use this skill when the automatic CRUD operations provided by `@table @export` are insufficient, and you need custom logic, third-party API integration, or specialized data handling for your REST endpoints.
1354
+ Apply this rule when creating custom HTTP endpoints, wrapping external APIs, or registering routes programmatically in a Harper application. Use it any time business logic must live outside a table-backed schema, or when a specific URL shape is required.
1029
1355
 
1030
1356
  #### How It Works
1031
1357
 
1032
- 1. **Check if a Custom Resource is Necessary**: Verify if [Automatic APIs](./automatic-apis.md) or [Extending Tables](./extending-tables.md) can satisfy the requirement first.
1033
- 2. **Create the Resource File**: Create a `.ts` or `.js` file in the directory specified by `jsResource` in `config.yaml` (typically `resources/`).
1034
- 3. **Define the Resource Class**: Export a class extending `Resource` from `harper` and define **static** methods for the HTTP verbs you handle. In Harper 5 the static methods are the HTTP handlers (mapped 1:1 to verbs); they receive a pre-parsed `RequestTarget`, and write handlers also receive the request body as an awaitable `data` argument:
1358
+ 1. **Import `Resource` from `harper`**: Always import from the `harper` package rather than relying on globals.
1035
1359
 
1036
- ```typescript
1037
- import { Resource } from 'harper';
1360
+ ```javascript
1361
+ import { tables, Resource } from 'harper';
1362
+ ```
1363
+
1364
+ 2. **Define a class that `extends Resource`**: Implement HTTP methods as `static` methods. Each method receives a `target` object.
1038
1365
 
1039
- export class MyResource extends Resource {
1040
- // v5 handlers are static and map 1:1 to HTTP verbs.
1041
- static async get(target: any) {
1042
- return { message: 'Hello from custom GET!' };
1366
+ ```javascript
1367
+ export class CustomEndpoint extends Resource {
1368
+ static get(target) {
1369
+ return {
1370
+ data: doSomething(),
1371
+ };
1043
1372
  }
1044
1373
  }
1045
1374
  ```
1046
1375
 
1047
- 4. **Implement HTTP Methods**: Add static methods (`get`, `post`, `put`, `patch`, or `delete`) to handle the corresponding requests. Read/delete handlers receive `(target)`; write handlers receive `(target, data)` where `data` is awaitable.
1048
- 5. **Route Nesting and Naming**: You can control the URL structure by how you export your resources:
1049
- - **Direct Class Export**: `export class Foo extends Resource` creates endpoints at `/Foo/`. Class names are case-sensitive in the URL.
1050
- - **Nested Objects**: `export const Bar = { Foo };` creates endpoints at `/Bar/Foo/`.
1051
- - **Lowercase and Hyphens**: Use object keys to define custom paths: `export const bar = { 'foo-baz': Foo };` exposes endpoints at `/bar/foo-baz/`.
1052
- 6. **Access Tables (Optional)**: Import and use the `tables` object to interact with your data:
1053
- ```typescript
1054
- import { tables } from 'harper';
1055
- // ... inside a method
1056
- const results = await tables.MyTable.list();
1376
+ 3. **Use `async` static methods for external calls**: Await fetch or other async operations inside `static` handlers.
1377
+
1378
+ ```javascript
1379
+ export class MyExternalData extends Resource {
1380
+ static async get(target) {
1381
+ const response = await fetch(`https://api.example.com/${target.id}`);
1382
+ return response.json();
1383
+ }
1384
+
1385
+ static async put(target, data) {
1386
+ return fetch(`https://api.example.com/${target.id}`, {
1387
+ method: 'PUT',
1388
+ body: JSON.stringify(await data),
1389
+ });
1390
+ }
1391
+ }
1057
1392
  ```
1058
- 7. **Configure Loading**: Ensure `config.yaml` points to your resource files (e.g., `jsResource: { files: 'resources/*.ts' }`).
1393
+
1394
+ 4. **Export the class to create an endpoint**: The export form controls the resulting URL. Choose the form that matches the URL shape you need.
1395
+
1396
+ | Export form | URL | Notes |
1397
+ | ------------------------------------------- | --------------- | --------------------------------------------------------------- |
1398
+ | `export class Foo extends Resource {}` | `/Foo/` | Class name becomes the path segment. Case-sensitive. |
1399
+ | `export const Bar = { Foo };` | `/Bar/Foo/` | Nest under an object to add a path prefix. |
1400
+ | `export const bar = { 'foo-baz': Foo };` | `/bar/foo-baz/` | Use object keys for lowercase, hyphens, or non-identifier URLs. |
1401
+ | `export { Foo as '/widget/:id' }` | `/widget/:id` | Rename the export to set the path directly. |
1402
+ | `static path = '/widget/:id'` (class field) | `/widget/:id` | Declare path on the class; overrides the export name. |
1403
+ | `server.resources.set('my-path', Foo);` | `/my-path/` | Programmatic registration for dynamic paths. |
1404
+
1405
+ URL path matching is case-sensitive — `/Foo/` and `/foo/` are different endpoints.
1406
+
1407
+ 5. **Declare path parameters with `static path`**: Use `:name` for a single segment and `*name` as a catch-all. Matched values are bound onto `target.<name>`.
1408
+
1409
+ ```javascript
1410
+ export class Widget extends Resource {
1411
+ static path = '/widget/:id/action/:action';
1412
+ static get(target) {
1413
+ return { id: target.id, action: target.action };
1414
+ }
1415
+ }
1416
+ ```
1417
+
1418
+ A `static path` takes precedence over the export name. A leading `/` makes the path root-relative (top-level). A leading `./` or bare name resolves relative to the component directory.
1419
+
1420
+ 6. **Register programmatically when the path is dynamic**: Use `server.resources.set(` when the path cannot be known at export time.
1421
+
1422
+ ```javascript
1423
+ server.resources.set('my-path', Foo);
1424
+ ```
1425
+
1426
+ 7. **Optionally source a table from a custom resource**: Use the resource as a caching layer for a local table.
1427
+ ```javascript
1428
+ tables.MyCache.sourcedFrom(MyExternalData);
1429
+ ```
1430
+
1431
+ #### Examples
1432
+
1433
+ ##### External API wrapper with GET and PUT
1434
+
1435
+ ```javascript
1436
+ import { tables, Resource } from 'harper';
1437
+
1438
+ export class MyExternalData extends Resource {
1439
+ static async get(target) {
1440
+ const response = await fetch(`https://api.example.com/${target.id}`);
1441
+ return response.json();
1442
+ }
1443
+
1444
+ static async put(target, data) {
1445
+ return fetch(`https://api.example.com/${target.id}`, {
1446
+ method: 'PUT',
1447
+ body: JSON.stringify(await data),
1448
+ });
1449
+ }
1450
+ }
1451
+
1452
+ // Use as a cache source for a local table
1453
+ tables.MyCache.sourcedFrom(MyExternalData);
1454
+ ```
1455
+
1456
+ ##### Path parameters with `static path`
1457
+
1458
+ ```javascript
1459
+ import { Resource } from 'harper';
1460
+
1461
+ export class Widget extends Resource {
1462
+ // GET /widget/10/action/jump -> target.id === '10', target.action === 'jump'
1463
+ static path = '/widget/:id/action/:action';
1464
+ static get(target) {
1465
+ return { id: target.id, action: target.action };
1466
+ }
1467
+ }
1468
+
1469
+ export class Files extends Resource {
1470
+ // GET /files/a/b/c.txt -> target.rest === 'a/b/c.txt'
1471
+ static path = '/files/*rest';
1472
+ static get(target) {
1473
+ return { path: target.rest };
1474
+ }
1475
+ }
1476
+ ```
1477
+
1478
+ ##### Programmatic registration
1479
+
1480
+ ```javascript
1481
+ import { Resource } from 'harper';
1482
+
1483
+ export class Foo extends Resource {
1484
+ static get(target) {
1485
+ return { data: doSomething() };
1486
+ }
1487
+ }
1488
+
1489
+ server.resources.set('my-path', Foo);
1490
+ ```
1491
+
1492
+ #### Notes
1493
+
1494
+ - A bare `*` wildcard (no name) binds under `target.wildcard`. A wildcard must be the final segment of the path.
1495
+ - Resolution order: exact/static paths always win over parameterized ones. Among parameterized routes, more specific paths win — a literal segment beats `:param`, which beats `*`, compared left to right.
1496
+ - Parameterized routes appear in the generated OpenAPI document as templated paths (e.g. `/widget/{id}/action/{action}`) and in MCP `resources/templates/list` as `{param}` URI templates.
1497
+ - If a resource `extends` an existing table, avoid conflicting exports between the schema and the JavaScript implementation.
1498
+ - Link the `harper` package in your component directory to ensure correct typings: `npm link harper`. All installed components have `harper` automatically linked.
1499
+ - Harper runs as a single process — `tables`, `databases`, and other APIs are the same live, process-wide objects regardless of which component accesses them.
1059
1500
 
1060
1501
  ### 3.2 Extending Tables
1061
1502
 
1062
- Instructions for the agent to follow when extending table resources in Harper.
1503
+ Instructions for the agent to follow when adding custom logic to automatically generated table resources in Harper.
1063
1504
 
1064
1505
  #### When to Use
1065
1506
 
1066
- Use this skill when you need to add custom validation, side effects (like webhooks), data transformation, or custom access control to the standard CRUD operations of a Harper table.
1507
+ Apply this rule when you need to add computed properties, intercept writes, enforce validation, or otherwise customize the behavior of a Harper table resource beyond what the default generated endpoints provide. Use it any time a `@table` type needs server-side logic attached to its REST handlers.
1067
1508
 
1068
1509
  #### How It Works
1069
1510
 
1070
- 1. **Define the Table in GraphQL**: In your `.graphql` schema, define the table using the `@table` directive. **Do not** use `@export` if you plan to extend it.
1511
+ 1. **Define the schema without `@export`**: Declare the table type in `schema.graphql` and omit the `@export` directive. Leaving `@export` on the schema while also exporting a subclass with the same name produces conflicting endpoints. Let the JavaScript class own the URL instead.
1512
+
1071
1513
  ```graphql
1514
+ # Omit the `@export` directive
1072
1515
  type MyTable @table {
1073
- id: ID @primaryKey
1074
- name: String
1516
+ id: Long @primaryKey
1517
+ # ...
1075
1518
  }
1076
1519
  ```
1077
- 2. **Create the Extension File**: Create a `.ts` file in your `resources/` directory.
1078
- 3. **Extend the Table Resource**: Export a class that extends `tables.YourTableName` and override the relevant **static** methods. In Harper 5 resource handlers are static and map 1:1 to HTTP verbs: `get(target)`, `post(target, data)`, `put(target, data)`, `patch(target, data)`, `delete(target)`. `target` is a pre-parsed `RequestTarget`; for writes, `data` is the request body and is **awaitable** (`await data`). Delegate to `super` to keep Harper's default behavior — a collection create passes just the record (`super.post(record)`), updates pass the target (`super.put(target, data)` / `super.patch(target, data)`), and reads/deletes pass the target (`super.get(target)`). To return a specific HTTP status from a thrown error, set **`.statusCode`** (e.g. `400`) on the error — a plain `.status` property is ignored.
1079
1520
 
1080
- ```typescript
1081
- import { tables } from 'harper';
1521
+ 2. **Extend the generated table class**: In `resources.js`, extend from the `tables.<TypeName>` global. The class name you export becomes the URL path. The exported class extends tables.
1082
1522
 
1523
+ ```javascript
1083
1524
  export class MyTable extends tables.MyTable {
1084
- // Static handler; receives (target, data) — data is awaitable.
1085
- static async post(target: any, data: any) {
1086
- const record = await data;
1087
- if (!record?.name) {
1088
- const error: any = new Error('Name is required');
1089
- error.statusCode = 400; // HTTP status (use statusCode, NOT status)
1090
- throw error;
1091
- }
1092
- return super.post(record); // create delegates with the record (no id)
1525
+ static async get(target) {
1526
+ const record = await super.get(target);
1527
+ return { ...record, computedField: 'value' };
1528
+ }
1529
+
1530
+ static async post(target, data) {
1531
+ this.create({ ...(await data), status: 'pending' });
1093
1532
  }
1094
1533
  }
1095
1534
  ```
1096
1535
 
1097
- 4. **Override Methods**: Override the static `get`, `post`, `put`, `patch`, or `delete` as needed, delegating to `super.<method>` (see the argument forms above) to preserve Harper's default behavior unless you intend to replace it entirely.
1098
- 5. **Implement Logic**: Use overrides for validation, side effects, or transforming data before/after database operations.
1536
+ 3. **Call `super` to preserve default behavior**: When delegating to `super`, match the argument form to the operation:
1537
+ - Reads/deletes: `super.get(target)` / `super.delete(target)`
1538
+ - Collection create: `super.post(target, record)` — target carries no id
1539
+ - Updates: `super.put(target, data)` / `super.patch(target, data)`
1540
+
1541
+ Omit the `super` call only if you intend to replace the default behavior entirely.
1542
+
1543
+ 4. **Set `statusCode` on thrown errors to control HTTP responses**: Uncaught errors are caught by the protocol handler and produce error responses for REST. Use `.statusCode` — a plain `.status` property is ignored.
1544
+
1545
+ ```javascript
1546
+ const error = new Error('Name is required');
1547
+ error.statusCode = 400; // use statusCode, NOT status
1548
+ throw error;
1549
+ ```
1550
+
1551
+ 5. **Configure Harper to load both files**: Ensure your configuration references the schema and resource files.
1552
+
1553
+ ```yaml
1554
+ rest: true
1555
+ graphqlSchema:
1556
+ files: schema.graphql
1557
+ jsResource:
1558
+ files: resources.js
1559
+ ```
1560
+
1561
+ #### Examples
1562
+
1563
+ Full end-to-end example — schema, resource class, and error handling:
1564
+
1565
+ ```graphql
1566
+ # schema.graphql — omit @export so the JS class owns the endpoint
1567
+ type MyTable @table {
1568
+ id: Long @primaryKey
1569
+ }
1570
+ ```
1571
+
1572
+ ```javascript
1573
+ // resources.js
1574
+ export class MyTable extends tables.MyTable {
1575
+ static async get(target) {
1576
+ // get the record from the database
1577
+ const record = await super.get(target);
1578
+ // add a computed property before returning
1579
+ return { ...record, computedField: 'value' };
1580
+ }
1581
+
1582
+ static async post(target, data) {
1583
+ // custom action on POST
1584
+ this.create({ ...(await data), status: 'pending' });
1585
+ }
1586
+ }
1587
+ ```
1588
+
1589
+ Throwing a controlled HTTP error:
1590
+
1591
+ ```javascript
1592
+ if (!authorized) {
1593
+ const error = new Error('Forbidden');
1594
+ error.statusCode = 403;
1595
+ throw error;
1596
+ }
1597
+ ```
1598
+
1599
+ #### Notes
1600
+
1601
+ - Always omit `@export` from the schema type when a JavaScript subclass is exporting the same name. The two registrations conflict.
1602
+ - `super` must be called with the correct arguments for each operation type — mismatched arguments will not behave as expected.
1603
+ - `statusCode` is the only recognized property for controlling HTTP status on thrown errors; `.status` is ignored.
1099
1604
 
1100
1605
  ### 3.3 Programmatic Table Requests
1101
1606
 
1102
- Instructions for the agent to follow when interacting with Harper tables via code.
1607
+ Instructions for the agent to interact with Harper tables programmatically using the `tables` object and its query API.
1103
1608
 
1104
1609
  #### When to Use
1105
1610
 
1106
- Use this skill when you need to perform database operations (CRUD, search, subscribe) from within Harper Resources or scripts.
1611
+ Apply this rule when writing server-side Harper code that reads from or writes to tables directly — for example, in request handlers, background jobs, or SSR entry points — instead of going through the REST API. Use it whenever you need to construct queries with `conditions`, paginate results, select specific fields, or perform CRDT-safe mutations with `addTo`.
1107
1612
 
1108
1613
  #### How It Works
1109
1614
 
1110
- 1. **Access the Table**: Use the global `tables` object followed by your table name (e.g., `tables.MyTable`).
1111
- 2. **Perform CRUD Operations**:
1112
- - **Get**: `await tables.MyTable.get(id)` for a single record or `await tables.MyTable.get({ conditions: [...] })` for multiple.
1113
- - **Create**: `await tables.MyTable.post(record)` (auto-generates ID) or `await tables.MyTable.put(id, record)`.
1114
- - **Update**: `await tables.MyTable.patch(id, partialRecord)` for partial updates.
1115
- - **Delete**: `await tables.MyTable.delete(id)`.
1116
- 3. **Use Updatable Records for Atomic Ops**: Call `update(id)` to get a reference, then use `addTo` or `subtractFrom` for atomic increments/decrements:
1117
- ```typescript
1118
- const stats = await tables.Stats.update('daily');
1119
- stats.addTo('viewCount', 1);
1120
- ```
1121
- 4. **Search and Stream**: Use `search(query)` for efficient streaming of large result sets:
1122
- ```typescript
1123
- for await (const record of tables.MyTable.search({ conditions: [...] })) {
1124
- // process record
1125
- }
1615
+ 1. **Import `tables`**: Import from the `harper` package. Each table defined in `schema.graphql` with `@table` is available as a named property.
1616
+
1617
+ ```javascript
1618
+ import { tables } from 'harper';
1619
+ const { Product } = tables;
1620
+ // same as: databases.data.Product
1126
1621
  ```
1127
- See the [Query Conditions](#query-conditions) section below for the full query object reference.
1128
- 5. **Real-time Subscriptions**: Use `subscribe(query)` to listen for changes:
1129
- ```typescript
1130
- for await (const event of tables.MyTable.subscribe(query)) {
1131
- // handle event
1622
+
1623
+ 2. **Define your schema**: Declare tables in `schema.graphql` using `@table` and `@primaryKey`.
1624
+
1625
+ ```graphql
1626
+ # schema.graphql
1627
+ type Product @table {
1628
+ id: Long @primaryKey
1629
+ name: String
1630
+ price: Float
1132
1631
  }
1133
1632
  ```
1134
- 6. **Publish Events**: Use `publish(id, message)` to trigger subscriptions without necessarily persisting data.
1135
1633
 
1136
- #### Query Conditions
1634
+ 3. **Create and mutate records**: Use `create`, `patch`, `get`, and `update` on the table class.
1137
1635
 
1138
- When passing a query to `search()`, `get()`, or `subscribe()`, use a query object with a `conditions` array.
1636
+ ```javascript
1637
+ // Create a new record (id auto-generated)
1638
+ const created = await Product.create({ name: 'Shirt', price: 9.5 });
1139
1639
 
1140
- ##### Condition Object Shape
1640
+ // Modify the record
1641
+ await Product.patch(created.id, { price: Math.round(created.price * 0.8 * 100) / 100 });
1141
1642
 
1142
- | Property | Description |
1143
- | ------------ | ------------------------------------------------------------------------------------------ |
1144
- | `attribute` | Field name, or array of field names to traverse a relationship (e.g., `['brand', 'name']`) |
1145
- | `value` | The value to compare against |
1146
- | `comparator` | One of the comparator strings below (default: `equals`) |
1147
- | `operator` | `and` (default) or `or` — applies to a nested `conditions` block |
1148
- | `conditions` | Nested array of condition objects for complex AND/OR logic |
1643
+ // Retrieve by primary key
1644
+ const record = await Product.get(created.id);
1645
+ ```
1149
1646
 
1150
- ##### Comparator Values
1647
+ 4. **Query with `search(` and `conditions`**: Pass a query object to `search()` to filter records. Iterate the async result.
1151
1648
 
1152
- Use these exact strings — incorrect comparator names will silently fail or error:
1649
+ ```javascript
1650
+ const query = {
1651
+ conditions: [{ attribute: 'price', comparator: 'less_than', value: 8.0 }],
1652
+ };
1653
+ for await (const record of Product.search(query)) {
1654
+ // ...
1655
+ }
1656
+ ```
1657
+
1658
+ 5. **Use `select` to shape results**: Pass a `select` array to return only specific properties, including nested relationship fields.
1659
+
1660
+ ```javascript
1661
+ const book = await Book.get({ id: 42, select: ['id', 'title', 'author'] });
1662
+ book.author.name; // full related Author record
1663
+
1664
+ // Partial related record
1665
+ const book = await Book.get({
1666
+ id: 42,
1667
+ select: ['id', 'title', { name: 'author', select: ['name'] }],
1668
+ });
1669
+ ```
1153
1670
 
1154
- | Comparator | Meaning |
1155
- | -------------------- | ---------------------------------------------------------- |
1156
- | `equals` | Exact match (default) |
1157
- | `not_equal` | Not equal |
1158
- | `greater_than` | `>` |
1159
- | `greater_than_equal` | `>=` |
1160
- | `less_than` | `<` |
1161
- | `less_than_equal` | `<=` |
1162
- | `starts_with` | String starts with value |
1163
- | `contains` | String contains value |
1164
- | `ends_with` | String ends with value |
1165
- | `between` | Value is between two bounds (pass `value` as `[min, max]`) |
1671
+ 6. **Use `addTo` for concurrent-safe increments**: Call `addTo` on a mutable resource instance obtained via `update()`. This uses CRDT incrementation, safe across threads and nodes.
1166
1672
 
1167
- ##### Query Object Parameters
1673
+ ```javascript
1674
+ static async post(target, data) {
1675
+ const record = await this.update(target.id);
1676
+ record.addTo('quantity', -1); // decrement safely across nodes
1677
+ }
1678
+ ```
1168
1679
 
1169
- | Property | Description |
1170
- | ------------ | ------------------------------------------------------------------------------------ |
1171
- | `conditions` | Array of condition objects |
1172
- | `limit` | Maximum number of records to return |
1173
- | `offset` | Number of records to skip (for pagination) |
1174
- | `select` | Array of attribute names to return; supports `$id` and `$updatedtime` |
1175
- | `sort` | Object with `attribute`, `descending` (bool), and optional `next` for secondary sort |
1680
+ 7. **Scope destructive operations carefully**: `update`, `patch`, and `delete` operate directly on stored data. Always use specific `conditions`, validate the affected set before writing, and gate behind authorization controls.
1176
1681
 
1177
- ##### Examples
1682
+ #### Examples
1178
1683
 
1179
- **Simple filter:**
1684
+ ##### Nested conditions query
1180
1685
 
1181
1686
  ```javascript
1182
- for await (const record of tables.Product.search({
1183
- conditions: [{ attribute: 'price', comparator: 'less_than', value: 100 }],
1184
- limit: 20,
1185
- })) { ... }
1687
+ Product.search({
1688
+ conditions: [
1689
+ { attribute: 'price', comparator: 'less_than', value: 100 },
1690
+ {
1691
+ operator: 'or',
1692
+ conditions: [
1693
+ { attribute: 'rating', comparator: 'greater_than', value: 4 },
1694
+ { attribute: 'featured', value: true },
1695
+ ],
1696
+ },
1697
+ ],
1698
+ });
1186
1699
  ```
1187
1700
 
1188
- **AND + nested OR:**
1701
+ ##### Chained attribute reference (relationship join)
1189
1702
 
1190
1703
  ```javascript
1191
- for await (const record of tables.Product.search({
1192
- conditions: [
1193
- { attribute: 'price', comparator: 'less_than', value: 100 },
1194
- {
1195
- operator: 'or',
1196
- conditions: [
1197
- { attribute: 'rating', comparator: 'greater_than', value: 4 },
1198
- { attribute: 'featured', value: true },
1199
- ],
1200
- },
1201
- ],
1202
- })) { ... }
1203
- ```
1204
-
1205
- **Relationship traversal:**
1704
+ Product.search({ conditions: [{ attribute: ['brand', 'name'], value: 'Harper' }] });
1705
+ ```
1706
+
1707
+ ##### Deep nested `select`
1206
1708
 
1207
1709
  ```javascript
1208
- for await (const record of tables.Book.search({
1209
- conditions: [{ attribute: ['brand', 'name'], comparator: 'equals', value: 'Harper' }],
1210
- })) { ... }
1710
+ select: [
1711
+ 'id',
1712
+ 'name',
1713
+ { name: 'segments', select: ['id', 'name', { name: 'client', select: ['id', 'name'] }] },
1714
+ ];
1211
1715
  ```
1212
1716
 
1213
- **Sort and paginate:**
1717
+ ##### SSR usage
1214
1718
 
1215
- ```javascript
1216
- for await (const record of tables.Product.search({
1217
- conditions: [{ attribute: 'inStock', value: true }],
1218
- sort: { attribute: 'price', descending: false },
1219
- limit: 10,
1220
- offset: 20,
1221
- })) { ... }
1719
+ ```typescript
1720
+ import { tables } from 'harper';
1721
+
1722
+ export async function render(url: string): Promise<string> {
1723
+ const product = await tables.Product.get(idFromUrl(url));
1724
+ return renderToString(/* <App product={product} /> */);
1725
+ }
1222
1726
  ```
1223
1727
 
1224
- #### Cautions
1728
+ #### Notes
1729
+
1730
+ ##### `conditions` comparator values
1731
+
1732
+ | Comparator | Description |
1733
+ | -------------------- | ---------------------- |
1734
+ | `equals` | Default equality match |
1735
+ | `greater_than` | Strictly greater |
1736
+ | `greater_than_equal` | Greater than or equal |
1737
+ | `less_than` | Strictly less |
1738
+ | `less_than_equal` | Less than or equal |
1739
+ | `starts_with` | String prefix match |
1740
+ | `contains` | String contains |
1741
+ | `ends_with` | String suffix match |
1742
+ | `between` | Range match |
1743
+ | `not_equal` | Inequality match |
1744
+
1745
+ ##### Query object options
1225
1746
 
1226
- Be very careful when performing updates and deletions! You may be dealing with live production data. The wrong request to delete, without approval from a human, could be devastating to a business. Always use the proper approval process.
1747
+ | Property | Description |
1748
+ | ----------------------- | ----------------------------------------------------------------------- |
1749
+ | `conditions` | Array of condition objects to filter records |
1750
+ | `operator` | Top-level `and` (default) or `or` for the `conditions` array |
1751
+ | `limit` | Maximum number of records to return |
1752
+ | `offset` | Number of records to skip (for pagination) |
1753
+ | `select` | Properties to include in each returned record |
1754
+ | `sort` | Sort order object with `attribute`, `descending`, and `next` properties |
1755
+ | `explain` | If `true`, returns conditions reordered as Harper will execute them |
1756
+ | `enforceExecutionOrder` | If `true`, forces conditions to execute in the order supplied |
1757
+
1758
+ ##### `select` special properties
1759
+
1760
+ - `$id` — Returns the primary key regardless of its name
1761
+ - `$updatedtime` — Returns the last-updated timestamp
1762
+ - `$distance` — Returns the computed distance from the target vector when the query uses a vector index
1763
+
1764
+ ##### Relationship join behavior
1765
+
1766
+ - Selecting a relationship **without** filtering on it behaves as a **LEFT JOIN** — records with no related row are still returned.
1767
+ - Adding a `conditions` entry on a related attribute (e.g. `attribute: ['author', 'name']`) behaves as an **INNER JOIN** — only records with a matching related row are returned.
1768
+
1769
+ - Keep `harper` external when bundling for SSR (e.g. `ssr: { external: ['harper'] }` in `vite.config`) so it resolves to the runtime instead of being bundled.
1770
+ - `tables`, `databases`, and other Harper APIs are the same live, process-wide objects regardless of whether accessed as globals or via `import { tables } from 'harper'`.
1227
1771
 
1228
1772
  ### 3.4 TypeScript Type Stripping in Harper
1229
1773
 
@@ -1281,15 +1825,15 @@ jsResource:
1281
1825
 
1282
1826
  ### 3.5 Caching External Data Sources in Harper
1283
1827
 
1284
- Instructions for the agent to implement integrated data caching in Harper by wrapping external sources with a cache table and `sourcedFrom`.
1828
+ Instructions for the agent to implement integrated data caching from external sources using Harper's cache table directives and `sourcedFrom` API.
1285
1829
 
1286
1830
  #### When to Use
1287
1831
 
1288
- Apply this rule when a Harper application needs to cache responses from an external API, microservice, or database to avoid repeated slow or expensive upstream calls. Use it whenever you need to define TTL-based cache expiration, observe ETag-based conditional responses, or manually invalidate cached entries.
1832
+ Apply this rule when an application needs to wrap an external API, microservice, or database with a fast local cache. Use it when you need to define TTL-based cache expiration, connect an upstream data source to a Harper table, or implement on-demand cache invalidation.
1289
1833
 
1290
1834
  #### How It Works
1291
1835
 
1292
- 1. **Define a cache table with `expiration`**: In `schema.graphql`, add the `expiration` argument to `@table`. The value is in seconds. Any record older than this threshold is considered stale and will be re-fetched on next access.
1836
+ 1. **Define a cache table with `expiration`**: Add the `expiration` argument to the `@table` directive in `schema.graphql`. The value is in seconds. When a record becomes stale, the next request fetches a fresh copy from the upstream source.
1293
1837
 
1294
1838
  ```graphql
1295
1839
  type JokeCache @table(expiration: 60) @export {
@@ -1299,7 +1843,7 @@ Apply this rule when a Harper application needs to cache responses from an exter
1299
1843
  }
1300
1844
  ```
1301
1845
 
1302
- 2. **Wrap the external source in `resources.js`**: Create an object with a `get(id)` method that fetches from the upstream source. Then call `sourcedFrom` on the table to register it.
1846
+ 2. **Implement an upstream source object**: In `resources.js`, create an object with a `get(id)` method that fetches data from the external API.
1303
1847
 
1304
1848
  ```javascript
1305
1849
  const jokeAPI = {
@@ -1308,18 +1852,22 @@ Apply this rule when a Harper application needs to cache responses from an exter
1308
1852
  return response.json();
1309
1853
  },
1310
1854
  };
1855
+ ```
1311
1856
 
1857
+ 3. **Connect the source with `sourcedFrom`**: Call `sourcedFrom` on the table to register the upstream source. Harper will call `jokeAPI.get()` automatically when a record is missing or stale.
1858
+
1859
+ ```javascript
1312
1860
  tables.JokeCache.sourcedFrom(jokeAPI);
1313
1861
  ```
1314
1862
 
1315
- Harper's caching behavior after `sourcedFrom` is registered:
1316
- - A request arrives for `/JokeCache/1`.
1317
- - Harper checks if the record with id `1` exists in `JokeCache` and is not stale.
1863
+ Harper's request flow after `sourcedFrom` is registered:
1864
+ - Request arrives for `/JokeCache/1`.
1865
+ - Harper checks if the record exists and is not stale.
1318
1866
  - If fresh, Harper returns it immediately.
1319
1867
  - If missing or stale, Harper calls `jokeAPI.get()`, stores the result in `JokeCache`, and returns it.
1320
1868
  - Multiple simultaneous requests for the same missing or stale record wait on a single upstream call — Harper prevents cache stampedes automatically.
1321
1869
 
1322
- 3. **Configure plugins in `config.yaml`**: Enable the schema, REST API, and JS resource plugins.
1870
+ 4. **Configure plugins in `config.yaml`**: Enable `graphqlSchema`, `rest`, and `jsResource`.
1323
1871
 
1324
1872
  ```yaml
1325
1873
  graphqlSchema:
@@ -1329,29 +1877,7 @@ Apply this rule when a Harper application needs to cache responses from an exter
1329
1877
  files: 'resources.js'
1330
1878
  ```
1331
1879
 
1332
- 4. **Observe caching via ETags**: Harper automatically computes an ETag from the record's last-modified timestamp. On the first request you receive a `200` with an `etag` header. Pass that value back in `If-None-Match` on subsequent requests; Harper returns `304 Not Modified` with an empty body if the record is unchanged.
1333
-
1334
- ```bash
1335
- curl -i 'http://localhost:9926/JokeCache/1' \
1336
- -H 'If-None-Match: "abCDefGHij"'
1337
- ```
1338
-
1339
- 5. **Force a cache bypass**: Send `Cache-Control: no-cache` to make Harper skip the local cache and always call the upstream source, regardless of TTL.
1340
-
1341
- ```bash
1342
- curl -i 'http://localhost:9926/JokeCache/1' \
1343
- -H 'Cache-Control: no-cache'
1344
- ```
1345
-
1346
- 6. **Invalidate a cache entry on demand**: Remove `@export` from the schema type, then export a class of the same name in `resources.js` that extends the table and implements a `post` handler calling `this.invalidate(target)`.
1347
-
1348
- ```graphql
1349
- type JokeCache @table(expiration: 60) {
1350
- id: ID @primaryKey
1351
- setup: String
1352
- punchline: String
1353
- }
1354
- ```
1880
+ 5. **Implement on-demand invalidation**: To invalidate a cache entry before its TTL expires, export a class extending the table and call `this.invalidate(target)` in a `post` handler. Remove `@export` from the schema when using this pattern the exported class provides the endpoint.
1355
1881
 
1356
1882
  ```javascript
1357
1883
  export class JokeCache extends tables.JokeCache {
@@ -1365,34 +1891,25 @@ Apply this rule when a Harper application needs to cache responses from an exter
1365
1891
  }
1366
1892
  ```
1367
1893
 
1368
- Trigger invalidation with a `POST`:
1894
+ Update the schema to remove `@export`:
1369
1895
 
1370
- ```bash
1371
- curl -X POST 'http://localhost:9926/JokeCache/1' \
1372
- -H 'Content-Type: application/json' \
1373
- -d '{"action": "invalidate"}'
1896
+ ```graphql
1897
+ type JokeCache @table(expiration: 60) {
1898
+ id: ID @primaryKey
1899
+ setup: String
1900
+ punchline: String
1901
+ }
1374
1902
  ```
1375
1903
 
1376
- The next `GET /JokeCache/1` will fetch fresh data from the upstream source regardless of TTL.
1377
-
1378
1904
  #### Examples
1379
1905
 
1380
- Complete `schema.graphql` and `resources.js` for a cached external API with on-demand invalidation:
1381
-
1382
- ```graphql
1383
- type JokeCache @table(expiration: 60) {
1384
- id: ID @primaryKey
1385
- setup: String
1386
- punchline: String
1387
- }
1388
- ```
1906
+ **Complete `resources.js`**:
1389
1907
 
1390
1908
  ```javascript
1391
1909
  // resources.js
1392
1910
 
1393
1911
  const jokeAPI = {
1394
- async get() {
1395
- const id = this.getId();
1912
+ async get(id) {
1396
1913
  const response = await fetch(`https://official-joke-api.appspot.com/jokes/${id}`);
1397
1914
  return response.json();
1398
1915
  },
@@ -1411,26 +1928,59 @@ export class JokeCache extends tables.JokeCache {
1411
1928
  }
1412
1929
  ```
1413
1930
 
1414
- First request — cache miss, upstream is called, `200` returned:
1931
+ **Complete `schema.graphql`**:
1415
1932
 
1416
- ```bash
1417
- curl -i 'http://localhost:9926/JokeCache/1'
1933
+ ```graphql
1934
+ type JokeCache @table(expiration: 60) {
1935
+ id: ID @primaryKey
1936
+ setup: String
1937
+ punchline: String
1938
+ }
1418
1939
  ```
1419
1940
 
1420
- Second request with ETag — cache hit, `304 Not Modified`:
1941
+ **Fetch a cached record**:
1421
1942
 
1422
- ```bash
1423
- curl -i 'http://localhost:9926/JokeCache/1' \
1424
- -H 'If-None-Match: "abCDefGHij"'
1943
+ ```javascript
1944
+ const response = await fetch('http://localhost:9926/JokeCache/1');
1945
+ console.log(response.status); // 200
1946
+ const etag = response.headers.get('etag'); // e.g. "abCDefGHij"
1947
+ const joke = await response.json();
1948
+ ```
1949
+
1950
+ **Use ETag for conditional requests** (returns `304 Not Modified` if unchanged):
1951
+
1952
+ ```javascript
1953
+ const second = await fetch('http://localhost:9926/JokeCache/1', {
1954
+ headers: { 'If-None-Match': etag },
1955
+ });
1956
+ console.log(second.status); // 304
1957
+ ```
1958
+
1959
+ **Bypass the cache with `Cache-Control: no-cache`**:
1960
+
1961
+ ```javascript
1962
+ const response = await fetch('http://localhost:9926/JokeCache/1', {
1963
+ headers: { 'Cache-Control': 'no-cache' },
1964
+ });
1965
+ ```
1966
+
1967
+ **Trigger invalidation via POST**:
1968
+
1969
+ ```javascript
1970
+ await fetch('http://localhost:9926/JokeCache/1', {
1971
+ method: 'POST',
1972
+ headers: { 'Content-Type': 'application/json' },
1973
+ body: JSON.stringify({ action: 'invalidate' }),
1974
+ });
1425
1975
  ```
1426
1976
 
1427
1977
  #### Notes
1428
1978
 
1429
1979
  - `expiration` is measured in seconds. Harper also supports separate `eviction` and `scanInterval` arguments on `@table` for fine-grained control over physical record removal.
1430
- - The `@export` directive on the schema type is not required when you export a Resource class of the same name from `resources.js` — the class export serves as the endpoint registration. See [custom-resources.md](custom-resources.md) for details on building Resource classes.
1431
- - Harper's REST layer automatically exposes `@export`-ed tables and Resource classes as HTTP endpoints. See [automatic-apis.md](automatic-apis.md) for how endpoints are structured and named.
1432
- - ETag values include their double quotes as part of the value — include them verbatim when passing the value in `If-None-Match`.
1433
- - `sourcedFrom` must be called after the table reference (`tables.JokeCache`) is available, which is guaranteed when the call is at the top level of `resources.js`.
1980
+ - ETags are automatically computed from a record's last-modified timestamp. Include the double quotes when passing an ETag back in `If-None-Match` — they are part of the value.
1981
+ - Exporting a class with the same name as a table (e.g., `export class JokeCache extends tables.JokeCache`) registers it as the HTTP endpoint for that table; `@export` in the schema is not required separately.
1982
+ - For defining custom upstream source behavior beyond a simple `get`, see [custom-resources.md](custom-resources.md).
1983
+ - For details on how `@table` and `@export` expose REST endpoints automatically, see [automatic-apis.md](automatic-apis.md).
1434
1984
 
1435
1985
  ## 4. Infrastructure & Ops
1436
1986
 
@@ -1678,6 +2228,34 @@ static:
1678
2228
  - Install dependencies: `npm install --save-dev vite @harperfast/vite @vitejs/plugin-react` (swap in your framework's Vite plugin, e.g. `@vitejs/plugin-vue`).
1679
2229
  - Then `harper dev .` runs the app with HMR and `harper run .` runs the production build. Vite does _not_ need to be executed separately.
1680
2230
 
2231
+ #### Reading Harper Data During SSR
2232
+
2233
+ The render entry (`src/entry-server.tsx`) runs **inside Harper**, so it can read straight from the database and render the data into the HTML — no client-side fetch/XHR. `tables` is the same live, process-wide registry available everywhere (see [Programmatic Table Requests](programmatic-table-requests.md)); import it and query a table in an async `render`:
2234
+
2235
+ ```tsx
2236
+ import { tables } from 'harper';
2237
+
2238
+ export async function render(url: string): Promise<string> {
2239
+ const product = await tables.Product.get(idFromUrl(url));
2240
+ return renderToString(
2241
+ <StrictMode>
2242
+ <App product={product} />
2243
+ </StrictMode>,
2244
+ );
2245
+ }
2246
+ ```
2247
+
2248
+ Keep `harper` external in `vite.config.ts` so this import resolves to Harper's running runtime instead of being bundled. `node_modules/harper` is symlinked to the running install, and symlinked deps aren't reliably auto-externalized for SSR:
2249
+
2250
+ ```typescript
2251
+ export default defineConfig({
2252
+ ssr: { external: ['harper'] },
2253
+ // ...plugins, resolve, build
2254
+ });
2255
+ ```
2256
+
2257
+ To hydrate on the client without re-fetching, embed the rendered data in the HTML (e.g. an inline `<script type="application/json">`) and read it back before hydration — so the page needs no XHR at all.
2258
+
1681
2259
  #### Deploying to Production
1682
2260
 
1683
2261
  Because `@harperfast/vite` builds on the node and `static` serves the output, deploy the component as-is — no manual build-and-move step is needed: