@org-quicko/silo-client 1.0.0 → 1.1.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 (46) hide show
  1. package/README.md +166 -124
  2. package/dist/collections/collection-handle.d.cts +15 -23
  3. package/dist/collections/collection-handle.d.ts +15 -23
  4. package/dist/collections/collections.d.cts +3 -5
  5. package/dist/collections/collections.d.ts +3 -5
  6. package/dist/entries/entry-envelope.d.cts +18 -0
  7. package/dist/entries/entry-envelope.d.ts +18 -0
  8. package/dist/entries/entry-read-options.d.cts +16 -0
  9. package/dist/entries/entry-read-options.d.ts +16 -0
  10. package/dist/entries/entry-reader.d.cts +16 -16
  11. package/dist/entries/entry-reader.d.ts +16 -16
  12. package/dist/entries/entry.d.cts +11 -23
  13. package/dist/entries/entry.d.ts +11 -23
  14. package/dist/errors/network-error.d.cts +5 -0
  15. package/dist/errors/network-error.d.ts +5 -0
  16. package/dist/index.cjs +87 -170
  17. package/dist/index.d.cts +4 -5
  18. package/dist/index.d.ts +4 -5
  19. package/dist/index.js +87 -170
  20. package/dist/media/media-asset.d.cts +17 -0
  21. package/dist/media/media-asset.d.ts +17 -0
  22. package/dist/media/media-file.d.cts +16 -0
  23. package/dist/media/media-file.d.ts +16 -0
  24. package/dist/media/media-replace.d.cts +17 -0
  25. package/dist/media/media-replace.d.ts +17 -0
  26. package/dist/media/media.d.cts +0 -4
  27. package/dist/media/media.d.ts +0 -4
  28. package/dist/search/search-hit.d.cts +5 -5
  29. package/dist/search/search-hit.d.ts +5 -5
  30. package/dist/search/search.d.cts +4 -1
  31. package/dist/search/search.d.ts +4 -1
  32. package/dist/transport/api-path.d.cts +1 -0
  33. package/dist/transport/api-path.d.ts +1 -0
  34. package/dist/transport/transport.d.cts +4 -0
  35. package/dist/transport/transport.d.ts +4 -0
  36. package/package.json +58 -58
  37. package/dist/collections/reserved-field-names.d.cts +0 -9
  38. package/dist/collections/reserved-field-names.d.ts +0 -9
  39. package/dist/entries/entry-base.d.cts +0 -31
  40. package/dist/entries/entry-base.d.ts +0 -31
  41. package/dist/entries/entry-mapper.d.cts +0 -13
  42. package/dist/entries/entry-mapper.d.ts +0 -13
  43. package/dist/entries/entry-payload.d.cts +0 -12
  44. package/dist/entries/entry-payload.d.ts +0 -12
  45. package/dist/entries/resolved-entry.d.cts +0 -11
  46. package/dist/entries/resolved-entry.d.ts +0 -11
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # silo-client
2
2
 
