@nohead/sdk 0.1.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 CHANGED
@@ -5,6 +5,130 @@ 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
+
120
+ ## 0.2.0
121
+
122
+ - `onRetry` client option: called before each retry with the attempt, the
123
+ delay, and the status or error that failed, for example to report retries.
124
+ - `assets.upload()` with an `idempotencyKey` no longer fails when it
125
+ completes the upload: the key only starts it. Running the same upload again
126
+ resumes it, and returns an asset that is already `ready` without sending the
127
+ bytes again; once the upload's URL has expired it throws `UploadError`
128
+ (start over with a new key).
129
+ - Types follow the API's current contract. They add types only for
130
+ operations an API key can't call, so no method changed.
131
+
8
132
  ## 0.1.0
9
133
 
10
134
  The first release.
package/README.md CHANGED
@@ -8,19 +8,20 @@ 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: { status: "published" },
11
+ filter: { published_at: { exists: true } },
12
12
  })) {
13
13
  console.log(post.data.title)
14
14
  }
15
15
  ```
16
16
 
17
- > **Status:** 0.x, not yet published to npm. Until it is, install from GitHub: `npm install github:nohead-io/nohead-typescript`.
17
+ > **Status:** 0.x until the Nohead API launches.
18
18
 
19
19
  ## Contents
20
20
 
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)
@@ -50,32 +51,36 @@ const nohead = new Nohead({
50
51
  })
51
52
  ```
52
53
 
