@harperfast/template-vue-ts-studio 1.9.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/harper-best-practices/AGENTS.md +770 -254
- package/.agents/skills/harper-best-practices/rules/caching.md +68 -62
- package/.agents/skills/harper-best-practices/rules/custom-resources.md +106 -23
- package/.agents/skills/harper-best-practices/rules/defining-relationships.md +152 -22
- package/.agents/skills/harper-best-practices/rules/extending-tables.md +90 -21
- package/.agents/skills/harper-best-practices/rules/handling-binary-data.md +103 -23
- package/.agents/skills/harper-best-practices/rules/programmatic-table-requests.md +51 -7
- package/.agents/skills/harper-best-practices/rules/schema-design-tooling.md +87 -64
- package/.agents/skills/harper-best-practices/rules/using-blob-datatype.md +96 -16
- package/.agents/skills/harper-best-practices/rules/vector-indexing.md +59 -29
- package/.agents/skills/harper-best-practices/rules.manifest.yaml +83 -6
- package/agent/skills/harper-best-practices/AGENTS.md +770 -254
- package/agent/skills/harper-best-practices/rules/caching.md +68 -62
- package/agent/skills/harper-best-practices/rules/custom-resources.md +106 -23
- package/agent/skills/harper-best-practices/rules/defining-relationships.md +152 -22
- package/agent/skills/harper-best-practices/rules/extending-tables.md +90 -21
- package/agent/skills/harper-best-practices/rules/handling-binary-data.md +103 -23
- package/agent/skills/harper-best-practices/rules/programmatic-table-requests.md +51 -7
- package/agent/skills/harper-best-practices/rules/schema-design-tooling.md +87 -64
- package/agent/skills/harper-best-practices/rules/using-blob-datatype.md +96 -16
- package/agent/skills/harper-best-practices/rules/vector-indexing.md +59 -29
- package/agent/skills/harper-best-practices/rules.manifest.yaml +83 -6
- package/package.json +1 -1
- 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
|
|
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
|
|
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:
|
|
62
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
|
-
|
|
90
|
+
If the field value is an array, each element is individually indexed. Null values are indexed by default.
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
100
|
+
Without `name`, the type name is used as the path segment.
|
|
77
101
|
|
|
78
|
-
|
|
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
|
-
|
|
90
|
-
- `
|
|
91
|
-
- `
|
|
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)
|
|
92
118
|
|
|
93
|
-
|
|
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
|
+
```
|
|
94
126
|
|
|
95
|
-
|
|
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
|
+
```
|
|
170
|
+
|
|
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
|
|
226
|
+
type RealityShow @table @export {
|
|
99
227
|
id: Long @primaryKey
|
|
100
|
-
|
|
101
|
-
|
|
228
|
+
networkId: Long @indexed
|
|
229
|
+
network: Network @relationship(from: networkId)
|
|
230
|
+
title: String @indexed
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type Network @table @export {
|
|
234
|
+
id: Long @primaryKey
|
|
235
|
+
name: String @indexed
|
|
102
236
|
}
|
|
103
237
|
```
|
|
104
238
|
|
|
105
|
-
|
|
239
|
+
For a many-to-many relationship, make the foreign key an array:
|
|
106
240
|
|
|
107
241
|
```graphql
|
|
108
|
-
type
|
|
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
|
-
|
|
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
|
|
252
|
+
type Network @table @export {
|
|
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 {
|
|
117
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
**
|
|
294
|
+
**Many-to-one relationship** — a show belongs to a network:
|
|
130
295
|
|
|
131
296
|
```graphql
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
319
|
+
type Network @table @export {
|
|
152
320
|
id: Long @primaryKey
|
|
153
|
-
|
|
321
|
+
name: String @indexed
|
|
322
|
+
shows: [RealityShow] @relationship(to: networkId)
|
|
154
323
|
}
|
|
155
324
|
```
|
|
156
325
|
|
|
157
|
-
**
|
|
326
|
+
**Many-to-many with array foreign keys** — a product has multiple resellers:
|
|
158
327
|
|
|
159
328
|
```graphql
|
|
160
|
-
type
|
|
329
|
+
type Product @table @export {
|
|
161
330
|
id: Long @primaryKey
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
331
|
+
name: String
|
|
332
|
+
resellerIds: [Long] @indexed
|
|
333
|
+
resellers: [Reseller] @relationship(from: resellerIds)
|
|
165
334
|
}
|
|
166
335
|
```
|
|
167
336
|
|
|
168
|
-
|
|
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
|
|
346
|
+
type OrderItem @table @export {
|
|
172
347
|
id: Long @primaryKey
|
|
173
|
-
|
|
348
|
+
orderId: Long @indexed
|
|
349
|
+
productSku: Long @indexed
|
|
350
|
+
product: Product @relationship(from: productSku, to: sku)
|
|
174
351
|
}
|
|
175
352
|
|
|
176
|
-
type
|
|
353
|
+
type Product @table @export {
|
|
177
354
|
id: Long @primaryKey
|
|
178
|
-
|
|
355
|
+
sku: Long @indexed
|
|
356
|
+
name: String
|
|
179
357
|
}
|
|
180
358
|
```
|
|
181
359
|
|
|
182
360
|
#### Notes
|
|
183
361
|
|
|
184
|
-
-
|
|
185
|
-
-
|
|
186
|
-
-
|
|
187
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`.
|
|
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.
|
|
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
|
|
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
|
+
```
|
|
436
|
+
|
|
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.
|
|
281
439
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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,
|
|
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
478
|
|
|
313
|
-
|
|
314
|
-
|
|
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,242 @@ let results = Document.search({
|
|
|
325
489
|
|
|
326
490
|
#### Notes
|
|
327
491
|
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
-
|
|
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
|
|
511
|
+
### 1.5 Using the Blob Data Type
|
|
334
512
|
|
|
335
|
-
Instructions for the agent to follow when
|
|
513
|
+
Instructions for the agent to follow when storing and retrieving large binary content using Harper's `Blob` data type.
|
|
336
514
|
|
|
337
515
|
#### When to Use
|
|
338
516
|
|
|
339
|
-
|
|
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 you need streaming support or want to avoid loading the entire value into memory. 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. **
|
|
521
|
+
1. **Declare a `Blob` field in your schema**: Add a field typed as `Blob` to a `@table` type.
|
|
522
|
+
|
|
344
523
|
```graphql
|
|
345
524
|
type MyTable @table {
|
|
346
|
-
id:
|
|
525
|
+
id: Any! @primaryKey
|
|
347
526
|
data: Blob
|
|
348
527
|
}
|
|
349
528
|
```
|
|
350
|
-
|
|
529
|
+
|
|
530
|
+
2. **Create a blob with `createBlob()`**: Pass a buffer, string, or stream as the first argument. Pass a `BlobOptions` object as the second argument to configure behavior.
|
|
531
|
+
|
|
532
|
+
```javascript
|
|
533
|
+
let blob = createBlob(largeBuffer);
|
|
534
|
+
await MyTable.put({ id: 'my-record', data: blob });
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
3. **Read blob data using standard Web API methods**: The `Blob` type implements the Web API `Blob` interface. Use `.bytes()`, `.text()`, `.arrayBuffer()`, `.stream()`, or `.slice()` to access content.
|
|
538
|
+
|
|
351
539
|
```javascript
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
await
|
|
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
|
-
|
|
357
|
-
4. **
|
|
545
|
+
|
|
546
|
+
4. **Use `saveBeforeCommit` for ACID-compliant writes**: By default, blobs are not ACID-compliant — a record can reference a blob before it is fully written. Set `saveBeforeCommit: true` to wait for the full write before the transaction commits.
|
|
547
|
+
|
|
358
548
|
```javascript
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
|
|
553
|
+
|
|
554
|
+
5. **Register an error handler when returning a blob via REST**: Interrupted streams must be handled explicitly to avoid stale records.
|
|
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`. Manual `createBlob()` calls are not required for plain JSON HTTP bodies or MQTT messages in most cases.
|
|
570
|
+
|
|
571
|
+
##### `BlobOptions` Reference
|
|
572
|
+
|
|
573
|
+
| Option | Type | Default | Description |
|
|
574
|
+
| ------------------ | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
575
|
+
| `type` | `string` | `undefined` | MIME type to associate with the blob (e.g., `image/jpeg`). Readable via `blob.type` and used when serving HTTP. |
|
|
576
|
+
| `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. |
|
|
577
|
+
| `saveBeforeCommit` | `boolean` | `false` | Wait for the blob to be fully written before committing the transaction. |
|
|
578
|
+
| `compress` | `boolean` | `false` | Compress the stored data with deflate. |
|
|
579
|
+
| `flush` | `boolean` | `false` | Flush the file to disk after writing, before the `createBlob` promise chain resolves. |
|
|
580
|
+
|
|
581
|
+
#### Examples
|
|
582
|
+
|
|
583
|
+
**Store an image with a MIME type:**
|
|
584
|
+
|
|
585
|
+
```javascript
|
|
586
|
+
let blob = createBlob(imageBuffer, { type: 'image/jpeg' });
|
|
587
|
+
await Photo.put({ id, data: blob });
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Stream large media with low latency:**
|
|
591
|
+
|
|
592
|
+
```javascript
|
|
593
|
+
let blob = createBlob(incomingStream);
|
|
594
|
+
// blob exists, but data is still streaming to storage
|
|
595
|
+
await MyTable.put({ id: 'my-record', data: blob });
|
|
596
|
+
|
|
597
|
+
let record = await MyTable.get('my-record');
|
|
598
|
+
// blob data is accessible as it arrives
|
|
599
|
+
let outgoingStream = record.data.stream();
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
**Guaranteed write before commit:**
|
|
603
|
+
|
|
604
|
+
```javascript
|
|
605
|
+
let blob = createBlob(stream, { saveBeforeCommit: true });
|
|
606
|
+
await MyTable.put({ id: 'my-record', data: blob });
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
#### Notes
|
|
610
|
+
|
|
611
|
+
- `Blob` stores data separately from the record; `Bytes` does not. Prefer `Blob` for content larger than 20KB.
|
|
612
|
+
- All standard Web API `Blob` methods are available: `.bytes()`, `.text()`, `.arrayBuffer()`, `.stream()`, `.slice()`.
|
|
613
|
+
- Blobs are **not** ACID-compliant by default when created from a stream. Use `saveBeforeCommit: true` to enforce transactional consistency.
|
|
614
|
+
- Always attach an `error` handler on blobs returned as HTTP response bodies to handle interrupted streams.
|
|
364
615
|
|
|
365
616
|
### 1.6 Handling Binary Data
|
|
366
617
|
|
|
367
|
-
Instructions for the agent to follow when
|
|
618
|
+
Instructions for the agent to follow when storing and serving binary data (images, audio, arbitrary content types) in Harper.
|
|
368
619
|
|
|
369
620
|
#### When to Use
|
|
370
621
|
|
|
371
|
-
|
|
622
|
+
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
623
|
|
|
373
624
|
#### How It Works
|
|
374
625
|
|
|
375
|
-
1. **
|
|
626
|
+
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
627
|
|
|
377
628
|
```typescript
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
629
|
+
import { type RequestTargetOrId, tables, createBlob } from 'harper';
|
|
630
|
+
|
|
631
|
+
export class Photo extends tables.Photo {
|
|
632
|
+
static async post(target: RequestTargetOrId, record: any) {
|
|
633
|
+
if (record.data) {
|
|
634
|
+
record.data = createBlob(Buffer.from(record.data, record.encoding || 'base64'), {
|
|
635
|
+
type: record.contentType || 'application/octet-stream',
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return super.post(target, record);
|
|
639
|
+
}
|
|
385
640
|
}
|
|
386
641
|
```
|
|
387
642
|
|
|
388
|
-
2. **Serve
|
|
643
|
+
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:
|
|
644
|
+
|
|
389
645
|
```typescript
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
646
|
+
export class Photo extends tables.Photo {
|
|
647
|
+
static async get(target: RequestTargetOrId) {
|
|
648
|
+
const record = await super.get(target);
|
|
649
|
+
if (record?.data) {
|
|
650
|
+
return {
|
|
651
|
+
status: 200,
|
|
652
|
+
headers: { 'Content-Type': record.data.type || 'application/octet-stream' },
|
|
653
|
+
body: record.data,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
return record;
|
|
657
|
+
}
|
|
400
658
|
}
|
|
401
659
|
```
|
|
402
|
-
|
|
660
|
+
|
|
661
|
+
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:
|
|
662
|
+
|
|
663
|
+
```http
|
|
664
|
+
PUT /my-resource/33
|
|
665
|
+
Content-Type: text/calendar
|
|
666
|
+
|
|
667
|
+
BEGIN:VCALENDAR
|
|
668
|
+
VERSION:2.0
|
|
669
|
+
...
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Harper stores this as:
|
|
673
|
+
|
|
674
|
+
```json
|
|
675
|
+
{ "contentType": "text/calendar", "data": "BEGIN:VCALENDAR\nVERSION:2.0\n..." }
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
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`).
|
|
679
|
+
|
|
680
|
+
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:
|
|
681
|
+
|
|
682
|
+
```http
|
|
683
|
+
PUT /my-resource/33/image
|
|
684
|
+
Content-Type: image/gif
|
|
685
|
+
|
|
686
|
+
...image data...
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
#### Examples
|
|
690
|
+
|
|
691
|
+
**End-to-end: accept base64 JSON, store as blob, serve as binary**
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
import { type RequestTargetOrId, tables, createBlob } from 'harper';
|
|
695
|
+
|
|
696
|
+
export class Photo extends tables.Photo {
|
|
697
|
+
// Accept base64-encoded uploads in JSON
|
|
698
|
+
static async post(target: RequestTargetOrId, record: any) {
|
|
699
|
+
if (record.data) {
|
|
700
|
+
record.data = createBlob(Buffer.from(record.data, record.encoding || 'base64'), {
|
|
701
|
+
type: record.contentType || 'application/octet-stream',
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return super.post(target, record);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Stream the blob back with the correct Content-Type
|
|
708
|
+
static async get(target: RequestTargetOrId) {
|
|
709
|
+
const record = await super.get(target);
|
|
710
|
+
if (record?.data) {
|
|
711
|
+
return {
|
|
712
|
+
status: 200,
|
|
713
|
+
headers: { 'Content-Type': record.data.type || 'application/octet-stream' },
|
|
714
|
+
body: record.data,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return record;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
#### Notes
|
|
723
|
+
|
|
724
|
+
- `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.
|
|
725
|
+
- Always fall back to `application/octet-stream` when no MIME type is known, both when creating and when serving blobs.
|
|
726
|
+
- 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.
|
|
727
|
+
- Non-`text` content types cause `data` to be stored and returned as a Node.js `Buffer`.
|
|
403
728
|
|
|
404
729
|
## 2. API & Communication
|
|
405
730
|
|
|
@@ -1021,81 +1346,222 @@ export class RefreshJWT extends Resource {
|
|
|
1021
1346
|
|
|
1022
1347
|
### 3.1 Custom Resources
|
|
1023
1348
|
|
|
1024
|
-
Instructions for the agent to follow when
|
|
1349
|
+
Instructions for the agent to follow when defining custom REST endpoints with JavaScript or TypeScript in Harper.
|
|
1025
1350
|
|
|
1026
1351
|
#### When to Use
|
|
1027
1352
|
|
|
1028
|
-
|
|
1353
|
+
Apply this rule when creating custom HTTP endpoints, wrapping external APIs, or registering programmatic routes in a Harper application. Use it any time business logic needs to live outside a standard table-backed resource.
|
|
1029
1354
|
|
|
1030
1355
|
#### How It Works
|
|
1031
1356
|
|
|
1032
|
-
1. **
|
|
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:
|
|
1357
|
+
1. **Import `Resource` from the `harper` package**: Always import explicitly rather than relying on globals.
|
|
1035
1358
|
|
|
1036
|
-
```
|
|
1037
|
-
import { Resource } from 'harper';
|
|
1359
|
+
```javascript
|
|
1360
|
+
import { tables, Resource } from 'harper';
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
2. **Define a class that `extends Resource`**: Use `export class` so Harper can expose it as an endpoint. Implement HTTP methods as `static` methods on the class.
|
|
1038
1364
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
static
|
|
1042
|
-
return {
|
|
1365
|
+
```javascript
|
|
1366
|
+
export class CustomEndpoint extends Resource {
|
|
1367
|
+
static get(target) {
|
|
1368
|
+
return {
|
|
1369
|
+
data: doSomething(),
|
|
1370
|
+
};
|
|
1043
1371
|
}
|
|
1044
1372
|
}
|
|
1045
1373
|
```
|
|
1046
1374
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1375
|
+
3. **Add async `static` methods for each HTTP verb you need**: Methods receive `target` (contains `target.id`, etc.) and, for write operations, `data`.
|
|
1376
|
+
|
|
1377
|
+
```javascript
|
|
1378
|
+
export class MyExternalData extends Resource {
|
|
1379
|
+
static async get(target) {
|
|
1380
|
+
const response = await fetch(`https://api.example.com/${target.id}`);
|
|
1381
|
+
return response.json();
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
static async put(target, data) {
|
|
1385
|
+
return fetch(`https://api.example.com/${target.id}`, {
|
|
1386
|
+
method: 'PUT',
|
|
1387
|
+
body: JSON.stringify(await data),
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
4. **Control the URL by choosing the export form**: The shape of the export determines the resulting URL path. Path matching is case-sensitive.
|
|
1394
|
+
|
|
1395
|
+
| Export form | URL | Notes |
|
|
1396
|
+
| ---------------------------------------- | --------------- | --------------------------------------------------------------- |
|
|
1397
|
+
| `export class Foo extends Resource {}` | `/Foo/` | Class name becomes the path segment. |
|
|
1398
|
+
| `export const Bar = { Foo };` | `/Bar/Foo/` | Nest under an object to add a path prefix. |
|
|
1399
|
+
| `export const bar = { 'foo-baz': Foo };` | `/bar/foo-baz/` | Use object keys for lowercase, hyphens, or non-identifier URLs. |
|
|
1400
|
+
| `server.resources.set('my-path', Foo);` | `/my-path/` | Programmatic registration; useful when the path is dynamic. |
|
|
1401
|
+
|
|
1402
|
+
5. **Register programmatically when the path is dynamic**: Use `server.resources.set(` with a path string and the class.
|
|
1403
|
+
|
|
1404
|
+
```javascript
|
|
1405
|
+
server.resources.set('my-path', Foo);
|
|
1057
1406
|
```
|
|
1058
|
-
|
|
1407
|
+
|
|
1408
|
+
6. **Optionally use the resource as a cache source for a local table**: Pass the class to `sourcedFrom`.
|
|
1409
|
+
```javascript
|
|
1410
|
+
tables.MyCache.sourcedFrom(MyExternalData);
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
#### Examples
|
|
1414
|
+
|
|
1415
|
+
Wrap an external API and expose it as an endpoint, then back a local cache table with it:
|
|
1416
|
+
|
|
1417
|
+
```javascript
|
|
1418
|
+
import { tables, Resource } from 'harper';
|
|
1419
|
+
|
|
1420
|
+
export class MyExternalData extends Resource {
|
|
1421
|
+
static async get(target) {
|
|
1422
|
+
const response = await fetch(`https://api.example.com/${target.id}`);
|
|
1423
|
+
return response.json();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
static async put(target, data) {
|
|
1427
|
+
return fetch(`https://api.example.com/${target.id}`, {
|
|
1428
|
+
method: 'PUT',
|
|
1429
|
+
body: JSON.stringify(await data),
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Use as a cache source for a local table
|
|
1435
|
+
tables.MyCache.sourcedFrom(MyExternalData);
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
Programmatic registration with a custom path:
|
|
1439
|
+
|
|
1440
|
+
```javascript
|
|
1441
|
+
import { Resource } from 'harper';
|
|
1442
|
+
|
|
1443
|
+
export class CustomEndpoint extends Resource {
|
|
1444
|
+
static get(target) {
|
|
1445
|
+
return {
|
|
1446
|
+
data: doSomething(),
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
server.resources.set('my-path', CustomEndpoint);
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
#### Notes
|
|
1455
|
+
|
|
1456
|
+
- `export class` directly produces a URL from the class name (e.g., `export class Foo extends Resource {}` → `/Foo/`). Do not export the same resource from both a schema file and a JavaScript file — this creates conflicting exports.
|
|
1457
|
+
- URL path segments are case-sensitive: `/Foo/` and `/foo/` are different endpoints.
|
|
1458
|
+
- For CommonJS modules, use `const { tables, Resource } = require('harper');` instead of the ESM import.
|
|
1459
|
+
- When developing a component in its own directory, run `npm link harper` to ensure typings match your installed version. All installed components have `harper` automatically linked.
|
|
1460
|
+
- The `static` keyword is required on all HTTP verb methods — Harper dispatches requests through static class methods, not instance methods.
|
|
1059
1461
|
|
|
1060
1462
|
### 3.2 Extending Tables
|
|
1061
1463
|
|
|
1062
|
-
Instructions for the agent to follow when
|
|
1464
|
+
Instructions for the agent to follow when adding custom logic to automatically generated table resources in Harper.
|
|
1063
1465
|
|
|
1064
1466
|
#### When to Use
|
|
1065
1467
|
|
|
1066
|
-
|
|
1468
|
+
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
1469
|
|
|
1068
1470
|
#### How It Works
|
|
1069
1471
|
|
|
1070
|
-
1. **Define the
|
|
1472
|
+
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.
|
|
1473
|
+
|
|
1071
1474
|
```graphql
|
|
1475
|
+
# Omit the `@export` directive
|
|
1072
1476
|
type MyTable @table {
|
|
1073
|
-
id:
|
|
1074
|
-
|
|
1477
|
+
id: Long @primaryKey
|
|
1478
|
+
# ...
|
|
1075
1479
|
}
|
|
1076
1480
|
```
|
|
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
1481
|
|
|
1080
|
-
|
|
1081
|
-
import { tables } from 'harper';
|
|
1482
|
+
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
1483
|
|
|
1484
|
+
```javascript
|
|
1083
1485
|
export class MyTable extends tables.MyTable {
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
}
|
|
1092
|
-
return super.post(record); // create delegates with the record (no id)
|
|
1486
|
+
static async get(target) {
|
|
1487
|
+
const record = await super.get(target);
|
|
1488
|
+
return { ...record, computedField: 'value' };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
static async post(target, data) {
|
|
1492
|
+
this.create({ ...(await data), status: 'pending' });
|
|
1093
1493
|
}
|
|
1094
1494
|
}
|
|
1095
1495
|
```
|
|
1096
1496
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1497
|
+
3. **Call `super` to preserve default behavior**: When delegating to `super`, match the argument form to the operation:
|
|
1498
|
+
- Reads/deletes: `super.get(target)` / `super.delete(target)`
|
|
1499
|
+
- Collection create: `super.post(target, record)` — target carries no id
|
|
1500
|
+
- Updates: `super.put(target, data)` / `super.patch(target, data)`
|
|
1501
|
+
|
|
1502
|
+
Omit the `super` call only if you intend to replace the default behavior entirely.
|
|
1503
|
+
|
|
1504
|
+
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.
|
|
1505
|
+
|
|
1506
|
+
```javascript
|
|
1507
|
+
const error = new Error('Name is required');
|
|
1508
|
+
error.statusCode = 400; // use statusCode, NOT status
|
|
1509
|
+
throw error;
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
5. **Configure Harper to load both files**: Ensure your configuration references the schema and resource files.
|
|
1513
|
+
|
|
1514
|
+
```yaml
|
|
1515
|
+
rest: true
|
|
1516
|
+
graphqlSchema:
|
|
1517
|
+
files: schema.graphql
|
|
1518
|
+
jsResource:
|
|
1519
|
+
files: resources.js
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1522
|
+
#### Examples
|
|
1523
|
+
|
|
1524
|
+
Full end-to-end example — schema, resource class, and error handling:
|
|
1525
|
+
|
|
1526
|
+
```graphql
|
|
1527
|
+
# schema.graphql — omit @export so the JS class owns the endpoint
|
|
1528
|
+
type MyTable @table {
|
|
1529
|
+
id: Long @primaryKey
|
|
1530
|
+
}
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
```javascript
|
|
1534
|
+
// resources.js
|
|
1535
|
+
export class MyTable extends tables.MyTable {
|
|
1536
|
+
static async get(target) {
|
|
1537
|
+
// get the record from the database
|
|
1538
|
+
const record = await super.get(target);
|
|
1539
|
+
// add a computed property before returning
|
|
1540
|
+
return { ...record, computedField: 'value' };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
static async post(target, data) {
|
|
1544
|
+
// custom action on POST
|
|
1545
|
+
this.create({ ...(await data), status: 'pending' });
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
```
|
|
1549
|
+
|
|
1550
|
+
Throwing a controlled HTTP error:
|
|
1551
|
+
|
|
1552
|
+
```javascript
|
|
1553
|
+
if (!authorized) {
|
|
1554
|
+
const error = new Error('Forbidden');
|
|
1555
|
+
error.statusCode = 403;
|
|
1556
|
+
throw error;
|
|
1557
|
+
}
|
|
1558
|
+
```
|
|
1559
|
+
|
|
1560
|
+
#### Notes
|
|
1561
|
+
|
|
1562
|
+
- Always omit `@export` from the schema type when a JavaScript subclass is exporting the same name. The two registrations conflict.
|
|
1563
|
+
- `super` must be called with the correct arguments for each operation type — mismatched arguments will not behave as expected.
|
|
1564
|
+
- `statusCode` is the only recognized property for controlling HTTP status on thrown errors; `.status` is ignored.
|
|
1099
1565
|
|
|
1100
1566
|
### 3.3 Programmatic Table Requests
|
|
1101
1567
|
|
|
@@ -1166,13 +1632,13 @@ Use these exact strings — incorrect comparator names will silently fail or err
|
|
|
1166
1632
|
|
|
1167
1633
|
##### Query Object Parameters
|
|
1168
1634
|
|
|
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
|
|
1175
|
-
| `sort` | Object with `attribute`, `descending` (bool), and optional `next` for secondary sort
|
|
1635
|
+
| Property | Description |
|
|
1636
|
+
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1637
|
+
| `conditions` | Array of condition objects |
|
|
1638
|
+
| `limit` | Maximum number of records to return |
|
|
1639
|
+
| `offset` | Number of records to skip (for pagination) |
|
|
1640
|
+
| `select` | Array of fields to return: attribute names (supports `$id` and `$updatedtime`), or an object `{ name, select }` to include fields from a related record — see [Selecting Related Data](#selecting-related-data) |
|
|
1641
|
+
| `sort` | Object with `attribute`, `descending` (bool), and optional `next` for secondary sort |
|
|
1176
1642
|
|
|
1177
1643
|
##### Examples
|
|
1178
1644
|
|
|
@@ -1221,6 +1687,50 @@ for await (const record of tables.Product.search({
|
|
|
1221
1687
|
})) { ... }
|
|
1222
1688
|
```
|
|
1223
1689
|
|
|
1690
|
+
#### Selecting Related Data
|
|
1691
|
+
|
|
1692
|
+
When a field is defined as a relationship (via `@relationship` — see [Defining Relationships](defining-relationships.md)), `select` can pull the related record(s) into your results as nested properties. This is the programmatic equivalent of the REST `select(name,author{name})` syntax (see [Querying REST APIs](querying-rest-apis.md)).
|
|
1693
|
+
|
|
1694
|
+
**Whole related record** — list the relationship field by name. The related record (or an array of records for a to-many relationship) is attached as a nested property:
|
|
1695
|
+
|
|
1696
|
+
```javascript
|
|
1697
|
+
for await (const book of tables.Book.search({
|
|
1698
|
+
conditions: [{ attribute: 'id', value: 42 }],
|
|
1699
|
+
select: ['id', 'title', 'author'], // `author` is a relationship field
|
|
1700
|
+
})) {
|
|
1701
|
+
console.log(book.author.name); // the full related Author record
|
|
1702
|
+
}
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
**Partial related record** — use an object `{ name, select }` to choose which fields of the related record to return. Unselected fields are omitted:
|
|
1706
|
+
|
|
1707
|
+
```javascript
|
|
1708
|
+
for await (const book of tables.Book.search({
|
|
1709
|
+
conditions: [{ attribute: ['author', 'name'], comparator: 'equals', value: 'Harper' }],
|
|
1710
|
+
select: ['id', 'title', { name: 'author', select: ['name'] }],
|
|
1711
|
+
})) {
|
|
1712
|
+
// book.author.name is present; other Author fields are undefined
|
|
1713
|
+
}
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
**Nesting** — a `select` inside an object entry may itself contain more `{ name, select }` objects, traversing multiple relationships in one query:
|
|
1717
|
+
|
|
1718
|
+
```javascript
|
|
1719
|
+
select: [
|
|
1720
|
+
'id',
|
|
1721
|
+
'name',
|
|
1722
|
+
{
|
|
1723
|
+
name: 'segments',
|
|
1724
|
+
select: ['id', 'name', { name: 'client', select: ['id', 'name'] }],
|
|
1725
|
+
},
|
|
1726
|
+
],
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
Notes:
|
|
1730
|
+
|
|
1731
|
+
- A to-many relationship resolves to an array of records; depending on access pattern you may need to `await` the property before iterating it.
|
|
1732
|
+
- Selecting a relationship without filtering on it produces LEFT JOIN behavior (records with no related row are still returned); adding a condition on a related attribute (e.g. `attribute: ['author', 'name']`) produces INNER JOIN behavior.
|
|
1733
|
+
|
|
1224
1734
|
#### Cautions
|
|
1225
1735
|
|
|
1226
1736
|
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.
|
|
@@ -1281,15 +1791,15 @@ jsResource:
|
|
|
1281
1791
|
|
|
1282
1792
|
### 3.5 Caching External Data Sources in Harper
|
|
1283
1793
|
|
|
1284
|
-
Instructions for the agent to implement integrated data caching
|
|
1794
|
+
Instructions for the agent to implement integrated data caching from external sources using Harper's cache table directives and `sourcedFrom` API.
|
|
1285
1795
|
|
|
1286
1796
|
#### When to Use
|
|
1287
1797
|
|
|
1288
|
-
Apply this rule when
|
|
1798
|
+
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
1799
|
|
|
1290
1800
|
#### How It Works
|
|
1291
1801
|
|
|
1292
|
-
1. **Define a cache table with `expiration`**:
|
|
1802
|
+
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
1803
|
|
|
1294
1804
|
```graphql
|
|
1295
1805
|
type JokeCache @table(expiration: 60) @export {
|
|
@@ -1299,7 +1809,7 @@ Apply this rule when a Harper application needs to cache responses from an exter
|
|
|
1299
1809
|
}
|
|
1300
1810
|
```
|
|
1301
1811
|
|
|
1302
|
-
2. **
|
|
1812
|
+
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
1813
|
|
|
1304
1814
|
```javascript
|
|
1305
1815
|
const jokeAPI = {
|
|
@@ -1308,18 +1818,22 @@ Apply this rule when a Harper application needs to cache responses from an exter
|
|
|
1308
1818
|
return response.json();
|
|
1309
1819
|
},
|
|
1310
1820
|
};
|
|
1821
|
+
```
|
|
1822
|
+
|
|
1823
|
+
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.
|
|
1311
1824
|
|
|
1825
|
+
```javascript
|
|
1312
1826
|
tables.JokeCache.sourcedFrom(jokeAPI);
|
|
1313
1827
|
```
|
|
1314
1828
|
|
|
1315
|
-
Harper's
|
|
1316
|
-
-
|
|
1317
|
-
- Harper checks if the record
|
|
1829
|
+
Harper's request flow after `sourcedFrom` is registered:
|
|
1830
|
+
- Request arrives for `/JokeCache/1`.
|
|
1831
|
+
- Harper checks if the record exists and is not stale.
|
|
1318
1832
|
- If fresh, Harper returns it immediately.
|
|
1319
1833
|
- If missing or stale, Harper calls `jokeAPI.get()`, stores the result in `JokeCache`, and returns it.
|
|
1320
1834
|
- Multiple simultaneous requests for the same missing or stale record wait on a single upstream call — Harper prevents cache stampedes automatically.
|
|
1321
1835
|
|
|
1322
|
-
|
|
1836
|
+
4. **Configure plugins in `config.yaml`**: Enable `graphqlSchema`, `rest`, and `jsResource`.
|
|
1323
1837
|
|
|
1324
1838
|
```yaml
|
|
1325
1839
|
graphqlSchema:
|
|
@@ -1329,29 +1843,7 @@ Apply this rule when a Harper application needs to cache responses from an exter
|
|
|
1329
1843
|
files: 'resources.js'
|
|
1330
1844
|
```
|
|
1331
1845
|
|
|
1332
|
-
|
|
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
|
-
```
|
|
1846
|
+
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
1847
|
|
|
1356
1848
|
```javascript
|
|
1357
1849
|
export class JokeCache extends tables.JokeCache {
|
|
@@ -1365,34 +1857,25 @@ Apply this rule when a Harper application needs to cache responses from an exter
|
|
|
1365
1857
|
}
|
|
1366
1858
|
```
|
|
1367
1859
|
|
|
1368
|
-
|
|
1860
|
+
Update the schema to remove `@export`:
|
|
1369
1861
|
|
|
1370
|
-
```
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1862
|
+
```graphql
|
|
1863
|
+
type JokeCache @table(expiration: 60) {
|
|
1864
|
+
id: ID @primaryKey
|
|
1865
|
+
setup: String
|
|
1866
|
+
punchline: String
|
|
1867
|
+
}
|
|
1374
1868
|
```
|
|
1375
1869
|
|
|
1376
|
-
The next `GET /JokeCache/1` will fetch fresh data from the upstream source regardless of TTL.
|
|
1377
|
-
|
|
1378
1870
|
#### Examples
|
|
1379
1871
|
|
|
1380
|
-
Complete `
|
|
1381
|
-
|
|
1382
|
-
```graphql
|
|
1383
|
-
type JokeCache @table(expiration: 60) {
|
|
1384
|
-
id: ID @primaryKey
|
|
1385
|
-
setup: String
|
|
1386
|
-
punchline: String
|
|
1387
|
-
}
|
|
1388
|
-
```
|
|
1872
|
+
**Complete `resources.js`**:
|
|
1389
1873
|
|
|
1390
1874
|
```javascript
|
|
1391
1875
|
// resources.js
|
|
1392
1876
|
|
|
1393
1877
|
const jokeAPI = {
|
|
1394
|
-
async get() {
|
|
1395
|
-
const id = this.getId();
|
|
1878
|
+
async get(id) {
|
|
1396
1879
|
const response = await fetch(`https://official-joke-api.appspot.com/jokes/${id}`);
|
|
1397
1880
|
return response.json();
|
|
1398
1881
|
},
|
|
@@ -1411,26 +1894,59 @@ export class JokeCache extends tables.JokeCache {
|
|
|
1411
1894
|
}
|
|
1412
1895
|
```
|
|
1413
1896
|
|
|
1414
|
-
|
|
1897
|
+
**Complete `schema.graphql`**:
|
|
1415
1898
|
|
|
1416
|
-
```
|
|
1417
|
-
|
|
1899
|
+
```graphql
|
|
1900
|
+
type JokeCache @table(expiration: 60) {
|
|
1901
|
+
id: ID @primaryKey
|
|
1902
|
+
setup: String
|
|
1903
|
+
punchline: String
|
|
1904
|
+
}
|
|
1418
1905
|
```
|
|
1419
1906
|
|
|
1420
|
-
|
|
1907
|
+
**Fetch a cached record**:
|
|
1421
1908
|
|
|
1422
|
-
```
|
|
1423
|
-
|
|
1424
|
-
|
|
1909
|
+
```javascript
|
|
1910
|
+
const response = await fetch('http://localhost:9926/JokeCache/1');
|
|
1911
|
+
console.log(response.status); // 200
|
|
1912
|
+
const etag = response.headers.get('etag'); // e.g. "abCDefGHij"
|
|
1913
|
+
const joke = await response.json();
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
**Use ETag for conditional requests** (returns `304 Not Modified` if unchanged):
|
|
1917
|
+
|
|
1918
|
+
```javascript
|
|
1919
|
+
const second = await fetch('http://localhost:9926/JokeCache/1', {
|
|
1920
|
+
headers: { 'If-None-Match': etag },
|
|
1921
|
+
});
|
|
1922
|
+
console.log(second.status); // 304
|
|
1923
|
+
```
|
|
1924
|
+
|
|
1925
|
+
**Bypass the cache with `Cache-Control: no-cache`**:
|
|
1926
|
+
|
|
1927
|
+
```javascript
|
|
1928
|
+
const response = await fetch('http://localhost:9926/JokeCache/1', {
|
|
1929
|
+
headers: { 'Cache-Control': 'no-cache' },
|
|
1930
|
+
});
|
|
1931
|
+
```
|
|
1932
|
+
|
|
1933
|
+
**Trigger invalidation via POST**:
|
|
1934
|
+
|
|
1935
|
+
```javascript
|
|
1936
|
+
await fetch('http://localhost:9926/JokeCache/1', {
|
|
1937
|
+
method: 'POST',
|
|
1938
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1939
|
+
body: JSON.stringify({ action: 'invalidate' }),
|
|
1940
|
+
});
|
|
1425
1941
|
```
|
|
1426
1942
|
|
|
1427
1943
|
#### Notes
|
|
1428
1944
|
|
|
1429
1945
|
- `expiration` is measured in seconds. Harper also supports separate `eviction` and `scanInterval` arguments on `@table` for fine-grained control over physical record removal.
|
|
1430
|
-
-
|
|
1431
|
-
-
|
|
1432
|
-
-
|
|
1433
|
-
-
|
|
1946
|
+
- 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.
|
|
1947
|
+
- 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.
|
|
1948
|
+
- For defining custom upstream source behavior beyond a simple `get`, see [custom-resources.md](custom-resources.md).
|
|
1949
|
+
- For details on how `@table` and `@export` expose REST endpoints automatically, see [automatic-apis.md](automatic-apis.md).
|
|
1434
1950
|
|
|
1435
1951
|
## 4. Infrastructure & Ops
|
|
1436
1952
|
|