@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.
- package/.agents/skills/harper-best-practices/AGENTS.md +916 -338
- package/.agents/skills/harper-best-practices/rules/caching.md +68 -62
- package/.agents/skills/harper-best-practices/rules/custom-resources.md +144 -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 +143 -91
- package/.agents/skills/harper-best-practices/rules/schema-design-tooling.md +87 -64
- package/.agents/skills/harper-best-practices/rules/serving-web-content.md +28 -0
- package/.agents/skills/harper-best-practices/rules/using-blob-datatype.md +97 -16
- package/.agents/skills/harper-best-practices/rules/vector-indexing.md +59 -29
- package/.agents/skills/harper-best-practices/rules.manifest.yaml +109 -7
- package/agent/skills/harper-best-practices/AGENTS.md +916 -338
- package/agent/skills/harper-best-practices/rules/caching.md +68 -62
- package/agent/skills/harper-best-practices/rules/custom-resources.md +144 -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 +143 -91
- package/agent/skills/harper-best-practices/rules/schema-design-tooling.md +87 -64
- package/agent/skills/harper-best-practices/rules/serving-web-content.md +28 -0
- package/agent/skills/harper-best-practices/rules/using-blob-datatype.md +97 -16
- package/agent/skills/harper-best-practices/rules/vector-indexing.md +59 -29
- package/agent/skills/harper-best-practices/rules.manifest.yaml +109 -7
- 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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
101
|
-
price: Float @indexed
|
|
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 {
|
|
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
|
-
|
|
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
|
+
```
|
|
281
436
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
**Distance-threshold filter (no ranking):**
|
|
314
478
|
|
|
315
|
-
|
|
316
|
-
let
|
|
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
|
-
|
|
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 the `Blob` data type in Harper.
|
|
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 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. **
|
|
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:
|
|
525
|
+
id: Any! @primaryKey
|
|
347
526
|
data: Blob
|
|
348
527
|
}
|
|
349
528
|
```
|
|
350
|
-
|
|
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
|
-
|
|
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` 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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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. **
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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. **
|
|
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
|
-
```
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
static
|
|
1042
|
-
return {
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
1074
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
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
|
|
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
|
-
|
|
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. **
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1634
|
+
3. **Create and mutate records**: Use `create`, `patch`, `get`, and `update` on the table class.
|
|
1137
1635
|
|
|
1138
|
-
|
|
1636
|
+
```javascript
|
|
1637
|
+
// Create a new record (id auto-generated)
|
|
1638
|
+
const created = await Product.create({ name: 'Shirt', price: 9.5 });
|
|
1139
1639
|
|
|
1140
|
-
|
|
1640
|
+
// Modify the record
|
|
1641
|
+
await Product.patch(created.id, { price: Math.round(created.price * 0.8 * 100) / 100 });
|
|
1141
1642
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1647
|
+
4. **Query with `search(` and `conditions`**: Pass a query object to `search()` to filter records. Iterate the async result.
|
|
1151
1648
|
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1682
|
+
#### Examples
|
|
1178
1683
|
|
|
1179
|
-
|
|
1684
|
+
##### Nested conditions query
|
|
1180
1685
|
|
|
1181
1686
|
```javascript
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1701
|
+
##### Chained attribute reference (relationship join)
|
|
1189
1702
|
|
|
1190
1703
|
```javascript
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1710
|
+
select: [
|
|
1711
|
+
'id',
|
|
1712
|
+
'name',
|
|
1713
|
+
{ name: 'segments', select: ['id', 'name', { name: 'client', select: ['id', 'name'] }] },
|
|
1714
|
+
];
|
|
1211
1715
|
```
|
|
1212
1716
|
|
|
1213
|
-
|
|
1717
|
+
##### SSR usage
|
|
1214
1718
|
|
|
1215
|
-
```
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
-
####
|
|
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
|
-
|
|
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
|
|
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
|
|
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`**:
|
|
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. **
|
|
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
|
|
1316
|
-
-
|
|
1317
|
-
- Harper checks if the record
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1894
|
+
Update the schema to remove `@export`:
|
|
1369
1895
|
|
|
1370
|
-
```
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
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 `
|
|
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
|
-
|
|
1931
|
+
**Complete `schema.graphql`**:
|
|
1415
1932
|
|
|
1416
|
-
```
|
|
1417
|
-
|
|
1933
|
+
```graphql
|
|
1934
|
+
type JokeCache @table(expiration: 60) {
|
|
1935
|
+
id: ID @primaryKey
|
|
1936
|
+
setup: String
|
|
1937
|
+
punchline: String
|
|
1938
|
+
}
|
|
1418
1939
|
```
|
|
1419
1940
|
|
|
1420
|
-
|
|
1941
|
+
**Fetch a cached record**:
|
|
1421
1942
|
|
|
1422
|
-
```
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
-
|
|
1431
|
-
-
|
|
1432
|
-
-
|
|
1433
|
-
-
|
|
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:
|