@nohead/sdk 0.2.0 → 0.4.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,134 @@ 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.4.0
9
+
10
+ - **Breaking:** a field's `api_key` is its `key` (in fields, their aliases,
11
+ `fields.create` and `fields.update`), as the editor's "Field key" says.
12
+ API keys keep their name.
13
+ - **Breaking:** `collections.schemaChanges.get(schemaChange)` takes the
14
+ schema change's ID alone (`GET /v1/schema-changes/{id}`).
15
+ - **Breaking:** `fields.update` no longer takes `type` or `multiple`; they
16
+ rewrite record values, so they are `fields.migrate`. The API refuses a
17
+ body key an operation doesn't take with `400` (`unknown_parameter`, naming
18
+ it), so a misspelt key no longer passes unnoticed.
19
+ - `Field` and `FieldCreate` are typed per field type: narrowing on
20
+ `field.type` types its `configuration` (`if (field.type === "enum")
21
+ field.configuration.options`), and `EnumFieldConfiguration`,
22
+ `RelationFieldConfiguration`... are exported.
23
+
24
+ ## 0.3.0
25
+
26
+ - **Breaking:** 0.1.0 and 0.2.0 no longer work with the API: its paths and
27
+ shapes changed (below), so upgrading is required.
28
+ - `changeNote` is sent percent-encoded (UTF-8), so change notes work in any
29
+ language: a note outside ASCII used to fail the request.
30
+ - Drafts: changes to a record that wait until they are merged into it, now
31
+ or at a set time. `drafts.create`, `list`, `get` (with `expand`, for
32
+ previews), `update`, `discard`, `merge`, `schedule` and `unschedule`, and
33
+ `records.drafts`. Drafts take the type of their data as records do
34
+ (`drafts.get<Post>(id)` is a `NoheadDraft<Post>`, also `Nohead.Draft`),
35
+ and `drafts.create(record)` needs no params to start from the record's
36
+ values. `records.publish` on a live record with a pending draft is a
37
+ `ConflictError` (`draft_pending`). Webhook events gain `DraftEvent`
38
+ (`draft.*`), and a merge's `record.updated` carries its `draft_id`.
39
+ - **Breaking:** request options follow what each operation takes, by type:
40
+ `changeNote` only on writes that change records or the schema
41
+ (`WriteOptions`: record writes, `drafts.merge`, collection and field
42
+ writes), not on assets, webhooks, schedules, search index rebuilds or
43
+ other draft writes, which never kept a note. `ifMatch` on
44
+ `records.update`, `delete`, `restore` (new), `publish`, `unpublish` and
45
+ `revisions.revert` (`ConditionalWriteOptions`) and on `drafts.update`,
46
+ `discard` and `merge`. `ConditionalRequestOptions` no longer includes
47
+ `changeNote`.
48
+ - **Breaking:** `records.schedule` sets the whole schedule: a time left out
49
+ is cleared (it used to keep its value), and `{}` clears both, as
50
+ `unschedule` does.
51
+ - `fields.get()`: one field by ID.
52
+ - **Breaking:** the types for what only the web app can do (organizations,
53
+ projects, members and invitations, billing, API keys, usage, account
54
+ changes and redaction, such as `Organization`, `Project`, `ApiKey`, `Billing`) are gone. Those
55
+ operations need a signed-in person, so an API key could never call them,
56
+ and the API reference no longer lists them.
57
+ - **Breaking:** the API is resource-oriented. Method names stay; what they
58
+ return and take follows it:
59
+ - Records have no `status`, `schedule` or `deleted`: a record is published
60
+ when `published_at` is set, and has `deleted_at` and `purge_after`.
61
+ Filter with `{ published_at: { exists: true } }`. `records.get` returns
62
+ deleted records too (no `include_deleted`).
63
+ - `records.schedule` and `unschedule` return the schedule, and
64
+ `records.getSchedule`, `records.publication` and
65
+ `records.scheduledChanges` are new. `records.bulk` returns a
66
+ `record_batch`: `BulkRecordsResult` and `BulkRecordsRequest` are now
67
+ `RecordBatch` and `RecordBatchRequest`.
68
+ - Project search takes `filter: { published_at: { exists: true } }`
69
+ instead of `filter: { status }`.
70
+ - Collections, fields, assets and webhooks have `deleted_at` and
71
+ `purge_after` instead of `deleted`, and migrations
72
+ `cancel_requested_at`.
73
+ - `assets.createUpload` returns the asset with its `upload`
74
+ (`AssetWithUpload`, was `AssetUpload`).
75
+ - `fields.reorder` returns the `field_order`; `fields.order` reads it.
76
+ - Webhook event types are those of the activity log (`record.restored`,
77
+ `field.updated`, …, and wildcards such as `record.*`), and event
78
+ payloads carry `actor` and `via`. `SchemaChangedEvent`
79
+ (`schema.changed`) is now `SchemaEvent` (`collection.*` and `field.*`,
80
+ with the `collection`, its `object`, the `schema_change` and, for a
81
+ field, its `field_id`), and `FieldAliasRemovedEvent` is new
82
+ (`field.alias_removed`). There is no `record.imported`. The event data
83
+ types are exported too (`RecordEventData`, `DraftEventData`,
84
+ `AssetEventData`, `SchemaEventData`, `FieldAliasEventData`,
85
+ `WebhookTestEventData`).
86
+ - `webhooks.rotateSecret` returns a `webhook_secret`
87
+ (`WebhookSecretWithValue`, was `WebhookWithSecret`); the old secret
88
+ signs for 24 hours more. `webhooks.secrets.list`,
89
+ `webhooks.secrets.retire`, `webhooks.restore` and
90
+ `webhooks.list({ deleted })` are new. `webhooks.deliveries.retry`
91
+ calls the API's new path.
92
+ - Audit events are `object: "event"`, and name what happened in `type`
93
+ (was `action`). `auditEvents.list` filters by `type`, `resource_type`
94
+ (one, or a list of them), `resource_id`, `actor_type`, `actor_id` and
95
+ `created_at: { gte, gt, lte, lt }` (was `action`, `since` and
96
+ `until`); an unknown filter is an `InvalidRequestError`. Field events
97
+ have the resource type `field`.
98
+ - `records.revisions.list` filters `created_at` by range (was `since`
99
+ and `until`), and revisions' `operation` can be `merge` (not `import`).
100
+ - `ScheduledChange` has no `last_error`, and `SchemaChange` no `status`.
101
+ `ChangeVia` is gone: `Via` names the client a change came through.
102
+ - Exported types for the params that were inline:
103
+ `ScheduledChangeListParams`, `DraftScheduleParams`,
104
+ `FieldReorderParams`, and the filters `AuditEventFilter`,
105
+ `RevisionFilter` and `TimeRange`.
106
+ - **Breaking:** the `datetime` field type is now `date`. A plain date field holds
107
+ `YYYY-MM-DD`; with `include_time` it holds a moment, written in the field's
108
+ `time_zone` when it has one. Field migrations take `time_zone`, the zone
109
+ whose day each moment falls on when the time is removed.
110
+ - Boolean fields can't be `required`: a boolean is true or false, and no
111
+ value reads as false.
112
+ - Text fields take a `format` (`email`, `url`, `slug`; slugs are always
113
+ unique) and text and integer fields `unique`; a taken value's error detail
114
+ has the `record_id` that has it (`code: "taken"`).
115
+ - Filters take operators: `{ price: { lt: 50 } }`, with `eq`, `ne`, `gt`,
116
+ `gte`, `lt`, `lte`, `in` (a list) and `exists`, and record lists sort by a
117
+ field's value. Commas and backslashes in list values are escaped, so a
118
+ value can hold a comma.
119
+ - Date filters take `today`, and dates with a time `now`.
120
+ - `assets.usage()`: where an asset is used, as counts of the records that use
121
+ it (and of those, the published ones) and the 10 most recently updated with
122
+ the fields that use it. Needs the `records:read` scope too.
123
+ - `assets.purge()`: permanently deletes a deleted asset now, instead of at
124
+ its `purge_after`, and frees its storage.
125
+ - An error reads only the fields of the API's error envelope that have the
126
+ right type, so JSON from a proxy or gateway that merely looks like one no
127
+ longer gives it a non-string `message`, `type` or `requestId`.
128
+ - `APIError.type` is typed as the known error types or any string: the API
129
+ can add types, and one newer than the SDK is kept as is. Code that checks
130
+ it exhaustively needs a default branch.
131
+ - An error whose type names a built-in property (`constructor`,
132
+ `toString`) is an `APIError`; it used to throw a value that wasn't one.
133
+ - An error's `details` keep only entries that are objects, so
134
+ `currentRevision` no longer throws on a `null` one.
135
+
8
136
  ## 0.2.0