53
- | Option | Default | |
54
- | ------------ | ---------------------------------------------- | ---------------------------------------------------- |
55
- | `apiKey` | `NOHEAD_API_KEY` | A project API key (`sk_live_…`). Required. |
56
- | `baseUrl` | `NOHEAD_API_URL`, else `https://api.nohead.io` | |
57
- | `projectId` | the key's project | Looked up once with `GET /v1/me` when omitted. |
58
- | `maxRetries` | `2` | See [retries](#retries-and-idempotency). |
59
- | `timeout` | `60000` | Milliseconds per attempt. |
60
- | `fetch` | `globalThis.fetch` | For proxies, instrumentation and tests. |
61
- | `headers` | `{}` | Added to every request. |
62
- | `warnings` | `true` | Logs deprecation and plan usage warnings, once each. |
54
+ | Option | Default | |
55
+ | ------------ | ---------------------------------------------- | ------------------------------------------------------------------ |
56
+ | `apiKey` | `NOHEAD_API_KEY` | A project API key (`sk_live_…`). Required. |
57
+ | `baseUrl` | `NOHEAD_API_URL`, else `https://api.nohead.io` | |
58
+ | `projectId` | the key's project | Looked up once with `GET /v1/me` when omitted. |
59
+ | `maxRetries` | `2` | See [retries](#retries-and-idempotency). |
60
+ | `timeout` | `60000` | Milliseconds per attempt. |
61
+ | `fetch` | `globalThis.fetch` | For proxies, instrumentation and tests. |
62
+ | `headers` | `{}` | Added to every request. |
63
+ | `warnings` | `true` | Logs deprecation and plan usage warnings, once each. |
64
+ | `onRetry` | | Called before each retry. See [retries](#retries-and-idempotency). |
63
65
 
64
66
  API keys belong to a project, so methods like `collections.list()` need no project ID. Collections can be named by ID or slug everywhere.
65
67
 
66
68
  ## Records
67
69
 
68
70
  ```ts
69
- const draft = await nohead.records.create("posts", {
71
+ const created = await nohead.records.create("posts", {
70
72
  data: { title: "Hello", author: "rec_01J9…" },
71
73
  })
72
- const post = await nohead.records.get(draft.id, { expand: ["author"] })
74
+ const post = await nohead.records.get(created.id, { expand: ["author"] })
73
75
  await nohead.records.update(post.id, { data: { title: "Hello again" } })
74
76
  await nohead.records.publish(post.id)
77
+ await nohead.records.publication(post.id) // since when, by whom, which revision
75
78
  await nohead.records.schedule(post.id, { unpublish_at: new Date("2027-01-01") })
76
79
  await nohead.records.delete(post.id) // soft delete; records.restore() undoes it
77
80
  ```
78
81
 
82
+ `schedule` sets the whole schedule: a time left out is cleared, so `{ unpublish_at }` alone drops a pending publish.
83
+
79
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.
80
85
 
81
86
  **Typed data.** Describe a collection's fields and pass the type:
@@ -94,6 +99,29 @@ The type is a promise you make; nothing is checked at runtime. The record type i
94
99
 
95
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).
96
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
+
97
125
  ## Pagination
98
126
 
99
127
  List methods return a promise of the first page that you can also loop over:
@@ -112,14 +140,27 @@ while (page.hasNextPage()) page = await page.getNextPage()
112
140
  await nohead.records.list("posts", { cursor: page.meta.next_cursor! })
113
141
  ```
114
142
 
115
- Filters are equality filters (for fields with several values: "contains"), and accept strings, numbers, booleans and dates:
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:
116
144
 
117
145
  ```ts
118
146
  nohead.records.list("posts", {
119
- filter: { status: "published", featured: true, author: "rec_01J9…" },
147
+ filter: {
148
+ published_at: { exists: true },
149
+ featured: true,
150
+ author: "rec_01J9…",
151
+ },
120
152
  sort: "-published_at",
121
153
  expand: ["author", "tags"],
122
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
+ })
123
164
  ```
124
165
 
125
166
  ## Errors
@@ -163,6 +204,17 @@ Failed requests are retried twice by default (`maxRetries`), with exponential ba
163
204
  - what's retried: connection errors, timeouts, 429, 500, 502, 503, 504, and a 409 for a request that is still running
164
205
  - `Retry-After` is honored up to 60 seconds; a longer one throws `RateLimitError` straight away
165
206
 
207
+ To report retries, pass `onRetry`. It gets the retry's `attempt` (1 for the first), `delay` in seconds, `url`, the failed `status` (none when the connection failed) and the `error`:
208
+
209
+ ```ts
210
+ const nohead = new Nohead({
211
+ onRetry: ({ attempt, delay, status }) =>
212
+ console.error(
213
+ `Retry ${attempt} in ${delay.toFixed(1)}s (${status ?? "network"})`
214
+ ),
215
+ })
216
+ ```
217
+
166
218
  Every write gets an `Idempotency-Key` that stays the same across its retries, so a retry after a lost response never writes twice. To make a write safe across your own retries (a job that may run twice), pass a key:
167
219
 
168
220
  ```ts
@@ -176,10 +228,10 @@ await nohead.records.create(
176
228
  Every method takes request options as its last argument:
177
229
 
178
230
  - `idempotencyKey`
179
- - `ifMatch` (record writes)
180
- - `changeNote`, a reason shown in history
181
231
  - `signal`, `timeout`, `maxRetries` and `headers`
182
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
+
183
235
  ## Concurrency
184
236
 
185
237
  Pass the revision you read to make sure nobody changed the record since:
@@ -195,7 +247,7 @@ try {
195
247
  }
196
248
  ```
197
249
 
198
- `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`.
199
251
 
200
252
  ## Assets
201
253
 
@@ -223,8 +275,18 @@ const { url } = await nohead.assets.imageUrl(asset.id, {
223
275
 
224
276
  **Errors:** `UploadError` if storage refuses the bytes; `ValidationError` if the file fails the checks.
225
277
 
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.
279
+
226
280
  **Uploading from a browser:** create the upload on your server with `createUpload`, `PUT` the file from the browser, then `complete` it.
227
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
+
228
290
  ## Search
229
291
 
230
292
  ```ts
@@ -277,14 +339,14 @@ export async function POST(request: Request) {
277
339
  secret: process.env.NOHEAD_WEBHOOK_SECRET!,
278
340
  }
279
341
  )
280
- if (event.type === "record.published") {
342
+ if (event.type === "record.published" && event.data.record) {
281
343
  revalidateTag(event.data.record.collection)
282
344
  }
283
345
  return new Response(null, { status: 204 })
284
346
  }
285
347
  ```
286
348
 
287
- `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.
288
350
 
289
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.
290
352
 
@@ -299,23 +361,25 @@ response.headers.get("RateLimit-Remaining")
299
361
 
300
362
  ## Reference
301
363
 
302
- | Resource | Methods |
303
- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
304
- | `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `schedule`, `unschedule`, `count`, `bulk`, `diff`, `search` |
305
- | `records.revisions` | `list`, `get`, `revert` |
306
- | `search` | across the project |
307
- | `collections` | `list`, `get`, `create`, `update`, `delete`, `restore`, `schema` |
308
- | `collections.schemaChanges` | `list`, `get` |
309
- | `collections.searchIndex` | `get`, `rebuild` |
310
- | `fields` | `list`, `create`, `update`, `delete`, `restore`, `reorder`, `removeAlias`, `migrate` |
311
- | `migrations` | `list`, `get`, `cancel` |
312
- | `assets` | `upload`, `createUpload`, `complete`, `list`, `get`, `delete`, `restore`, `imageUrl`, `downloadUrl` |
313
- | `webhooks` | `list`, `get`, `create`, `update`, `delete`, `rotateSecret`, `test`, `unwrap` |
314
- | `webhooks.deliveries` | `list`, `get`, `retry` |
315
- | `auditEvents` | `list` |
316
- | `featureFlags` | `list` |
317
- | `me` | `get` |
318
- | `health` | `check` |
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` |
319
383
 
320
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).
321
385
 
@@ -336,7 +400,7 @@ NOHEAD_API_URL=http://localhost:3000 NOHEAD_API_KEY=sk_live_… npm run smoke
336
400
  - the types, in `src/generated/types.gen.ts` (hey-api)
337
401
  - the operation table, in `src/generated/operations.gen.ts`
338
402
  - The methods in `src/resources` are written by hand.
339
- - `test/contract.test.ts` calls every public method. It fails when an API-key operation in the contract has no method, 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.
340
404
 
341
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.
342
406
 
@@ -344,9 +408,9 @@ The smoke test (`smoke/smoke.ts`) runs the core flow against a real API, using t
344
408
 
345
409
  1. Bump the version in `package.json` and `src/version.ts`.
346
410
  2. Add a section for it to `CHANGELOG.md` (`## 1.2.3`), which becomes the release's notes.
347
- 3. Merge to `main`.
348
- 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`.
349
- 5. The tag starts `.github/workflows/release.yml`. It checks the version and its notes, tests, and stages the package on npm with provenance through trusted publishing (no token). Then it creates a draft GitHub release.
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.
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.
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.
350
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.
351
415
 
352
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
- return this.#core.paginate("projects_search", { query: { q: query, ...params } }, options);
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
@@ -1,4 +1,5 @@
1
1
  import { APIPromise, PagePromise } from "./api-promise.ts";
2
+ import { NoheadError } from "./errors.ts";
2
3
  import { type OperationId, type Operations } from "./generated/operations.gen.ts";
3
4
  import type { ListMeta } from "./generated/types.gen.ts";
4
5
  export declare const DEFAULT_BASE_URL = "https://api.nohead.io";
@@ -19,12 +20,25 @@ export interface ClientOptions {
19
20
  headers?: Record<string, string>;
20
21
  /** Log deprecation and plan usage warnings (once each). Default true. */
21
22
  warnings?: boolean;
23
+ /** Called before each retry, for example to report it. */
24
+ onRetry?: (retry: RetryEvent) => void;
25
+ }
26
+ /** A retry about to happen (`onRetry`). */
27
+ export interface RetryEvent {
28
+ /** 1 for the first retry of the request, 2 for the second… */
29
+ attempt: number;
30
+ /** Seconds the client waits before it. */
31
+ delay: number;
32
+ /** The URL being retried. */
33
+ url: string;
34
+ /** The status that failed, or undefined when the connection did. */
35
+ status?: number;
36
+ /** What failed: an `APIError` or a `ConnectionError` (none for storage). */
37
+ error?: NoheadError;
22
38
  }
23
39
  export interface RequestOptions {
24
40
  /** Sent as `Idempotency-Key` on writes. Generated when omitted. */
25
41
  idempotencyKey?: string;
26
- /** A reason for the change, shown in history (`Nohead-Change-Note`). */
27
- changeNote?: string;
28
42
  signal?: AbortSignal;
29
43
  /** Milliseconds per attempt, overriding the client's. */
30
44
  timeout?: number;
@@ -32,28 +46,53 @@ export interface RequestOptions {
32
46
  maxRetries?: number;
33
47
  headers?: Record<string, string>;
34
48
  }
35
- /** Options of record writes that accept `If-Match`. */
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`. */
36
58
  export interface ConditionalRequestOptions extends RequestOptions {
37
59
  /**
38
- * Only write if the record is still at this revision; otherwise the API
39
- * answers `412` (`PreconditionFailedError`). A record works too.
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.
40
63
  */
41
64
  ifMatch?: number | {
42
65
  revision: number;
43
66
  };
44
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
+ });
45
85
  export interface RequestInput {
46
86
  path?: Record<string, string | number | undefined>;
47
87
  query?: object;
48
88
  body?: unknown;
49
89
  }
