@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.
- package/README.md +166 -124
- package/dist/collections/collection-handle.d.cts +15 -23
- package/dist/collections/collection-handle.d.ts +15 -23
- package/dist/collections/collections.d.cts +3 -5
- package/dist/collections/collections.d.ts +3 -5
- package/dist/entries/entry-envelope.d.cts +18 -0
- package/dist/entries/entry-envelope.d.ts +18 -0
- package/dist/entries/entry-read-options.d.cts +16 -0
- package/dist/entries/entry-read-options.d.ts +16 -0
- package/dist/entries/entry-reader.d.cts +16 -16
- package/dist/entries/entry-reader.d.ts +16 -16
- package/dist/entries/entry.d.cts +11 -23
- package/dist/entries/entry.d.ts +11 -23
- package/dist/errors/network-error.d.cts +5 -0
- package/dist/errors/network-error.d.ts +5 -0
- package/dist/index.cjs +87 -170
- package/dist/index.d.cts +4 -5
- package/dist/index.d.ts +4 -5
- package/dist/index.js +87 -170
- package/dist/media/media-asset.d.cts +17 -0
- package/dist/media/media-asset.d.ts +17 -0
- package/dist/media/media-file.d.cts +16 -0
- package/dist/media/media-file.d.ts +16 -0
- package/dist/media/media-replace.d.cts +17 -0
- package/dist/media/media-replace.d.ts +17 -0
- package/dist/media/media.d.cts +0 -4
- package/dist/media/media.d.ts +0 -4
- package/dist/search/search-hit.d.cts +5 -5
- package/dist/search/search-hit.d.ts +5 -5
- package/dist/search/search.d.cts +4 -1
- package/dist/search/search.d.ts +4 -1
- package/dist/transport/api-path.d.cts +1 -0
- package/dist/transport/api-path.d.ts +1 -0
- package/dist/transport/transport.d.cts +4 -0
- package/dist/transport/transport.d.ts +4 -0
- package/package.json +58 -58
- package/dist/collections/reserved-field-names.d.cts +0 -9
- package/dist/collections/reserved-field-names.d.ts +0 -9
- package/dist/entries/entry-base.d.cts +0 -31
- package/dist/entries/entry-base.d.ts +0 -31
- package/dist/entries/entry-mapper.d.cts +0 -13
- package/dist/entries/entry-mapper.d.ts +0 -13
- package/dist/entries/entry-payload.d.cts +0 -12
- package/dist/entries/entry-payload.d.ts +0 -12
- package/dist/entries/resolved-entry.d.cts +0 -11
- 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
|
|
4
|
-
silo
|
|
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.
|
|
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
|
|
22
|
+
const movies = silo.project("moviespace").environment("prod").collection("movies")
|
|
21
23
|
|
|
22
|
-
const page = await
|
|
23
|
-
for (const
|
|
24
|
-
console.log(
|
|
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
|
-
|
|
29
|
-
so nothing
|
|
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
|
-
|
|
35
|
+
Describe your fields and the collection becomes typed.
|
|
38
36
|
|
|
39
37
|
```ts
|
|
40
|
-
interface
|
|
38
|
+
interface Movie {
|
|
41
39
|
title: string
|
|
40
|
+
year: number
|
|
42
41
|
status: "draft" | "published"
|
|
43
|
-
|
|
42
|
+
genres: string[]
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
const
|
|
45
|
+
const movies = silo.project("moviespace").environment("prod").collection<Movie>("movies")
|
|
47
46
|
|
|
48
|
-
const created = await
|
|
47
|
+
const created = await movies.create({
|
|
48
|
+
title: "Arrival",
|
|
49
|
+
year: 2016,
|
|
50
|
+
status: "draft",
|
|
51
|
+
genres: ["sci-fi"],
|
|
52
|
+
})
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
`
|
|
81
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
where:
|
|
97
|
-
.and(
|
|
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
|
|
104
|
-
|
|
129
|
+
`field` addresses your own fields. `each` addresses every element of an array.
|
|
130
|
+
`meta` addresses the envelope.
|
|
105
131
|
|
|
106
132
|
```ts
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
114
|
-
`Filter`
|
|
115
|
-
and `Filter.raw(node)` takes the wire
|
|
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
|
-
|
|
118
|
-
|
|
146
|
+
`each` is a separate call because the two ways of writing a wildcard ask
|
|
147
|
+
different questions:
|
|
119
148
|
|
|
120
149
|
```ts
|
|
121
|
-
|
|
122
|
-
Filter.not(
|
|
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
|
|
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
|
|
138
|
-
`next()`
|
|
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
|
|
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
|
|
147
|
-
for await (const page of
|
|
175
|
+
for await (const entry of movies.all({ where })) { }
|
|
176
|
+
for await (const page of movies.pages({ limit: 100 })) { }
|
|
148
177
|
```
|
|
149
178
|
|
|
150
|
-
|
|
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
|
|
187
|
+
const poster = await silo.media.upload({
|
|
159
188
|
bytes,
|
|
160
|
-
filename: "
|
|
161
|
-
contentType: "image/
|
|
162
|
-
folder: "
|
|
189
|
+
filename: "arrival.jpg",
|
|
190
|
+
contentType: "image/jpeg",
|
|
191
|
+
folder: "posters",
|
|
163
192
|
})
|
|
164
193
|
|
|
165
|
-
await
|
|
166
|
-
title: "
|
|
194
|
+
await movies.create({
|
|
195
|
+
title: "Arrival",
|
|
196
|
+
year: 2016,
|
|
167
197
|
status: "draft",
|
|
168
|
-
|
|
169
|
-
|
|
198
|
+
genres: ["sci-fi"],
|
|
199
|
+
poster: MediaReference.of(poster.id),
|
|
170
200
|
})
|
|
171
201
|
```
|
|
172
202
|
|
|
173
|
-
|
|
174
|
-
nothing. `asset.url`
|
|
175
|
-
store. Storing the URL is
|
|
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
|
|
179
|
-
await
|
|
180
|
-
await
|
|
181
|
-
await
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
usage
|
|
186
|
-
usage.
|
|
187
|
-
usage.
|
|
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
|
-
|
|
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("
|
|
195
|
-
await silo.media.folders.rename("
|
|
196
|
-
await silo.media.folders.delete("
|
|
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
|
-
|
|
242
|
+
You declare a variable once per project, and give it a value per environment.
|
|
206
243
|
|
|
207
244
|
```ts
|
|
208
|
-
const
|
|
209
|
-
await
|
|
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 =
|
|
251
|
+
const environment = moviespace.environment("prod")
|
|
212
252
|
await environment.variables.list()
|
|
213
|
-
await environment.variables.set("
|
|
214
|
-
await environment.variables.unset("
|
|
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
|
|
218
|
-
not `""`. An empty value substitutes as empty
|
|
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
|
|
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
|
|
228
|
-
await environment.search({ query: "
|
|
229
|
-
await silo.search({ query: "
|
|
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
|
|
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: "
|
|
236
|
-
results.hits[0].collection // "
|
|
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
|
-
|
|
283
|
+
There is one class per failure, so you can branch on the type.
|
|
244
284
|
|
|
245
285
|
```ts
|
|
246
|
-
import { ConflictError, ValidationFailedError
|
|
286
|
+
import { ConflictError, ValidationFailedError } from "@org-quicko/silo-client"
|
|
247
287
|
|
|
248
288
|
try {
|
|
249
|
-
await
|
|
289
|
+
await movies.replace(movie.id, movie.rev, fields)
|
|
250
290
|
} catch (error) {
|
|
251
291
|
if (error instanceof ConflictError) {
|
|
252
|
-
|
|
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
|
|
265
|
-
not prove the write failed. Read the entry back before
|
|
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
|
|
273
|
-
await
|
|
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
|
|
277
|
-
Nothing is retried for you
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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 {
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
+
}
|