@nohead/sdk 0.2.0 → 0.3.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/CHANGELOG.md +114 -1
- package/README.md +80 -30
- package/dist/client.d.ts +4 -1
- package/dist/client.js +14 -1
- package/dist/core.d.ts +33 -9
- package/dist/core.js +24 -7
- package/dist/errors.d.ts +5 -2
- package/dist/errors.js +25 -11
- package/dist/generated/operations.gen.d.ts +164 -12
- package/dist/generated/operations.gen.js +26 -7
- package/dist/generated/schemas.gen.d.ts +1 -1
- package/dist/generated/types.gen.d.ts +2516 -3015
- package/dist/index.d.ts +2 -2
- package/dist/resources/assets.d.ts +25 -6
- package/dist/resources/assets.js +34 -8
- package/dist/resources/drafts.d.ts +41 -0
- package/dist/resources/drafts.js +63 -0
- package/dist/resources/other.d.ts +6 -1
- package/dist/resources/other.js +16 -2
- package/dist/resources/records.d.ts +46 -17
- package/dist/resources/records.js +40 -3
- package/dist/resources/resource.d.ts +5 -0
- package/dist/resources/resource.js +14 -0
- package/dist/resources/schema.d.ts +26 -19
- package/dist/resources/schema.js +22 -6
- package/dist/resources/webhooks.d.ts +20 -6
- package/dist/resources/webhooks.js +25 -5
- package/dist/types.d.ts +99 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +37 -17
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,118 @@ Changes to `@nohead/sdk` that you can notice. Versions follow
|
|
|
5
5
|
releases; a change that could break your code is a major one. Each release's
|
|
6
6
|
section is its GitHub release's notes.
|
|
7
7
|
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
- **Breaking:** 0.1.0 and 0.2.0 no longer work with the API: its paths and
|
|
11
|
+
shapes changed (below), so upgrading is required.
|
|
12
|
+
- `changeNote` is sent percent-encoded (UTF-8), so change notes work in any
|
|
13
|
+
language: a note outside ASCII used to fail the request.
|
|
14
|
+
- Drafts: changes to a record that wait until they are merged into it, now
|
|
15
|
+
or at a set time. `drafts.create`, `list`, `get` (with `expand`, for
|
|
16
|
+
previews), `update`, `discard`, `merge`, `schedule` and `unschedule`, and
|
|
17
|
+
`records.drafts`. Drafts take the type of their data as records do
|
|
18
|
+
(`drafts.get<Post>(id)` is a `NoheadDraft<Post>`, also `Nohead.Draft`),
|
|
19
|
+
and `drafts.create(record)` needs no params to start from the record's
|
|
20
|
+
values. `records.publish` on a live record with a pending draft is a
|
|
21
|
+
`ConflictError` (`draft_pending`). Webhook events gain `DraftEvent`
|
|
22
|
+
(`draft.*`), and a merge's `record.updated` carries its `draft_id`.
|
|
23
|
+
- **Breaking:** request options follow what each operation takes, by type:
|
|
24
|
+
`changeNote` only on writes that change records or the schema
|
|
25
|
+
(`WriteOptions`: record writes, `drafts.merge`, collection and field
|
|
26
|
+
writes), not on assets, webhooks, schedules, search index rebuilds or
|
|
27
|
+
other draft writes, which never kept a note. `ifMatch` on
|
|
28
|
+
`records.update`, `delete`, `restore` (new), `publish`, `unpublish` and
|
|
29
|
+
`revisions.revert` (`ConditionalWriteOptions`) and on `drafts.update`,
|
|
30
|
+
`discard` and `merge`. `ConditionalRequestOptions` no longer includes
|
|
31
|
+
`changeNote`.
|
|
32
|
+
- **Breaking:** `records.schedule` sets the whole schedule: a time left out
|
|
33
|
+
is cleared (it used to keep its value), and `{}` clears both, as
|
|
34
|
+
`unschedule` does.
|
|
35
|
+
- `fields.get()`: one field by ID.
|
|
36
|
+
- **Breaking:** the types for what only the web app can do (organizations,
|
|
37
|
+
projects, members and invitations, billing, API keys, usage, account
|
|
38
|
+
changes and redaction, such as `Organization`, `Project`, `ApiKey`, `Billing`) are gone. Those
|
|
39
|
+
operations need a signed-in person, so an API key could never call them,
|
|
40
|
+
and the API reference no longer lists them.
|
|
41
|
+
- **Breaking:** the API is resource-oriented. Method names stay; what they
|
|
42
|
+
return and take follows it:
|
|
43
|
+
- Records have no `status`, `schedule` or `deleted`: a record is published
|
|
44
|
+
when `published_at` is set, and has `deleted_at` and `purge_after`.
|
|
45
|
+
Filter with `{ published_at: { exists: true } }`. `records.get` returns
|
|
46
|
+
deleted records too (no `include_deleted`).
|
|
47
|
+
- `records.schedule` and `unschedule` return the schedule, and
|
|
48
|
+
`records.getSchedule`, `records.publication` and
|
|
49
|
+
`records.scheduledChanges` are new. `records.bulk` returns a
|
|
50
|
+
`record_batch`: `BulkRecordsResult` and `BulkRecordsRequest` are now
|
|
51
|
+
`RecordBatch` and `RecordBatchRequest`.
|
|
52
|
+
- Project search takes `filter: { published_at: { exists: true } }`
|
|
53
|
+
instead of `filter: { status }`.
|
|
54
|
+
- Collections, fields, assets and webhooks have `deleted_at` and
|
|
55
|
+
`purge_after` instead of `deleted`, and migrations
|
|
56
|
+
`cancel_requested_at`.
|
|
57
|
+
- `assets.createUpload` returns the asset with its `upload`
|
|
58
|
+
(`AssetWithUpload`, was `AssetUpload`).
|
|
59
|
+
- `fields.reorder` returns the `field_order`; `fields.order` reads it.
|
|
60
|
+
- Webhook event types are those of the activity log (`record.restored`,
|
|
61
|
+
`field.updated`, …, and wildcards such as `record.*`), and event
|
|
62
|
+
payloads carry `actor` and `via`. `SchemaChangedEvent`
|
|
63
|
+
(`schema.changed`) is now `SchemaEvent` (`collection.*` and `field.*`,
|
|
64
|
+
with the `collection`, its `object`, the `schema_change` and, for a
|
|
65
|
+
field, its `field_id`), and `FieldAliasRemovedEvent` is new
|
|
66
|
+
(`field.alias_removed`). There is no `record.imported`. The event data
|
|
67
|
+
types are exported too (`RecordEventData`, `DraftEventData`,
|
|
68
|
+
`AssetEventData`, `SchemaEventData`, `FieldAliasEventData`,
|
|
69
|
+
`WebhookTestEventData`).
|
|
70
|
+
- `webhooks.rotateSecret` returns a `webhook_secret`
|
|
71
|
+
(`WebhookSecretWithValue`, was `WebhookWithSecret`); the old secret
|
|
72
|
+
signs for 24 hours more. `webhooks.secrets.list`,
|
|
73
|
+
`webhooks.secrets.retire`, `webhooks.restore` and
|
|
74
|
+
`webhooks.list({ deleted })` are new. `webhooks.deliveries.retry`
|
|
75
|
+
calls the API's new path.
|
|
76
|
+
- Audit events are `object: "event"`, and name what happened in `type`
|
|
77
|
+
(was `action`). `auditEvents.list` filters by `type`, `resource_type`
|
|
78
|
+
(one, or a list of them), `resource_id`, `actor_type`, `actor_id` and
|
|
79
|
+
`created_at: { gte, gt, lte, lt }` (was `action`, `since` and
|
|
80
|
+
`until`); an unknown filter is an `InvalidRequestError`. Field events
|
|
81
|
+
have the resource type `field`.
|
|
82
|
+
- `records.revisions.list` filters `created_at` by range (was `since`
|
|
83
|
+
and `until`), and revisions' `operation` can be `merge` (not `import`).
|
|
84
|
+
- `ScheduledChange` has no `last_error`, and `SchemaChange` no `status`.
|
|
85
|
+
`ChangeVia` is gone: `Via` names the client a change came through.
|
|
86
|
+
- Exported types for the params that were inline:
|
|
87
|
+
`ScheduledChangeListParams`, `DraftScheduleParams`,
|
|
88
|
+
`FieldReorderParams`, and the filters `AuditEventFilter`,
|
|
89
|
+
`RevisionFilter` and `TimeRange`.
|
|
90
|
+
- **Breaking:** the `datetime` field type is now `date`. A plain date field holds
|
|
91
|
+
`YYYY-MM-DD`; with `include_time` it holds a moment, written in the field's
|
|
92
|
+
`time_zone` when it has one. Field migrations take `time_zone`, the zone
|
|
93
|
+
whose day each moment falls on when the time is removed.
|
|
94
|
+
- Boolean fields can't be `required`: a boolean is true or false, and no
|
|
95
|
+
value reads as false.
|
|
96
|
+
- Text fields take a `format` (`email`, `url`, `slug`; slugs are always
|
|
97
|
+
unique) and text and integer fields `unique`; a taken value's error detail
|
|
98
|
+
has the `record_id` that has it (`code: "taken"`).
|
|
99
|
+
- Filters take operators: `{ price: { lt: 50 } }`, with `eq`, `ne`, `gt`,
|
|
100
|
+
`gte`, `lt`, `lte`, `in` (a list) and `exists`, and record lists sort by a
|
|
101
|
+
field's value. Commas and backslashes in list values are escaped, so a
|
|
102
|
+
value can hold a comma.
|
|
103
|
+
- Date filters take `today`, and dates with a time `now`.
|
|
104
|
+
- `assets.usage()`: where an asset is used, as counts of the records that use
|
|
105
|
+
it (and of those, the published ones) and the 10 most recently updated with
|
|
106
|
+
the fields that use it. Needs the `records:read` scope too.
|
|
107
|
+
- `assets.purge()`: permanently deletes a deleted asset now, instead of at
|
|
108
|
+
its `purge_after`, and frees its storage.
|
|
109
|
+
- An error reads only the fields of the API's error envelope that have the
|
|
110
|
+
right type, so JSON from a proxy or gateway that merely looks like one no
|
|
111
|
+
longer gives it a non-string `message`, `type` or `requestId`.
|
|
112
|
+
- `APIError.type` is typed as the known error types or any string: the API
|
|
113
|
+
can add types, and one newer than the SDK is kept as is. Code that checks
|
|
114
|
+
it exhaustively needs a default branch.
|
|
115
|
+
- An error whose type names a built-in property (`constructor`,
|
|
116
|
+
`toString`) is an `APIError`; it used to throw a value that wasn't one.
|
|
117
|
+
- An error's `details` keep only entries that are objects, so
|
|
118
|
+
`currentRevision` no longer throws on a `null` one.
|
|
119
|
+
|
|
8
120
|
## 0.2.0
|
|
9
121
|
|
|
10
122
|
- `onRetry` client option: called before each retry with the attempt, the
|
|
@@ -12,7 +124,8 @@ section is its GitHub release's notes.
|
|
|
12
124
|
- `assets.upload()` with an `idempotencyKey` no longer fails when it
|
|
13
125
|
completes the upload: the key only starts it. Running the same upload again
|
|
14
126
|
resumes it, and returns an asset that is already `ready` without sending the
|
|
15
|
-
bytes again
|
|
127
|
+
bytes again; once the upload's URL has expired it throws `UploadError`
|
|
128
|
+
(start over with a new key).
|
|
16
129
|
- Types follow the API's current contract. They add types only for
|
|
17
130
|
operations an API key can't call, so no method changed.
|
|
18
131
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ import { Nohead } from "@nohead/sdk"
|
|
|
8
8
|
const nohead = new Nohead() // reads NOHEAD_API_KEY
|
|
9
9
|
|
|
10
10
|
for await (const post of nohead.records.list("posts", {
|
|
11
|
-
filter: {
|
|
11
|
+
filter: { published_at: { exists: true } },
|
|
12
12
|
})) {
|
|
13
13
|
console.log(post.data.title)
|
|
14
14
|
}
|
|
@@ -21,6 +21,7 @@ for await (const post of nohead.records.list("posts", {
|
|
|
21
21
|
- [Installation](#installation)
|
|
22
22
|
- [Configuration](#configuration)
|
|
23
23
|
- [Records](#records)
|
|
24
|
+
- [Drafts](#drafts)
|
|
24
25
|
- [Pagination](#pagination)
|
|
25
26
|
- [Errors](#errors)
|
|
26
27
|
- [Retries and idempotency](#retries-and-idempotency)
|
|
@@ -67,16 +68,19 @@ API keys belong to a project, so methods like `collections.list()` need no proje
|
|
|
67
68
|
## Records
|
|
68
69
|
|
|
69
70
|
```ts
|
|
70
|
-
const
|
|
71
|
+
const created = await nohead.records.create("posts", {
|
|
71
72
|
data: { title: "Hello", author: "rec_01J9…" },
|
|
72
73
|
})
|
|
73
|
-
const post = await nohead.records.get(
|
|
74
|
+
const post = await nohead.records.get(created.id, { expand: ["author"] })
|
|
74
75
|
await nohead.records.update(post.id, { data: { title: "Hello again" } })
|
|
75
76
|
await nohead.records.publish(post.id)
|
|
77
|
+
await nohead.records.publication(post.id) // since when, by whom, which revision
|
|
76
78
|
await nohead.records.schedule(post.id, { unpublish_at: new Date("2027-01-01") })
|
|
77
79
|
await nohead.records.delete(post.id) // soft delete; records.restore() undoes it
|
|
78
80
|
```
|
|
79
81
|
|
|
82
|
+
`schedule` sets the whole schedule: a time left out is cleared, so `{ unpublish_at }` alone drops a pending publish.
|
|
83
|
+
|
|
80
84
|
Methods return the resource and throw on failure. Field names stay as the API sends them (`published_at`, `data`), so the API reference applies as-is.
|
|
81
85
|
|
|
82
86
|
**Typed data.** Describe a collection's fields and pass the type:
|
|
@@ -95,6 +99,29 @@ The type is a promise you make; nothing is checked at runtime. The record type i
|
|
|
95
99
|
|
|
96
100
|
**More:** `count`, `bulk` (up to 100 records at once), `diff`, `revisions.list`, `revisions.get` and `revisions.revert` (with `{ dry_run: true }` for a preview).
|
|
97
101
|
|
|
102
|
+
## Drafts
|
|
103
|
+
|
|
104
|
+
A draft holds changes to a record until they are merged, now or at a set time, while the record keeps its current content: changes to review before they go live, or changes due later.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const draft = await nohead.drafts.create(post.id, {
|
|
108
|
+
data: { title: "Hello, revised" },
|
|
109
|
+
})
|
|
110
|
+
await nohead.drafts.update(
|
|
111
|
+
draft.id,
|
|
112
|
+
{ data: { summary: "…" } },
|
|
113
|
+
{ ifMatch: draft }
|
|
114
|
+
)
|
|
115
|
+
const preview = await nohead.drafts.get(draft.id, { expand: ["author"] }) // data as it would read
|
|
116
|
+
await nohead.drafts.merge(draft.id) // or drafts.schedule(draft.id, { merge_at })
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- A record has at most one draft; starting a second is a `ConflictError` (detail code `draft_exists`, with its `draft_id`).
|
|
120
|
+
- A merge changes content only: a live record stays live, an unpublished one unpublished. Publishing a live record with a pending draft is a `ConflictError` (`draft_pending`).
|
|
121
|
+
- `records.drafts(id)` lists a record's draft, and `drafts.list(collection, { record_id })` a collection's (`record_id` takes up to 100 IDs).
|
|
122
|
+
- `drafts.discard` deletes one for good.
|
|
123
|
+
- Drafts take the type of their data as records do: `drafts.get<Post>(id)` is a `Nohead.Draft<Post>` (also exported as `NoheadDraft`).
|
|
124
|
+
|
|
98
125
|
## Pagination
|
|
99
126
|
|
|
100
127
|
List methods return a promise of the first page that you can also loop over:
|
|
@@ -113,14 +140,27 @@ while (page.hasNextPage()) page = await page.getNextPage()
|
|
|
113
140
|
await nohead.records.list("posts", { cursor: page.meta.next_cursor! })
|
|
114
141
|
```
|
|
115
142
|
|
|
116
|
-
Filters
|
|
143
|
+
Filters take a value to equal, or operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in` (a list) and `exists`. They combine with AND. On fields with several values, equal means "contains". Values are strings, numbers, booleans and dates. A `Date` is sent as a moment, for date fields with a time; a plain date field takes its `YYYY-MM-DD` string, and both take `"today"` (dates with a time `"now"` too). Lists sort by a field too, with records that have no value last:
|
|
117
144
|
|
|
118
145
|
```ts
|
|
119
146
|
nohead.records.list("posts", {
|
|
120
|
-
filter: {
|
|
147
|
+
filter: {
|
|
148
|
+
published_at: { exists: true },
|
|
149
|
+
featured: true,
|
|
150
|
+
author: "rec_01J9…",
|
|
151
|
+
},
|
|
121
152
|
sort: "-published_at",
|
|
122
153
|
expand: ["author", "tags"],
|
|
123
154
|
})
|
|
155
|
+
|
|
156
|
+
nohead.records.list("products", {
|
|
157
|
+
filter: {
|
|
158
|
+
price: { lt: 50 },
|
|
159
|
+
category: { in: ["shoes", "bags"] },
|
|
160
|
+
cover: { exists: true },
|
|
161
|
+
},
|
|
162
|
+
sort: "price",
|
|
163
|
+
})
|
|
124
164
|
```
|
|
125
165
|
|
|
126
166
|
## Errors
|
|
@@ -188,10 +228,10 @@ await nohead.records.create(
|
|
|
188
228
|
Every method takes request options as its last argument:
|
|
189
229
|
|
|
190
230
|
- `idempotencyKey`
|
|
191
|
-
- `ifMatch` (record writes)
|
|
192
|
-
- `changeNote`, a reason shown in history
|
|
193
231
|
- `signal`, `timeout`, `maxRetries` and `headers`
|
|
194
232
|
|
|
233
|
+
Writes that change records or the schema also take `changeNote`, a reason kept with the revision, schema change or migration they make. Record and draft writes take `ifMatch` (see [concurrency](#concurrency)).
|
|
234
|
+
|
|
195
235
|
## Concurrency
|
|
196
236
|
|
|
197
237
|
Pass the revision you read to make sure nobody changed the record since:
|
|
@@ -207,7 +247,7 @@ try {
|
|
|
207
247
|
}
|
|
208
248
|
```
|
|
209
249
|
|
|
210
|
-
`ifMatch` takes a record or a revision number, on `update`, `delete`, `publish`, `unpublish` and `revisions.revert`.
|
|
250
|
+
`ifMatch` takes a record or a revision number, on `records.update`, `delete`, `restore`, `publish`, `unpublish` and `revisions.revert`, and a draft or its revision on `drafts.update`, `discard` and `merge`.
|
|
211
251
|
|
|
212
252
|
## Assets
|
|
213
253
|
|
|
@@ -235,10 +275,18 @@ const { url } = await nohead.assets.imageUrl(asset.id, {
|
|
|
235
275
|
|
|
236
276
|
**Errors:** `UploadError` if storage refuses the bytes; `ValidationError` if the file fails the checks.
|
|
237
277
|
|
|
238
|
-
**Resuming:** pass an `idempotencyKey` (`upload(file, {}, { idempotencyKey })`) and running the same upload again
|
|
278
|
+
**Resuming:** pass an `idempotencyKey` (`upload(file, {}, { idempotencyKey })`) and running the same upload again resumes it: an asset that is already `ready` comes back without sending the bytes again. Once the upload's URL has expired (its `upload.expires_at`), it throws `UploadError`: start over with a new key.
|
|
239
279
|
|
|
240
280
|
**Uploading from a browser:** create the upload on your server with `createUpload`, `PUT` the file from the browser, then `complete` it.
|
|
241
281
|
|
|
282
|
+
**Deleting:** `delete` keeps a deleted asset until its `purge_after` (30 days), and `restore` brings it back. To remove it for good now, `purge` it after the delete. Records that use it keep an ID that no longer resolves, so check `usage` first:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const usage = await nohead.assets.usage(asset.id)
|
|
286
|
+
usage.records // how many records use it, usage.published of them published
|
|
287
|
+
usage.uses // the 10 most recently updated, with the fields that use it
|
|
288
|
+
```
|
|
289
|
+
|
|
242
290
|
## Search
|
|
243
291
|
|
|
244
292
|
```ts
|
|
@@ -291,14 +339,14 @@ export async function POST(request: Request) {
|
|
|
291
339
|
secret: process.env.NOHEAD_WEBHOOK_SECRET!,
|
|
292
340
|
}
|
|
293
341
|
)
|
|
294
|
-
if (event.type === "record.published") {
|
|
342
|
+
if (event.type === "record.published" && event.data.record) {
|
|
295
343
|
revalidateTag(event.data.record.collection)
|
|
296
344
|
}
|
|
297
345
|
return new Response(null, { status: 204 })
|
|
298
346
|
}
|
|
299
347
|
```
|
|
300
348
|
|
|
301
|
-
`unwrap` checks the signature and the timestamp (Standard Webhooks), and throws `WebhookVerificationError` if either is off. Pass the raw body: parsing and re-serializing JSON changes the bytes.
|
|
349
|
+
Event types are those of the activity log (`record.published`, `record.restored`, `field.updated`, `asset.purged`, …), and a webhook subscribes to types or wildcards (`record.*`, `*`). Narrowing by `type` types `event.data`, and `unwrap<Post>` types the records and drafts in it. `unwrap` checks the signature and the timestamp (Standard Webhooks), and throws `WebhookVerificationError` if either is off. After `rotateSecret`, requests carry a signature for the old secret and the new one for 24 hours, so switch the receiver's secret within that day (or end it sooner with `webhooks.secrets.retire`). Pass the raw body: parsing and re-serializing JSON changes the bytes.
|
|
302
350
|
|
|
303
351
|
A receiver that never calls the API can use `unwrapWebhook` from `@nohead/sdk/webhooks` without a client. Events can arrive more than once, so deduplicate by the `webhook-id` header.
|
|
304
352
|
|
|
@@ -313,23 +361,25 @@ response.headers.get("RateLimit-Remaining")
|
|
|
313
361
|
|
|
314
362
|
## Reference
|
|
315
363
|
|
|
316
|
-
| Resource | Methods
|
|
317
|
-
| --------------------------- |
|
|
318
|
-
| `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `schedule`, `unschedule`, `count`, `bulk`, `diff`, `search` |
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
322
|
-
| `collections
|
|
323
|
-
| `collections.
|
|
324
|
-
| `
|
|
325
|
-
| `
|
|
326
|
-
| `
|
|
327
|
-
| `
|
|
328
|
-
| `webhooks
|
|
329
|
-
| `
|
|
330
|
-
| `
|
|
331
|
-
| `
|
|
332
|
-
| `
|
|
364
|
+
| Resource | Methods |
|
|
365
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
366
|
+
| `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `publication`, `getSchedule`, `schedule`, `unschedule`, `scheduledChanges`, `drafts`, `count`, `bulk`, `diff`, `search` |
|
|
367
|
+
| `drafts` | `create`, `list`, `get`, `update`, `discard`, `merge`, `schedule`, `unschedule` |
|
|
368
|
+
| `records.revisions` | `list`, `get`, `revert` |
|
|
369
|
+
| `search` | across the project |
|
|
370
|
+
| `collections` | `list`, `get`, `create`, `update`, `delete`, `restore`, `schema` |
|
|
371
|
+
| `collections.schemaChanges` | `list`, `get` |
|
|
372
|
+
| `collections.searchIndex` | `get`, `rebuild` |
|
|
373
|
+
| `fields` | `list`, `get`, `create`, `update`, `delete`, `restore`, `order`, `reorder`, `removeAlias`, `migrate` |
|
|
374
|
+
| `migrations` | `list`, `get`, `cancel` |
|
|
375
|
+
| `assets` | `upload`, `createUpload`, `complete`, `list`, `get`, `delete`, `restore`, `purge`, `usage`, `imageUrl`, `downloadUrl` |
|
|
376
|
+
| `webhooks` | `list`, `get`, `create`, `update`, `delete`, `restore`, `rotateSecret`, `test`, `unwrap` |
|
|
377
|
+
| `webhooks.secrets` | `list`, `retire` |
|
|
378
|
+
| `webhooks.deliveries` | `list`, `get`, `retry` |
|
|
379
|
+
| `auditEvents` | `list` |
|
|
380
|
+
| `featureFlags` | `list` |
|
|
381
|
+
| `me` | `get` |
|
|
382
|
+
| `health` | `check` |
|
|
333
383
|
|
|
334
384
|
The SDK covers every operation an API key can call. Organizations, projects, members and API keys are managed in the web app. The full API is documented at [docs.nohead.io](https://docs.nohead.io).
|
|
335
385
|
|
|
@@ -350,7 +400,7 @@ NOHEAD_API_URL=http://localhost:3000 NOHEAD_API_KEY=sk_live_… npm run smoke
|
|
|
350
400
|
- the types, in `src/generated/types.gen.ts` (hey-api)
|
|
351
401
|
- the operation table, in `src/generated/operations.gen.ts`
|
|
352
402
|
- The methods in `src/resources` are written by hand.
|
|
353
|
-
- `test/contract.test.ts` calls every public method. It fails when an API-key operation in the contract has no method, when a query parameter is never sent (no method takes it, or no call passes it), or when a request doesn't match its operation.
|
|
403
|
+
- `test/contract.test.ts` calls every public method. It fails when an API-key operation in the contract has no method, when a query parameter is never sent (no method takes it, or no call passes it), or when a request doesn't match its operation (method, path, query parameters and JSON body, against their schemas). Each call gets an example of its operation's response, built from the contract's schema, and must return it.
|
|
354
404
|
|
|
355
405
|
The smoke test (`smoke/smoke.ts`) runs the core flow against a real API, using the built package. Nohead's own CI runs it on every API contract change.
|
|
356
406
|
|
|
@@ -360,7 +410,7 @@ The smoke test (`smoke/smoke.ts`) runs the core flow against a real API, using t
|
|
|
360
410
|
2. Add a section for it to `CHANGELOG.md` (`## 1.2.3`), which becomes the release's notes.
|
|
361
411
|
3. Merge to `main`. Its ruleset requires the **CI passed** check, so the commit goes through a pull request or a branch whose CI passed, and force pushes are refused.
|
|
362
412
|
4. Run the **SDK release** workflow in the Nohead API repository. It runs this commit's smoke test against the API and pushes the tag `v1.2.3`. Nobody else can push `v*` tags: a tag ruleset lets only that workflow's deploy key through.
|
|
363
|
-
5. The tag starts `.github/workflows/release.yml`. Its publishing job runs in the `release` environment, which only `v*` tags can use, and the registry's trusted publisher accepts only that environment. It
|
|
413
|
+
5. The tag starts `.github/workflows/release.yml`. A build job checks the tag is on `main` and matches the version and its notes, and packs the package, with no publishing credential. Its publishing job runs in the `release` environment, which only `v*` tags can use, and the registry's trusted publisher accepts only that environment. It stages that package on npm with provenance through trusted publishing (no token), then creates a draft GitHub release.
|
|
364
414
|
6. Approve the staged version with 2FA, on npmjs.com, or with `npm stage list @nohead/sdk` and `npm stage approve <stage-id>`. Only then is it installable. Publish the draft GitHub release.
|
|
365
415
|
|
|
366
416
|
## License
|
package/dist/client.d.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { PagePromise } from "./api-promise.ts";
|
|
|
2
2
|
import { type ClientOptions, type RequestOptions } from "./core.ts";
|
|
3
3
|
import type * as G from "./generated/types.gen.ts";
|
|
4
4
|
import { Assets } from "./resources/assets.ts";
|
|
5
|
+
import { Drafts } from "./resources/drafts.ts";
|
|
5
6
|
import { AuditEvents, FeatureFlags, Health, Me } from "./resources/other.ts";
|
|
6
7
|
import { Records } from "./resources/records.ts";
|
|
7
8
|
import { Collections, Fields, Migrations } from "./resources/schema.ts";
|
|
8
9
|
import { Webhooks } from "./resources/webhooks.ts";
|
|
9
|
-
import type { NoheadRecord, ProjectSearchParams, RecordData, SearchMeta } from "./types.ts";
|
|
10
|
+
import type { NoheadDraft, NoheadRecord, ProjectSearchParams, RecordData, SearchMeta } from "./types.ts";
|
|
10
11
|
/**
|
|
11
12
|
* A client for the Nohead API, authenticated with a project API key.
|
|
12
13
|
*
|
|
@@ -18,6 +19,7 @@ import type { NoheadRecord, ProjectSearchParams, RecordData, SearchMeta } from "
|
|
|
18
19
|
export declare class Nohead {
|
|
19
20
|
#private;
|
|
20
21
|
readonly records: Records;
|
|
22
|
+
readonly drafts: Drafts;
|
|
21
23
|
readonly collections: Collections;
|
|
22
24
|
readonly fields: Fields;
|
|
23
25
|
readonly migrations: Migrations;
|
|
@@ -37,6 +39,7 @@ export declare class Nohead {
|
|
|
37
39
|
/** The main types, also reachable as `Nohead.Record`, `Nohead.Collection`… */
|
|
38
40
|
export declare namespace Nohead {
|
|
39
41
|
type Record<D = RecordData> = NoheadRecord<D>;
|
|
42
|
+
type Draft<D = RecordData> = NoheadDraft<D>;
|
|
40
43
|
type Collection = G.Collection;
|
|
41
44
|
type Field = G.Field;
|
|
42
45
|
type Asset = G.Asset;
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Core } from "./core.js";
|
|
2
2
|
import { Assets } from "./resources/assets.js";
|
|
3
|
+
import { Drafts } from "./resources/drafts.js";
|
|
3
4
|
import { AuditEvents, FeatureFlags, Health, Me } from "./resources/other.js";
|
|
4
5
|
import { Records } from "./resources/records.js";
|
|
5
6
|
import { Collections, Fields, Migrations } from "./resources/schema.js";
|
|
@@ -14,6 +15,7 @@ import { Webhooks } from "./resources/webhooks.js";
|
|
|
14
15
|
*/
|
|
15
16
|
export class Nohead {
|
|
16
17
|
records;
|
|
18
|
+
drafts;
|
|
17
19
|
collections;
|
|
18
20
|
fields;
|
|
19
21
|
migrations;
|
|
@@ -28,6 +30,7 @@ export class Nohead {
|
|
|
28
30
|
const core = new Core(options);
|
|
29
31
|
this.#core = core;
|
|
30
32
|
this.records = new Records(core);
|
|
33
|
+
this.drafts = new Drafts(core);
|
|
31
34
|
this.collections = new Collections(core);
|
|
32
35
|
this.fields = new Fields(core);
|
|
33
36
|
this.migrations = new Migrations(core);
|
|
@@ -43,6 +46,16 @@ export class Nohead {
|
|
|
43
46
|
* collections), most relevant first, through the first 1,000 hits.
|
|
44
47
|
*/
|
|
45
48
|
search(query, params = {}, options) {
|
|
46
|
-
|
|
49
|
+
const { collections, filter, ...rest } = params;
|
|
50
|
+
return this.#core.paginate("projects_search", {
|
|
51
|
+
query: {
|
|
52
|
+
q: query,
|
|
53
|
+
...rest,
|
|
54
|
+
filter: {
|
|
55
|
+
...filter,
|
|
56
|
+
...(collections ? { collection: { in: collections } } : {}),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}, options);
|
|
47
60
|
}
|
|
48
61
|
}
|
package/dist/core.d.ts
CHANGED
|
@@ -39,8 +39,6 @@ export interface RetryEvent {
|
|
|
39
39
|
export interface RequestOptions {
|
|
40
40
|
/** Sent as `Idempotency-Key` on writes. Generated when omitted. */
|
|
41
41
|
idempotencyKey?: string;
|
|
42
|
-
/** A reason for the change, shown in history (`Nohead-Change-Note`). */
|
|
43
|
-
changeNote?: string;
|
|
44
42
|
signal?: AbortSignal;
|
|
45
43
|
/** Milliseconds per attempt, overriding the client's. */
|
|
46
44
|
timeout?: number;
|
|
@@ -48,28 +46,53 @@ export interface RequestOptions {
|
|
|
48
46
|
maxRetries?: number;
|
|
49
47
|
headers?: Record<string, string>;
|
|
50
48
|
}
|
|
51
|
-
/** Options of
|
|
49
|
+
/** Options of writes that change records or the schema. */
|
|
50
|
+
export interface WriteOptions extends RequestOptions {
|
|
51
|
+
/**
|
|
52
|
+
* A reason for the change, kept with the revision, schema change or
|
|
53
|
+
* migration it makes (`Nohead-Change-Note`).
|
|
54
|
+
*/
|
|
55
|
+
changeNote?: string;
|
|
56
|
+
}
|
|
57
|
+
/** Options of draft writes that accept `If-Match`. */
|
|
52
58
|
export interface ConditionalRequestOptions extends RequestOptions {
|
|
53
59
|
/**
|
|
54
|
-
* Only write if the record is still at this revision;
|
|
55
|
-
* answers `412` (`PreconditionFailedError`). A record
|
|
60
|
+
* Only write if the record (or the draft) is still at this revision;
|
|
61
|
+
* otherwise the API answers `412` (`PreconditionFailedError`). A record
|
|
62
|
+
* or a draft works too.
|
|
56
63
|
*/
|
|
57
64
|
ifMatch?: number | {
|
|
58
65
|
revision: number;
|
|
59
66
|
};
|
|
60
67
|
}
|
|
68
|
+
/** Options of record writes, which accept `If-Match` and a change note. */
|
|
69
|
+
export interface ConditionalWriteOptions extends WriteOptions, ConditionalRequestOptions {
|
|
70
|
+
}
|
|
71
|
+
/** The request headers an operation declares. */
|
|
72
|
+
type HeadersOf<Op extends OperationId> = Operations[Op]["data"] extends {
|
|
73
|
+
headers?: infer H;
|
|
74
|
+
} ? keyof NonNullable<H> : never;
|
|
75
|
+
/**
|
|
76
|
+
* The options an operation takes: `ifMatch` and `changeNote` only where it
|
|
77
|
+
* declares `If-Match` and `Nohead-Change-Note`, so a method can't offer them
|
|
78
|
+
* elsewhere.
|
|
79
|
+
*/
|
|
80
|
+
export type OptionsOf<Op extends OperationId> = RequestOptions & ("If-Match" extends HeadersOf<Op> ? Pick<ConditionalRequestOptions, "ifMatch"> : {
|
|
81
|
+
ifMatch?: never;
|
|
82
|
+
}) & ("Nohead-Change-Note" extends HeadersOf<Op> ? Pick<WriteOptions, "changeNote"> : {
|
|
83
|
+
changeNote?: never;
|
|
84
|
+
});
|
|
61
85
|
export interface RequestInput {
|
|
62
86
|
path?: Record<string, string | number | undefined>;
|
|
63
87
|
query?: object;
|
|
64
88
|
body?: unknown;
|
|
65
89
|
}
|
|
66
|
-
type AllOptions = ConditionalRequestOptions;
|
|
67
90
|
export declare class Core {
|
|
68
91
|
#private;
|
|
69
92
|
constructor(options: ClientOptions);
|
|
70
93
|
/** The API key's project, from `GET /v1/me` the first time. */
|
|
71
94
|
projectId(): Promise<string>;
|
|
72
|
-
request<Op extends OperationId>(operation: Op, input?: RequestInput, options?:
|
|
95
|
+
request<Op extends OperationId>(operation: Op, input?: RequestInput, options?: OptionsOf<Op>): APIPromise<Operations[Op]["response"]>;
|
|
73
96
|
/** A list operation as pages of `Item`. */
|
|
74
97
|
paginate<Item, Meta extends ListMeta = ListMeta>(operation: OperationId, input: RequestInput, options?: RequestOptions): PagePromise<Item, Meta>;
|
|
75
98
|
/** `fetch` with this client's timeout and retries, for presigned URLs. */
|
|
@@ -79,8 +102,9 @@ export declare class Core {
|
|
|
79
102
|
}
|
|
80
103
|
/**
|
|
81
104
|
* Query parameters as the API reads them: objects become `key[sub]=…`
|
|
82
|
-
* (`filter[
|
|
83
|
-
*
|
|
105
|
+
* (`filter[price][lt]=50`), arrays are comma-separated (`expand=author,tags`)
|
|
106
|
+
* with `\,` for a comma inside a value and `\\` for a backslash, dates are
|
|
107
|
+
* ISO 8601.
|
|
84
108
|
*/
|
|
85
109
|
export declare function appendQuery(params: URLSearchParams, value: unknown, key?: string): void;
|
|
86
110
|
export {};
|
package/dist/core.js
CHANGED
|
@@ -3,6 +3,12 @@ import { AbortError, ConnectionError, NoheadError, TimeoutError, apiError, retry
|
|
|
3
3
|
import { OPERATIONS, } from "./generated/operations.gen.js";
|
|
4
4
|
import { VERSION } from "./version.js";
|
|
5
5
|
export const DEFAULT_BASE_URL = "https://api.nohead.io";
|
|
6
|
+
/**
|
|
7
|
+
* The string with lone surrogates (half an emoji, from cutting a string)
|
|
8
|
+
* replaced by U+FFFD, as `String.prototype.toWellFormed` does, so
|
|
9
|
+
* `encodeURIComponent` can encode it.
|
|
10
|
+
*/
|
|
11
|
+
const wellFormed = (text) => text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
6
12
|
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
7
13
|
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
8
14
|
const warnedDeprecations = new Set();
|
|
@@ -25,8 +31,7 @@ export class Core {
|
|
|
25
31
|
throw new NoheadError("Missing API key: pass `apiKey` or set NOHEAD_API_KEY");
|
|
26
32
|
}
|
|
27
33
|
this.#apiKey = apiKey;
|
|
28
|
-
this.#baseUrl = (options.baseUrl ??
|
|
29
|
-
(env.NOHEAD_API_URL || DEFAULT_BASE_URL)).replace(/\/+$/, "");
|
|
34
|
+
this.#baseUrl = withoutTrailingSlashes(options.baseUrl ?? (env.NOHEAD_API_URL || DEFAULT_BASE_URL));
|
|
30
35
|
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
31
36
|
this.#headers = options.headers ?? {};
|
|
32
37
|
this.#maxRetries = options.maxRetries ?? 2;
|
|
@@ -109,8 +114,10 @@ export class Core {
|
|
|
109
114
|
: options.ifMatch;
|
|
110
115
|
headers["If-Match"] = `"${revision}"`;
|
|
111
116
|
}
|
|
117
|
+
// Percent-encoded UTF-8: header values are bytes, and fetch refuses
|
|
118
|
+
// anything past Latin-1 (the API decodes it).
|
|
112
119
|
if (options.changeNote)
|
|
113
|
-
headers["Nohead-Change-Note"] = options.changeNote;
|
|
120
|
+
headers["Nohead-Change-Note"] = encodeURIComponent(wellFormed(options.changeNote));
|
|
114
121
|
Object.assign(headers, options.headers);
|
|
115
122
|
const init = {
|
|
116
123
|
method,
|
|
@@ -252,8 +259,9 @@ async function parseBody(response) {
|
|
|
252
259
|
}
|
|
253
260
|
/**
|
|
254
261
|
* Query parameters as the API reads them: objects become `key[sub]=…`
|
|
255
|
-
* (`filter[
|
|
256
|
-
*
|
|
262
|
+
* (`filter[price][lt]=50`), arrays are comma-separated (`expand=author,tags`)
|
|
263
|
+
* with `\,` for a comma inside a value and `\\` for a backslash, dates are
|
|
264
|
+
* ISO 8601.
|
|
257
265
|
*/
|
|
258
266
|
export function appendQuery(params, value, key) {
|
|
259
267
|
if (value === undefined || value === null)
|
|
@@ -262,8 +270,10 @@ export function appendQuery(params, value, key) {
|
|
|
262
270
|
params.append(key, value.toISOString());
|
|
263
271
|
}
|
|
264
272
|
else if (Array.isArray(value)) {
|
|
265
|
-
|
|
266
|
-
|
|
273
|
+
// Missing items are left out, as missing values are.
|
|
274
|
+
const items = value.filter((item) => item !== undefined && item !== null);
|
|
275
|
+
if (items.length > 0)
|
|
276
|
+
params.append(key, items.map((item) => scalar(item).replace(/[\\,]/g, "\\$&")).join(","));
|
|
267
277
|
}
|
|
268
278
|
else if (typeof value === "object") {
|
|
269
279
|
for (const [name, inner] of Object.entries(value)) {
|
|
@@ -293,6 +303,13 @@ function runtimeVersion() {
|
|
|
293
303
|
return `node/${g.process.versions.node}`;
|
|
294
304
|
return undefined;
|
|
295
305
|
}
|
|
306
|
+
/** `url` without trailing slashes; a loop, as a regex backtracks on long runs of them. */
|
|
307
|
+
function withoutTrailingSlashes(url) {
|
|
308
|
+
let end = url.length;
|
|
309
|
+
while (end > 0 && url[end - 1] === "/")
|
|
310
|
+
end--;
|
|
311
|
+
return url.slice(0, end);
|
|
312
|
+
}
|
|
296
313
|
function environment() {
|
|
297
314
|
const process = globalThis.process;
|
|
298
315
|
return process?.env ?? {};
|
package/dist/errors.d.ts
CHANGED
|
@@ -8,8 +8,11 @@ export declare class APIError extends NoheadError {
|
|
|
8
8
|
name: string;
|
|
9
9
|
/** The HTTP status. */
|
|
10
10
|
readonly status: number;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The error `type`, e.g. `validation_error`; undefined if the body had
|
|
13
|
+
* none. A type newer than this SDK is kept as its string.
|
|
14
|
+
*/
|
|
15
|
+
readonly type: ErrorType | (string & {}) | undefined;
|
|
13
16
|
/** The request's ID (`req_…`), for support requests and logs. */
|
|
14
17
|
readonly requestId: string | undefined;
|
|
15
18
|
/** Field-level details, e.g. `[{ field: "title", code: "required", … }]`. */
|