9
137
 
10
138
  - `onRetry` client option: called before each retry with the attempt, the
@@ -12,7 +140,8 @@ section is its GitHub release's notes.
12
140
  - `assets.upload()` with an `idempotencyKey` no longer fails when it
13
141
  completes the upload: the key only starts it. Running the same upload again
14
142
  resumes it, and returns an asset that is already `ready` without sending the
15
- bytes again.
143
+ bytes again; once the upload's URL has expired it throws `UploadError`
144
+ (start over with a new key).
16
145
  - Types follow the API's current contract. They add types only for
17
146
  operations an API key can't call, so no method changed.
18
147
 
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: { status: "published" },
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 draft = await nohead.records.create("posts", {
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(draft.id, { expand: ["author"] })
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 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:
117
144
 
118
145
  ```ts
119
146
  nohead.records.list("posts", {
120
- filter: { status: "published", featured: true, author: "rec_01J9…" },
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 picks up where it stopped. An asset that is already `ready` comes back without sending the bytes 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
@@ -258,11 +306,11 @@ Search needs `search_enabled` collections and the `search:read` scope. It pages
258
306
  const posts = await nohead.collections.create({
259
307
  name: "Posts",
260
308
  slug: "posts",
261
- fields: [{ name: "Title", api_key: "title", type: "text", required: true }],
309
+ fields: [{ name: "Title", key: "title", type: "text", required: true }],
262
310
  })
263
311
  await nohead.fields.create("posts", {
264
312
  name: "Summary",
265
- api_key: "summary",
313
+ key: "summary",
266
314
  type: "long_text",
267
315
  })
268
316
 
@@ -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
- | `records.revisions` | `list`, `get`, `revert` |
320
- | `search` | across the project |
321
- | `collections` | `list`, `get`, `create`, `update`, `delete`, `restore`, `schema` |
322
- | `collections.schemaChanges` | `list`, `get` |
323
- | `collections.searchIndex` | `get`, `rebuild` |
324
- | `fields` | `list`, `create`, `update`, `delete`, `restore`, `reorder`, `removeAlias`, `migrate` |
325
- | `migrations` | `list`, `get`, `cancel` |
326
- | `assets` | `upload`, `createUpload`, `complete`, `list`, `get`, `delete`, `restore`, `imageUrl`, `downloadUrl` |
327
- | `webhooks` | `list`, `get`, `create`, `update`, `delete`, `rotateSecret`, `test`, `unwrap` |
328
- | `webhooks.deliveries` | `list`, `get`, `retry` |
329
- | `auditEvents` | `list` |
330
- | `featureFlags` | `list` |
331
- | `me` | `get` |
332
- | `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` |
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 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.
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
- 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
@@ -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 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`. */
52
58
  export interface ConditionalRequestOptions extends RequestOptions {
53
59
  /**
54
- * Only write if the record is still at this revision; otherwise the API
55
- * 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.
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?: AllOptions): APIPromise<Operations[Op]["response"]>;
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[status]=published`), arrays are comma-separated
83
- * (`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.
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,
@@ -171,6 +178,11 @@ export class Core {
171
178
  }
172
179
  async #path(template, values = {}) {
173
180
  let path = template;
181
+ const names = [...template.matchAll(/\{(\w+)\}/g)].map(([, name]) => name);
182
+ // A value the template doesn't take is a method out of step with its operation.
183
+ for (const name of Object.keys(values))
184
+ if (!names.includes(name))
185
+ throw new TypeError(`${name} is not a path parameter of ${template}`);
174
186
  for (const [, name] of template.matchAll(/\{(\w+)\}/g)) {
175
187
  let value = values[name];
176
188
  if (value === undefined && name === "project_id") {
@@ -252,8 +264,9 @@ async function parseBody(response) {
252
264
  }
253
265
  /**
254
266
  * Query parameters as the API reads them: objects become `key[sub]=…`
255
- * (`filter[status]=published`), arrays are comma-separated
256
- * (`expand=author,tags`), dates are ISO 8601.
267
+ * (`filter[price][lt]=50`), arrays are comma-separated (`expand=author,tags`)
268
+ * with `\,` for a comma inside a value and `\\` for a backslash, dates are
269
+ * ISO 8601.
257
270
  */
258
271
  export function appendQuery(params, value, key) {
259
272
  if (value === undefined || value === null)
@@ -262,8 +275,10 @@ export function appendQuery(params, value, key) {
262
275
  params.append(key, value.toISOString());
263
276
  }
264
277
  else if (Array.isArray(value)) {
265
- if (value.length > 0)
266
- params.append(key, value.map(scalar).join(","));
278
+ // Missing items are left out, as missing values are.
279
+ const items = value.filter((item) => item !== undefined && item !== null);
280
+ if (items.length > 0)
281
+ params.append(key, items.map((item) => scalar(item).replace(/[\\,]/g, "\\$&")).join(","));
267
282
  }
268
283
  else if (typeof value === "object") {
269
284
  for (const [name, inner] of Object.entries(value)) {
@@ -293,6 +308,13 @@ function runtimeVersion() {
293
308
  return `node/${g.process.versions.node}`;
294
309
  return undefined;
295
310
  }
311
+ /** `url` without trailing slashes; a loop, as a regex backtracks on long runs of them. */
312
+ function withoutTrailingSlashes(url) {
313
+ let end = url.length;
314
+ while (end > 0 && url[end - 1] === "/")
315
+ end--;
316
+ return url.slice(0, end);
317
+ }
296
318
  function environment() {
297
319
  const process = globalThis.process;
298
320
  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
- /** The error `type`, e.g. `validation_error`; undefined if the body had none. */
12
- readonly type: ErrorType | undefined;
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", … }]`. */