@relyio/sdk 0.5.0 → 0.7.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +540 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/{chunk-HAQELDZN.cjs → chunk-72UK5U7T.cjs} +70 -60
  4. package/dist/{chunk-5AZUUV5B.js → chunk-GYHAVYND.js} +1086 -494
  5. package/dist/{chunk-I3ESNANB.js → chunk-H6IBQSMM.js} +2 -2
  6. package/dist/{chunk-HT6NFRM2.cjs → chunk-TZL374LS.cjs} +2 -2
  7. package/dist/{chunk-CIRT6TVT.cjs → chunk-VH2DE7HZ.cjs} +1088 -497
  8. package/dist/{chunk-XY5KFEFY.js → chunk-YEJUMW4X.js} +70 -60
  9. package/dist/client/index.cjs +4 -3
  10. package/dist/client/index.d.cts +4 -4
  11. package/dist/client/index.d.ts +4 -4
  12. package/dist/client/index.js +2 -1
  13. package/dist/{index-C5mRBb6t.d.cts → define-content-type-DiVpbwNs.d.cts} +1629 -665
  14. package/dist/{index-mXUx2H99.d.ts → define-content-type-DiVpbwNs.d.ts} +1629 -665
  15. package/dist/{define-schema-DzYXk4HL.d.ts → define-schema-C-5an28C.d.ts} +211 -429
  16. package/dist/{define-schema-2xbHgtK-.d.cts → define-schema-D7MButVt.d.cts} +211 -429
  17. package/dist/{index-Dzo-upNS.d.ts → index-1G97sNA8.d.ts} +677 -296
  18. package/dist/index-BOQIHnsh.d.ts +818 -0
  19. package/dist/index-DbsTUw3Q.d.cts +818 -0
  20. package/dist/{index-BozPz4n1.d.cts → index-M0X45k7J.d.cts} +677 -296
  21. package/dist/index.cjs +180 -103
  22. package/dist/index.d.cts +307 -32
  23. package/dist/index.d.ts +307 -32
  24. package/dist/index.js +95 -8
  25. package/dist/schema/index.cjs +35 -35
  26. package/dist/schema/index.d.cts +21 -15
  27. package/dist/schema/index.d.ts +21 -15
  28. package/dist/schema/index.js +1 -1
  29. package/dist/types/index.d.cts +2 -2
  30. package/dist/types/index.d.ts +2 -2
  31. package/dist/utils/index.cjs +19 -19
  32. package/dist/utils/index.d.cts +10 -1
  33. package/dist/utils/index.d.ts +10 -1
  34. package/dist/utils/index.js +1 -1
  35. package/package.json +4 -3
  36. package/dist/define-content-type-CBfnTWSS.d.cts +0 -716
  37. package/dist/define-content-type-CBfnTWSS.d.ts +0 -716