3
- The typed client for [silo](https://github.com/org-quicko/silo). It follows
4
- silo's own shape: an instance holds projects, a project holds environments, an
5
- environment holds collections, and a collection holds entries.
3
+ The typed client for [silo](https://github.com/org-quicko/silo). It has the same
4
+ shape as silo itself: an instance holds projects, a project holds environments,
5
+ an environment holds collections, and a collection holds entries.
6
6
 
7
7
  ```sh
8
8
  npm install @org-quicko/silo-client
9
9
  ```
10
10
 
11
- Runs on Node 18+, Bun, Deno, browsers and workers. No dependencies.
11
+ Runs on Node 18+, Bun, Deno, browsers and workers. It has no dependencies.
12
+
13
+ The examples below build moviespace, a small film database.
12
14
 
13
15
  ## Start
14
16
 
@@ -17,115 +19,142 @@ import { Silo } from "@org-quicko/silo-client"
17
19
 
18
20
  const silo = new Silo({ url: "http://localhost:8090", key: process.env.SILO_KEY })
19
21
 
20
- const posts = silo.project("acme").environment("prod").collection("posts")
22
+ const movies = silo.project("moviespace").environment("prod").collection("movies")
21
23
 
22
- const page = await posts.list({ limit: 10 })
23
- for (const post of page.entries) {
24
- console.log(post.fields.title)
24
+ const page = await movies.list({ limit: 10 })
25
+ for (const movie of page.entries) {
26
+ console.log(movie.title)
25
27
  }
26
28
  ```
27
29
 
28
- Handles are cheap. `silo.project("acme").environment("prod")` makes no request,
29
- so nothing above needs an `await` until the read.
30
-
31
- Both scope names are always explicit. There is no default project or
32
- environment, because a client that guesses reads the wrong environment quietly.
33
- `silo.scope("acme", "prod")` is the same chain in one call.
30
+ `silo.project("moviespace").environment("prod")` sends no request. It only
31
+ builds the path, so nothing needs an `await` until the read.
34
32
 
35
33
  ## Entries
36
34
 
37
- Name your fields and the collection is typed.
35
+ Describe your fields and the collection becomes typed.
38
36
 
39
37
  ```ts
40
- interface Post {
38
+ interface Movie {
41
39
  title: string
40
+ year: number
42
41
  status: "draft" | "published"
43
- tags: string[]
42
+ genres: string[]
44
43
  }
45
44
 
46
- const posts = silo.project("acme").environment("prod").collection<Post>("posts")
45
+ const movies = silo.project("moviespace").environment("prod").collection<Movie>("movies")
47
46
 
48
- const created = await posts.create({ title: "Hello", status: "draft", tags: [] })
47
+ const created = await movies.create({
48
+ title: "Arrival",
49
+ year: 2016,
50
+ status: "draft",
51
+ genres: ["sci-fi"],
52
+ })
49
53
 
50
- const draft = await posts.edit(created.id)
51
- draft.fields.status = "published"
52
- await draft.save()
54
+ await movies.replace(created.id, created.rev, {
55
+ title: "Arrival",
56
+ year: 2016,
57
+ status: "published",
58
+ genres: ["sci-fi", "drama"],
59
+ })
53
60
  ```
54
61
 
55
- An entry keeps its own revision, so you never hold one. `save()` sends the
56
- revision it read, adopts the one it gets back, and raises `ConflictError` if
57
- somebody wrote first.
62
+ A row is exactly what the API sent back: your fields and silo's envelope keys
63
+ together in one flat object.
58
64
 
59
- `get` and `edit` answer different things, and the difference matters.
65
+ ```ts
66
+ {
67
+ id: "01M24ZX2ZK60T72CNPCM222E3Z",
68
+ rev: 1,
69
+ title: "Arrival",
70
+ year: 2016,
71
+ status: "published",
72
+ genres: ["sci-fi", "drama"],
73
+ created_at: "2026-09-10T06:25:55.699Z",
74
+ updated_at: "2026-09-10T06:25:55.699Z",
75
+ }
76
+ ```
60
77
 
61
- | Call | Answers | Variables | `save()` |
62
- |------|---------|-----------|----------|
63
- | `posts.get(id)` | `ResolvedEntry<Post>` | substituted | not available |
64
- | `posts.edit(id)` | `Entry<Post>` | left as written | available |
65
78
 
66
- A `{{VARIABLE}}` in your content is substituted on the way out. Saving an entry
67
- you read that way would write the substituted value over the reference, so a
68
- read that substitutes has no `save()` at all, and `edit` is how you read an
69
- entry you mean to write back.
79
+ Writes are calls on the collection, and each one takes the revision it expects:
70
80
 
71
- `posts.editable` is the whole read surface with the same rule, for a tool that
72
- edits more than one entry at a time.
81
+ ```ts
82
+ await movies.create(fields)
83
+ await movies.replace(id, rev, fields)
84
+ await movies.delete(id, rev)
85
+ ```
86
+
87
+ `rev` is the revision the row reported. If it is out of date the call raises
88
+ `ConflictError`. A fresh read gives you the current one. `replace` needs
89
+ every field, because the route replaces the whole entry.
90
+
91
+ ### Reading an entry you plan to edit
92
+
93
+ silo substitutes a `{{VARIABLE}}` in your content on the way out. If you write
94
+ that value back, you replace the reference somebody typed with whatever it
95
+ happened to mean today, and you cannot recover the template from the result. So
96
+ read raw before you edit:
73
97
 
74
98
  ```ts
75
- await posts.editable.get(id)
76
- await posts.editable.list({ limit: 50 })
77
- for await (const post of posts.editable.all()) { }
99
+ const draft = await movies.get(id, { variables: "raw" })
100
+ draft.trailerUrl // "{{CDN_URL}}/trailers/arrival.mp4", as stored
101
+
102
+ const { id: _, rev, created_at, updated_at, ...fields } = draft
103
+ await movies.replace(draft.id, rev, { ...fields, status: "published" })
78
104
  ```
79
105
 
80
- `replace(id, rev, fields)` and `delete(id, rev)` are there for when you hold an
81
- id and a revision from somewhere else. Both `replace` and `save` send every
82
- field, because the route is a full replace.
106
+ `{ variables: "raw" }` works on every read: `get`, `list`, `all` and `pages`.
107
+ Writes always send raw, so `create` and `replace` answer with what you sent.
83
108
 
84
- Five field names never survive a round trip, because silo strips them before it
85
- answers: `id`, `rev`, `seq`, `created_at` and `updated_at`. `ReservedFieldNames`
86
- holds the list, and creating a collection warns if its schema declares one.
109
+ Five field names belong to the envelope: `id`, `rev`, `seq`, `created_at` and
110
+ `updated_at`. silo refuses a schema that declares one when you create the
111
+ collection, and refuses an entry that carries one when you write it. A row can
112
+ never collide with its own envelope.
87
113
 
88
114
  ## Queries
89
115
 
90
- Filters are built, and a typed collection types them.
116
+ You build filters, and a typed collection types them.
91
117
 
92
118
  ```ts
93
119
  import { Filter, Sort } from "@org-quicko/silo-client"
94
120
 
95
- const page = await posts.list({
96
- where: posts.filter.field("status").equals("published")
97
- .and(posts.filter.each("tags").equals("release")),
121
+ const page = await movies.list({
122
+ where: movies.filter.field("status").equals("published")
123
+ .and(movies.filter.each("genres").equals("sci-fi")),
98
124
  sort: Sort.recentlyUpdated(),
99
125
  limit: 20,
100
126
  })
101
127
  ```
102
128
 
103
- `field` addresses your own fields, `each` addresses every element of an array,
104
- and `meta` addresses the envelope.
129
+ `field` addresses your own fields. `each` addresses every element of an array.
130
+ `meta` addresses the envelope.
105
131
 
106
132
  ```ts
107
- posts.filter.field("author.name").contains("ada")
108
- posts.filter.each("tags").equals("release")
133
+ movies.filter.field("title").contains("arrival")
134
+ movies.filter.each("genres").equals("sci-fi")
109
135
  Filter.meta("updated_at").greaterThan("2026-01-01T00:00:00Z")
110
136
  ```
111
137
 
138
+ A dot reaches inside a nested field, so `field("director.name")` addresses the
139
+ `name` of a `director` object.
140
+
112
141
  The operators are `equals`, `notEquals`, `contains`, `greaterThan`, `atLeast`,
113
- `lessThan`, `atMost`, `oneOf` and `exists`, joined with `and`, `or` and `not`.
114
- `Filter` has the same surface untyped, for a filter you assemble at runtime,
115
- and `Filter.raw(node)` takes the wire AST.
142
+ `lessThan`, `atMost`, `oneOf` and `exists`. Join them with `and`, `or` and
143
+ `not`. `Filter` offers the same calls without types, for a filter you assemble
144
+ at runtime, and `Filter.raw(node)` takes the wire format directly.
116
145
 
117
- Two spellings of a wildcard mean two different things, which is why `each` is
118
- its own call:
146
+ `each` is a separate call because the two ways of writing a wildcard ask
147
+ different questions:
119
148
 
120
149
  ```ts
121
- posts.filter.each("tags").notEquals("draft") // some tag is not "draft"
122
- Filter.not(posts.filter.each("tags").equals("draft")) // no tag is "draft"
150
+ movies.filter.each("genres").notEquals("horror") // some genre is not "horror"
151
+ Filter.not(movies.filter.each("genres").equals("horror")) // no genre is "horror"
123
152
  ```
124
153
 
125
154
  ## Pagination
126
155
 
127
156
  ```ts
128
- const first = await posts.list({ limit: 25 })
157
+ const first = await movies.list({ limit: 25 })
129
158
  first.total // 137
130
159
  first.pageNumber // 1
131
160
  first.hasMore // true
@@ -134,66 +163,74 @@ const second = await first.next()
134
163
  ```
135
164
 
136
165
  A page reports the window silo actually used, which is not always the one you
137
- asked for: silo caps `limit` at 500 and replaces a nonpositive one with 50.
138
- `next()` advances by the answered window, so an oversized request pages
139
- correctly instead of stepping over entries.
166
+ asked for. silo caps `limit` at 500, and replaces a limit of zero or less with
167
+ 50. `next()` moves forward by the window silo reported, so an oversized request
168
+ still pages correctly instead of stepping over entries.
140
169
 
141
- Every page is iterable, and two iterators page for you.
170
+ Every page can be iterated, and two helpers page for you.
142
171
 
143
172
  ```ts
144
173
  for (const entry of page) { }
145
174
 
146
- for await (const entry of posts.all({ where })) { }
147
- for await (const page of posts.pages({ limit: 100 })) { }
175
+ for await (const entry of movies.all({ where })) { }
176
+ for await (const page of movies.pages({ limit: 100 })) { }
148
177
  ```
149
178
 
150
- Offset paging over data being written is not a snapshot. Sort by something
151
- stable when that matters.
179
+ Paging by offset over data that is being written is not a snapshot. Sort by
180
+ something stable when that matters.
152
181
 
153
182
  ## Media
154
183
 
155
184
  ```ts
156
185
  import { MediaReference } from "@org-quicko/silo-client"
157
186
 
158
- const asset = await silo.media.upload({
187
+ const poster = await silo.media.upload({
159
188
  bytes,
160
- filename: "hero.png",
161
- contentType: "image/png",
162
- folder: "heroes",
189
+ filename: "arrival.jpg",
190
+ contentType: "image/jpeg",
191
+ folder: "posters",
163
192
  })
164
193
 
165
- await posts.create({
166
- title: "Hello",
194
+ await movies.create({
195
+ title: "Arrival",
196
+ year: 2016,
167
197
  status: "draft",
168
- tags: [],
169
- cover: MediaReference.of(asset.id),
198
+ genres: ["sci-fi"],
199
+ poster: MediaReference.of(poster.id),
170
200
  })
171
201
  ```
172
202
 
173
- Entries reference an asset by id, so renaming or moving a file rewrites
174
- nothing. `asset.url` is the link to serve; `asset.reference` is the value to
175
- store. Storing the URL is the mistake a later rename breaks.
203
+ An entry refers to an asset by id, so renaming or moving a file rewrites
204
+ nothing. Use `asset.url` for the link you serve, and `asset.reference` for the
205
+ value you store. Storing the URL instead is what a later rename breaks.
176
206
 
177
207
  ```ts
178
- await asset.rename("hero-2.png")
179
- await asset.moveTo("heroes/2026")
180
- await asset.setTags(["banner"]) // replaces the list
181
- await asset.delete() // refused while an entry references it
182
- await asset.delete({ force: true })
183
-
184
- const usage = await asset.usages()
185
- usage.usages // the referrers this key may read
186
- usage.total // the true count
187
- usage.visible // what this key may see of it
208
+ await poster.rename("arrival-2016.jpg")
209
+ await poster.moveTo("posters/2016")
210
+ await poster.setTags(["poster"]) // replaces the whole list
211
+ await poster.replace(file) // new bytes, same id, name and URL
212
+ await poster.delete() // refused while an entry refers to it
213
+ await poster.delete({ force: true })
214
+
215
+ const usage = await poster.usages()
216
+ usage.usages // the referring entries this key may read
217
+ usage.total // the true count
218
+ usage.visible // how many of them this key may see
188
219
  ```
189
220
 
190
- Folders, and a bulk delete capped at 100 ids:
221
+ `replace` swaps the file behind an asset. The id, the reference, the name and
222
+ the URL all stay, so every entry that refers to it shows the new file and none
223
+ of them is rewritten. The new file must keep the same extension. It needs the
224
+ `media:replace` claim, and `entries:update` on each scope that refers to the
225
+ asset.
226
+
227
+ Folders, and a bulk delete that takes up to 100 ids:
191
228
 
192
229
  ```ts
193
230
  await silo.media.folders.list()
194
- await silo.media.folders.create("heroes/2026")
195
- await silo.media.folders.rename("heroes", "banners", { merge: true })
196
- await silo.media.folders.delete("banners", { recursive: true })
231
+ await silo.media.folders.create("posters/2016")
232
+ await silo.media.folders.rename("posters", "artwork", { merge: true })
233
+ await silo.media.folders.delete("artwork", { recursive: true })
197
234
 
198
235
  const report = await silo.media.deleteMany(ids, { force: true })
199
236
  report.deleted
@@ -202,80 +239,85 @@ report.failed
202
239
 
203
240
  ## Variables
204
241
 
205
- A variable is declared once per project and valued per environment.
242
+ You declare a variable once per project, and give it a value per environment.
206
243
 
207
244
  ```ts
208
- const acme = silo.project("acme")
209
- await acme.variables.declare("API_URL", { environment: "prod", value: "https://api.acme.com" })
245
+ const moviespace = silo.project("moviespace")
246
+ await moviespace.variables.declare("CDN_URL", {
247
+ environment: "prod",
248
+ value: "https://cdn.moviespace.com",
249
+ })
210
250
 
211
- const environment = acme.environment("prod")
251
+ const environment = moviespace.environment("prod")
212
252
  await environment.variables.list()
213
- await environment.variables.set("API_URL", "https://api.acme.com")
214
- await environment.variables.unset("API_URL")
253
+ await environment.variables.set("CDN_URL", "https://cdn.moviespace.com")
254
+ await environment.variables.unset("CDN_URL")
215
255
  ```
216
256
 
217
- `variable.value` is `null` when this environment has given it nothing, which is
218
- not `""`. An empty value substitutes as empty; an unset one leaves `{{API_URL}}`
219
- standing in the response.
257
+ `variable.value` is `null` when this environment has given it no value, which is
258
+ not the same as `""`. An empty value substitutes as empty. An unset one leaves
259
+ `{{CDN_URL}}` standing in the response.
220
260
 
221
261
  ## Search
222
262
 
223
- The reach is whatever you call it on, so a missing argument cannot widen a
263
+ The reach is whatever you call it on, so leaving out an argument cannot widen a
224
264
  search.
225
265
 
226
266
  ```ts
227
- await posts.search({ query: "pricing" }) // one collection
228
- await environment.search({ query: "pricing" }) // one environment
229
- await silo.search({ query: "pricing" }) // everything the key can read
267
+ await movies.search({ query: "arrival" }) // one collection
268
+ await environment.search({ query: "arrival" }) // one environment
269
+ await silo.search({ query: "arrival" }) // everything the key can read
230
270
  ```
231
271
 
232
- A hit says where it was found and quotes why it matched.
272
+ A hit says where it was found, and quotes the text that matched.
233
273
 
234
274
  ```ts
235
- const results = await silo.search({ query: "pricing" })
236
- results.hits[0].collection // "posts"
275
+ const results = await silo.search({ query: "arrival" })
276
+ results.hits[0].collection // "movies"
237
277
  results.hits[0].snippets // [{ path, before, match, after }]
238
278
  results.engine // "fts5" when the index answered, "scan" when it walked
239
279
  ```
240
280
 
241
281
  ## Errors
242
282
 
243
- One class per failure, so you branch on the type.
283
+ There is one class per failure, so you can branch on the type.
244
284
 
245
285
  ```ts
246
- import { ConflictError, ValidationFailedError, NetworkError } from "@org-quicko/silo-client"
286
+ import { ConflictError, ValidationFailedError } from "@org-quicko/silo-client"
247
287
 
248
288
  try {
249
- await draft.save()
289
+ await movies.replace(movie.id, movie.rev, fields)
250
290
  } catch (error) {
251
291
  if (error instanceof ConflictError) {
252
- await draft.refresh() // somebody else wrote first
292
+ // Somebody else wrote first. Read again for the current revision.
293
+ const current = await movies.get(movie.id)
294
+ await movies.replace(current.id, current.rev, fields)
253
295
  } else if (error instanceof ValidationFailedError) {
254
296
  error.details // [{ path: "/title", message }]
255
297
  }
256
298
  }
257
299
  ```
258
300
 
259
- `SiloError` is the base for anything silo refused: `ValidationFailedError`,
301
+ `SiloError` is the base class for anything silo refused: `ValidationFailedError`,
260
302
  `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`,
261
303
  `MediaInUseError`, `MediaDeleteStalledError` and `InternalError`.
262
304
 
263
305
  `NetworkError`, `TimeoutError`, `RequestAbortedError` and `InvalidResponseError`
264
- are not `SiloError`, because nothing answered. A `NetworkError` on a write does
265
- not prove the write failed. Read the entry back before deciding.
306
+ are not `SiloError`, because silo never answered. A `NetworkError` on a write
307
+ does not prove the write failed. Read the entry back before you decide.
266
308
 
267
309
  ## Cancellation
268
310
 
269
311
  Every call takes the same last argument.
270
312
 
271
313
  ```ts
272
- await posts.list({ limit: 20 }, { signal: controller.signal })
273
- await posts.get(id, { timeoutMilliseconds: 2_000 })
314
+ await movies.list({ limit: 20 }, { signal: controller.signal })
315
+ await movies.get(id, { timeoutMilliseconds: 2_000 })
274
316
  ```
275
317
 
276
- `abort()` raises `RequestAbortedError` and the deadline raises `TimeoutError`.
277
- Nothing is retried for you: a retried `POST` is a duplicate entry, and only the
278
- caller knows whether a call was safe to repeat.
318
+ `abort()` raises `RequestAbortedError`, and the deadline raises `TimeoutError`.
319
+ Nothing is retried for you. A retried `POST` creates a second entry, and only
320
+ the caller knows whether a call was safe to repeat.
279
321
 
280
322
  ## Anonymous reads
281
323
 
@@ -283,14 +325,14 @@ A key is optional. Without one you reach the collections whose schema does not
283
325
  set `x-silo-auth`.
284
326
 
285
327
  ```ts
286
- const silo = new Silo({ url: "https://cms.example.com" })
328
+ const silo = new Silo({ url: "https://cms.moviespace.com" })
287
329
  ```
288
330
 
289
331
  ## What this client does not reach
290
332
 
291
333
  Keys, claims, plugins, export and import, settings, audit and observability.
292
334
  Those are operator surfaces, and the admin UI and the CLI own them. There is no
293
- generic `request()` either: `RouteInventory` lists every route this client
335
+ generic `request()` either. `RouteInventory` lists every route this client
294
336
  covers and every route it leaves out, and a test holds that list against the
295
337
  server's own registrations.
296
338
 
@@ -1,10 +1,9 @@
1
- import { Entry } from "../entries/entry.cjs";
1
+ import type { Entry } from "../entries/entry.cjs";
2
2
  import type { EntryListQuery } from "../entries/entry-list-query.cjs";
3
3
  import type { EntryPage } from "../entries/entry-page.cjs";
4
4
  import type { EntryPageStream } from "../entries/entry-page-stream.cjs";
5
- import { EntryReader } from "../entries/entry-reader.cjs";
5
+ import type { EntryReadOptions } from "../entries/entry-read-options.cjs";
6
6
  import type { EntryStream } from "../entries/entry-stream.cjs";
7
- import { ResolvedEntry } from "../entries/resolved-entry.cjs";
8
7
  import { TypedFilter } from "../query/typed-filter.cjs";
9
8
  import type { RequestOptions } from "../request-options.cjs";
10
9
  import { RenameReport } from "../scope/rename-report.cjs";
@@ -17,38 +16,31 @@ import { CollectionSchema } from "./collection-schema.cjs";
17
16
  * One collection, typed to its fields:
18
17
  * `environment.collection<Post>("posts")`.
19
18
  *
20
- * Reads here substitute every `{{NAME}}` an entry holds and answer a
21
- * `ResolvedEntry`, which has no `save()`. Writing one back would replace the
22
- * reference somebody typed with whatever it happened to mean, so reading for
23
- * a write goes through `edit` or `editable`.
19
+ * Reads answer the wire's own flat rows and writes take explicit arguments, so
20
+ * there is one entry shape here and no second read surface. Pass
21
+ * `{ variables: "raw" }` to any read to get the stored `{{NAME}}` templates
22
+ * instead of what they resolve to, which is what editing one requires (D62).
24
23
  */
25
24
  export declare class CollectionHandle<Fields = Record<string, unknown>> {
26
25
  private readonly scope;
27
26
  readonly name: string;
28
27
  readonly filter: TypedFilter<Fields>;
29
28
  readonly schema: CollectionSchema;
30
- /**
31
- * The same collection read for writing back. Every read here keeps the
32
- * templates as stored and answers an `Entry`, which has `save()`.
33
- */
34
- readonly editable: EntryReader<Entry<Fields>>;
35
- private readonly context;
36
- private readonly resolved;
29
+ private readonly reader;
37
30
  constructor(scope: ScopeReference, name: string);
38
- /** One entry, with its variables substituted. Read-only: see `edit`. */
39
- get(id: string, options?: RequestOptions): Promise<ResolvedEntry<Fields>>;
40
- /** One entry as stored, ready to change and `save()`. */
41
- edit(id: string, options?: RequestOptions): Promise<Entry<Fields>>;
42
- list(query?: EntryListQuery, options?: RequestOptions): Promise<EntryPage<ResolvedEntry<Fields>>>;
43
- all(query?: EntryListQuery, options?: RequestOptions): EntryStream<ResolvedEntry<Fields>>;
44
- pages(query?: EntryListQuery, options?: RequestOptions): EntryPageStream<ResolvedEntry<Fields>>;
31
+ get(id: string, options?: EntryReadOptions): Promise<Entry<Fields>>;
32
+ list(query?: EntryListQuery, options?: EntryReadOptions): Promise<EntryPage<Entry<Fields>>>;
33
+ all(query?: EntryListQuery, options?: EntryReadOptions): EntryStream<Entry<Fields>>;
34
+ pages(query?: EntryListQuery, options?: EntryReadOptions): EntryPageStream<Entry<Fields>>;
45
35
  create(fields: Fields, options?: RequestOptions): Promise<Entry<Fields>>;
46
- /** A full replace, which is what the route is: send every field. */
36
+ /** A full replace, which is what the route is: send every field. `rev` is
37
+ * the one the entry answered when it was read, and a stale one is a
38
+ * `ConflictError`. */
47
39
  replace(id: string, rev: number, fields: Fields, options?: RequestOptions): Promise<Entry<Fields>>;
48
40
  delete(id: string, rev: number, options?: RequestOptions): Promise<void>;
49
41
  search(query: SearchQuery, options?: RequestOptions): Promise<SearchPage>;
50
42
  rename(name: string, options?: RenameOptions): Promise<RenameReport>;
51
43
  /** Both writes ask for the stored templates back, so what returns is what
52
- * was sent and is safe to hold and `save()` straight away. */
44
+ * was sent rather than a resolved snapshot of it. */
53
45
  private write;
54
46
  }
@@ -1,10 +1,9 @@
1
- import { Entry } from "../entries/entry.js";
1
+ import type { Entry } from "../entries/entry.js";
2
2
  import type { EntryListQuery } from "../entries/entry-list-query.js";
3
3
  import type { EntryPage } from "../entries/entry-page.js";
4
4
  import type { EntryPageStream } from "../entries/entry-page-stream.js";
5
- import { EntryReader } from "../entries/entry-reader.js";
5
+ import type { EntryReadOptions } from "../entries/entry-read-options.js";
6
6
  import type { EntryStream } from "../entries/entry-stream.js";
7
- import { ResolvedEntry } from "../entries/resolved-entry.js";
8
7
  import { TypedFilter } from "../query/typed-filter.js";
9
8
  import type { RequestOptions } from "../request-options.js";
10
9
  import { RenameReport } from "../scope/rename-report.js";
@@ -17,38 +16,31 @@ import { CollectionSchema } from "./collection-schema.js";
17
16
  * One collection, typed to its fields:
18
17
  * `environment.collection<Post>("posts")`.
19
18
  *
20
- * Reads here substitute every `{{NAME}}` an entry holds and answer a
21
- * `ResolvedEntry`, which has no `save()`. Writing one back would replace the
22
- * reference somebody typed with whatever it happened to mean, so reading for
23
- * a write goes through `edit` or `editable`.
19
+ * Reads answer the wire's own flat rows and writes take explicit arguments, so
20
+ * there is one entry shape here and no second read surface. Pass
21
+ * `{ variables: "raw" }` to any read to get the stored `{{NAME}}` templates
22
+ * instead of what they resolve to, which is what editing one requires (D62).
24
23
  */
25
24
  export declare class CollectionHandle<Fields = Record<string, unknown>> {
26
25
  private readonly scope;
27
26
  readonly name: string;
28
27
  readonly filter: TypedFilter<Fields>;
29
28
  readonly schema: CollectionSchema;
30
- /**
31
- * The same collection read for writing back. Every read here keeps the
32
- * templates as stored and answers an `Entry`, which has `save()`.
33
- */
34
- readonly editable: EntryReader<Entry<Fields>>;
35
- private readonly context;
36
- private readonly resolved;
29
+ private readonly reader;
37
30
  constructor(scope: ScopeReference, name: string);
38
- /** One entry, with its variables substituted. Read-only: see `edit`. */
39
- get(id: string, options?: RequestOptions): Promise<ResolvedEntry<Fields>>;
40
- /** One entry as stored, ready to change and `save()`. */
41
- edit(id: string, options?: RequestOptions): Promise<Entry<Fields>>;
42
- list(query?: EntryListQuery, options?: RequestOptions): Promise<EntryPage<ResolvedEntry<Fields>>>;
43
- all(query?: EntryListQuery, options?: RequestOptions): EntryStream<ResolvedEntry<Fields>>;
44
- pages(query?: EntryListQuery, options?: RequestOptions): EntryPageStream<ResolvedEntry<Fields>>;
31
+ get(id: string, options?: EntryReadOptions): Promise<Entry<Fields>>;
32
+ list(query?: EntryListQuery, options?: EntryReadOptions): Promise<EntryPage<Entry<Fields>>>;
33
+ all(query?: EntryListQuery, options?: EntryReadOptions): EntryStream<Entry<Fields>>;
34
+ pages(query?: EntryListQuery, options?: EntryReadOptions): EntryPageStream<Entry<Fields>>;
45
35
  create(fields: Fields, options?: RequestOptions): Promise<Entry<Fields>>;
46
- /** A full replace, which is what the route is: send every field. */
36
+ /** A full replace, which is what the route is: send every field. `rev` is
37
+ * the one the entry answered when it was read, and a stale one is a
38
+ * `ConflictError`. */
47
39
  replace(id: string, rev: number, fields: Fields, options?: RequestOptions): Promise<Entry<Fields>>;
48
40
  delete(id: string, rev: number, options?: RequestOptions): Promise<void>;
49
41
  search(query: SearchQuery, options?: RequestOptions): Promise<SearchPage>;
50
42
  rename(name: string, options?: RenameOptions): Promise<RenameReport>;
51
43
  /** Both writes ask for the stored templates back, so what returns is what
52
- * was sent and is safe to hold and `save()` straight away. */
44
+ * was sent rather than a resolved snapshot of it. */
53
45
  private write;
54
46
  }
@@ -9,11 +9,9 @@ export declare class Collections {
9
9
  private readonly scope;
10
10
  constructor(scope: ScopeReference);
11
11
  list(options?: RequestOptions): Promise<CollectionSummary[]>;
12
+ /** A schema declaring a field named `id`, `rev`, `seq`, `created_at` or
13
+ * `updated_at` is refused by the server with a `ValidationFailedError`
14
+ * naming it, so there is nothing to warn about here (D62). */
12
15
  create(name: string, schema: JsonSchema, options?: RequestOptions): Promise<CollectionDefinition>;
13
16
  private static toSummary;
14
- /** The only `console` use in this package: a schema declaring a reserved
15
- * field still validates, so this is the one place a caller can learn
16
- * about it before finding out from a field that is silently never
17
- * returned. */
18
- private static warnOnReservedFields;
19
17
  }
@@ -9,11 +9,9 @@ export declare class Collections {
9
9
  private readonly scope;
10
10
  constructor(scope: ScopeReference);
11
11
  list(options?: RequestOptions): Promise<CollectionSummary[]>;
12
+ /** A schema declaring a field named `id`, `rev`, `seq`, `created_at` or
13
+ * `updated_at` is refused by the server with a `ValidationFailedError`
14
+ * naming it, so there is nothing to warn about here (D62). */
12
15
  create(name: string, schema: JsonSchema, options?: RequestOptions): Promise<CollectionDefinition>;
13
16
  private static toSummary;
14
- /** The only `console` use in this package: a schema declaring a reserved
15
- * field still validates, so this is the one place a caller can learn
16
- * about it before finding out from a field that is silently never
17
- * returned. */
18
- private static warnOnReservedFields;
19
17
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The four keys silo puts on every entry alongside the author's own fields.
3
+ *
4
+ * `rev` is here because `replace()` and `delete()` require the revision the
5
+ * caller expects and a mismatch is a `409`, so the number has to survive the
6
+ * trip from a read to a write. The timestamps are ISO-8601 strings rather than
7
+ * `Date`s, and `created_at` keeps the wire's spelling, because a row read from
8
+ * one call and handed to the next unchanged is worth more than a prettier one.
9
+ *
10
+ * No field can collide with these: silo refuses a schema declaring one and an
11
+ * entry carrying one (D62).
12
+ */
13
+ export interface EntryEnvelope {
14
+ id: string;
15
+ rev: number;
16
+ created_at: string;
17
+ updated_at: string;
18
+ }