50
- type AllOptions = ConditionalRequestOptions;
51
90
  export declare class Core {
52
91
  #private;
53
92
  constructor(options: ClientOptions);
54
93
  /** The API key's project, from `GET /v1/me` the first time. */
55
94
  projectId(): Promise<string>;
56
- request<Op extends OperationId>(operation: Op, input?: RequestInput, options?: AllOptions): APIPromise<Operations[Op]["response"]>;
95
+ request<Op extends OperationId>(operation: Op, input?: RequestInput, options?: OptionsOf<Op>): APIPromise<Operations[Op]["response"]>;
57
96
  /** A list operation as pages of `Item`. */
58
97
  paginate<Item, Meta extends ListMeta = ListMeta>(operation: OperationId, input: RequestInput, options?: RequestOptions): PagePromise<Item, Meta>;
59
98
  /** `fetch` with this client's timeout and retries, for presigned URLs. */
@@ -63,8 +102,9 @@ export declare class Core {
63
102
  }
64
103
  /**
65
104
  * Query parameters as the API reads them: objects become `key[sub]=…`
66
- * (`filter[status]=published`), arrays are comma-separated
67
- * (`expand=author,tags`), dates are ISO 8601.
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.
68
108
  */
69
109
  export declare function appendQuery(params: URLSearchParams, value: unknown, key?: string): void;
70
110
  export {};