package/CHANGELOG.md CHANGED
@@ -1,5 +1,545 @@
1
1
  # @relyio/sdk
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4e50077: **`relyio validate` refuses what deploy refuses.** It used to check only that a schema parses; deploy then runs the full validation — reference targets, component field types, asset types, roles, queries — so a schema could validate cleanly and be refused by `relyio deploy`. `relyio init --example blog`'s old template did exactly that: no error from `validate`, six from deploy. `validate` (and the AI assistant's validation) now runs the same validation deploy runs, with the same settings, and prints its warnings too. What it still cannot check offline is a change against the schema your branch has deployed, such as a field whose type changed; `relyio status` checks those.
8
+
9
+ **A schema written in YAML and the same schema written in TypeScript deploy as the same schema version.** They used to differ in small ways, so moving a project from YAML to a `schema.ts` pulled with `relyio generate-types` deployed a new version with no change in it:
10
+
11
+ - a YAML item no longer sends its `id` inside the item, and a TypeScript one no longer sends its `name` — the key it is declared under is its id;
12
+ - a TypeScript schema with no asset types sends `assetTypes: {}`, as YAML does;
13
+ - the SDK's builders no longer write settings you did not: `hierarchical: false` on a content type or singleton, `deprecated: false` on a component, `requiredApprovals: 1` on a workflow stage, `enabled: false` and `stages: []` on a workflow. Each one you write is kept. Each was already the platform's default, so nothing behaves differently;
14
+ - the builders keep an empty list you write: a field's `rules: []`, an asset type's `accessTags: []`, and `defineContentTypeWorkflow({ stages: [] })`.
15
+
16
+ In the SDK's types, `ContentTypeDefinition.hierarchical`, `SingletonContentTypeDefinition.hierarchical`, `ComponentDefinition.deprecated`, `WorkflowStageDefinition.requiredApprovals` and `WorkflowDefinition`'s `enabled` and `stages` are now optional, since a definition carries them only when you set them.
17
+
18
+ **`defineSchema()` takes a schema with no content types**, as deploy does — `relyio init`'s bare template is one — and `relyio generate-types` writes one as a `schema.ts` that compiles. `contentTypes` itself is still required.
19
+
20
+ **A `schema.ts` section of the wrong shape is refused**, as it is in YAML. A `roles` written as an object rather than a list used to be dropped, and the schema deployed without its roles.
21
+
22
+ - 3ccd298: **Requests from a web page no longer send cookies to another origin by default.** `browser.includeCredentials` now defaults to `false`, so the client sends each request from a page with `credentials: 'same-origin'` unless you set the option to `true`. The RelyIO API authenticates with your API key and reads no cookie, so nothing it answers changes. A server that answers CORS with `Access-Control-Allow-Origin: *`, which a browser refuses for a request that carries cookies, now serves reads made with the default. If a server of your own on another origin sits in front of the API and reads cookies, opt back in:
23
+
24
+ ```ts
25
+ const relyio = createClient({
26
+ apiKey: 'rly_pub_…',
27
+ browser: { includeCredentials: true },
28
+ })
29
+ ```
30
+
31
+ Leaving the option out and writing `includeCredentials: undefined` both mean `false`. On a server, in an edge runtime or in a Web Worker the client still sets no `credentials` mode.
32
+
33
+ **An option passed to `createClient()` as `undefined` now takes its default.** The client used to copy `undefined` over the default, so `createClient({ apiKey, baseUrl: process.env.RELYIO_BASE_URL })` with the variable unset sent every request to `undefined/v1/…`. Now `undefined` means the same as leaving the option out, for every top-level option and for every field in `browser`, `cache`, `realtime` and `debug`:
34
+
35
+ ```ts
36
+ const relyio = createClient({
37
+ apiKey: process.env.RELYIO_API_KEY!,
38
+ baseUrl: process.env.RELYIO_BASE_URL, // unset: https://api.relyio.com
39
+ })
40
+ ```
41
+
42
+ A header given in `headers` as `undefined` is no longer sent. It used to go out with the string `undefined` as its value.
43
+
44
+ `client.getConfig()` now types every option that has a default as present, including each field of `browser`, `cache`, `realtime` and `debug` except `onCorsError`, `wsUrl` and `logger`. If you construct `HttpClient` yourself, its configuration must supply those fields too.
45
+
46
+ - f16fb55: **`_metadata` on an entry, a singleton and a settings group is now typed as the API sends it.** The types are read from the API's published document instead of being written out by hand, and the hand-written ones had drifted from what the API sends:
47
+
48
+ - `createdAt` and `updatedAt` are ISO 8601 strings, not `Date`s. The API has always sent strings, and the client never turned them into dates.
49
+ - An entry's `_metadata` now declares `availableLocales`, `defaultLocale`, `slug`, `refs`, `publishedAt`, `firstPublishedAt`, and, when the request asked for them, `requestedLocale`, `localeFallbacksApplied` and `localizations`. A single-entry read also declares `versionCount` and `versions`. `localeHasDraft`, `draftLocales` and `hasDraft` are always sent, so they are no longer optional, and `entryType` is one of `'content'`, `'asset'`, `'singleton'` or `'settings'`.
50
+ - `parentId` and `sourceDatasetId` are removed from an entry's `_metadata`. No read sends them.
51
+ - `SingletonMetadata` identifies the singleton by `id` and `type`, and `key` and `singletonType` are removed. It now declares `status`, the per-locale publication fields, `hasDraft`, `versionCount`, `versions`, `publishedAt` and `firstPublishedAt`.
52
+ - `SettingsGroupMetadata` identifies the group by `id` and `groupId`, and `key`, `groupType` and `createdAt` are removed. It now declares `locale`, the per-locale publication fields, `versionCount` and `versions`.
53
+ - `LocaleStatus`, one row of `availableLocales`, always has a `version`. `LocaleMetadata`, the fields `localeHelpers` read, declares `locale`, `defaultLocale` and `availableLocales` as always present.
54
+ - `SingletonContent` is now the singleton body exactly as the API sends it.
55
+
56
+ ```ts
57
+ const post = await relyio.content('post').findById(id).execute()
58
+ if (post) {
59
+ const updated = new Date(post._metadata.updatedAt) // a string, which the old type called a Date
60
+ const locales = post._metadata.availableLocales.map(locale => locale.code)
61
+ }
62
+ ```
63
+
64
+ Code that used `_metadata.createdAt` or `updatedAt` as a `Date`, or that builds a `_metadata` object by hand, such as a test fixture, may stop compiling. Wrap the string in `new Date(…)`, and give a fixture every field the type declares. Nothing the client sends or receives changes.
65
+
66
+ - c77bf58: **`AuthenticationError`, `SchemaValidationError`, `RequiredFieldError` and `InvalidFieldTypeError` are removed.** The SDK never threw them, so a `catch` that tested for one could never match. A key the API will not accept arrives as `MissingApiKeyError` or `InvalidApiKeyError`, and content the schema refuses as `ValidationError`, whose `violations` name each field and its problem:
67
+
68
+ ```ts
69
+ try {
70
+ await relyio.content('product').create({ title: '' })
71
+ } catch (error) {
72
+ if (error instanceof MissingApiKeyError || error instanceof InvalidApiKeyError) {
73
+ // check the key
74
+ } else if (error instanceof ValidationError) {
75
+ console.log(error.getViolationsForField('title'))
76
+ }
77
+ }
78
+ ```
79
+
80
+ **`waitUntilReady()` and `upload().waitForReady()` reject with an error you can branch on.** Both threw a plain `Error`. A file whose processing failed is now a `FileUploadError` whose `code` is the reason the service stored — `UNSUPPORTED_CODEC`, `CORRUPT_FILE` and so on — or `PROCESSING_FAILED` where it stored none this SDK knows, with what it stored in `context.processingError` and its `retriable` hint as `retryable`. A wait that runs past `timeoutMs` is a `TimeoutError`: nothing failed, and the file may still become ready. `FileUploadError['code']` is now typed `FileUploadErrorCode`, which is `FileErrorCode` plus `PROCESSING_FAILED`.
81
+
82
+ ```ts
83
+ try {
84
+ await relyio.asset('productPhoto').file(id).waitUntilReady()
85
+ } catch (error) {
86
+ if (error instanceof FileUploadError) console.log(`Processing failed: ${error.code}`)
87
+ else if (error instanceof TimeoutError) console.log('Still processing')
88
+ else throw error
89
+ }
90
+ ```
91
+
92
+ **`retryAttempts` must be a whole number of at least 1.** It is the most attempts one request makes, the first included — `1` turns retrying off — as the configuration page has said. `createClient()` and `updateConfig()` used to accept `0`, which made one attempt like `1`, and `NaN`, `Infinity` or a fraction, of which the first two never stopped retrying a failure the client retries; they now throw.
93
+
94
+ **`SearchClient.global()` is removed.** `createClient()` offered no way to reach it. Search every content type with `across('*')`:
95
+
96
+ ```ts
97
+ const results = await relyio.search('sony').across('*').limit(20).execute()
98
+ ```
99
+
100
+ - 62909ac: **Field builder options are spelled as the schema declares them.** Deploy stores a field's `config` as you write it, so an option the schema does not declare is accepted and then read by nothing. Three builders took such options, and the enum builder did not require a label the schema requires:
101
+
102
+ | Field | Was | Now |
103
+ | ------------------ | ---------------------------- | ------------------------------------ |
104
+ | `boolean` | `trueLabel`, `falseLabel` | `labelTrue`, `labelFalse` |
105
+ | `dateTime` | `timezone: boolean` | `timeZoneHandling: 'UTC' \| 'local'` |
106
+ | `date`, `dateTime` | `config.format` | `ui.options.format` |
107
+ | `enum` | an option's `label` optional | required |
108
+
109
+ ```ts
110
+ fields.boolean('featured', { config: { labelTrue: 'Featured', labelFalse: 'Not featured' } })
111
+ fields.dateTime('startsAt', {
112
+ config: { timeZoneHandling: 'UTC' },
113
+ ui: { displayName: 'Starts at', options: { format: 'YYYY-MM-DD HH:mm' } },
114
+ })
115
+ fields.enum('status', {
116
+ config: {
117
+ options: [
118
+ { value: 'draft', label: 'Draft' },
119
+ { value: 'live', label: 'Live' },
120
+ ],
121
+ },
122
+ })
123
+ ```
124
+
125
+ The old spellings stop compiling. An enum option without a `label` already failed at `relyio deploy`, which refuses it; it now fails where you write it.
126
+
127
+ `defaultValue` now takes what the field type's schema accepts, where it took anything: a string for text, slug, email, URL, date and dateTime fields, a number for integer and decimal, a boolean for boolean, and a string or a list of strings for enum and reference. Tags and list fields still take any value.
128
+
129
+ - a949208: **`updateConfig()` can clear an option: pass it as `null`.** Passing `undefined` keeps the current value, so there was no way left to drop a `locale` or `dataset` you had set short of creating a new client. Now `null` clears every option that has no default — `locale`, `dataset`, `apiKey`, `dev`, `fetch`, `browser.onCorsError`, `realtime.wsUrl` and `debug.logger` — and the client runs as though it had never been given one: reads name no locale of the client's, no `X-RelyIO-Dataset` header is sent, requests go through the runtime's own `fetch`, and so on. An option that has a default takes no `null` in TypeScript; from JavaScript, `null` gives it its default back, where it used to leave the option empty (`baseUrl: null` sent requests to `undefined/v1/…`).
130
+
131
+ ```ts
132
+ relyio.updateConfig({ locale: null }) // reads name no locale of the client's
133
+ relyio.updateConfig({ dataset: null }) // requests go to the key's default dataset
134
+ ```
135
+
136
+ **A sub-client you took before `updateConfig()` now sends through the update.** `content()`, `asset()`, `singleton()`, `settings()` and `query` clients kept the connection and the locale they were taken with, and so did a builder — `query()`, `findById()`, `file()`, `upload()`, `search()`, `batch()`, `adhoc()` — so after `updateConfig({ baseUrl, locale })` they went on sending to the old address, with the old headers, in the old locale, and `locale: null` never cleared theirs. Now every request after the call is sent as the update says — address, headers, key, dataset, locale, timeout, retries, `fetch` and logging — whichever client makes it. A locale the call names, a fluent `.locale()` and a sub-client's own `withLocale()` still win over the client's; a client made by `withLocale()` is a client of its own, which an update of its parent does not reach. An `upload()` stays in the locale it started in until it finishes. `getHttpClient()` returns the same client before and after an update.
137
+
138
+ ```ts
139
+ const posts = relyio.content('post')
140
+ relyio.updateConfig({ baseUrl: 'https://proxy.example.com', locale: 'fr' })
141
+ await posts.fetchOne(id) // sent to https://proxy.example.com, with ?locale=fr
142
+ ```
143
+
144
+ **Header names are matched without regard to case.** A header you pass — in `headers`, to `updateConfig()`, per request, or to `setDefaultHeaders()` — replaces the client's header of the same name however either is spelled, instead of being sent beside it, which `fetch` joined into one value (`main, staging`). `updateConfig({ headers: { 'X-Tenant': null } })` now stops sending a header configured as `x-tenant`.
145
+
146
+ **The key the client holds is the key it sends.** When a key is given both as `apiKey` and as an `X-API-Key` header, the client sends `apiKey` — or, under `relyio dev`, the sandbox's key — where it used to send the header, or both. A key in `headers` is sent only when the client holds no `apiKey`. `setApiKey()` and `updateConfig({ apiKey })` both remove a key given in `headers`, so a later `updateConfig()` no longer sends the old key beside the new one.
147
+
148
+ - 166a4ff: **An include's object form is `IncludeSelection`.** `IncludePath` — `{ path, include: string[], where }`
149
+ — described a subset of it and is removed: every value it typed is an `IncludeSelection`. Its `where`,
150
+ and a nested include's, is a `WhereCondition`, as a fetch's is, so an object naming no operator is a
151
+ type error, as the service refuses it.
152
+
153
+ ```ts
154
+ // before
155
+ import type { IncludePath } from '@relyio/sdk/schema'
156
+ const include: IncludePath = { path: 'items.product', include: ['brand'] }
157
+
158
+ // after
159
+ import type { IncludeSelection } from '@relyio/sdk/schema'
160
+ const include: IncludeSelection = { path: 'items.product', include: ['brand'] }
161
+ ```
162
+
163
+ - 1142d78: **A query's operator and include types match what deploy accepts and the Content API runs.**
164
+
165
+ - `WhereOperator` takes `eq`, which the API always ran, and every operator takes a number, string or boolean; `in`, `nin`, `exists` and `notExists` also take a `${param}`.
166
+ - `filters` take every `where` operator: `FilterOperator` is `WhereOperator`. It held six, each a `${param}` string, so `contains`, `startsWith` and `exists` filters — which the API runs — did not type-check.
167
+ - `AggregateBlock.having` takes the new `HavingOperator`: the same operators, each with a literal. It was `WhereOperator`, whose parameters `having` never reads.
168
+ - `IncludeRelationship.aggregate` is `'count'`. `sum`, `avg`, `min` and `max` computed nothing and deploy now refuses them.
169
+ - The ad-hoc client's `FilterValue` is a parameter string or a `FilterOperator`. A bare number or boolean, which it took, is not read as a filter.
170
+
171
+ ```ts
172
+ // before: a type error, though the API ran it
173
+ defineQuery('onSale', {
174
+ endpoint: '/api/on-sale',
175
+ fetch: { contentType: 'product' },
176
+ // @ts-expect-error
177
+ filters: { discount: { exists: true } },
178
+ })
179
+
180
+ // after
181
+ defineQuery('onSale', {
182
+ endpoint: '/api/on-sale',
183
+ fetch: { contentType: 'product' },
184
+ filters: { discount: { exists: true } },
185
+ })
186
+ ```
187
+
188
+ - cd52b9c: **A query's types match what deploy accepts and the Content API runs.**
189
+
190
+ - `defineQuery()` no longer takes a `similar` block, and `SimilarBlock`, `AdhocSimilarBlock` and `AdhocQueryBuilder.similar()` are removed. Deploy refused a query built on `similar` alone, and the API ran none. Find similar content with `relyio.similar(uid)`, which is unchanged.
191
+ - An aggregate block's metrics are `metrics`. `AggregateBlock.aggregations`, which these types called the main spelling, is removed: deploy refused it.
192
+ - `AggregateMetric` takes `first` and `last`, which the API runs.
193
+
194
+ ```ts
195
+ // before
196
+ defineQuery('totals', {
197
+ endpoint: '/api/totals',
198
+ aggregate: { source: 'order', aggregations: { total: 'amount.sum' } },
199
+ })
200
+
201
+ // after
202
+ defineQuery('totals', {
203
+ endpoint: '/api/totals',
204
+ aggregate: { source: 'order', metrics: { total: 'amount.sum' } },
205
+ })
206
+ ```
207
+
208
+ - adb19cb: **Every setting the schema declares can be written with the SDK, and each is deployed as written.**
209
+
210
+ The builders now take settings the schema declares and they refused:
211
+
212
+ | Where | Now takes |
213
+ | -------------------- | ---------------------------------------------------------------------------------------------------- |
214
+ | `fieldset()` | `collapsedByDefault` |
215
+ | `fields.component()` | `config` — `required`, `unique`, `defaultValue` — which validation reads as it does any field's |
216
+ | `defineQuery()` | `type` and `fullText`; a cache policy object without `policy` (it defaults to `public`) |
217
+ | a query's `include` | the schema's selection form, `{ path, fields, include, where }`, one level deep as the schema has it |
218
+ | a view filter | the schema's operators and values |
219
+
220
+ ```ts
221
+ fieldset('seo', {
222
+ displayName: 'SEO',
223
+ collapsedByDefault: true,
224
+ fields: [
225
+ /* … */
226
+ ],
227
+ })
228
+ fields.component('seo', 'seoMetadata', { config: { required: true } })
229
+ defineQuery('searchPosts', {
230
+ endpoint: '/api/search',
231
+ fetch: { contentType: 'blogPost' },
232
+ fullText: { fields: ['title', 'body'], query: '${q}' },
233
+ })
234
+ ```
235
+
236
+ `defineSingleton()` keeps the `workflow` it takes; it used to drop it. It no longer takes `hierarchical: true`, which deploy refuses for a singleton and it wrote as `false`.
237
+
238
+ A view filter's operators are now the schema's: `notContains`, `notIncludes`, `isOn`, `isBetween`, `thisYear` and `hasValue` are new, and `doesNotContain`, `isNotEmpty`, `isOnOrBefore`, `isOnOrAfter`, `tomorrow`, `lastWeek`, `lastMonth`, `includesAll`, `includesAny` and `excludes` stop compiling — deploy refused each of them. A filter's `value` is typed as the schema's.
239
+
240
+ `defineView()` no longer makes up a slug when you leave it out. It wrote `published-posts` for `publishedPosts`, while the platform gives a view without a slug its own, so the same view had a different URL written in TypeScript than in YAML. `ViewDefinition.slug` is optional.
241
+
242
+ `defineLocale()`, `defineRole()`, `defineWorkflowStage()`, `defineContentType()`, `defineSingleton()`, `defineSettingsGroup()` and `defineSchema()` write each setting you give them. They dropped `direction: 'ltr'`, `active: true` and empty lists — `fallback`, `accessTags`, `includes`, `permissions`, `allowedRoles`, `roles`, `views` — and an empty `description`. An empty `locales` is no longer dropped: it declares no default locale, and is refused at load as it is in YAML.
243
+
244
+ - d797377: **A search hit is typed as the hit the API sends, not as an entry.** `search()`, `similar()` and `autocomplete()` results are now read from the API's published document:
245
+
246
+ - A search or similar hit's `_metadata` is the new `SearchHitMetadata`: `id`, `type`, `slug`, `status`, the per-locale fields (`version`, `locale`, `localeStatus`, `localeHasDraft`, `draftLocales`), `hasDraft`, `createdAt`, `updatedAt` and `publishedAt`. It has no `entryType`, `defaultLocale`, `availableLocales`, `refs` or `firstPublishedAt`, which the old type promised and no hit carried. Read the entry for those: `relyio.content(hit._metadata.type).findById(hit._metadata.id)`.
247
+ - A hit's fields keep their types, and `_score`, `_scoreBreakdown` (`keyword`, `semantic`, `combined`) and `_highlights` (Atlas Search's `{ path, score, texts }` list) are typed as sent. `SimilarResult` is the same hit.
248
+ - `AutocompleteSuggestion` is `{ text, score, id, highlight? }`. The old type named `highlighted`, `entryId` and `contentType`, which the API never sent.
249
+ - `localeHelpers` no longer accept a search hit, which has no `availableLocales` for them to read: passing one is a compile error rather than an empty answer.
250
+ - `SettingsGroup` is removed. It described a body no method returns.
251
+
252
+ **A condition on `_metadata` is refused before it is sent.** The API filters on the fields a content type declares and on nothing under `_metadata`: `where({ _metadata: { status: 'active' } })` was answered with a 500, and `where({ '_metadata.status': 'active' })` with an empty page. `where()` on `content().query()`, `search()`, `autocomplete()` and `similar()` now throws a `ValidationError` naming the key, and sends nothing; a client typed by a schema is refused at compile time.
253
+
254
+ ```ts
255
+ const hits = await relyio.search('shoes').in('product').items()
256
+ for (const hit of hits) {
257
+ hit._metadata.localeStatus // as before
258
+ // hit._metadata.availableLocales — a compile error now: read the entry for its locales
259
+ }
260
+ ```
261
+
262
+ `@relyio/agents`: `searchEntries`' `entries` are typed as entries on a list read and as hits on a search or similar read.
263
+
264
+ - c34fa5e: `setApiKey()` now changes the key the client sends. It used to add an `Authorization: Bearer` header and leave `X-API-Key` as it was, and the API reads `X-API-Key` first, so a client created with a key kept sending the old one. A client created without a key kept refusing every request with `MissingApiKeyError`. Now:
265
+
266
+ - every later request sends the new key as `X-API-Key`, and no `Authorization` header, including requests from a `content()` or other sub-client you took before the call
267
+ - `getConfig().apiKey` returns the new key
268
+ - an empty or blank key counts as no key, just as it does when passed to `createClient`: the next request rejects with `MissingApiKeyError` and nothing is sent
269
+ - under `relyio dev`, the sandbox's key is still used, as it is over the key passed to `createClient`
270
+
271
+ ```ts
272
+ const relyio = createClient({ apiKey: process.env.RELYIO_API_KEY! })
273
+ relyio.setApiKey(rotatedKey)
274
+ await relyio.content('post').fetchOne(id) // sent with X-API-Key: rotatedKey
275
+ ```
276
+
277
+ **Breaking:** `HttpClient.setAuthToken()` is removed. The `Authorization` header it set had no effect: the API ignores it when `X-API-Key` is present, and the client does not send a request that has no `X-API-Key`. Use the client's `setApiKey()` instead.
278
+
279
+ The client now decides whether it is running in a browser with `isBrowser()`, which requires both a `window` and a `document`. Before, a `window` alone was enough. So in a runtime whose global `window` has no `document`, such as Deno 1.x, the client now behaves as it does on a server: it sends its `User-Agent`, sets no `credentials` mode, does not call `browser.onCorsError`, and no longer removes headers that only a browser forbids, such as `Origin`. Browsers, Node, edge runtimes and Web Workers behave as before.
280
+
281
+ - fda485b: `similar()` no longer has a `where()` method. Similar content is ranked from the source entry's stored vector, and that index can narrow by content type and locale only — never by an entry field — so a `where` sent to `POST /v1/search/similar` was accepted and ignored. The API now refuses it with `REQUEST_INVALID`, and the builder no longer offers it.
282
+
283
+ ```ts
284
+ // before: the filter was sent and silently ignored
285
+ await relyio.similar('entry_abc123').in('product').where({ category: 'electronics' }).execute()
286
+
287
+ // after: narrow by type and locale
288
+ await relyio.similar('entry_abc123').in('product').locale('fr-FR').limit(5).execute()
289
+ ```
290
+
291
+ - d9b076c: **A client with no schema registered can write, sort and select any field.** Without generated types, `create()` and `update()` accepted no fields at all, so the way the documentation writes content without codegen was a type error:
292
+
293
+ ```ts
294
+ const relyio = createClient({ apiKey })
295
+
296
+ await relyio.content('post').create({ title: 'Hello' }) // compiles; before, TS2353 on `title`
297
+ await relyio.content('post').update(id, { title: 'Hello again' })
298
+ await relyio.asset('productPhoto').create({ alt: 'Hero' }, { fileName: 'hero.jpg' })
299
+ await relyio.content('post').query().sort({ title: 'asc' }).select(['title']).execute()
300
+ ```
301
+
302
+ The same holds for every `batch()` operation's `content`, which no schema types for any client, and for `sort({ … })` and `select([…])`, which took only `_metadata` from a client with no schema. The API still checks what you send against the deployed schema. A client typed by a schema, registered or passed as `schema:`, still accepts only the fields its content types declare, typed as they declare them.
303
+
304
+ A condition or a sort on an asset client (`asset(type).query().where(…)`, `.sort({ … })`, `fetchFirst(…)`) now names the asset type's fields. A client with no schema accepts any field there too, where before it accepted only `file` and `urls`. A typed client no longer offers `file` and `urls` as conditions or sort keys: they are the blocks a read carries beside the fields, not fields, and a condition took no value for them besides an operator such as `$exists`. Code that named `file` or `urls` there stops compiling; `select()` still offers them. `ContentQueryBuilder` and `QueryOptions` take an optional second type parameter, the shape a condition and a sort name, which defaults to the first, so code that names one type parameter is unchanged. Nothing the client sends or receives changes.
305
+
306
+ - 8e77037: **`updateConfig()` now changes only what you pass it.** It used to replace each option you named whole. Naming one field of `browser`, `cache`, `realtime` or `debug` replaced the whole group: a configured `onCorsError` or `logger` was dropped and the other fields went back to their defaults. `headers` replaced every header the client sent, and an option passed as `undefined` went back to its default. Now:
307
+
308
+ - a group you name keeps every field you leave out
309
+ - `headers` adds or replaces the headers you name and keeps sending the rest; pass a header as `null` to stop sending it
310
+ - an option passed as `undefined` keeps its current value, the same as leaving it out
311
+
312
+ ```ts
313
+ const relyio = createClient({ apiKey, headers: { 'X-Tenant': 'acme' }, browser: { onCorsError } })
314
+
315
+ relyio.updateConfig({ headers: { 'X-Trace': traceId } }) // X-Tenant is still sent
316
+ relyio.updateConfig({ browser: { includeCredentials: true } }) // onCorsError is kept
317
+ relyio.updateConfig({ headers: { 'X-Tenant': null } }) // X-Tenant is no longer sent
318
+ ```
319
+
320
+ `updateConfig({ locale: undefined })` used to clear the client's locale, and `updateConfig({ dataset: undefined })` its dataset. Both now keep the current value; pass `null` to clear an option instead (see the note on clearing options). The argument's type is exported as `RelyIOConfigUpdate`. `createClient()` has nothing to clear, so its options still take no `null`. From JavaScript, a header passed to `createClient()` as `null` is left out instead of being sent as the string `null`.
321
+
322
+ Under `relyio dev`, `updateConfig()` checks for the sandbox again, as `createClient()` does. While the sandbox is active, the client still sends its key and dataset. If the sandbox has ended, the client goes back to the dataset and key you configured, including a key you passed to `setApiKey()` while the sandbox was active. It used to keep the sandbox's dataset and key while `client.sandbox` reported the sandbox as gone. A client from `withLocale()` behaves the same way.
323
+
324
+ - 62a189d: **`waitUntilReady()` reports a failed upload to a production key.** An asset is a draft until its file is ready and stays one when its file fails, so a key that reads published content found no entry, and the wait ran out its whole limit as a `TimeoutError` on a file that had already failed. When the entry read finds nothing, the wait now reads the file's binding and rejects with the `FileUploadError` the service stored — `UNSUPPORTED_MIME`, `CORRUPT_FILE` and so on. A file that is ready in an asset the key never sees (created with `publish: false`, or awaiting a workflow) still ends in a `TimeoutError`, whose message now says so; a read-only key, which cannot read the binding, is refused with `ReadOnlyKeyError` instead of waiting.
325
+
326
+ The binding read is also yours to call: `relyio.asset(type).file(id).status({ locale })` answers `{ status, processing? }` — the processing stage, progress and error — from the new `GET /v1/{assetType}/{id}/file`, whatever the asset is published in, and never the entry's fields. It takes the capability the upload calls take. The result type is exported as `FileStatusResult`.
327
+
328
+ - 54c0fad: **Writes and version history now follow a `withLocale()` client's locale.** These calls wrote to, or read the history of, the default locale, and now use the client's:
329
+
330
+ - `content(type)`: `create()`, `update()`, `versions()` and `version()`
331
+ - `asset(type)`: `create()` and `update()`, and the multipart calls `file(id).signPart()`, `listParts()`, `completeMultipart()` and `abortMultipart()`
332
+ - `singleton(key)`: `update()`, `versions()` and `version()`
333
+ - `settings(group)`: `update()`, `reset()`, `versions()` and `version()`
334
+ - every create, update, replace and singleton operation that `batch()` builds, including `bulkCreate()` and `bulkUpdate()`
335
+
336
+ ```ts
337
+ const fr = relyio.withLocale('fr')
338
+
339
+ await fr.content('post').update(id, { title: 'Bonjour' }) // writes French; before, the default locale
340
+ await fr.singleton('home').versions() // French history; before, the default locale's
341
+ await fr
342
+ .batch()
343
+ .create('post', { content: { title: 'Salut' } })
344
+ .execute() // a French entry
345
+ ```
346
+
347
+ A locale you name on the call still wins over the client's: the `locale` option or argument, and for singletons and settings the fluent `.locale()` too, which `versions()` and `version()` now also read. `updateConfig({ locale })` now reaches `batch()`. A client with no locale sends none, as before. A delete names no locale, `restore()` still takes the locale it restores, and an operation added with `batch().raw()` is sent exactly as you wrote it.
348
+
349
+ `settings(group).update()` now sends its fields as `{ content, locale }`, the body the API accepts. It used to send them at the top level, which the API refused with `REQUEST_INVALID`. `settings(group).reset(locale)` now sends the locale as `?locale=`, where the API reads it. It used to send it in the body, so the reset applied to the default locale.
350
+
351
+ ### Patch Changes
352
+
353
+ - d30ac92: **An asset's `file` is exactly the documented block.** `AssetFileBlock`, `AssetFileExtracted` and `FileStatusResult` are now read from the API's committed document (`AssetFile`, `FileStatus`) instead of being written out by hand, and the API now serves only what those declare. Until now every read of an asset returned the stored binding whole — `objectKey`, `storageVersion`, `streamVideoId`, `processing.autoPublish`, `processing.startedAt` and `processing.completedAt`, and, while a file was processing, `processing.claimedBy` — although these types said they were redacted.
354
+
355
+ The members the three types name are unchanged; they are type aliases now rather than interfaces, so a module augmentation of one no longer merges. `extracted` stays open for the keys enrichment adds.
356
+
357
+ - 95725c9: **`defineAssetType` takes an asset type with neither `fields` nor `fieldsets`.** The schema makes both optional — a file with no metadata of its own is an ordinary asset type — but `defineAssetType` required one of them in its types and threw `must have either fields or fieldsets` when both were absent. `AssetTypeConfig` gains the `AssetTypeConfigWithoutFields` member.
358
+
359
+ ```ts
360
+ const brandGuidelines = defineAssetType('brandGuidelines', {
361
+ displayName: 'Brand Guidelines',
362
+ file: { upload: { allowedMimeTypes: ['application/pdf'] } },
363
+ })
364
+ ```
365
+
366
+ - 98899df: **In a web page, the client now leaves out every header the browser sets itself, however you spell it.** It used to leave out `origin` and `ORIGIN` but send `Origin`, and the same for `User-Agent`, `Referer`, `Host`, `Connection` and `Upgrade`, so a header written the usual way reached `fetch`, which the browser then refused or ignored. On a server, in an edge runtime or in a Web Worker these headers are still sent as you wrote them.
367
+
368
+ `mountToolbar()` now asks the SDK's `isBrowser()` whether there is a page to mount into, as the rest of the SDK does. Where there is a global `document` but no `window` with one, it returns `{ state: 'disabled', reason: 'no-document' }` instead of mounting — and, where `window` could not take event listeners, instead of throwing.
369
+
370
+ - 81c6aa2: **`timeout` must be a number of milliseconds from 1 to 2147483647.** `createClient()` and `updateConfig()` accepted `0`, `NaN`, `Infinity` and any number too large for a timer, and none of them waits: a timer given one fires at once, so every request the client made ended as a `TimeoutError` on the next tick, whatever the server did. They now throw, naming the option. No value turns the timeout off; set one as long as the slowest request you expect. A fraction from 1 up is accepted.
371
+ - 62a189d: **`waitUntilReady()` refuses a `timeoutMs` or `pollIntervalMs` it cannot honour.** `timeoutMs: NaN` made a deadline no time reaches, so the wait never ended; `pollIntervalMs` of `0`, `NaN`, a fraction below 1, `Infinity` or more than 2147483647 went to a timer that fires after about 1 ms, so the wait read the asset about a thousand times a second. Both are now checked before the first read — and by the upload builder's `.waitForReady(options)` before anything is uploaded — and throw naming the option. `pollIntervalMs` takes the rule `timeout` does, 1 to 2147483647 ms; `timeoutMs` takes 0 to 2147483647 ms, and `0` still reads the file once and rejects with a `TimeoutError` if it is not ready.
372
+
373
+ ## 0.6.0
374
+
375
+ ### Minor Changes
376
+
377
+ - af38235: `relyio.changesHead()` returns the changes feed's current position without waiting for a change. Take it before you read what you are going to follow, and start the feed from it, so a write that lands between your read and the feed's start is replayed rather than missed:
378
+
379
+ ```ts
380
+ const since = await relyio.changesHead()
381
+ const post = await relyio.content('post').findById(id).execute()
382
+ for await (const change of relyio.changes({ since, ids: [id] })) {
383
+ // …
384
+ }
385
+ ```
386
+
387
+ `RELYIO_RESYNC_EVENT` (`'relyio:resync'`) is the event a preview surface dispatches on `window` when the feed lost its place and it re-read everything, so anything on the page may be stale. Its `detail`, `RelyIOResyncDetail`, says why: `{ cause: 'expired', reason }` or `{ cause: 'no-cursor' }`. It is never dispatched as a `relyio:change`, so a listener that reads a change's ids never receives one without them.
388
+
389
+ - 80cb307: `fetchOne()` and `fetchOneWithTag()` take the headers one read adds, and `findById()` has a matching `.headers()` step. A name the client already sends is replaced for that request only, so this reads an entry from another dataset your key can see without changing the client:
390
+
391
+ ```ts
392
+ const draft = await relyio.content('post').fetchOne('welcome', {
393
+ headers: { 'X-RelyIO-Dataset': 'staging' },
394
+ })
395
+ ```
396
+
397
+ - 1fa0ba4: `followChanges()` follows the changes feed for as long as you need it, and reconnects for you. Hand it where the changes come from and what to do with each one:
398
+
399
+ ```ts
400
+ import { createClient, followChanges } from '@relyio/sdk'
401
+
402
+ const relyio = createClient({ apiKey: process.env.RELYIO_API_KEY! })
403
+ const controller = new AbortController()
404
+
405
+ const end = await followChanges(from => relyio.changes({ ...from, types: ['post'] }), {
406
+ signal: controller.signal,
407
+ onChange: change => refetch(change.contentType, change.entryId),
408
+ onResync: () => refetchEverything(),
409
+ onState: state => console.info(state.kind),
410
+ })
411
+ if (end.kind === 'stopped') console.error(end.error)
412
+ ```
413
+
414
+ - Changes are handed to `onChange` one at a time, in order, and the next is not taken until the one before it has settled.
415
+ - When the feed fails with something retrying can fix, it waits and reopens from the last change it delivered, so nothing is skipped. The wait starts at up to one second and doubles to at most thirty, shortened by a random amount of up to half so that many clients do not reconnect at the same instant, and it is never shorter than a `Retry-After` the service sent. A delivered change resets it.
416
+ - `onResync` is called when what you derived from the feed may be stale: the cursor could no longer be replayed, or the feed failed before any change arrived, so there was nothing to resume from. Re-read what you hold there.
417
+ - Anything retrying cannot fix, such as a revoked key, ends it with `{ kind: 'stopped', error }`. Aborting the signal ends it with `{ kind: 'aborted', since }`, where `since` is the cursor to start a later follow from. It never rejects.
418
+ - `onState` reports `following`, `reconnecting` (with the error and the wait), `resynced` and `stopped`. `classifyFollowFailure()` is the default decision between retrying and stopping, and `FOLLOW_RETRY_BASE_MS` and `FOLLOW_RETRY_MAX_MS` are the wait's bounds.
419
+ - The first argument can be any source of changes, not only `relyio.changes()`. A source you write can call the `opened()` it is handed once its connection is up; the next time that connection drops, the wait starts again from one second even though no change arrived.
420
+
421
+ - 028739b: `createClient()` no longer throws when `apiKey` is missing or empty. The key is now required by each request instead: a client created without one sends nothing, and every read or write it makes rejects with `MissingApiKeyError` naming `apiKey`. A client created at the top of a module, such as `createClient({ apiKey: process.env.RELYIO_API_KEY! })`, can now be loaded by a build that has no keys set, such as `next build` in CI. Under `relyio dev` the sandbox's key is used, so `RELYIO_API_KEY` no longer needs to be set there either.
422
+
423
+ A key that is only whitespace is now refused the same way, before the request is sent. Before, it was sent and the API refused it.
424
+
425
+ ```ts
426
+ import { createClient, MissingApiKeyError } from '@relyio/sdk'
427
+
428
+ const relyio = createClient({ apiKey: process.env.RELYIO_API_KEY! }) // no longer throws when unset
429
+
430
+ try {
431
+ await relyio.content('post').findById('welcome').execute()
432
+ } catch (error) {
433
+ if (error instanceof MissingApiKeyError) console.error('Set RELYIO_API_KEY')
434
+ }
435
+ ```
436
+
437
+ - 2f3e7e1: `isPublishableKey(apiKey)` says whether a key is a publishable key (`rly_pub_…`), the one kind of key that may sit in a browser. It reads the key's prefix only; whether the key is valid, active or allowed from the page's origin is still the API's answer. `PUBLISHABLE_KEY_PREFIX` is the prefix it checks.
438
+
439
+ ```ts
440
+ import { isPublishableKey } from '@relyio/sdk'
441
+
442
+ isPublishableKey(import.meta.env.VITE_RELYIO_PUBLISHABLE_KEY) // true
443
+ ```
444
+
445
+ - 7719c0a: The list, search, similar and ad-hoc query reads take the headers one read adds, so each can read another dataset your key can see without changing the client: `content(type).query().headers(…)`, `search(q).headers(…)`, `similar(id).headers(…)`, and a fourth `{ headers }` argument to `query.execute()`.
446
+
447
+ ```ts
448
+ const drafts = await relyio
449
+ .search('welcome')
450
+ .across('*')
451
+ .headers({ 'X-RelyIO-Dataset': 'staging' })
452
+ .execute()
453
+ ```
454
+
455
+ `search()` gains `.sort(field, direction)`, which sends the route's `sort` array (relevance orders the results otherwise), and `similar()` gains `.across(contentTypes | '*')` to find similar entries across several content types or all of them.
456
+
457
+ **Aggregate metrics are now the expression the service reads.** `AggregateMetric` was `{ fn, field? }`, a shape `POST /v1/query` refuses on every request — its validator tests each metric against `<field>.<fn>` and read the object as `[object Object]`. It is now that string, e.g. `'id.count'` or `'price.avg'`, and `AggregateBlock` gains the `where` the service already applies before grouping (in the Query DSL's bare spelling, `{ price: { gte: 10 } }`):
458
+
459
+ ```ts
460
+ await relyio.query.aggregate('product', { count: 'id.count', avgPrice: 'price.avg' }, 'status')
461
+ ```
462
+
463
+ - 1772f21: Entries are typed field by field. Once `relyio generate-types` has registered your schema — or you pass `schema:` — every field of an entry has the type a read returns, where it used to be `unknown`:
464
+
465
+ ```ts
466
+ const post = await relyio.content('post').fetchOne(id)
467
+
468
+ post?.title // string | undefined
469
+ post?.status // 'draft' | 'published' | undefined — an enum is the union of its options
470
+ post?.author // string | null | Author | undefined — the author's id, or the author once `include` names it
471
+ ```
472
+
473
+ | Field type | Read as |
474
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
475
+ | `shortText`, `longText`, `email`, `url`, `slug` | `string` |
476
+ | `richText` | `string` — the stored HTML |
477
+ | `integer`, `decimal` | `number` |
478
+ | `boolean` | `boolean` |
479
+ | `enum` | the union of its option values; an array of them with `multi: true` |
480
+ | `date`, `dateTime` | `string` — `YYYY-MM-DD` and ISO 8601; the client does not convert them to `Date` |
481
+ | `reference` | the target's id, or the target entry once `include` expands it; `null` after its target was deleted with `onTargetDelete: 'nullify'`; an array of either with `multi: true` |
482
+ | `tags` | `string[]` |
483
+ | `list` | objects of its subfields, or `string[]` when it declares none |
484
+ | `component(…)` | an object of the component's fields |
485
+
486
+ - Every field is optional: a read leaves out a field with no value — a draft, a `fields` projection, a locale with nothing to fall back to.
487
+ - A localized field is the value for the locale you read, like any other field.
488
+ - `where()` is typed by the same fields: each key is one of the content type's fields and each value one of that field's values — a list field matches one of its items, a reference the target's id, an array means "any of". With no schema, `where()` now accepts any field name; before, it accepted only `_metadata`.
489
+ - `search()` results are typed by the schema: every content type's entries until `.in('post')` or `.across(['post', 'page'])` narrows them, and `.in()` takes only your content types' names.
490
+ - `InferContentType`, `InferSingleton`, `InferAssetType` and `InferSettingsGroup` take the schema as an optional second argument — `InferContentType<typeof post, typeof schema>` — which is what types an expanded reference as its entry and a component as its fields. Without it, those two are typed as an entry or an object whose fields are not known.
491
+ - `defineComponent()` keeps its fields' types, as `defineContentType()` does.
492
+ - New types: `SchemaEntry`, `SchemaEntryOf`, `FieldValue`, `FieldValues`, `UnresolvedEntry`, `UnresolvedComponent`, `SearchEntry`, `SearchTypeName`, `WhereScalar` and `WhereFieldCondition`.
493
+
494
+ Code that relied on a field being `unknown` may now fail to compile where it passes a value the field cannot hold — `create({ price: 'free' })` for a `decimal`, `where({ status: 'archived' })` when `archived` is not an option. Nothing changes at runtime.
495
+
496
+ - 88b004e: `create()` and `update()` take the headers one write adds, and a batch has a matching `.headers()` step. A name the client already sends is replaced for that request only, so this writes to another dataset your key can write without changing the client:
497
+
498
+ ```ts
499
+ await relyio.content('post').update(
500
+ 'welcome',
501
+ { title: 'Hello' },
502
+ {
503
+ locale: 'de-DE',
504
+ headers: { 'X-RelyIO-Dataset': 'staging' },
505
+ },
506
+ )
507
+ ```
508
+
509
+ `ifMatch` and `idempotencyKey` still decide `If-Match` and `Idempotency-Key`, whatever the headers name.
510
+
511
+ ### Patch Changes
512
+
513
+ - ec18021: `browser: { includeCredentials: false }` now keeps the browser's cookies off requests to another origin. It used to send them exactly as `true` does. In a web page the client now sends `credentials: 'same-origin'` for `false` and `credentials: 'include'` for `true` or when the option is left unset, which is still the default. Outside a web page (on a server, in an edge runtime or in a Web Worker) the client sets no `credentials` mode at all, as before. The RelyIO API authenticates with your API key and reads no cookie, so turning cookies off changes nothing it answers:
514
+
515
+ ```ts
516
+ const relyio = createClient({
517
+ apiKey: 'rly_pub_…',
518
+ browser: { includeCredentials: false },
519
+ })
520
+ ```
521
+
522
+ - 88b004e: `REQUEST_INVALID`, `CONTENT_INVALID` and `REQUEST_TIMEOUT` refusals now keep the body the API sent on `error.context.response`, as every other refusal already did, so every message and `details` the API returned can be read — not only the first message the error class keeps. `InvalidPayloadError`, `ValidationError` and `TimeoutError` take an optional `context` as their last constructor argument, and a `REQUEST_TIMEOUT` refusal's `TimeoutError` now carries its status, `408`.
523
+ - d99bece: A client from `withLocale()` now reads in its locale everywhere, not only through `content()` and `asset()`. These reads asked for the default locale, and now ask for the client's:
524
+
525
+ - `singleton(key)`: `fetch()`, `fetchWithTag()` and `exists()`
526
+ - `settings(group)`: `fetch()`, `fetchWithTag()` and `exists()`
527
+ - `search()`, `similar()` and `autocomplete()`
528
+ - `content(type).children(id)`
529
+ - `executeQuery(name, params)`
530
+ - the ad-hoc queries, `adhoc()` and every `query` method, which send it as the `locale` parameter
531
+
532
+ ```ts
533
+ const fr = relyio.withLocale('fr')
534
+
535
+ await fr.singleton('home').fetch() // French; before, the default locale
536
+ await fr.search('chaussures').execute() // French; before, the default locale
537
+ ```
538
+
539
+ A locale you name on the call still wins over the client's: `.locale('de')`, `fetch({ locale: 'de' })`, `children(id, { locale: 'de' })`, or a `locale` parameter (for `executeQuery()`, `locales` as well). `updateConfig({ locale })` now reaches these reads too. A client with no locale sends none, as before.
540
+
541
+ Version history (`versions()`, `version()`) and writes are unchanged.
542
+
3
543
  ## 0.5.0
4
544
 
5
545
  ### Minor Changes