@thomasfosterau/legation-jsonapi 0.0.0-stage → 0.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/CHANGELOG.md +973 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/dist/index.d.ts +1708 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4304 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/package.json +53 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,4304 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
|
|
2
|
+
import { ApiError as ApiError$1, Atomic, Document, Filter, Handlers, MEDIA_TYPE, Middleware, Query, Relationship, Resource } from "@thomasfosterau/effect-jsonapi";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Arr from "effect/Array";
|
|
6
|
+
import * as Context from "effect/Context";
|
|
7
|
+
import * as P from "effect/Predicate";
|
|
8
|
+
import * as R from "effect/Record";
|
|
9
|
+
import * as Result from "effect/Result";
|
|
10
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
11
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
12
|
+
import * as Option from "effect/Option";
|
|
13
|
+
import * as SchemaIssue from "effect/SchemaIssue";
|
|
14
|
+
import * as Str from "effect/String";
|
|
15
|
+
import { Authorization, AuthorizationError, Backend, BackendError, ClientStore, Collection, CrudMutation, Edge, FilterPolicy, MutationRejected, Mutator, Node, Predicate, Query as Query$1, QueryError, Transport } from "@thomasfosterau/legation";
|
|
16
|
+
import * as Iterable from "effect/Iterable";
|
|
17
|
+
import * as HttpApi from "effect/http-api/HttpApi";
|
|
18
|
+
import * as Ref from "effect/Ref";
|
|
19
|
+
import * as Semaphore from "effect/Semaphore";
|
|
20
|
+
import { ClientEngine, Hash, RejectionMemory, Related, Subset, SyncRoom, SyncServer, UnknownMutator } from "@thomasfosterau/legation/internal";
|
|
21
|
+
import * as Duration from "effect/Duration";
|
|
22
|
+
import * as Match from "effect/Match";
|
|
23
|
+
import { flow } from "effect/Function";
|
|
24
|
+
import * as HashSet from "effect/HashSet";
|
|
25
|
+
import * as HashMap from "effect/HashMap";
|
|
26
|
+
import * as Stream from "effect/Stream";
|
|
27
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
28
|
+
import * as Layer from "effect/Layer";
|
|
29
|
+
import { KeyValueStore } from "effect/persistence";
|
|
30
|
+
// src/errors.ts
|
|
31
|
+
/**
|
|
32
|
+
* Synchronizer Errors
|
|
33
|
+
*
|
|
34
|
+
* Tagged error types for the JSON:API adapter. All errors extend
|
|
35
|
+
* `Schema.TaggedError`, so they are yieldable inside `Effect.gen`, usable with
|
|
36
|
+
* `Effect.fail`, and handled with `Effect.catchTag` / `Effect.catchTags`.
|
|
37
|
+
* Because `Schema.TaggedError` extends `Error`, the `message` field is also
|
|
38
|
+
* available as `.message` on instances.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Base error for synchronizer operations.
|
|
42
|
+
*/
|
|
43
|
+
var SyncError = class extends Schema.TaggedError()("SyncError", {
|
|
44
|
+
message: Schema.String,
|
|
45
|
+
cause: Schema.optional(Schema.Defect())
|
|
46
|
+
}) {};
|
|
47
|
+
/**
|
|
48
|
+
* Network-related error (connection failure, timeout, etc.).
|
|
49
|
+
*/
|
|
50
|
+
var NetworkError = class extends Schema.TaggedError()("NetworkError", {
|
|
51
|
+
message: Schema.String,
|
|
52
|
+
cause: Schema.optional(Schema.Defect())
|
|
53
|
+
}) {};
|
|
54
|
+
/**
|
|
55
|
+
* Parsing error for JSON:API documents.
|
|
56
|
+
*/
|
|
57
|
+
var ParseError = class extends Schema.TaggedError()("ParseError", {
|
|
58
|
+
message: Schema.String,
|
|
59
|
+
cause: Schema.optional(Schema.Defect())
|
|
60
|
+
}) {};
|
|
61
|
+
/**
|
|
62
|
+
* API error carrying the JSON:API error objects returned by the server.
|
|
63
|
+
*/
|
|
64
|
+
var ApiError = class extends Schema.TaggedError()("ApiError", {
|
|
65
|
+
message: Schema.String,
|
|
66
|
+
errors: Schema.Array(Document.ErrorObject)
|
|
67
|
+
}) {};
|
|
68
|
+
/**
|
|
69
|
+
* Conflict error raised when local and remote resource versions mismatch.
|
|
70
|
+
*/
|
|
71
|
+
var ConflictError = class extends Schema.TaggedError()("ConflictError", {
|
|
72
|
+
message: Schema.String,
|
|
73
|
+
localVersion: Schema.optional(Schema.String),
|
|
74
|
+
remoteVersion: Schema.optional(Schema.String)
|
|
75
|
+
}) {};
|
|
76
|
+
// src/schema.ts
|
|
77
|
+
/**
|
|
78
|
+
* JSON:API Schemas
|
|
79
|
+
*
|
|
80
|
+
* Generic codecs for the JSON:API v1.1 document types this loader decodes from
|
|
81
|
+
* arbitrary upstream servers, plus a `decodeDocument` helper that validates
|
|
82
|
+
* untrusted input (e.g. a parsed HTTP response body) into a `JsonApiDocument`.
|
|
83
|
+
*
|
|
84
|
+
* The spec primitives — link objects, the `jsonapi` member, error objects, and
|
|
85
|
+
* free-form `meta` — are adopted from `@thomasfosterau/effect-jsonapi`'s
|
|
86
|
+
* `Document` module (the canonical, spec-compliant definitions) rather than
|
|
87
|
+
* hand-rolled here. What stays local are the *generic, untyped* composites
|
|
88
|
+
* (`ResourceObject`, `Relationship`, `JsonApiDocument`): the library models
|
|
89
|
+
* documents per known `Resource` definition, but a collection loader reads
|
|
90
|
+
* resources whose `type` and attribute shapes it does not know ahead of time,
|
|
91
|
+
* so it needs a resource-agnostic document schema layered over those
|
|
92
|
+
* primitives.
|
|
93
|
+
*
|
|
94
|
+
* JSON:API is an *open* format: documents and most member objects may carry
|
|
95
|
+
* implementation-specific members beyond those the spec names. Effect v4
|
|
96
|
+
* `Schema.Struct` already drops excess properties during decoding rather than
|
|
97
|
+
* rejecting them, so these schemas validate the *shape* of the members we care
|
|
98
|
+
* about while tolerating extra members. Free-form members (`meta`,
|
|
99
|
+
* `attributes`) are modelled as `Document.AnyMeta`
|
|
100
|
+
* (`Schema.Record(Schema.String, Schema.Unknown)`).
|
|
101
|
+
*
|
|
102
|
+
* Schema failures are mapped to the package's `ParseError`, with the
|
|
103
|
+
* underlying `Schema` issue attached as `cause`.
|
|
104
|
+
*/
|
|
105
|
+
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
106
|
+
JsonApiDocument: () => JsonApiDocument,
|
|
107
|
+
JsonApiError: () => JsonApiError,
|
|
108
|
+
LinkObject: () => LinkObject,
|
|
109
|
+
Links: () => Links,
|
|
110
|
+
Relationship: () => Relationship$1,
|
|
111
|
+
ResourceIdentifier: () => ResourceIdentifier,
|
|
112
|
+
ResourceObject: () => ResourceObject,
|
|
113
|
+
decodeDocument: () => decodeDocument
|
|
114
|
+
});
|
|
115
|
+
/** Free-form `meta` / `attributes` object: arbitrary JSON values by key. */
|
|
116
|
+
const MetaSchema = Document.AnyMeta;
|
|
117
|
+
/**
|
|
118
|
+
* Schema for a JSON:API link object (`{ href, ... }`), adopted from the
|
|
119
|
+
* library's spec-compliant definition.
|
|
120
|
+
*/
|
|
121
|
+
const LinkObject = Document.LinkObject;
|
|
122
|
+
/**
|
|
123
|
+
* Schema for a single link value: either a URL string or a link object.
|
|
124
|
+
*/
|
|
125
|
+
const LinkValue = Document.Link;
|
|
126
|
+
/**
|
|
127
|
+
* Schema for a JSON:API `links` object. The spec allows arbitrary link names,
|
|
128
|
+
* so this is a record of link values; `self` / `related` are simply well-known
|
|
129
|
+
* keys within it.
|
|
130
|
+
*/
|
|
131
|
+
const Links = Schema.Record(Schema.String, LinkValue);
|
|
132
|
+
/**
|
|
133
|
+
* Schema for a JSON:API resource identifier (`{ type, id, meta? }`).
|
|
134
|
+
*/
|
|
135
|
+
const ResourceIdentifier = Schema.Struct({
|
|
136
|
+
type: Schema.String,
|
|
137
|
+
id: Schema.String,
|
|
138
|
+
meta: Schema.optional(MetaSchema)
|
|
139
|
+
});
|
|
140
|
+
/**
|
|
141
|
+
* Schema for a JSON:API relationship (`{ data?, links?, meta? }`). The
|
|
142
|
+
* `data` member may be a single identifier, an array of identifiers, or
|
|
143
|
+
* `null`.
|
|
144
|
+
*/
|
|
145
|
+
const Relationship$1 = Schema.Struct({
|
|
146
|
+
data: Schema.optional(Schema.NullOr(Schema.Union([ResourceIdentifier, Schema.Array(ResourceIdentifier)]))),
|
|
147
|
+
links: Schema.optional(Links),
|
|
148
|
+
meta: Schema.optional(MetaSchema)
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* Schema for a JSON:API resource object (a resource identifier plus
|
|
152
|
+
* optional `attributes`, `relationships`, and `links`).
|
|
153
|
+
*/
|
|
154
|
+
const ResourceObject = Schema.Struct({
|
|
155
|
+
type: Schema.String,
|
|
156
|
+
id: Schema.String,
|
|
157
|
+
meta: Schema.optional(MetaSchema),
|
|
158
|
+
attributes: Schema.optional(MetaSchema),
|
|
159
|
+
relationships: Schema.optional(Schema.Record(Schema.String, Relationship$1)),
|
|
160
|
+
links: Schema.optional(Links)
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* Schema for a JSON:API error object, adopted from the library's
|
|
164
|
+
* spec-compliant definition.
|
|
165
|
+
*/
|
|
166
|
+
const JsonApiError = Document.ErrorObject;
|
|
167
|
+
/**
|
|
168
|
+
* Schema for a top-level JSON:API document.
|
|
169
|
+
*
|
|
170
|
+
* The `data` member may be a single resource, a collection of resources, a
|
|
171
|
+
* resource identifier (or collection thereof), or `null`. The other
|
|
172
|
+
* top-level members (`errors`, `meta`, `included`, `links`, `jsonapi`) are
|
|
173
|
+
* all optional. The `jsonapi` member reuses the library's `JsonApiObject`.
|
|
174
|
+
*/
|
|
175
|
+
const JsonApiDocument = Schema.Struct({
|
|
176
|
+
data: Schema.optional(Schema.NullOr(Schema.Union([
|
|
177
|
+
ResourceObject,
|
|
178
|
+
Schema.Array(ResourceObject),
|
|
179
|
+
ResourceIdentifier,
|
|
180
|
+
Schema.Array(ResourceIdentifier)
|
|
181
|
+
]))),
|
|
182
|
+
errors: Schema.optional(Schema.Array(JsonApiError)),
|
|
183
|
+
meta: Schema.optional(MetaSchema),
|
|
184
|
+
included: Schema.optional(Schema.Array(ResourceObject)),
|
|
185
|
+
links: Schema.optional(Links),
|
|
186
|
+
jsonapi: Schema.optional(Document.JsonApiObject)
|
|
187
|
+
});
|
|
188
|
+
const decodeJsonApiDocument = Schema.decodeUnknownEffect(JsonApiDocument);
|
|
189
|
+
/**
|
|
190
|
+
* Decode an unknown value (e.g. a parsed HTTP response body) into a
|
|
191
|
+
* `JsonApiDocument`. Schema failures become `ParseError`, with the schema
|
|
192
|
+
* issue attached as `cause`.
|
|
193
|
+
*/
|
|
194
|
+
const decodeDocument = (input) => decodeJsonApiDocument(input).pipe(Effect.mapError((cause) => new ParseError({
|
|
195
|
+
message: "Invalid JSON:API document",
|
|
196
|
+
cause
|
|
197
|
+
})));
|
|
198
|
+
// src/JsonApiQuery.ts
|
|
199
|
+
/**
|
|
200
|
+
* JsonApiQuery
|
|
201
|
+
*
|
|
202
|
+
* The bridge between the filter grammar's URL form and the engine's plan
|
|
203
|
+
* (ADR-0028 §5, issue #161) — a façade-boundary projection for one job
|
|
204
|
+
* (ADR-0029 §6: a URL must mean the same thing answered from the replica as
|
|
205
|
+
* answered by the network), not a general translation between two dialects
|
|
206
|
+
* of one language. effect-jsonapi owns the grammar — its AST (`Filter.Ast`),
|
|
207
|
+
* the `filter[...]` ⇄ AST codec (`Query.Filter`), the whole query codec
|
|
208
|
+
* (`Query.schema`) and the canonical query string (`Query.canonical`); this
|
|
209
|
+
* module owns the three translations legation needs on top of them:
|
|
210
|
+
*
|
|
211
|
+
* - **Filter AST ⇄ `Predicate`** ({@link fromFilterAst}, {@link toFilterAst}):
|
|
212
|
+
* both directions are partial, and neither is the other's inverse.
|
|
213
|
+
* {@link fromFilterAst} rejects a literal that is not a wire scalar
|
|
214
|
+
* (`string | number | boolean`) or a `Date` (encoded to its instant text —
|
|
215
|
+
* see below) with a typed `QueryError`, since a grammar AST is typed by
|
|
216
|
+
* the attribute's **decoded** schema and that type is open-ended (issue
|
|
217
|
+
* #218's design note). {@link toFilterAst} returns `None` for `Exists`
|
|
218
|
+
* (server-only, no URL form), an empty `In` / `NotIn` list, and a `null`
|
|
219
|
+
* literal (the grammar's lists are non-empty and `NULL` is never a
|
|
220
|
+
* literal — `isnull` names it); those callers fail closed.
|
|
221
|
+
* {@link foldConstants} removes the two definite cases before encoding:
|
|
222
|
+
* an empty `In` is false and an empty `NotIn` is true under ADR-0028's
|
|
223
|
+
* per-operator table, so they fold away exactly. Both functions carry a
|
|
224
|
+
* **field-namespace precondition**: a field name is copied verbatim
|
|
225
|
+
* between the two ASTs, which is correct only because every caller in
|
|
226
|
+
* this module pre-synthesises a resource whose field names are already
|
|
227
|
+
* the engine's ({@link resourcesOf} — a to-one edge is named by its FK key
|
|
228
|
+
* field, never the relationship name). Neither function checks that a
|
|
229
|
+
* field it is given actually names something on the other side; a caller
|
|
230
|
+
* bridging an AST built over a resource with its own naming must
|
|
231
|
+
* translate field names first. A `Date` literal's conversion to
|
|
232
|
+
* `value.toISOString()` is an **encode, not a copy**: ADR-0028 rule (e)
|
|
233
|
+
* fixes instant comparison to that exact text, so an instant literal that
|
|
234
|
+
* arrives any other way (a differently-formatted string, say) will not
|
|
235
|
+
* compare correctly downstream — this is the one step in either direction
|
|
236
|
+
* that changes a value's representation rather than re-tagging it, which
|
|
237
|
+
* is also why {@link toFilterAst} is a separate partial function rather
|
|
238
|
+
* than {@link fromFilterAst}'s inverse: the type erasure `Date → string`
|
|
239
|
+
* cannot be undone from the `Predicate` side alone.
|
|
240
|
+
* - **URL → `Query.Read`** ({@link parseRead}, {@link parseParams}): a
|
|
241
|
+
* request URL is decoded with effect-jsonapi's `Query.schema` for the
|
|
242
|
+
* collection's node — `include`, `fields`, `sort`, `page` and `filter` —
|
|
243
|
+
* and mapped onto the declarative read: `filter` → `where`, `sort` →
|
|
244
|
+
* `order`, `page[limit]` / `page[after]` → `first` / `after`, `include`
|
|
245
|
+
* → the edge selects. `fields[]` is a serialisation-time projection
|
|
246
|
+
* (ADR-0029 §5) and is carried beside the read, never into the plan.
|
|
247
|
+
* Every rejection is a `QueryError` naming the parameter, so a façade or
|
|
248
|
+
* a derived REST handler answers 400 with `source.parameter` set.
|
|
249
|
+
* - **`Query.Read` → canonical string** ({@link canonical}): the read is
|
|
250
|
+
* compiled with `Query.plan` and its plan is encoded through the
|
|
251
|
+
* canonical encoder, so two reads with one plan have one string —
|
|
252
|
+
* subscription identity for `Client.subscribe(url)` (ADR-0029 §2).
|
|
253
|
+
* {@link urlReads} packages the parse and the canonical string as the
|
|
254
|
+
* `Client.UrlReads` seam a Client or an engine host is built with (#159).
|
|
255
|
+
*
|
|
256
|
+
* The query codec is derived from the **node graph**, not from an
|
|
257
|
+
* effect-jsonapi resource: {@link resourcesOf} synthesises one resource per
|
|
258
|
+
* node whose filterable attributes carry the node's filterable set (the
|
|
259
|
+
* operators and literal kinds of ADR-0028 rule d, #154) and whose
|
|
260
|
+
* relationships are the node's edges. A filter names the row field the
|
|
261
|
+
* engine evaluates — a `one`-edge is filtered by its key field
|
|
262
|
+
* (`filter[categoryId]=c-audio`, as in ADR-0029's worked examples), not by
|
|
263
|
+
* the edge name. Include paths are single level (decision D1 of ADR-0028):
|
|
264
|
+
* a read includes a node's own edges only, so `include=variants.product` is
|
|
265
|
+
* rejected as a parse failure.
|
|
266
|
+
*
|
|
267
|
+
* The **exact encoder** ({@link defaultEncoder}) is what `JsonApiBackend`
|
|
268
|
+
* pushes to the server: the whole predicate as the grammar's canonical
|
|
269
|
+
* `filter[...]` record (operators, `or`, `not`, `isnull` and `nin`
|
|
270
|
+
* included — nothing is dropped any more), plus `sort`. Order, cursor and
|
|
271
|
+
* limit stay local: `Subset.apply` still finalises the window, which is now
|
|
272
|
+
* redundancy over an exact filter rather than correction of a superset
|
|
273
|
+
* (ADR-0028 §6). A predicate that can never match (an empty allow-set)
|
|
274
|
+
* encodes to `None` and the backend skips the request.
|
|
275
|
+
*/
|
|
276
|
+
var JsonApiQuery_exports = /* @__PURE__ */ __exportAll({
|
|
277
|
+
PageWindow: () => PageWindow,
|
|
278
|
+
buildQueryString: () => buildQueryString,
|
|
279
|
+
canonical: () => canonical,
|
|
280
|
+
defaultEncoder: () => defaultEncoder,
|
|
281
|
+
flatFilter: () => flatFilter,
|
|
282
|
+
foldConstants: () => foldConstants,
|
|
283
|
+
fromFilterAst: () => fromFilterAst,
|
|
284
|
+
invalidParameter: () => invalidParameter,
|
|
285
|
+
parseParams: () => parseParams,
|
|
286
|
+
parseRead: () => parseRead,
|
|
287
|
+
querySchema: () => querySchema,
|
|
288
|
+
resourcesOf: () => resourcesOf,
|
|
289
|
+
singleRowParams: () => singleRowParams,
|
|
290
|
+
singleRowSchema: () => singleRowSchema,
|
|
291
|
+
toFilterAst: () => toFilterAst,
|
|
292
|
+
toRequestOptions: () => toRequestOptions,
|
|
293
|
+
urlReads: () => urlReads
|
|
294
|
+
});
|
|
295
|
+
/**
|
|
296
|
+
* A grammar literal as a `Predicate` literal: scalars as they are, a `Date`
|
|
297
|
+
* as its instant text (the load-bearing encode of ADR-0028 rule e), or a
|
|
298
|
+
* typed `QueryError` for anything else — a grammar AST is typed by the
|
|
299
|
+
* attribute's decoded schema (issue #218's design note), which is
|
|
300
|
+
* open-ended, so this is the one place that type meets `Predicate.Literal`'s
|
|
301
|
+
* closed wire-scalar union and can fail.
|
|
302
|
+
*/
|
|
303
|
+
const literalOf = (field, value) => {
|
|
304
|
+
switch (typeof value) {
|
|
305
|
+
case "string":
|
|
306
|
+
case "number":
|
|
307
|
+
case "boolean": return Result.succeed(value);
|
|
308
|
+
default: return value instanceof Date ? Result.succeed(value.toISOString()) : Result.fail(new QueryError({
|
|
309
|
+
message: `Filter literal ${JSON.stringify(value)} for field "${field}" is not a scalar`,
|
|
310
|
+
parameter: `filter[${field}]`
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* The engine form of a filter AST, or a typed `QueryError` naming the field
|
|
316
|
+
* whose literal {@link literalOf} rejects: `Compare` / `In` / `NotIn` /
|
|
317
|
+
* `IsNull` map node for node, `And` / `Or` / `Not` recurse, and the empty
|
|
318
|
+
* conjunction (the grammar's "true") is `Predicate.always`. The map is
|
|
319
|
+
* structural — a singleton group, which the grammar's normal form keeps
|
|
320
|
+
* after deduplicating `Or(x, x)`, stays a singleton group rather than
|
|
321
|
+
* collapsing through `Predicate.or` — so {@link canonical} is idempotent.
|
|
322
|
+
* Literals are the codec's decoded scalars; a `Date` (a `DateFromString`
|
|
323
|
+
* attribute) becomes its ISO-8601 UTC text (see the module doc's encode
|
|
324
|
+
* note). Field names are copied verbatim — the module doc's
|
|
325
|
+
* field-namespace precondition.
|
|
326
|
+
*
|
|
327
|
+
* This module's own callers ({@link readOf}, {@link filterOf}) never see
|
|
328
|
+
* this fail: their ASTs are decoded through {@link resourcesOf}'s synthetic
|
|
329
|
+
* resource, whose filterable attributes admit only wire scalars, or are
|
|
330
|
+
* built by {@link toFilterAst} from a `Predicate.Literal`, which is one
|
|
331
|
+
* already. The typed failure exists for this function's public contract —
|
|
332
|
+
* any caller bridging an AST built over a resource with a richer decoded
|
|
333
|
+
* attribute type (a `Date`-valued one aside, which is handled) — not for
|
|
334
|
+
* either call site here.
|
|
335
|
+
*/
|
|
336
|
+
const fromFilterAst = (ast) => {
|
|
337
|
+
switch (ast._tag) {
|
|
338
|
+
case "Compare": return Result.map(literalOf(ast.field, ast.value), (value) => Predicate.compare(ast.op, ast.field, value));
|
|
339
|
+
case "In": return Result.map(Result.all(ast.values.map((value) => literalOf(ast.field, value))), (values) => Predicate.isIn(ast.field, values));
|
|
340
|
+
case "NotIn": return Result.map(Result.all(ast.values.map((value) => literalOf(ast.field, value))), (values) => Predicate.notIn(ast.field, values));
|
|
341
|
+
case "IsNull": return Result.succeed(ast.negated ? Predicate.isNotNull(ast.field) : Predicate.isNull(ast.field));
|
|
342
|
+
case "And": return Arr.isReadonlyArrayEmpty(ast.members) ? Result.succeed(Predicate.always) : Result.map(Result.all(ast.members.map(fromFilterAst)), (predicates) => ({
|
|
343
|
+
_tag: "And",
|
|
344
|
+
predicates
|
|
345
|
+
}));
|
|
346
|
+
case "Or": return Result.map(Result.all(ast.members.map(fromFilterAst)), (predicates) => ({
|
|
347
|
+
_tag: "Or",
|
|
348
|
+
predicates
|
|
349
|
+
}));
|
|
350
|
+
case "Not": return Result.map(fromFilterAst(ast.member), Predicate.not);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
/** A non-empty list of non-null literals, or `None` (the grammar has neither an empty list nor a `NULL` literal). */
|
|
354
|
+
const literalList = (values) => {
|
|
355
|
+
const [head, ...tail] = values;
|
|
356
|
+
return head === void 0 || head === null || tail.includes(null) ? Option.none() : Option.some([head, ...tail]);
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* The grammar form of a predicate, or `None` where it has no URL form:
|
|
360
|
+
* `Exists` (server-only, ADR-0028 §5), an empty `In` / `NotIn` list, or a
|
|
361
|
+
* `null` literal (the deprecated wire form of `isnull`; it evaluates to
|
|
362
|
+
* unknown and cannot be folded). `True` is the empty conjunction.
|
|
363
|
+
* `fromFilterAst ∘ toFilterAst` is the identity on every constructor-built
|
|
364
|
+
* predicate it accepts.
|
|
365
|
+
*/
|
|
366
|
+
const toFilterAst = (predicate) => {
|
|
367
|
+
switch (predicate._tag) {
|
|
368
|
+
case "True": return Option.some(Filter.and());
|
|
369
|
+
case "Compare": return predicate.value === null ? Option.none() : Option.some({
|
|
370
|
+
_tag: "Compare",
|
|
371
|
+
op: predicate.op,
|
|
372
|
+
field: predicate.field,
|
|
373
|
+
value: predicate.value
|
|
374
|
+
});
|
|
375
|
+
case "In": return Option.map(literalList(predicate.values), (values) => Filter.isIn(predicate.field, values));
|
|
376
|
+
case "NotIn": return Option.map(literalList(predicate.values), (values) => Filter.notIn(predicate.field, values));
|
|
377
|
+
case "IsNull": return Option.some(Filter.isNull(predicate.field, predicate.negated === true));
|
|
378
|
+
case "And": return Option.map(Option.all(predicate.predicates.map(toFilterAst)), (members) => Filter.and(...members));
|
|
379
|
+
case "Or": return Option.map(Option.all(predicate.predicates.map(toFilterAst)), (members) => Filter.or(...members));
|
|
380
|
+
case "Not": return Option.map(toFilterAst(predicate.predicate), Filter.not);
|
|
381
|
+
case "Exists":
|
|
382
|
+
case "InSet": return Option.none();
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
/** The definite-false predicate: the empty disjunction (`Some(false)` for every row). */
|
|
386
|
+
const FALSE = {
|
|
387
|
+
_tag: "Or",
|
|
388
|
+
predicates: []
|
|
389
|
+
};
|
|
390
|
+
const isTrue = (predicate) => predicate._tag === "True";
|
|
391
|
+
const isFalse = (predicate) => predicate._tag === "Or" && Arr.isReadonlyArrayEmpty(predicate.predicates);
|
|
392
|
+
/** Kleene `And` over folded members: any false is false; true members vanish. */
|
|
393
|
+
const foldAnd = (members) => {
|
|
394
|
+
const folded = members.map(foldConstants);
|
|
395
|
+
if (folded.some(isFalse)) return FALSE;
|
|
396
|
+
const kept = folded.filter((member) => !isTrue(member));
|
|
397
|
+
return Arr.isReadonlyArrayEmpty(kept) ? Predicate.always : Predicate.and(...kept);
|
|
398
|
+
};
|
|
399
|
+
/** Kleene `Or` over folded members: any true is true; false members vanish. */
|
|
400
|
+
const foldOr = (members) => {
|
|
401
|
+
const folded = members.map(foldConstants);
|
|
402
|
+
if (folded.some(isTrue)) return Predicate.always;
|
|
403
|
+
const kept = folded.filter((member) => !isFalse(member));
|
|
404
|
+
return Arr.isReadonlyArrayEmpty(kept) ? FALSE : Predicate.or(...kept);
|
|
405
|
+
};
|
|
406
|
+
const foldNot = (inner) => {
|
|
407
|
+
const folded = foldConstants(inner);
|
|
408
|
+
return isTrue(folded) ? FALSE : isFalse(folded) ? Predicate.always : Predicate.not(folded);
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* Fold the definite constants out of a predicate, exactly under ADR-0028's
|
|
412
|
+
* three-valued semantics: an empty `In` is false and an empty `NotIn` is
|
|
413
|
+
* true for every row (the compilers render them `1 = 0` / `1 = 1`), a
|
|
414
|
+
* false conjunct or a true disjunct decides its connective, and true
|
|
415
|
+
* conjuncts / false disjuncts vanish. Unknown is never folded — a `null`
|
|
416
|
+
* literal stays where it is. The result is `Predicate.always`, the empty
|
|
417
|
+
* `Or` (false), or a predicate free of empty lists.
|
|
418
|
+
*/
|
|
419
|
+
const foldConstants = (predicate) => {
|
|
420
|
+
switch (predicate._tag) {
|
|
421
|
+
case "In": return Arr.isReadonlyArrayEmpty(predicate.values) ? FALSE : predicate;
|
|
422
|
+
case "NotIn": return Arr.isReadonlyArrayEmpty(predicate.values) ? Predicate.always : predicate;
|
|
423
|
+
case "And": return foldAnd(predicate.predicates);
|
|
424
|
+
case "Or": return foldOr(predicate.predicates);
|
|
425
|
+
case "Not": return foldNot(predicate.predicate);
|
|
426
|
+
default: return predicate;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
/** A check that a decoded literal is a value of the kind (`FilterPolicy.matchesKind`). */
|
|
430
|
+
const kindCheck = (kind) => Schema.makeFilter((value) => FilterPolicy.matchesKind(kind, value), {
|
|
431
|
+
identifier: `LiteralKind.${kind}`,
|
|
432
|
+
title: kind,
|
|
433
|
+
description: `a literal of kind "${kind}"`
|
|
434
|
+
});
|
|
435
|
+
/**
|
|
436
|
+
* The attribute schema a filter literal of the kind decodes through: the
|
|
437
|
+
* wire string is parsed as the scalar the schema's encoded form names, then
|
|
438
|
+
* checked against the kind (int4 range, the fixed instant format).
|
|
439
|
+
*/
|
|
440
|
+
const literalSchema = (kind) => {
|
|
441
|
+
switch (kind) {
|
|
442
|
+
case "text": return Schema.String;
|
|
443
|
+
case "instant": return Schema.String.pipe(Schema.check(kindCheck("instant")));
|
|
444
|
+
case "integer": return Schema.Number.pipe(Schema.check(kindCheck("integer")));
|
|
445
|
+
case "double": return Schema.Number;
|
|
446
|
+
case "boolean": return Schema.Boolean;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
/**
|
|
450
|
+
* The synthetic resource's attributes: every filterable field (`id` and the
|
|
451
|
+
* `one`-edge keys included) with its kind's schema and its operators
|
|
452
|
+
* declared through `Filter.able`; every other attribute as `Unknown`, so
|
|
453
|
+
* `fields[type]` still names it but no filter reaches it.
|
|
454
|
+
*/
|
|
455
|
+
const attributesOf$2 = (node) => {
|
|
456
|
+
const filterable = R.map(node.filterable, (declared) => literalSchema(declared.kind).pipe(Filter.able(declared.operators)));
|
|
457
|
+
return {
|
|
458
|
+
...filterable,
|
|
459
|
+
...R.map(R.filter(node.attributes, (_, field) => !R.has(filterable, field)), () => Schema.Unknown)
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* The synthetic resource's relationships: the node's edges, targets
|
|
464
|
+
* resolved lazily through the graph. A paginated edge is a `many`
|
|
465
|
+
* relationship here: effect-jsonapi keeps `Relationship.paginated` out of
|
|
466
|
+
* `include` paths, whereas a read includes a paginated edge like any other
|
|
467
|
+
* (its per-parent window is the plan's, not the URL's — {@link canonical}
|
|
468
|
+
* refuses `include: { first }`).
|
|
469
|
+
*/
|
|
470
|
+
/**
|
|
471
|
+
* The resource of node `type`. {@link resourcesOf} builds one per node and
|
|
472
|
+
* every edge targets a node, so a miss is a defect, never a request error.
|
|
473
|
+
*/
|
|
474
|
+
const resourceIn = (resources, type) => {
|
|
475
|
+
const resource = resources.get(type);
|
|
476
|
+
if (resource === void 0) throw new QueryError({ message: `No resource for node "${type}"` });
|
|
477
|
+
return resource;
|
|
478
|
+
};
|
|
479
|
+
const relationshipsOf = (node, resources) => {
|
|
480
|
+
return R.map(node.edges, (edge) => {
|
|
481
|
+
const target = () => resourceIn(resources, edge.target);
|
|
482
|
+
return edge.kind === "one" ? Relationship.one(target) : Relationship.many(target);
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
const resourceGraphs = /* @__PURE__ */ new WeakMap();
|
|
486
|
+
/**
|
|
487
|
+
* The node graph as effect-jsonapi resource definitions, one per node —
|
|
488
|
+
* the declaration the URL codecs are derived from. Attributes are typed by
|
|
489
|
+
* the node's filterable set (kind and operators), relationships are its
|
|
490
|
+
* edges; nothing else about the resource is faithful (a document is never
|
|
491
|
+
* serialised through it). Memoised per registry.
|
|
492
|
+
*/
|
|
493
|
+
const resourcesOf = (nodes) => {
|
|
494
|
+
const cached = resourceGraphs.get(nodes);
|
|
495
|
+
if (cached !== void 0) return cached;
|
|
496
|
+
const resources = /* @__PURE__ */ new Map();
|
|
497
|
+
nodes.nodes.forEach((node) => resources.set(node.type, Resource.make(node.type, {
|
|
498
|
+
attributes: attributesOf$2(node),
|
|
499
|
+
relationships: relationshipsOf(node, resources)
|
|
500
|
+
})));
|
|
501
|
+
resourceGraphs.set(nodes, resources);
|
|
502
|
+
return resources;
|
|
503
|
+
};
|
|
504
|
+
/**
|
|
505
|
+
* The root window's page parameters: `page[limit]` is the read's `first`
|
|
506
|
+
* (a positive integer), `page[after]` its `after` cursor (a row id,
|
|
507
|
+
* opaque on the wire), `page[offset]` its `offset` (a non-negative
|
|
508
|
+
* integer, #209 — every audited app's pagination idiom). `page[offset]`
|
|
509
|
+
* and `page[after]` are different pagination idioms; {@link parseParams}
|
|
510
|
+
* rejects a request naming both, and {@link decodedOf} emits `limit` then
|
|
511
|
+
* whichever of `after` / `offset` the read carries — the order the
|
|
512
|
+
* canonical string keeps.
|
|
513
|
+
*/
|
|
514
|
+
const PageWindow = {
|
|
515
|
+
limit: Schema.optionalKey(Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)))),
|
|
516
|
+
after: Schema.optionalKey(Schema.String),
|
|
517
|
+
offset: Schema.optionalKey(Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))))
|
|
518
|
+
};
|
|
519
|
+
/** A value derived per node, memoised per registry then per node type. */
|
|
520
|
+
const memoised = (store, nodes, node, build) => {
|
|
521
|
+
let byType = store.get(nodes);
|
|
522
|
+
if (byType === void 0) {
|
|
523
|
+
byType = /* @__PURE__ */ new Map();
|
|
524
|
+
store.set(nodes, byType);
|
|
525
|
+
}
|
|
526
|
+
const cached = byType.get(node.type);
|
|
527
|
+
if (cached !== void 0) return cached;
|
|
528
|
+
const value = build();
|
|
529
|
+
byType.set(node.type, value);
|
|
530
|
+
return value;
|
|
531
|
+
};
|
|
532
|
+
/** The `include` option of a node's query codecs: single-level paths only (ADR-0028 D1); no edges, no `include` at all. */
|
|
533
|
+
const includeOption = (node) => !R.isEmptyReadonlyRecord(node.edges) ? { depth: 1 } : false;
|
|
534
|
+
const queryCodecs = /* @__PURE__ */ new WeakMap();
|
|
535
|
+
/** Build (or fetch) the query codec of one node: `Query.schema` over its synthetic resource. */
|
|
536
|
+
const codecOf = (nodes, node) => memoised(queryCodecs, nodes, node, () => {
|
|
537
|
+
const resource = resourceIn(resourcesOf(nodes), node.type);
|
|
538
|
+
const schema = Query.schema(resource, {
|
|
539
|
+
include: includeOption(node),
|
|
540
|
+
fields: true,
|
|
541
|
+
sort: node.sortable,
|
|
542
|
+
page: PageWindow,
|
|
543
|
+
filter: true
|
|
544
|
+
});
|
|
545
|
+
return {
|
|
546
|
+
schema,
|
|
547
|
+
canonical: Query.canonical(schema)
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
/** The node a collection names, or a `QueryError` thrown — a definition-time defect. */
|
|
551
|
+
const nodeOrThrow = (nodes, collection) => {
|
|
552
|
+
const node = nodes.nodes.get(collection);
|
|
553
|
+
if (node === void 0) throw new QueryError({
|
|
554
|
+
message: `Unknown collection: ${collection}`,
|
|
555
|
+
query: collection
|
|
556
|
+
});
|
|
557
|
+
return node;
|
|
558
|
+
};
|
|
559
|
+
/**
|
|
560
|
+
* The query codec of a collection's `list` endpoint — `include`, `fields[…]`,
|
|
561
|
+
* `sort`, `page[…]` and `filter[…]` exactly as the node graph declares them:
|
|
562
|
+
* the codec {@link parseParams} decodes with, so an effect-jsonapi endpoint
|
|
563
|
+
* served by a derived handler (`RestHandlers.list`) declares the grammar the
|
|
564
|
+
* handler parses when it is passed as `Endpoint.list(R, { query })`.
|
|
565
|
+
* effect-jsonapi's own `filter: true` derives a grammar from the *resource*
|
|
566
|
+
* instead, and the two spell a to-one relationship's filter differently: the
|
|
567
|
+
* resource grammar names the relationship (`filter[category]=c-audio`), the
|
|
568
|
+
* node graph — and every URL the engine emits ({@link canonical}) — names the
|
|
569
|
+
* key field (`filter[categoryId]=c-audio`, as ADR-0029's URLs do). One codec
|
|
570
|
+
* on both sides keeps the endpoint's contract and the handler's parse the
|
|
571
|
+
* same language. Every edge is includable under it, so a resource served
|
|
572
|
+
* with it declares its collection-valued edges `Relationship.many`, never
|
|
573
|
+
* `paginated` (whose `include=` only the resource grammar refuses). Throws a
|
|
574
|
+
* `QueryError` for an unknown collection — a definition-time defect.
|
|
575
|
+
*/
|
|
576
|
+
const querySchema = (nodes, collection) => codecOf(nodes, nodeOrThrow(nodes, collection)).schema;
|
|
577
|
+
const singleRowCodecs = /* @__PURE__ */ new WeakMap();
|
|
578
|
+
/**
|
|
579
|
+
* The query codec of a collection's `get` endpoint — `include` and
|
|
580
|
+
* `fields[…]` exactly as the node graph declares them, the two parameters a
|
|
581
|
+
* single-resource request takes ({@link singleRowParams}) — for
|
|
582
|
+
* `Endpoint.get(R, { query })`, the counterpart of {@link querySchema}: the
|
|
583
|
+
* endpoint's contract and the derived handler's parse are one grammar on
|
|
584
|
+
* the single-row route too. Throws a `QueryError` for an unknown collection.
|
|
585
|
+
*/
|
|
586
|
+
const singleRowSchema = (nodes, collection) => {
|
|
587
|
+
const node = nodeOrThrow(nodes, collection);
|
|
588
|
+
return memoised(singleRowCodecs, nodes, node, () => Query.schema(resourceIn(resourcesOf(nodes), node.type), {
|
|
589
|
+
include: includeOption(node),
|
|
590
|
+
fields: true
|
|
591
|
+
}));
|
|
592
|
+
};
|
|
593
|
+
/** The flat query record: `URLSearchParams` with repeated keys as arrays (as `UrlParams.toRecord` does). */
|
|
594
|
+
const recordOf$1 = (params) => {
|
|
595
|
+
if (!(params instanceof URLSearchParams)) return { ...params };
|
|
596
|
+
const record = {};
|
|
597
|
+
params.forEach((value, key) => {
|
|
598
|
+
const existing = record[key];
|
|
599
|
+
record[key] = existing === void 0 ? value : P.isString(existing) ? [existing, value] : [...existing, value];
|
|
600
|
+
});
|
|
601
|
+
return record;
|
|
602
|
+
};
|
|
603
|
+
/**
|
|
604
|
+
* The rejection of one query parameter, in the shape every check here
|
|
605
|
+
* raises (`parameter` names the offending key, so a caller answers 400 with
|
|
606
|
+
* `source.parameter`). Exported for a caller that checks a parsed URL
|
|
607
|
+
* against a declaration of its own — the replica-side writer against the
|
|
608
|
+
* resource it is bound to — and must reject the way the codec does.
|
|
609
|
+
*/
|
|
610
|
+
const invalidParameter = (collection, parameter, detail) => new QueryError({
|
|
611
|
+
message: `Invalid query for "${collection}" (${parameter}): ${detail}`,
|
|
612
|
+
query: collection,
|
|
613
|
+
parameter
|
|
614
|
+
});
|
|
615
|
+
const invalid$1 = invalidParameter;
|
|
616
|
+
const nodeOf$1 = (nodes, collection) => {
|
|
617
|
+
const node = nodes.nodes.get(collection);
|
|
618
|
+
return node === void 0 ? Effect.fail(new QueryError({
|
|
619
|
+
message: `Unknown collection: ${collection}`,
|
|
620
|
+
query: collection
|
|
621
|
+
})) : Effect.succeed(node);
|
|
622
|
+
};
|
|
623
|
+
/** The `include` tokens of the wire value: the comma form, the repeated key, or both. */
|
|
624
|
+
const includeTokens = (value) => value === void 0 ? [] : (P.isString(value) ? [value] : value).flatMap((item) => item.split(","));
|
|
625
|
+
/**
|
|
626
|
+
* Check `include` before decoding: every path is one of the node's own
|
|
627
|
+
* edges. A nested path is rejected by decision D1; an undeclared edge is
|
|
628
|
+
* rejected so the caller can rule on it (a façade falls through, ADR-0029
|
|
629
|
+
* §1). The codec would reject both too; the check gives the reason.
|
|
630
|
+
*/
|
|
631
|
+
const checkInclude = (node, value) => failFirst(includeTokens(value), (token) => token.includes(".") ? Option.some(invalid$1(node.type, "include", `nested include path "${token}" is not supported; a read includes a node's own edges only (ADR-0028 D1)`)) : node.edges[token] === void 0 ? Option.some(invalid$1(node.type, "include", `"${token}" is not an edge of "${node.type}" (edges: ${Object.keys(node.edges).join(", ")})`)) : Option.none());
|
|
632
|
+
/** Fail with the first item's error, in order; succeed when no item has one. */
|
|
633
|
+
const failFirst = (items, errorOf) => Option.match(Arr.findFirst(items, errorOf), {
|
|
634
|
+
onNone: () => Effect.void,
|
|
635
|
+
onSome: Effect.fail
|
|
636
|
+
});
|
|
637
|
+
const keyOf = (segment) => P.isObjectOrArray(segment) ? segment.key : segment;
|
|
638
|
+
/** The types a read on the node can carry: its own and its edge targets'. */
|
|
639
|
+
const carriedTypes$1 = (node) => /* @__PURE__ */ new Set([node.type, ...Object.values(node.edges).map((edge) => edge.target)]);
|
|
640
|
+
/**
|
|
641
|
+
* The rejection of a `fields[type]` projection the read cannot carry, or
|
|
642
|
+
* `undefined` when it can. The codec silently drops such a type; a
|
|
643
|
+
* projection nobody can apply is a client mistake worth a 400, and the same
|
|
644
|
+
* rule keeps {@link canonical} and {@link parseParams} in agreement.
|
|
645
|
+
*/
|
|
646
|
+
const projectionError = (node, type) => {
|
|
647
|
+
const types = carriedTypes$1(node);
|
|
648
|
+
return types.has(type) ? void 0 : invalid$1(node.type, `fields[${type}]`, `"${type}" is not a type this read can carry (types: ${[...types].join(", ")})`);
|
|
649
|
+
};
|
|
650
|
+
/** Check every `fields[…]` key before decoding (see {@link projectionError}). */
|
|
651
|
+
const checkFields = (node, record) => failFirst(Object.keys(record), (key) => {
|
|
652
|
+
const match = /^fields\[(.*)\]$/.exec(key);
|
|
653
|
+
return Option.fromUndefinedOr(match === null ? void 0 : projectionError(node, match[1] ?? ""));
|
|
654
|
+
});
|
|
655
|
+
/**
|
|
656
|
+
* `page[offset]` and `page[after]` are different pagination idioms — one
|
|
657
|
+
* request names one (#209). The schema alone cannot reject the
|
|
658
|
+
* combination (each key decodes fine on its own), so this runs before
|
|
659
|
+
* decoding, the way {@link checkInclude} rejects a nested path.
|
|
660
|
+
*/
|
|
661
|
+
const checkPage = (node, record) => record["page[after]"] !== void 0 && record["page[offset]"] !== void 0 ? Effect.fail(invalid$1(node.type, "page[offset]", `"page[offset]" and "page[after]" are different pagination idioms; a request names one`)) : Effect.void;
|
|
662
|
+
/** The query-string parameter an issue path names: the flat key itself, or `family[member]` for the nested families. */
|
|
663
|
+
const parameterOf = (path) => {
|
|
664
|
+
const [head, second] = (path ?? []).map(keyOf);
|
|
665
|
+
if (!P.isString(head)) return void 0;
|
|
666
|
+
return (head === "fields" || head === "page") && P.isString(second) ? `${head}[${second}]` : head;
|
|
667
|
+
};
|
|
668
|
+
/** A codec rejection as a `QueryError` naming the parameter (the issue's path is the flat key). */
|
|
669
|
+
const decodeError = (collection) => (error) => {
|
|
670
|
+
const issue = SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues[0];
|
|
671
|
+
const parameter = parameterOf(issue?.path);
|
|
672
|
+
return new QueryError({
|
|
673
|
+
message: `Invalid query for "${collection}"${parameter === void 0 ? "" : ` (${parameter})`}: ${issue?.message ?? error.message}`,
|
|
674
|
+
query: collection,
|
|
675
|
+
parameter,
|
|
676
|
+
cause: error
|
|
677
|
+
});
|
|
678
|
+
};
|
|
679
|
+
/** Sort terms as `order` tokens (`name`, `-createdAt`). */
|
|
680
|
+
const orderOf = (sort) => sort.map((term) => term.direction === "desc" ? `-${term.field}` : term.field);
|
|
681
|
+
/**
|
|
682
|
+
* The declarative read a decoded query describes, or the typed `QueryError`
|
|
683
|
+
* {@link fromFilterAst} fails with for `decoded.filter` — unreachable in
|
|
684
|
+
* practice (the module doc's note on this call site), carried as a `Result`
|
|
685
|
+
* rather than thrown so {@link parseParams}'s `Effect.Effect<_, QueryError>`
|
|
686
|
+
* contract holds even if that precondition is ever violated.
|
|
687
|
+
*/
|
|
688
|
+
const readOf = (collection, decoded) => {
|
|
689
|
+
const whereResult = decoded.filter === void 0 ? Result.succeed(void 0) : fromFilterAst(decoded.filter);
|
|
690
|
+
return Result.map(whereResult, (where) => ({
|
|
691
|
+
collection,
|
|
692
|
+
...where === void 0 ? {} : { where },
|
|
693
|
+
...decoded.include === void 0 || Arr.isReadonlyArrayEmpty(decoded.include) ? {} : { include: Object.fromEntries(decoded.include.map((edge) => [edge, true])) },
|
|
694
|
+
...decoded.sort === void 0 || Arr.isReadonlyArrayEmpty(decoded.sort) ? {} : { order: orderOf(decoded.sort) },
|
|
695
|
+
...decoded.page?.limit === void 0 ? {} : { first: decoded.page.limit },
|
|
696
|
+
...decoded.page?.after === void 0 ? {} : { after: decoded.page.after },
|
|
697
|
+
...decoded.page?.offset === void 0 ? {} : { offset: decoded.page.offset }
|
|
698
|
+
}));
|
|
699
|
+
};
|
|
700
|
+
/**
|
|
701
|
+
* Parse a collection's query parameters into a read. `include`, `fields`,
|
|
702
|
+
* `sort`, `page` and `filter` are decoded with the node-derived query codec
|
|
703
|
+
* (unknown filter fields, undeclared operators, literals of the wrong kind,
|
|
704
|
+
* unsortable fields, unknown sparse fields and nested include paths all
|
|
705
|
+
* fail with a `QueryError` naming the parameter) and mapped onto the read;
|
|
706
|
+
* `fields[]` rides beside it as the projection.
|
|
707
|
+
*/
|
|
708
|
+
const parseParams = (nodes, collection, params) => Effect.gen(function* () {
|
|
709
|
+
const node = yield* nodeOf$1(nodes, collection);
|
|
710
|
+
const record = recordOf$1(params);
|
|
711
|
+
yield* checkInclude(node, record["include"]);
|
|
712
|
+
yield* checkFields(node, record);
|
|
713
|
+
yield* checkPage(node, record);
|
|
714
|
+
const decoded = yield* Schema.decodeUnknownEffect(codecOf(nodes, node).schema)(record).pipe(Effect.mapError(decodeError(collection)));
|
|
715
|
+
return {
|
|
716
|
+
read: yield* Effect.fromResult(readOf(collection, decoded)),
|
|
717
|
+
...decoded.fields === void 0 ? {} : { fields: decoded.fields }
|
|
718
|
+
};
|
|
719
|
+
});
|
|
720
|
+
/** A path segment percent-decoded, or a `QueryError` naming `id` when it is malformed. */
|
|
721
|
+
const decodeId = (collection, segment) => Effect.try({
|
|
722
|
+
try: () => decodeURIComponent(segment),
|
|
723
|
+
catch: () => invalid$1(collection, "id", `path segment "${segment}" is not percent-encoded`)
|
|
724
|
+
});
|
|
725
|
+
/**
|
|
726
|
+
* The resource a request path addresses: `…/{type}/{id}` (one row) when the
|
|
727
|
+
* segment before the last names a node, else `…/{type}` (a listing) when
|
|
728
|
+
* the last does. A node named earlier on the path — the spec's related
|
|
729
|
+
* resource (`/products/p-1/variants`) and relationship (`…/relationships/…`)
|
|
730
|
+
* URLs — is not a read this parser serves (read the related collection
|
|
731
|
+
* with its key filter instead), so it is rejected rather than misread as a
|
|
732
|
+
* whole-collection listing.
|
|
733
|
+
*/
|
|
734
|
+
const locate = (nodes, url) => {
|
|
735
|
+
const segments = url.pathname.split("/").filter((segment) => Str.isNonEmpty(segment));
|
|
736
|
+
const last = segments.at(-1);
|
|
737
|
+
const previous = segments.at(-2);
|
|
738
|
+
if (previous !== void 0 && last !== void 0 && nodes.nodes.has(previous)) return Effect.map(decodeId(previous, last), (id) => ({
|
|
739
|
+
collection: previous,
|
|
740
|
+
id
|
|
741
|
+
}));
|
|
742
|
+
const message = segments.slice(0, -2).some((segment) => nodes.nodes.has(segment)) ? `Nested resource path "${url.pathname}" is not a read; filter the related collection by its key instead` : last !== void 0 && nodes.nodes.has(last) ? void 0 : `No collection on path "${url.pathname}"`;
|
|
743
|
+
return message === void 0 && last !== void 0 ? Effect.succeed({ collection: last }) : Effect.fail(new QueryError({
|
|
744
|
+
message: message ?? `No collection on path "${url.pathname}"`,
|
|
745
|
+
query: url.pathname
|
|
746
|
+
}));
|
|
747
|
+
};
|
|
748
|
+
/**
|
|
749
|
+
* The query of a single-row read — `filter[id]=…`, first 1 (ADR-0029 §3) —
|
|
750
|
+
* from a `/{type}/{id}` request's own query, which takes `include` and
|
|
751
|
+
* `fields[…]` only. Shared by {@link parseRead} and the derived `get`
|
|
752
|
+
* handler, which has the id from the route instead of the path.
|
|
753
|
+
*/
|
|
754
|
+
const singleRowParams = (collection, id, params) => {
|
|
755
|
+
const extra = Arr.findFirst(params.keys(), (key) => key !== "include" && !key.startsWith("fields["));
|
|
756
|
+
if (Option.isSome(extra)) return Effect.fail(invalid$1(collection, extra.value, `a single-resource request takes "include" and "fields[…]" only`));
|
|
757
|
+
const widened = new URLSearchParams(params);
|
|
758
|
+
widened.set("filter[id]", flatFilter(Filter.eq("id", id))["filter[id]"] ?? "");
|
|
759
|
+
widened.set("page[limit]", "1");
|
|
760
|
+
return Effect.succeed(widened);
|
|
761
|
+
};
|
|
762
|
+
/**
|
|
763
|
+
* Parse a request URL into a read. The collection is the path's last
|
|
764
|
+
* segment that names a node (`/api/products?…`, any prefix), or the
|
|
765
|
+
* segment before a row id (`/api/products/p-1?…`, which reads as
|
|
766
|
+
* `filter[id]=p-1` with `first` 1). The query is then {@link parseParams}.
|
|
767
|
+
*/
|
|
768
|
+
const parseRead = (nodes, url) => Effect.gen(function* () {
|
|
769
|
+
const parsed = P.isString(url) ? new URL(url, "http://localhost") : url;
|
|
770
|
+
const located = yield* locate(nodes, parsed);
|
|
771
|
+
const params = located.id === void 0 ? parsed.searchParams : yield* singleRowParams(located.collection, located.id, parsed.searchParams);
|
|
772
|
+
const result = yield* parseParams(nodes, located.collection, params);
|
|
773
|
+
return located.id === void 0 ? result : {
|
|
774
|
+
...result,
|
|
775
|
+
id: located.id
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
const noUrlForm = (collection, detail) => new QueryError({
|
|
779
|
+
message: `Read on "${collection}" has no URL form: ${detail}`,
|
|
780
|
+
query: collection
|
|
781
|
+
});
|
|
782
|
+
/** The plan's edge selects as include paths, in code-point order (the include set is unordered). */
|
|
783
|
+
const includeOf = (collection, edges) => edges.map((edge) => {
|
|
784
|
+
if (edge.first !== void 0) throw noUrlForm(collection, `the per-edge window on "${edge.edge}" (include: { first }) has no query parameter`);
|
|
785
|
+
return edge.edge;
|
|
786
|
+
}).toSorted((a, b) => Predicate.compareText(a, b));
|
|
787
|
+
const astOf = (collection, predicate) => Option.getOrThrowWith(toFilterAst(predicate), () => noUrlForm(collection, `"${predicate._tag}" carries an Exists node or a null literal`));
|
|
788
|
+
/** Passes {@link filterOf} allows the normal form to settle in; two suffice in practice. */
|
|
789
|
+
const NORMAL_FORM_PASSES = 8;
|
|
790
|
+
/**
|
|
791
|
+
* The plan's root predicate as a filter AST in normal form; `undefined` for
|
|
792
|
+
* the whole collection. Two normalisations meet here: {@link foldConstants}
|
|
793
|
+
* on the engine side (constants, singleton groups) and `Filter.normalise`
|
|
794
|
+
* on the grammar side (member order, deduplication, literal-list order).
|
|
795
|
+
* Each can hand the other new work — deduplicating `Or(x, x)` leaves a
|
|
796
|
+
* singleton group the fold then collapses — so the result is their
|
|
797
|
+
* fixpoint, which makes {@link canonical} idempotent: parsing a canonical
|
|
798
|
+
* string and canonicalising again gives the same string.
|
|
799
|
+
*/
|
|
800
|
+
const filterOf = (collection, declaration, where) => {
|
|
801
|
+
const settle = (predicate, pass) => {
|
|
802
|
+
if (pass >= NORMAL_FORM_PASSES) return Option.some(astOf(collection, predicate));
|
|
803
|
+
if (isTrue(predicate)) return Option.none();
|
|
804
|
+
const ast = Filter.normalise(astOf(collection, predicate), declaration);
|
|
805
|
+
const back = fromFilterAst(ast);
|
|
806
|
+
if (Result.isFailure(back)) throw back.failure;
|
|
807
|
+
const next = foldConstants(back.success);
|
|
808
|
+
if (JSON.stringify(next) === JSON.stringify(predicate)) return Option.some(ast);
|
|
809
|
+
return settle(next, pass + 1);
|
|
810
|
+
};
|
|
811
|
+
return Option.getOrUndefined(settle(foldConstants(where), 0));
|
|
812
|
+
};
|
|
813
|
+
/**
|
|
814
|
+
* The decoded query a compiled read denotes. The page comes from the
|
|
815
|
+
* **read**, not the compiled window: an offset read's `root.limit`
|
|
816
|
+
* absorbs `offset` (`Query.plan`'s `rootLimit`, #209), so reconstructing
|
|
817
|
+
* `page[limit]`/`page[after]` from `root` would leak the inflated fetch
|
|
818
|
+
* size into the URL instead of the request the caller actually made.
|
|
819
|
+
*/
|
|
820
|
+
const decodedOf = (compiled, declaration, fields, read) => {
|
|
821
|
+
const { root } = compiled;
|
|
822
|
+
const include = includeOf(root.collection, compiled.edges);
|
|
823
|
+
const filter = filterOf(root.collection, declaration, root.where);
|
|
824
|
+
const page = read.offset === void 0 ? {
|
|
825
|
+
...root.limit === void 0 ? {} : { limit: root.limit },
|
|
826
|
+
...root.cursor === void 0 ? {} : { after: root.cursor }
|
|
827
|
+
} : {
|
|
828
|
+
...read.first === void 0 ? {} : { limit: read.first },
|
|
829
|
+
offset: read.offset
|
|
830
|
+
};
|
|
831
|
+
return {
|
|
832
|
+
...Arr.isReadonlyArrayEmpty(include) ? {} : { include },
|
|
833
|
+
...fields === void 0 ? {} : { fields: projectionOf(fields) },
|
|
834
|
+
...filter === void 0 ? {} : { filter },
|
|
835
|
+
...Arr.isReadonlyArrayEmpty(root.orderBy) ? {} : { sort: root.orderBy },
|
|
836
|
+
...R.isEmptyReadonlyRecord(page) ? {} : { page }
|
|
837
|
+
};
|
|
838
|
+
};
|
|
839
|
+
/** A projection with each type's field list in code-point order (a set). */
|
|
840
|
+
const projectionOf = (fields) => Object.fromEntries(Object.entries(fields).map(([type, names]) => [type, names.toSorted(Predicate.compareText)]));
|
|
841
|
+
/**
|
|
842
|
+
* The canonical URL of a read — `/{collection}?{query}` (no API prefix;
|
|
843
|
+
* `parseRead` accepts it back) — through effect-jsonapi's canonical encoder
|
|
844
|
+
* over the compiled plan: one string per plan, whatever spelling the read
|
|
845
|
+
* used (`where` as a map or a predicate, conjuncts in any order, `True`
|
|
846
|
+
* conjuncts). It is the subscription identity of ADR-0029 §2. Throws a
|
|
847
|
+
* `QueryError` for a read the plan or the grammar refuses: an unknown
|
|
848
|
+
* collection or edge, a field outside the filterable set, an `Exists`, a
|
|
849
|
+
* `null` literal, or a per-edge window. A `fields[]` projection rides
|
|
850
|
+
* along when given.
|
|
851
|
+
*/
|
|
852
|
+
const canonical = (nodes, read, fields) => {
|
|
853
|
+
const compiled = Query$1.plan(nodes, read);
|
|
854
|
+
const node = nodes.nodes.get(read.collection);
|
|
855
|
+
if (node === void 0) throw new QueryError({
|
|
856
|
+
message: `Unknown collection: ${read.collection}`,
|
|
857
|
+
query: read.collection
|
|
858
|
+
});
|
|
859
|
+
Object.keys(fields ?? {}).forEach((type) => {
|
|
860
|
+
const error = projectionError(node, type);
|
|
861
|
+
if (error !== void 0) throw error;
|
|
862
|
+
});
|
|
863
|
+
const encoded = Result.try({
|
|
864
|
+
try: () => {
|
|
865
|
+
const declaration = Resource.filterable(resourceIn(resourcesOf(nodes), node.type));
|
|
866
|
+
return codecOf(nodes, node).canonical(decodedOf(compiled, declaration, fields, read));
|
|
867
|
+
},
|
|
868
|
+
catch: (error) => error instanceof QueryError ? error : new QueryError({
|
|
869
|
+
message: `Read on "${read.collection}" has no URL form: ${error instanceof Error ? error.message : String(error)}`,
|
|
870
|
+
query: read.collection,
|
|
871
|
+
cause: error
|
|
872
|
+
})
|
|
873
|
+
});
|
|
874
|
+
if (Result.isFailure(encoded)) throw encoded.failure;
|
|
875
|
+
const query = encoded.success;
|
|
876
|
+
return query === "" ? `/${read.collection}` : `/${read.collection}?${query}`;
|
|
877
|
+
};
|
|
878
|
+
/** {@link canonical} as an effect: the `QueryError` it throws becomes a typed failure. */
|
|
879
|
+
const tryCanonical = (nodes, read) => Effect.suspend(() => {
|
|
880
|
+
const result = Result.try(() => canonical(nodes, read));
|
|
881
|
+
if (Result.isSuccess(result)) return Effect.succeed(result.success);
|
|
882
|
+
return result.failure instanceof QueryError ? Effect.fail(result.failure) : Effect.die(result.failure);
|
|
883
|
+
});
|
|
884
|
+
/**
|
|
885
|
+
* The `Client.UrlReads` seam over this bridge (#159, ADR-0029 §2): pass as
|
|
886
|
+
* `urls` to `Client.make` / `Client.connect` / `Client.fromView` so
|
|
887
|
+
* `client.subscribe(url)` parses through {@link parseRead} and keys the pin
|
|
888
|
+
* by {@link canonical}, and to an engine host (`ServiceWorkerHost.run`) so
|
|
889
|
+
* a page with no Client can register by URL. The `fields[]` projection a
|
|
890
|
+
* URL carries is dropped: it never changes what a subscription syncs
|
|
891
|
+
* (ADR-0029 §5), so two URLs differing only in `fields[]` are one
|
|
892
|
+
* subscription.
|
|
893
|
+
*/
|
|
894
|
+
const urlReads = (nodes) => ({
|
|
895
|
+
parse: (url) => Effect.map(parseRead(nodes, url), (parsed) => parsed.read),
|
|
896
|
+
canonical: (read) => tryCanonical(nodes, read)
|
|
897
|
+
});
|
|
898
|
+
/** The attribute schema of a field whose literals have the JS type. */
|
|
899
|
+
const scalarSchema = (type) => type === "string" ? Schema.String : type === "number" ? Schema.Number : Schema.Boolean;
|
|
900
|
+
/** Collect the literal JS types each field of a tree is compared with (mutates `types`). */
|
|
901
|
+
const collectLiteralTypes = (ast, types) => {
|
|
902
|
+
const add = (field, value) => {
|
|
903
|
+
const set = types.get(field) ?? /* @__PURE__ */ new Set();
|
|
904
|
+
set.add(typeof value);
|
|
905
|
+
types.set(field, set);
|
|
906
|
+
};
|
|
907
|
+
switch (ast._tag) {
|
|
908
|
+
case "Compare": return add(ast.field, ast.value);
|
|
909
|
+
case "In":
|
|
910
|
+
case "NotIn": return ast.values.forEach((value) => add(ast.field, value));
|
|
911
|
+
case "IsNull":
|
|
912
|
+
types.get(ast.field) ?? types.set(ast.field, /* @__PURE__ */ new Set());
|
|
913
|
+
return;
|
|
914
|
+
case "And":
|
|
915
|
+
case "Or": return ast.members.forEach((member) => collectLiteralTypes(member, types));
|
|
916
|
+
case "Not": return collectLiteralTypes(ast.member, types);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
/** The one JS type each field of a tree is compared with (`string` for a field only null-tested). */
|
|
920
|
+
const literalTypesOf = (ast) => {
|
|
921
|
+
const types = /* @__PURE__ */ new Map();
|
|
922
|
+
collectLiteralTypes(ast, types);
|
|
923
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
924
|
+
types.forEach((kinds, field) => {
|
|
925
|
+
const [kind, ...rest] = kinds;
|
|
926
|
+
if (Arr.isReadonlyArrayNonEmpty(rest)) throw new QueryError({
|
|
927
|
+
message: `Field "${field}" is compared with literals of several types (${[...kinds].join(", ")}); ADR-0028 rule (d) admits one`,
|
|
928
|
+
parameter: `filter[${field}]`
|
|
929
|
+
});
|
|
930
|
+
resolved.set(field, kind ?? "string");
|
|
931
|
+
});
|
|
932
|
+
return resolved;
|
|
933
|
+
};
|
|
934
|
+
const flatFilterCodecs = /* @__PURE__ */ new Map();
|
|
935
|
+
/**
|
|
936
|
+
* The codec for encoding a tree without a node graph: each field it names
|
|
937
|
+
* admits every operator, typed by the JS type of its literals. The wire
|
|
938
|
+
* form of a scalar does not depend on its declared kind, so this encodes
|
|
939
|
+
* exactly what the typed codec would. Memoised by the field → type
|
|
940
|
+
* signature, since a backend encodes the same shapes on every pull.
|
|
941
|
+
*/
|
|
942
|
+
const flatFilterCodecOf = (ast) => {
|
|
943
|
+
const types = literalTypesOf(ast);
|
|
944
|
+
const signature = [...types].map(([field, type]) => `${field}:${type}`).toSorted(Predicate.compareText).join("\n");
|
|
945
|
+
const cached = flatFilterCodecs.get(signature);
|
|
946
|
+
if (cached !== void 0) return cached;
|
|
947
|
+
const attributes = R.fromIterableWith(types, ([field, type]) => [field, scalarSchema(type).pipe(Filter.able())]);
|
|
948
|
+
const codec = Query.Filter(Resource.make("query", { attributes }));
|
|
949
|
+
flatFilterCodecs.set(signature, codec);
|
|
950
|
+
return codec;
|
|
951
|
+
};
|
|
952
|
+
/**
|
|
953
|
+
* The canonical flat `filter[...]` record of a filter AST — the grammar's
|
|
954
|
+
* own encoder (`Query.Filter`), so the shorthand, the group form, escaping
|
|
955
|
+
* and member order are effect-jsonapi's byte for byte.
|
|
956
|
+
*/
|
|
957
|
+
const flatFilter = (ast) => Schema.encodeSync(flatFilterCodecOf(ast))(ast);
|
|
958
|
+
/**
|
|
959
|
+
* Serialize sort terms into the JSON:API `sort` value (`-field,field`) through
|
|
960
|
+
* the library's `Query.Sort` codec, so the wire encoding matches the spec's
|
|
961
|
+
* descending-prefix convention by construction.
|
|
962
|
+
*
|
|
963
|
+
* Since effect-jsonapi 0.15 the codec's encoded side is `Repeatable`
|
|
964
|
+
* (`string | ReadonlyArray<string>`) because it also decodes the repeated-key
|
|
965
|
+
* spelling; its encoder emits the comma form, and an array is joined into
|
|
966
|
+
* that same form rather than trusted to never appear.
|
|
967
|
+
*/
|
|
968
|
+
const encodeSort = (terms) => {
|
|
969
|
+
const encoded = Schema.encodeSync(Query.Sort(terms.map((term) => term.field)))(terms.map((term) => ({
|
|
970
|
+
field: term.field,
|
|
971
|
+
direction: term.direction
|
|
972
|
+
})));
|
|
973
|
+
return P.isString(encoded) ? encoded : encoded.join(",");
|
|
974
|
+
};
|
|
975
|
+
/**
|
|
976
|
+
* Build the JSON:API query string (with its leading `?`, or `` when empty)
|
|
977
|
+
* for `include` / `fields` / `filter` / `sort` / `page`, in the canonical
|
|
978
|
+
* family order and percent-encoding (`Query.serialise`). `filter` is a
|
|
979
|
+
* filter AST, encoded exactly through {@link flatFilter}.
|
|
980
|
+
*/
|
|
981
|
+
const buildQueryString = (options) => {
|
|
982
|
+
if (options === void 0) return "";
|
|
983
|
+
const pairs = [];
|
|
984
|
+
if (options.include && Arr.isReadonlyArrayNonEmpty(options.include)) pairs.push(["include", options.include.join(",")]);
|
|
985
|
+
pairs.push(...Object.entries(options.fields ?? {}).map(([type, names]) => [`fields[${type}]`, names.join(",")]));
|
|
986
|
+
if (options.filter !== void 0) pairs.push(...Object.entries(flatFilter(options.filter)));
|
|
987
|
+
if (options.sort && Arr.isReadonlyArrayNonEmpty(options.sort)) pairs.push(["sort", encodeSort(options.sort)]);
|
|
988
|
+
pairs.push(...Object.entries(options.page ?? {}).flatMap(([key, value]) => value === void 0 ? [] : [[`page[${key}]`, String(value)]]));
|
|
989
|
+
const query = Query.serialise(pairs);
|
|
990
|
+
return query === "" ? "" : `?${query}`;
|
|
991
|
+
};
|
|
992
|
+
/**
|
|
993
|
+
* The request options a subset encodes to, or `None` when its predicate
|
|
994
|
+
* folds to false. The filter is the whole predicate, constants folded;
|
|
995
|
+
* `orderBy` becomes `sort`. Limit and cursor are not pushed: the window is
|
|
996
|
+
* applied locally over the exact filter. Throws a `QueryError` for a
|
|
997
|
+
* predicate with no URL form (`Exists`, a `null` literal).
|
|
998
|
+
*/
|
|
999
|
+
const toRequestOptions = (subset) => {
|
|
1000
|
+
const where = foldConstants(subset.where);
|
|
1001
|
+
if (isFalse(where)) return Option.none();
|
|
1002
|
+
const filter = isTrue(where) ? void 0 : Option.getOrThrowWith(toFilterAst(where), () => noUrlForm(subset.collection, "the predicate carries an Exists node or a null literal"));
|
|
1003
|
+
const sort = subset.orderBy.map((term) => ({
|
|
1004
|
+
field: term.field,
|
|
1005
|
+
direction: term.direction
|
|
1006
|
+
}));
|
|
1007
|
+
return Option.some({
|
|
1008
|
+
...filter === void 0 ? {} : { filter },
|
|
1009
|
+
...Arr.isReadonlyArrayEmpty(sort) ? {} : { sort }
|
|
1010
|
+
});
|
|
1011
|
+
};
|
|
1012
|
+
/** The shipped encoder: exact under ADR-0028 (see the module doc). */
|
|
1013
|
+
const defaultEncoder = { encode: (subset) => Effect.try({
|
|
1014
|
+
try: () => Option.map(toRequestOptions(subset), buildQueryString),
|
|
1015
|
+
catch: (error) => error instanceof QueryError ? error : new QueryError({
|
|
1016
|
+
message: `Subset on "${subset.collection}" could not be encoded: ${error instanceof Error ? error.message : String(error)}`,
|
|
1017
|
+
query: subset.collection,
|
|
1018
|
+
cause: error
|
|
1019
|
+
})
|
|
1020
|
+
}) };
|
|
1021
|
+
// src/CollectionLoader.ts
|
|
1022
|
+
/**
|
|
1023
|
+
* CollectionLoader
|
|
1024
|
+
*
|
|
1025
|
+
* The JSON:API package's place in the v2 model (ADR-0008): a *collection
|
|
1026
|
+
* loader*. A JSON:API `type` is a collection, each resource's `id` is a row
|
|
1027
|
+
* id, and a resource maps to a row value (attributes + resolved
|
|
1028
|
+
* relationship references). Loaders read documents into collection rows;
|
|
1029
|
+
* they do not own a sync model of their own.
|
|
1030
|
+
*
|
|
1031
|
+
* This is a read-side adapter. Writes in the v2 engine are named mutators
|
|
1032
|
+
* (ADR-0008), not ad-hoc REST calls, so the former conflict-strategy
|
|
1033
|
+
* push/pull helpers are gone. To sync a JSON:API upstream, back a `Backend`
|
|
1034
|
+
* (or a read-only collection) with `load`/`loadOne` and emit row versions
|
|
1035
|
+
* from the upstream's own change token (ETag, `meta.version`, …).
|
|
1036
|
+
*/
|
|
1037
|
+
var CollectionLoader_exports = /* @__PURE__ */ __exportAll({
|
|
1038
|
+
JsonApiSource: () => JsonApiSource,
|
|
1039
|
+
buildQueryString: () => buildQueryString,
|
|
1040
|
+
documentToRows: () => documentToRows,
|
|
1041
|
+
load: () => load,
|
|
1042
|
+
loadOne: () => loadOne,
|
|
1043
|
+
parseResponse: () => parseResponse,
|
|
1044
|
+
resourceToRow: () => resourceToRow
|
|
1045
|
+
});
|
|
1046
|
+
const identifier = (ref) => ({
|
|
1047
|
+
type: ref.type,
|
|
1048
|
+
id: ref.id
|
|
1049
|
+
});
|
|
1050
|
+
/**
|
|
1051
|
+
* `Array.isArray` types its guard as `any[]`, so it does not narrow the
|
|
1052
|
+
* `ReadonlyArray` members the schema-derived document types use. This guard
|
|
1053
|
+
* narrows them correctly.
|
|
1054
|
+
*/
|
|
1055
|
+
const isReadonlyArray = (value) => Array.isArray(value);
|
|
1056
|
+
/** Map a resource (or bare identifier) to a collection row. */
|
|
1057
|
+
const resourceToRow = (resource) => {
|
|
1058
|
+
const full = resource;
|
|
1059
|
+
const relationships = R.filterMap(full.relationships ?? {}, ({ data }) => data === void 0 ? Result.failVoid : Result.succeed(data === null ? null : isReadonlyArray(data) ? data.map(identifier) : identifier(data)));
|
|
1060
|
+
return {
|
|
1061
|
+
collection: resource.type,
|
|
1062
|
+
id: resource.id,
|
|
1063
|
+
value: {
|
|
1064
|
+
type: resource.type,
|
|
1065
|
+
id: resource.id,
|
|
1066
|
+
attributes: full.attributes ?? {},
|
|
1067
|
+
relationships
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
};
|
|
1071
|
+
const dataResources = (data) => data === void 0 || data === null ? [] : isReadonlyArray(data) ? data : [data];
|
|
1072
|
+
/** Map a whole document to its primary rows and its `included` rows. */
|
|
1073
|
+
const documentToRows = (document) => ({
|
|
1074
|
+
rows: dataResources(document.data).map(resourceToRow),
|
|
1075
|
+
included: (document.included ?? []).map(resourceToRow)
|
|
1076
|
+
});
|
|
1077
|
+
/** The {@link JsonApiSource} service loaders read from. */
|
|
1078
|
+
const JsonApiSource = Context.Service("@thomasfosterau/legation-jsonapi/JsonApiSource");
|
|
1079
|
+
const jsonApiHeaders$1 = {
|
|
1080
|
+
"Content-Type": "application/vnd.api+json",
|
|
1081
|
+
Accept: "application/vnd.api+json"
|
|
1082
|
+
};
|
|
1083
|
+
/** Validate a JSON:API response into a document (non-2xx / `errors` → ApiError). */
|
|
1084
|
+
const parseResponse = (response) => Effect.gen(function* () {
|
|
1085
|
+
if (response.status < 200 || response.status >= 300) {
|
|
1086
|
+
const body = yield* response.json.pipe(Effect.orElseSucceed(() => void 0));
|
|
1087
|
+
const candidate = P.isObjectOrArray(body) ? body.errors : void 0;
|
|
1088
|
+
const errors = Array.isArray(candidate) ? candidate : [];
|
|
1089
|
+
return yield* new ApiError({
|
|
1090
|
+
message: `HTTP ${response.status}`,
|
|
1091
|
+
errors
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
const json = yield* response.json.pipe(Effect.mapError((cause) => new ParseError({
|
|
1095
|
+
message: "Failed to parse JSON body",
|
|
1096
|
+
cause
|
|
1097
|
+
})));
|
|
1098
|
+
const document = yield* decodeDocument(json);
|
|
1099
|
+
if (document.errors && Arr.isReadonlyArrayNonEmpty(document.errors)) return yield* new ApiError({
|
|
1100
|
+
message: "Document carried errors",
|
|
1101
|
+
errors: document.errors
|
|
1102
|
+
});
|
|
1103
|
+
return document;
|
|
1104
|
+
});
|
|
1105
|
+
const get = (url) => HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(jsonApiHeaders$1));
|
|
1106
|
+
/** Load a collection (`GET /{type}`) into rows. */
|
|
1107
|
+
const load = (type, options) => Effect.gen(function* () {
|
|
1108
|
+
const source = yield* JsonApiSource;
|
|
1109
|
+
const client = yield* HttpClient.HttpClient;
|
|
1110
|
+
const url = `${source.baseUrl}/${type}${buildQueryString(options)}`;
|
|
1111
|
+
const response = yield* Effect.scoped(client.execute(get(url)));
|
|
1112
|
+
return documentToRows(yield* parseResponse(response));
|
|
1113
|
+
});
|
|
1114
|
+
/** Load a single resource (`GET /{type}/{id}`); `undefined` if absent. */
|
|
1115
|
+
const loadOne = (type, id, options) => Effect.gen(function* () {
|
|
1116
|
+
const source = yield* JsonApiSource;
|
|
1117
|
+
const client = yield* HttpClient.HttpClient;
|
|
1118
|
+
const url = `${source.baseUrl}/${type}/${id}${buildQueryString(options)}`;
|
|
1119
|
+
const response = yield* Effect.scoped(client.execute(get(url)));
|
|
1120
|
+
return documentToRows(yield* parseResponse(response)).rows[0];
|
|
1121
|
+
});
|
|
1122
|
+
// src/Convention.ts
|
|
1123
|
+
/**
|
|
1124
|
+
* Convention
|
|
1125
|
+
*
|
|
1126
|
+
* Convention-first discovery of a JSON:API resource surface from an app's
|
|
1127
|
+
* Effect `HttpApi` (the happy path: "bring a JSON:API api, get sync"). We
|
|
1128
|
+
* reflect the api's endpoints and classify each one by HTTP method + path
|
|
1129
|
+
* into a JSON:API role, so no per-endpoint annotation is required:
|
|
1130
|
+
*
|
|
1131
|
+
* | method | path shape | role |
|
|
1132
|
+
* | ------- | --------------- | -------- |
|
|
1133
|
+
* | GET | `/{type}` | list |
|
|
1134
|
+
* | GET | `/{type}/:id` | getOne |
|
|
1135
|
+
* | POST | `/{type}` | create |
|
|
1136
|
+
* | PATCH | `/{type}/:id` | update |
|
|
1137
|
+
* | PUT | `/{type}/:id` | update |
|
|
1138
|
+
* | DELETE | `/{type}/:id` | delete |
|
|
1139
|
+
*
|
|
1140
|
+
* The resource `type` (the collection name) is the last static path segment
|
|
1141
|
+
* (the segment before a trailing `:id` param, or the final segment). Any
|
|
1142
|
+
* prefix (`/api`, version segments) is ignored. Endpoints that don't fit the
|
|
1143
|
+
* table — nested routes, RPCs, custom actions — are simply not part of the
|
|
1144
|
+
* derived CRUD surface; the app can still call them directly.
|
|
1145
|
+
*
|
|
1146
|
+
* One endpoint is recognised structurally rather than by path: an atomic
|
|
1147
|
+
* operations endpoint (the JSON:API Atomic Operations extension, e.g.
|
|
1148
|
+
* effect-jsonapi's `Endpoint.operations`, conventionally `POST /operations`)
|
|
1149
|
+
* is any endpoint whose request payload is an `atomic:operations` document.
|
|
1150
|
+
* By path shape alone it would read as `create` on a phantom `operations`
|
|
1151
|
+
* resource, so `reflectResources` skips it and `reflectAtomic` reports it
|
|
1152
|
+
* instead — the backend then pushes a whole batch as one request.
|
|
1153
|
+
*/
|
|
1154
|
+
var Convention_exports = /* @__PURE__ */ __exportAll({
|
|
1155
|
+
classify: () => classify,
|
|
1156
|
+
isAtomicEndpoint: () => isAtomicEndpoint,
|
|
1157
|
+
isParam: () => isParam,
|
|
1158
|
+
reflectAtomic: () => reflectAtomic,
|
|
1159
|
+
reflectResources: () => reflectResources,
|
|
1160
|
+
segmentsOf: () => segmentsOf
|
|
1161
|
+
});
|
|
1162
|
+
/** A route template's segments, empty ones dropped (`/todos/:id` is `["todos", ":id"]`). */
|
|
1163
|
+
const segmentsOf = (path) => path.split("/").filter((segment) => Str.isNonEmpty(segment));
|
|
1164
|
+
/** Whether a template segment is a path parameter (`:id`). */
|
|
1165
|
+
const isParam = (segment) => segment.startsWith(":");
|
|
1166
|
+
/** Map an HTTP method + whether the route has a trailing id to a CRUD role. */
|
|
1167
|
+
const roleOf = (method, hasId) => {
|
|
1168
|
+
switch (method.toUpperCase()) {
|
|
1169
|
+
case "GET": return hasId ? "getOne" : "list";
|
|
1170
|
+
case "POST": return hasId ? void 0 : "create";
|
|
1171
|
+
case "PATCH":
|
|
1172
|
+
case "PUT": return hasId ? "update" : void 0;
|
|
1173
|
+
case "DELETE": return hasId ? "delete" : void 0;
|
|
1174
|
+
default: return;
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
/**
|
|
1178
|
+
* Classify a (method, path) into a resource type + role, or `undefined` if
|
|
1179
|
+
* it isn't a conventional JSON:API CRUD route — `/{type}` or `/{type}/:id`,
|
|
1180
|
+
* where the type is a static segment and the only param is a trailing id
|
|
1181
|
+
* (nested routes like `/todos/:id/comments` are not CRUD on a single type).
|
|
1182
|
+
*/
|
|
1183
|
+
const classify = (method, path) => {
|
|
1184
|
+
const segments = segmentsOf(path);
|
|
1185
|
+
const last = segments.at(-1);
|
|
1186
|
+
if (last === void 0) return void 0;
|
|
1187
|
+
const hasId = isParam(last);
|
|
1188
|
+
const type = hasId ? segments[segments.length - 2] : last;
|
|
1189
|
+
if (type === void 0 || isParam(type)) return void 0;
|
|
1190
|
+
const params = segments.filter(isParam);
|
|
1191
|
+
if (params.length > 1 || params.length === 1 && !hasId) return void 0;
|
|
1192
|
+
const operation = roleOf(method, hasId);
|
|
1193
|
+
return operation === void 0 ? void 0 : {
|
|
1194
|
+
type,
|
|
1195
|
+
operation
|
|
1196
|
+
};
|
|
1197
|
+
};
|
|
1198
|
+
/** The top-level member that makes a request an atomic operations document. */
|
|
1199
|
+
const ATOMIC_OPERATIONS_MEMBER = "atomic:operations";
|
|
1200
|
+
/** Depth bound for walking a payload schema's unions and suspensions. */
|
|
1201
|
+
const MEMBER_WALK_DEPTH = 8;
|
|
1202
|
+
/**
|
|
1203
|
+
* Whether a schema is (or, through unions and suspensions, may be) an object
|
|
1204
|
+
* with a top-level `atomic:operations` member.
|
|
1205
|
+
*/
|
|
1206
|
+
const hasAtomicMember = (ast, depth = 0) => {
|
|
1207
|
+
if (depth > MEMBER_WALK_DEPTH) return false;
|
|
1208
|
+
switch (ast._tag) {
|
|
1209
|
+
case "Objects": return ast.propertySignatures.some((property) => property.name === ATOMIC_OPERATIONS_MEMBER);
|
|
1210
|
+
case "Union": return ast.types.some((member) => hasAtomicMember(member, depth + 1));
|
|
1211
|
+
case "Suspend": return hasAtomicMember(ast.thunk(), depth + 1);
|
|
1212
|
+
default: return false;
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
/**
|
|
1216
|
+
* Whether an endpoint takes an `atomic:operations` request document: some
|
|
1217
|
+
* payload schema (any content type) is an object with a top-level
|
|
1218
|
+
* `atomic:operations` member, possibly one arm of a union. This is what
|
|
1219
|
+
* makes an endpoint the atomic operations endpoint — not its path, which is
|
|
1220
|
+
* configurable.
|
|
1221
|
+
*/
|
|
1222
|
+
const isAtomicEndpoint = (endpoint) => Iterable.some(endpoint.payload.values(), ({ schemas }) => Iterable.some(schemas, (schema) => hasAtomicMember(schema.ast)));
|
|
1223
|
+
const refOf$1 = (group, endpoint) => ({
|
|
1224
|
+
groupId: group.identifier,
|
|
1225
|
+
name: endpoint.identifier,
|
|
1226
|
+
method: endpoint.method,
|
|
1227
|
+
path: endpoint.path
|
|
1228
|
+
});
|
|
1229
|
+
/**
|
|
1230
|
+
* Reflect an `HttpApi` into its JSON:API resource surface. The first endpoint
|
|
1231
|
+
* matching each (type, role) wins; later duplicates are ignored. An atomic
|
|
1232
|
+
* operations endpoint is never a resource (see `reflectAtomic`).
|
|
1233
|
+
*/
|
|
1234
|
+
const reflectResources = (api) => {
|
|
1235
|
+
const resources = /* @__PURE__ */ new Map();
|
|
1236
|
+
HttpApi.reflect(api, {
|
|
1237
|
+
onGroup: () => {},
|
|
1238
|
+
onEndpoint: ({ group, endpoint }) => {
|
|
1239
|
+
if (isAtomicEndpoint(endpoint)) return;
|
|
1240
|
+
const role = classify(endpoint.method, endpoint.path);
|
|
1241
|
+
if (role === void 0) return;
|
|
1242
|
+
const existing = resources.get(role.type) ?? { type: role.type };
|
|
1243
|
+
if (existing[role.operation] !== void 0) return;
|
|
1244
|
+
existing[role.operation] = refOf$1(group, endpoint);
|
|
1245
|
+
resources.set(role.type, existing);
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
return resources;
|
|
1249
|
+
};
|
|
1250
|
+
/**
|
|
1251
|
+
* Reflect an `HttpApi`'s atomic operations endpoint, if it declares one: the
|
|
1252
|
+
* first endpoint whose request payload is an `atomic:operations` document.
|
|
1253
|
+
* `JsonApiSync` uses it to push a batch as one all-or-nothing request.
|
|
1254
|
+
*/
|
|
1255
|
+
const reflectAtomic = (api) => {
|
|
1256
|
+
let atomic;
|
|
1257
|
+
HttpApi.reflect(api, {
|
|
1258
|
+
onGroup: () => {},
|
|
1259
|
+
onEndpoint: ({ group, endpoint }) => {
|
|
1260
|
+
if (atomic === void 0 && isAtomicEndpoint(endpoint)) atomic = refOf$1(group, endpoint);
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
return atomic;
|
|
1264
|
+
};
|
|
1265
|
+
// src/Derive.ts
|
|
1266
|
+
/**
|
|
1267
|
+
* Derive
|
|
1268
|
+
*
|
|
1269
|
+
* Turn a discovered JSON:API resource surface (Convention.ts) into the
|
|
1270
|
+
* engine's shared model: one `Collection` per resource type, and the three
|
|
1271
|
+
* CRUD mutators (create / update / delete) per type that has the matching
|
|
1272
|
+
* endpoint. Only the *client* (optimistic) handlers live here — they write
|
|
1273
|
+
* the overlay; the authoritative side is JsonApiBackend.ts, which replays
|
|
1274
|
+
* the same intents as HTTP calls.
|
|
1275
|
+
*
|
|
1276
|
+
* Rows are stored as the loader's `ResourceValue` (type, id, attributes,
|
|
1277
|
+
* relationship linkage), so the derived collections line up exactly with
|
|
1278
|
+
* what `CollectionLoader.documentToRows` produces on the read path.
|
|
1279
|
+
*/
|
|
1280
|
+
var Derive_exports = /* @__PURE__ */ __exportAll({
|
|
1281
|
+
CreateArgs: () => CreateArgs,
|
|
1282
|
+
DeleteArgs: () => DeleteArgs,
|
|
1283
|
+
RowSchema: () => RowSchema,
|
|
1284
|
+
UpdateArgs: () => UpdateArgs,
|
|
1285
|
+
collectionFor: () => collectionFor,
|
|
1286
|
+
derive: () => derive,
|
|
1287
|
+
mutatorName: () => mutatorName,
|
|
1288
|
+
toRow: () => toRow
|
|
1289
|
+
});
|
|
1290
|
+
/** Flatten a loaded resource into an engine row (attributes at top level). */
|
|
1291
|
+
const toRow = (value) => ({
|
|
1292
|
+
...value.attributes,
|
|
1293
|
+
id: value.id,
|
|
1294
|
+
relationships: value.relationships
|
|
1295
|
+
});
|
|
1296
|
+
/** Permissive codec for a row (the engine never decodes rows through it). */
|
|
1297
|
+
const RowSchema = Schema.Record(Schema.String, Schema.Unknown);
|
|
1298
|
+
/** The collection for a resource type: rows keyed by resource id. */
|
|
1299
|
+
const collectionFor = (type) => Collection.make({
|
|
1300
|
+
name: type,
|
|
1301
|
+
schema: RowSchema,
|
|
1302
|
+
id: (value) => value.id
|
|
1303
|
+
});
|
|
1304
|
+
/** The mutator name for a (type, operation): `"{type}/{operation}"`. */
|
|
1305
|
+
const mutatorName = (type, operation) => `${type}/${operation}`;
|
|
1306
|
+
/** Create: the full resource to insert (client supplies the id). */
|
|
1307
|
+
const CreateArgs = Schema.Struct({
|
|
1308
|
+
type: Schema.String,
|
|
1309
|
+
id: Schema.String,
|
|
1310
|
+
attributes: Schema.Record(Schema.String, Schema.Unknown),
|
|
1311
|
+
relationships: Schema.optional(Schema.Record(Schema.String, Schema.Unknown))
|
|
1312
|
+
});
|
|
1313
|
+
/** Update: a partial attribute patch, merged over the current row. */
|
|
1314
|
+
const UpdateArgs = Schema.Struct({
|
|
1315
|
+
type: Schema.String,
|
|
1316
|
+
id: Schema.String,
|
|
1317
|
+
attributes: Schema.Record(Schema.String, Schema.Unknown)
|
|
1318
|
+
});
|
|
1319
|
+
/** Delete: address one row. */
|
|
1320
|
+
const DeleteArgs = Schema.Struct({
|
|
1321
|
+
type: Schema.String,
|
|
1322
|
+
id: Schema.String
|
|
1323
|
+
});
|
|
1324
|
+
/**
|
|
1325
|
+
* Derive collections, CRUD mutators, and operation metadata from a resource
|
|
1326
|
+
* map. A mutator is only derived when the resource exposes the corresponding
|
|
1327
|
+
* write endpoint (no create endpoint ⇒ no `create` mutator).
|
|
1328
|
+
*/
|
|
1329
|
+
const derive = (resources) => {
|
|
1330
|
+
const collections = [];
|
|
1331
|
+
const entries = [];
|
|
1332
|
+
const operations = /* @__PURE__ */ new Map();
|
|
1333
|
+
const add = (type, operation, args, local) => {
|
|
1334
|
+
const name = mutatorName(type, operation);
|
|
1335
|
+
entries.push({
|
|
1336
|
+
mutator: Mutator.make(name, args),
|
|
1337
|
+
local
|
|
1338
|
+
});
|
|
1339
|
+
operations.set(name, {
|
|
1340
|
+
operation,
|
|
1341
|
+
type
|
|
1342
|
+
});
|
|
1343
|
+
};
|
|
1344
|
+
resources.forEach((resource) => {
|
|
1345
|
+
const type = resource.type;
|
|
1346
|
+
collections.push(collectionFor(type));
|
|
1347
|
+
if (resource.create !== void 0) add(type, "create", CreateArgs, (args, writer) => writer.put(type, args.id, toRow({
|
|
1348
|
+
type,
|
|
1349
|
+
id: args.id,
|
|
1350
|
+
attributes: args.attributes,
|
|
1351
|
+
relationships: args.relationships ?? {}
|
|
1352
|
+
})));
|
|
1353
|
+
if (resource.update !== void 0) add(type, "update", UpdateArgs, (args, writer) => Effect.gen(function* () {
|
|
1354
|
+
const current = yield* writer.get(type, args.id);
|
|
1355
|
+
yield* writer.put(type, args.id, {
|
|
1356
|
+
...current ?? {
|
|
1357
|
+
id: args.id,
|
|
1358
|
+
relationships: {}
|
|
1359
|
+
},
|
|
1360
|
+
...args.attributes,
|
|
1361
|
+
id: args.id
|
|
1362
|
+
});
|
|
1363
|
+
}));
|
|
1364
|
+
if (resource.delete !== void 0) add(type, "delete", DeleteArgs, (args, writer) => writer.del(type, args.id));
|
|
1365
|
+
});
|
|
1366
|
+
return {
|
|
1367
|
+
collections,
|
|
1368
|
+
registry: Mutator.registry(entries),
|
|
1369
|
+
operations
|
|
1370
|
+
};
|
|
1371
|
+
};
|
|
1372
|
+
// src/DocumentWriter.ts
|
|
1373
|
+
/**
|
|
1374
|
+
* DocumentWriter
|
|
1375
|
+
*
|
|
1376
|
+
* Rows to a JSON:API compound document — the **one serialiser** ADR-0029 §6
|
|
1377
|
+
* binds both the derived REST handlers ({@link RestHandlers}, #163) and the
|
|
1378
|
+
* replica-side writer behind the service-worker façade
|
|
1379
|
+
* ({@link ReplicaDocuments}, #164) to: "same URL, same result". The
|
|
1380
|
+
* document is a pure function of the node graph, the read, the resolved
|
|
1381
|
+
* rows and the `fields[]` projection — no HTTP, no backend, no clock — so
|
|
1382
|
+
* the two sides agree byte for byte on document content whichever source
|
|
1383
|
+
* produced the rows.
|
|
1384
|
+
*
|
|
1385
|
+
* The mapping, node for node (CONTEXT.md: a `type` is a collection, a
|
|
1386
|
+
* resource `id` is a row id, a resource is a row value):
|
|
1387
|
+
*
|
|
1388
|
+
* - **Attributes** are the node's declared attributes, in declaration order,
|
|
1389
|
+
* read off the row value. A `one`-edge's key field (`categoryId`) is not
|
|
1390
|
+
* an attribute: it is linkage.
|
|
1391
|
+
* - **Relationships** come from two places. A `one` edge's linkage is its
|
|
1392
|
+
* key on the row (`{ data: { type, id } }`, or `data: null` when the key
|
|
1393
|
+
* is null). A collection-valued edge's linkage is the edge rows the read
|
|
1394
|
+
* resolved onto the row (`related[edge]`): present when the read path
|
|
1395
|
+
* fetched them, absent otherwise — a relationship never claims an empty
|
|
1396
|
+
* list it did not see. A paginated edge always carries the spec's `links`
|
|
1397
|
+
* (`self` / `related`, effect-jsonapi's model of an unbounded
|
|
1398
|
+
* relationship) and, when the read resolved its rows (`include=reviews`
|
|
1399
|
+
* includes a paginated edge like any other; only a per-edge window has no
|
|
1400
|
+
* URL form), their linkage beside the links.
|
|
1401
|
+
* - **`included`** holds the rows of every edge the read `include`s, in
|
|
1402
|
+
* code-point order of edge name then root-row order, deduplicated by
|
|
1403
|
+
* `(type, id)` with full linkage checked (`Handlers.buildIncluded`: an
|
|
1404
|
+
* included row no resource identifier in the document references is
|
|
1405
|
+
* thrown at, never emitted). An included resource carries its own
|
|
1406
|
+
* `one`-edge linkage and paginated links, and the collection-valued
|
|
1407
|
+
* linkage the caller resolved for it ({@link Input.linkage} — the third
|
|
1408
|
+
* batch, keyed by the included rows); an edge with no resolved rows is
|
|
1409
|
+
* omitted. The member itself is present exactly when the read has an
|
|
1410
|
+
* `include`, as an empty array when nothing resolved (JSON:API 1.1
|
|
1411
|
+
* §Fetching Includes: a response to a request carrying `include` is a
|
|
1412
|
+
* compound document).
|
|
1413
|
+
* - **`fields[type]`** is applied last, to attributes only, on primary and
|
|
1414
|
+
* included resources alike (ADR-0029 §5: a serialisation-time projection
|
|
1415
|
+
* over full rows). Linkage is never projected away.
|
|
1416
|
+
* - **`links.self`** is the read's canonical string (`JsonApiQuery.canonical`)
|
|
1417
|
+
* — one string per plan, whatever the request's spelling — and a
|
|
1418
|
+
* collection whose window is full (`first` rows came back) carries
|
|
1419
|
+
* `links.next`: the same canonical query with `page[after]` at the last
|
|
1420
|
+
* row's id, the cursor `Subset.apply` continues from. A short page has no
|
|
1421
|
+
* `next`. An optional `prefix` (the api's mount path) is prepended to
|
|
1422
|
+
* every link; ADR-0029 §6 lets the host differ, nothing else.
|
|
1423
|
+
*
|
|
1424
|
+
* Only **node rows** are written (a row value carrying the node's attributes
|
|
1425
|
+
* and its `one`-edge key fields, as `Node.row` decodes and `Query.run`
|
|
1426
|
+
* resolves). The `JsonApiRow` shape of `Derive.ts` (linkage under
|
|
1427
|
+
* `relationships`, no node graph) is not a second strategy here: its
|
|
1428
|
+
* collections declare no graph to plan against, so nothing can write them
|
|
1429
|
+
* until a bridge (`NodeBridge` over the app's resources) gives them one —
|
|
1430
|
+
* the seam is {@link relationshipOf}, the one place a row's linkage is read.
|
|
1431
|
+
*/
|
|
1432
|
+
var DocumentWriter_exports = /* @__PURE__ */ __exportAll({
|
|
1433
|
+
collection: () => collection,
|
|
1434
|
+
data: () => data,
|
|
1435
|
+
idOf: () => idOf$1,
|
|
1436
|
+
relatedValuesOf: () => relatedValuesOf
|
|
1437
|
+
});
|
|
1438
|
+
/** A row value as a record (a node row always is one). */
|
|
1439
|
+
const recordOf = (value) => P.isObjectOrArray(value) ? value : {};
|
|
1440
|
+
/** The row id a row value carries (node rows carry `id` in the value). */
|
|
1441
|
+
const idOf$1 = (value) => String(recordOf(value)["id"]);
|
|
1442
|
+
/** A resolved edge's value as its rows: a list as it is, a single row alone, `null` / `undefined` as none. */
|
|
1443
|
+
const relatedValuesOf = (value) => Array.isArray(value) ? value : value === null || value === void 0 ? [] : [value];
|
|
1444
|
+
const envOf = (input) => ({
|
|
1445
|
+
nodes: input.nodes,
|
|
1446
|
+
linkage: input.linkage,
|
|
1447
|
+
prefix: input.prefix ?? ""
|
|
1448
|
+
});
|
|
1449
|
+
/** The node's declared attributes off the row value, in declaration order; absent ones are left out. */
|
|
1450
|
+
const attributesOf$1 = (node, value) => {
|
|
1451
|
+
return R.filterMap(node.attributes, (_, field) => {
|
|
1452
|
+
const attribute = value[field];
|
|
1453
|
+
return attribute === void 0 ? Result.failVoid : Result.succeed(attribute);
|
|
1454
|
+
});
|
|
1455
|
+
};
|
|
1456
|
+
/** The resolved rows of one collection-valued edge of a row: `related` first, then the caller's linkage. */
|
|
1457
|
+
const edgeRowsOf = (env, node, row, name) => {
|
|
1458
|
+
const related = row.related?.[name];
|
|
1459
|
+
return Array.isArray(related) ? related : env.linkage?.get(node.type)?.get(row.id)?.[name];
|
|
1460
|
+
};
|
|
1461
|
+
/**
|
|
1462
|
+
* The relationship object of one edge, or `undefined` when nothing is known
|
|
1463
|
+
* about it. This is where a row's linkage is read: a `one` edge's key off
|
|
1464
|
+
* its `via` field, a collection-valued edge's identifiers off its resolved
|
|
1465
|
+
* rows.
|
|
1466
|
+
*/
|
|
1467
|
+
const relationshipOf = (env, node, row, name) => {
|
|
1468
|
+
const edge = node.edges[name];
|
|
1469
|
+
if (edge === void 0) return void 0;
|
|
1470
|
+
if (edge.kind === "one") {
|
|
1471
|
+
const key = recordOf(row.value)[edge.via];
|
|
1472
|
+
return { data: key === void 0 || key === null ? null : {
|
|
1473
|
+
type: edge.target,
|
|
1474
|
+
id: String(key)
|
|
1475
|
+
} };
|
|
1476
|
+
}
|
|
1477
|
+
const rows = edgeRowsOf(env, node, row, name);
|
|
1478
|
+
const data = rows === void 0 ? void 0 : { data: rows.map((value) => ({
|
|
1479
|
+
type: edge.target,
|
|
1480
|
+
id: idOf$1(value)
|
|
1481
|
+
})) };
|
|
1482
|
+
if (edge.kind === "many") return data;
|
|
1483
|
+
return {
|
|
1484
|
+
links: {
|
|
1485
|
+
self: `${env.prefix}${Handlers.relationshipLink(node.type, row.id, name)}`,
|
|
1486
|
+
related: `${env.prefix}${Handlers.relatedLink(node.type, row.id, name)}`
|
|
1487
|
+
},
|
|
1488
|
+
...data
|
|
1489
|
+
};
|
|
1490
|
+
};
|
|
1491
|
+
/** The resource object of one row of a node. */
|
|
1492
|
+
const resourceOf = (env, node, row) => {
|
|
1493
|
+
const relationships = R.filterMap(node.edges, (_, name) => {
|
|
1494
|
+
const relationship = relationshipOf(env, node, row, name);
|
|
1495
|
+
return relationship === void 0 ? Result.failVoid : Result.succeed(relationship);
|
|
1496
|
+
});
|
|
1497
|
+
return {
|
|
1498
|
+
type: node.type,
|
|
1499
|
+
id: row.id,
|
|
1500
|
+
attributes: attributesOf$1(node, recordOf(row.value)),
|
|
1501
|
+
...R.isEmptyReadonlyRecord(relationships) ? {} : { relationships }
|
|
1502
|
+
};
|
|
1503
|
+
};
|
|
1504
|
+
/** The edge rows of one included edge across the root rows, as resources of the target node. */
|
|
1505
|
+
const includedOf = (env, node, rows, name) => {
|
|
1506
|
+
const edge = node.edges[name];
|
|
1507
|
+
const target = edge === void 0 ? void 0 : env.nodes.nodes.get(edge.target);
|
|
1508
|
+
if (target === void 0) return [];
|
|
1509
|
+
return rows.flatMap((row) => relatedValuesOf(row.related[name]).map((related) => resourceOf(env, target, {
|
|
1510
|
+
id: idOf$1(related),
|
|
1511
|
+
value: related
|
|
1512
|
+
})));
|
|
1513
|
+
};
|
|
1514
|
+
/** The resource's attributes narrowed to its type's `fields[]` entry, when one is given. */
|
|
1515
|
+
const project = (resource, fields) => {
|
|
1516
|
+
const names = fields?.[resource.type];
|
|
1517
|
+
if (names === void 0) return resource;
|
|
1518
|
+
const attributes = R.filter(resource.attributes, (_, field) => names.includes(field));
|
|
1519
|
+
return {
|
|
1520
|
+
...resource,
|
|
1521
|
+
attributes
|
|
1522
|
+
};
|
|
1523
|
+
};
|
|
1524
|
+
/** The node a read addresses; a read on an unknown collection is a definition defect. */
|
|
1525
|
+
const nodeOf = (nodes, read) => {
|
|
1526
|
+
const node = nodes.nodes.get(read.collection);
|
|
1527
|
+
if (node === void 0) throw new Error(`DocumentWriter: unknown collection "${read.collection}"`);
|
|
1528
|
+
return node;
|
|
1529
|
+
};
|
|
1530
|
+
/**
|
|
1531
|
+
* The primary resources and the compound `included`, both projected.
|
|
1532
|
+
* `included` is `undefined` for a read with no `include`, so the member is
|
|
1533
|
+
* omitted; a read with one gets the array even when it is empty, because a
|
|
1534
|
+
* response to a request carrying `include` is a compound document (JSON:API
|
|
1535
|
+
* 1.1 §Fetching Includes).
|
|
1536
|
+
*/
|
|
1537
|
+
const assemble = (input, node) => {
|
|
1538
|
+
const env = envOf(input);
|
|
1539
|
+
const data = input.rows.map((row) => project(resourceOf(env, node, row), input.fields));
|
|
1540
|
+
const include = input.read.include;
|
|
1541
|
+
return {
|
|
1542
|
+
data,
|
|
1543
|
+
included: include === void 0 ? void 0 : Object.keys(include).toSorted(Predicate.compareText).flatMap((name) => includedOf(env, node, input.rows, name)).map((resource) => project(resource, input.fields))
|
|
1544
|
+
};
|
|
1545
|
+
};
|
|
1546
|
+
/** `links.self`: the read's canonical string behind the prefix. */
|
|
1547
|
+
const selfLink = (input) => `${input.prefix ?? ""}${canonical(input.nodes, input.read, input.fields)}`;
|
|
1548
|
+
/**
|
|
1549
|
+
* The read's resolved rows sliced to the page it actually asked for
|
|
1550
|
+
* (#209). An offset read's root window was compiled with
|
|
1551
|
+
* `limit = offset + first` (`Query.plan`'s `rootLimit`) so a covering
|
|
1552
|
+
* subscription could be judged by the ordinary limit-prefix rule and the
|
|
1553
|
+
* window fetched in one shot; `input.rows` therefore holds up to
|
|
1554
|
+
* `offset + first` rows in window order, and this is where they are
|
|
1555
|
+
* sliced back down to the `first` rows starting at `offset` — the one
|
|
1556
|
+
* place both the derived REST handler and the replica-side writer apply
|
|
1557
|
+
* the slice, since both call this module. A cursor-paginated read has no
|
|
1558
|
+
* `offset`, so its rows are its page already.
|
|
1559
|
+
*/
|
|
1560
|
+
const pageOf = (input) => {
|
|
1561
|
+
const { offset, first } = input.read;
|
|
1562
|
+
if (offset === void 0) return input.rows;
|
|
1563
|
+
return input.rows.slice(offset, first === void 0 ? void 0 : offset + first);
|
|
1564
|
+
};
|
|
1565
|
+
/**
|
|
1566
|
+
* `links.next` for a full page: an offset read continues at `offset +
|
|
1567
|
+
* first` in the same idiom it was asked in; a cursor read continues after
|
|
1568
|
+
* the page's last row (`page[after]`), the cursor `Subset.apply` resumes
|
|
1569
|
+
* from. A short page — fewer rows than `first`, or no window at all — has
|
|
1570
|
+
* no next; switching a cursor request to `page[after]`-of-offset (or vice
|
|
1571
|
+
* versa) is never done — pagination links stay in the idiom the request
|
|
1572
|
+
* used (#209).
|
|
1573
|
+
*/
|
|
1574
|
+
const nextLink = (input, page) => {
|
|
1575
|
+
const { first, offset } = input.read;
|
|
1576
|
+
if (first === void 0 || page.length < first) return void 0;
|
|
1577
|
+
const link = (read) => `${input.prefix ?? ""}${canonical(input.nodes, read, input.fields)}`;
|
|
1578
|
+
if (offset !== void 0) return link({
|
|
1579
|
+
...input.read,
|
|
1580
|
+
offset: offset + first
|
|
1581
|
+
});
|
|
1582
|
+
const last = page.at(-1);
|
|
1583
|
+
return last === void 0 ? void 0 : link({
|
|
1584
|
+
...input.read,
|
|
1585
|
+
after: last.id
|
|
1586
|
+
});
|
|
1587
|
+
};
|
|
1588
|
+
/**
|
|
1589
|
+
* `links.prev` for an offset page past the first: the same query at
|
|
1590
|
+
* `max(0, offset - first)`. Cursor pagination has no `prev` — a forward
|
|
1591
|
+
* cursor names no way back, and ADR-0029's model is unbounded-forward only
|
|
1592
|
+
* (#209 adds `prev` for the offset idiom alone).
|
|
1593
|
+
*/
|
|
1594
|
+
const prevLink = (input) => {
|
|
1595
|
+
const { first, offset } = input.read;
|
|
1596
|
+
if (offset === void 0 || offset <= 0 || first === void 0) return void 0;
|
|
1597
|
+
const read = {
|
|
1598
|
+
...input.read,
|
|
1599
|
+
offset: Math.max(0, offset - first)
|
|
1600
|
+
};
|
|
1601
|
+
return `${input.prefix ?? ""}${canonical(input.nodes, read, input.fields)}`;
|
|
1602
|
+
};
|
|
1603
|
+
/**
|
|
1604
|
+
* The collection document of a read: every row of its page as primary
|
|
1605
|
+
* data, the included edges compounded, `links.self` canonical and
|
|
1606
|
+
* `links.next` (and, for an offset read past the first page, `links.prev`)
|
|
1607
|
+
* when the page is full. Throws for a read the canonical encoder refuses
|
|
1608
|
+
* (a per-edge window, an `Exists`) — such a read has no URL, so it is
|
|
1609
|
+
* never one a URL produced.
|
|
1610
|
+
*/
|
|
1611
|
+
const collection = (input) => {
|
|
1612
|
+
const page = pageOf(input);
|
|
1613
|
+
const { data, included } = assemble({
|
|
1614
|
+
...input,
|
|
1615
|
+
rows: page
|
|
1616
|
+
}, nodeOf(input.nodes, input.read));
|
|
1617
|
+
const next = nextLink(input, page);
|
|
1618
|
+
const prev = prevLink(input);
|
|
1619
|
+
return Handlers.collection(data, {
|
|
1620
|
+
...included === void 0 ? {} : { included },
|
|
1621
|
+
links: {
|
|
1622
|
+
self: selfLink(input),
|
|
1623
|
+
...next === void 0 ? {} : { next },
|
|
1624
|
+
...prev === void 0 ? {} : { prev }
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
};
|
|
1628
|
+
/**
|
|
1629
|
+
* The single-resource document of a read: the first row as primary data
|
|
1630
|
+
* (`null` when the read matched nothing — the caller decides the 404), the
|
|
1631
|
+
* included edges compounded, `links.self` canonical.
|
|
1632
|
+
*/
|
|
1633
|
+
const data = (input) => {
|
|
1634
|
+
const first = input.rows[0];
|
|
1635
|
+
const { data: primary, included } = assemble({
|
|
1636
|
+
...input,
|
|
1637
|
+
rows: first === void 0 ? [] : [first]
|
|
1638
|
+
}, nodeOf(input.nodes, input.read));
|
|
1639
|
+
return Handlers.data(primary[0] ?? null, {
|
|
1640
|
+
...included === void 0 ? {} : { included },
|
|
1641
|
+
links: { self: selfLink(input) }
|
|
1642
|
+
});
|
|
1643
|
+
};
|
|
1644
|
+
// src/Ledger.ts
|
|
1645
|
+
/**
|
|
1646
|
+
* Ledger
|
|
1647
|
+
*
|
|
1648
|
+
* The ledger a `JsonApiBackend` keeps for each client group (#468): the highest
|
|
1649
|
+
* acknowledged mutation id per client (`lastMutationIds`) and the permanent
|
|
1650
|
+
* rejections it still remembers, each with its reason (#366, #365). A
|
|
1651
|
+
* `LedgerStore` is where that state is read from and written to. The default,
|
|
1652
|
+
* {@link memory}, keeps nothing, so the backend's own in-memory copy is the
|
|
1653
|
+
* ledger and a restart forgets it. {@link keyValue} stores each group's ledger
|
|
1654
|
+
* in an Effect `KeyValueStore`, which survives a restart when that store does.
|
|
1655
|
+
*/
|
|
1656
|
+
var Ledger_exports = /* @__PURE__ */ __exportAll({
|
|
1657
|
+
emptyGroup: () => emptyGroup,
|
|
1658
|
+
keyValue: () => keyValue,
|
|
1659
|
+
memory: () => memory
|
|
1660
|
+
});
|
|
1661
|
+
/** An empty group ledger: nothing acknowledged, nothing remembered. */
|
|
1662
|
+
const emptyGroup = {
|
|
1663
|
+
lastMutationIds: {},
|
|
1664
|
+
rejections: []
|
|
1665
|
+
};
|
|
1666
|
+
/**
|
|
1667
|
+
* The in-memory ledger store: it persists nothing, so the backend keeps each
|
|
1668
|
+
* group's ledger in its own memory for the life of the instance. A restart of
|
|
1669
|
+
* the backend forgets every acknowledged id and remembered rejection.
|
|
1670
|
+
*/
|
|
1671
|
+
const memory = {
|
|
1672
|
+
read: () => Effect.succeed(emptyGroup),
|
|
1673
|
+
write: () => Effect.void
|
|
1674
|
+
};
|
|
1675
|
+
const Rejection = Schema.Struct({
|
|
1676
|
+
clientId: Schema.String,
|
|
1677
|
+
mutationId: Schema.Number,
|
|
1678
|
+
reason: Schema.String
|
|
1679
|
+
});
|
|
1680
|
+
const GroupLedger = Schema.Struct({
|
|
1681
|
+
lastMutationIds: Schema.Record(Schema.String, Schema.Number),
|
|
1682
|
+
rejections: Schema.Array(Rejection)
|
|
1683
|
+
});
|
|
1684
|
+
const decodeGroup = Schema.decodeUnknownEffect(GroupLedger);
|
|
1685
|
+
/**
|
|
1686
|
+
* A ledger store over an Effect `KeyValueStore`: each client group's ledger is
|
|
1687
|
+
* one JSON value under `${prefix}${clientGroupId}`. Use a durable key/value
|
|
1688
|
+
* store (a file system, SQL or browser storage layer) for it to survive a
|
|
1689
|
+
* restart. A stored value that does not decode is a `BackendError`, not an
|
|
1690
|
+
* empty ledger, so a corrupt store is never mistaken for a fresh one.
|
|
1691
|
+
*/
|
|
1692
|
+
const keyValue = (store, options = {}) => {
|
|
1693
|
+
const keyOf = (clientGroupId) => `${options.prefix ?? "legation-jsonapi:ledger:"}${clientGroupId}`;
|
|
1694
|
+
return {
|
|
1695
|
+
read: (clientGroupId) => store.get(keyOf(clientGroupId)).pipe(Effect.mapError((cause) => new BackendError({
|
|
1696
|
+
message: "Ledger read failed",
|
|
1697
|
+
cause
|
|
1698
|
+
})), Effect.flatMap((raw) => raw === void 0 ? Effect.succeed(emptyGroup) : Effect.try({
|
|
1699
|
+
try: () => JSON.parse(raw),
|
|
1700
|
+
catch: (cause) => new BackendError({
|
|
1701
|
+
message: "Ledger is not JSON",
|
|
1702
|
+
cause
|
|
1703
|
+
})
|
|
1704
|
+
}).pipe(Effect.flatMap((value) => decodeGroup(value).pipe(Effect.mapError((cause) => new BackendError({
|
|
1705
|
+
message: "Ledger does not decode",
|
|
1706
|
+
cause
|
|
1707
|
+
}))))))),
|
|
1708
|
+
write: (clientGroupId, ledger) => store.set(keyOf(clientGroupId), JSON.stringify(ledger)).pipe(Effect.mapError((cause) => new BackendError({
|
|
1709
|
+
message: "Ledger write failed",
|
|
1710
|
+
cause
|
|
1711
|
+
})))
|
|
1712
|
+
};
|
|
1713
|
+
};
|
|
1714
|
+
// src/JsonApiBackend.ts
|
|
1715
|
+
/**
|
|
1716
|
+
* JsonApiBackend
|
|
1717
|
+
*
|
|
1718
|
+
* An implementation of the core `Backend` contract (ADR-0009) backed by a
|
|
1719
|
+
* JSON:API HTTP surface instead of app-owned storage. This is what lets the
|
|
1720
|
+
* library "figure out the sync story" from a plain REST API: the sync server
|
|
1721
|
+
* (in-process for the client-direct happy path) drives this backend's three
|
|
1722
|
+
* operations, and the existing engine supplies journal, overlay, rebase, and
|
|
1723
|
+
* CVR diffing on top.
|
|
1724
|
+
*
|
|
1725
|
+
* Mapping:
|
|
1726
|
+
* - `evaluate` lists the collection (`GET /{type}`), pushing the whole
|
|
1727
|
+
* predicate to the server as the filter grammar's canonical `filter[...]`
|
|
1728
|
+
* form via the `QueryEncoder` (exact under ADR-0028; a predicate that can
|
|
1729
|
+
* never match skips the request) and applying predicate, order and limit
|
|
1730
|
+
* locally with `Subset.apply` — the window is local, and re-running the
|
|
1731
|
+
* filter is redundancy over an exact server answer, not correction.
|
|
1732
|
+
* With `includes`, it adds `?include=...` and harvests the compound document's
|
|
1733
|
+
* related rows, serving auto-subscribed `id IN (...)` subsets from that cache.
|
|
1734
|
+
* - `getRows` serves row values from the cache `evaluate` populates and this
|
|
1735
|
+
* backend's own writes keep current (the sync server always evaluates before
|
|
1736
|
+
* fetching put rows in the same pull), falling back to `GET /{type}/{id}`.
|
|
1737
|
+
* The cache reflects this instance's own writes only, so a deployment runs
|
|
1738
|
+
* one JsonApiBackend per server and writes the server only through it
|
|
1739
|
+
* (#447): another writer's changes go unseen, and `getRows` can serve a
|
|
1740
|
+
* superseded row.
|
|
1741
|
+
* - `applyMutations` replays the batch as HTTP writes. Each mutation is one
|
|
1742
|
+
* JSON:API write (`Write`: a create, update or delete of one resource —
|
|
1743
|
+
* derived CRUD mutators map directly, and `writes` expresses custom
|
|
1744
|
+
* mutators). With `atomic` configured the whole batch goes as one
|
|
1745
|
+
* `atomic:operations` request (`create` → `add`, `update` → `update`,
|
|
1746
|
+
* `delete` → `remove`, sent with the extension's media type); otherwise
|
|
1747
|
+
* each write is its own `POST`/`PATCH`/`DELETE`.
|
|
1748
|
+
*
|
|
1749
|
+
* **Versioning.** A plain REST API exposes no row-version token, so versions
|
|
1750
|
+
* are a content hash of the resource value: the hash changes iff the row
|
|
1751
|
+
* changed, which is exactly what CVR diffing needs.
|
|
1752
|
+
*
|
|
1753
|
+
* **Change hints.** A push's hints carry each touched row's pre-push value as
|
|
1754
|
+
* `oldValues`, as MemoryBackend's do, so topic-derived fan-out (ADR-0023) pokes
|
|
1755
|
+
* the windows a row moved away from. A REST API keeps no change token, and
|
|
1756
|
+
* nothing here says whether a cached row is still current (a write made
|
|
1757
|
+
* elsewhere goes unseen, #447; this backend's own writes do keep its cache
|
|
1758
|
+
* current, #395), so the old value is always read: each update or delete does one
|
|
1759
|
+
* `GET /{type}/{id}` before its write, once per row per push. A row the push
|
|
1760
|
+
* creates was absent before the push, so it is not read (`enterBatchCreate`),
|
|
1761
|
+
* unless the server refuses the create and a later write of the row needs its
|
|
1762
|
+
* value (`readRefusedCreates`, #402). Every failed read fails the push with
|
|
1763
|
+
* BackendError, a 4xx and a 404 included, and so does a 2xx with no resource, so
|
|
1764
|
+
* an update or delete is either sent with its old value or not sent. On the
|
|
1765
|
+
* atomic path nothing has been written by then, so the retry reads again. On the
|
|
1766
|
+
* per-resource path the writes before the failed read have already applied and
|
|
1767
|
+
* been acknowledged, so the retry does not resend them
|
|
1768
|
+
* and their hints are lost: the at-least-once delivery described below, as for
|
|
1769
|
+
* any transient write failure there. A read the server refuses (a 4xx) stalls
|
|
1770
|
+
* that client's queue until the refusal is resolved. A resource that receives
|
|
1771
|
+
* updates or deletes needs a `getOne` route: without one each of its updates
|
|
1772
|
+
* and deletes fails its push, before anything is written. The new value of a
|
|
1773
|
+
* create or update is the server's returned document when it sends one, else
|
|
1774
|
+
* the row read before the push merged with the write (so a partial PATCH hints
|
|
1775
|
+
* the full row), else the written value.
|
|
1776
|
+
*
|
|
1777
|
+
* **Delivery semantics.** Without a cooperating server there is no
|
|
1778
|
+
* server-side mutation-id ledger, so this backend keeps one per client group:
|
|
1779
|
+
* the highest acknowledged id per client, and the rejections it remembers.
|
|
1780
|
+
* The ledger lives in this instance's memory by default (`Ledger.memory`), so
|
|
1781
|
+
* a restart of the backend forgets it: a resent mutation that was already
|
|
1782
|
+
* applied looks new, guarded only by the server's own idempotency, if any, and
|
|
1783
|
+
* a replayed copy or a pull can no longer report an earlier rejection. A
|
|
1784
|
+
* deployment that restarts its backend passes a durable `Ledger.LedgerStore`
|
|
1785
|
+
* (`ledger`, e.g. `Ledger.keyValue` over an Effect `KeyValueStore`) to keep
|
|
1786
|
+
* both across restarts (#468). Each ledger change is written after the server
|
|
1787
|
+
* has applied its writes and before the ids are acknowledged, in the order the
|
|
1788
|
+
* push already uses. If that ledger write fails after the server write
|
|
1789
|
+
* succeeded, the push fails with a `BackendError`. Mutations acknowledged earlier
|
|
1790
|
+
* in the same push keep their acknowledgement; the failed one and the rest of the
|
|
1791
|
+
* push are not acknowledged, so the client's resend re-applies them. On the atomic
|
|
1792
|
+
* path a rejection recorded before the failing write stays remembered, and the
|
|
1793
|
+
* resend records it again. That is the same window as a crash between the two
|
|
1794
|
+
* writes, and with or without a store the server's own idempotency is what guards
|
|
1795
|
+
* it.
|
|
1796
|
+
*
|
|
1797
|
+
* Over an atomic operations endpoint (`atomic` set) a push is all-or-nothing
|
|
1798
|
+
* per batch: one request, and the ledger advances only once the server has
|
|
1799
|
+
* applied every operation in it. A transient failure — transport, 5xx, or a
|
|
1800
|
+
* 4xx naming no operation — leaves the server holding nothing and the ledger
|
|
1801
|
+
* unmoved, so the client's retry resends the same batch. A 4xx error document
|
|
1802
|
+
* whose `source.pointer` names an operation (`Atomic.operationPointer(i)`) is
|
|
1803
|
+
* a permanent rejection of that mutation (ADR-0008's poison-mutation
|
|
1804
|
+
* semantics): it is recorded as rejected and the batch is resent without it,
|
|
1805
|
+
* until the remainder applies or nothing is left; every mutation in the batch
|
|
1806
|
+
* is then acknowledged in order (one round trip per rejection, bounded by the
|
|
1807
|
+
* batch size). `lid` is supported by the wire format but unused: derived
|
|
1808
|
+
* creates carry the client-generated `id`, and no mutator yet needs a
|
|
1809
|
+
* server-assigned one.
|
|
1810
|
+
*
|
|
1811
|
+
* The ledger also remembers the rejections it has made (#366), so a
|
|
1812
|
+
* replayed copy of a rejected mutation lists it in `rejected` with the same reason,
|
|
1813
|
+
* as MemoryBackend does. A push forgets the rejections below its lowest id per
|
|
1814
|
+
* client. The memory is bounded per client: a client that never pushes again keeps
|
|
1815
|
+
* its last push's rejections. It assumes one journal per `clientId`, which backend
|
|
1816
|
+
* contiguity already requires.
|
|
1817
|
+
*
|
|
1818
|
+
* Over per-resource endpoints (no `atomic`) delivery is *at-least-once*:
|
|
1819
|
+
* make writes idempotent (client-generated ids on create; PATCH/DELETE are
|
|
1820
|
+
* naturally idempotent) and retries are safe.
|
|
1821
|
+
*/
|
|
1822
|
+
var JsonApiBackend_exports = /* @__PURE__ */ __exportAll({
|
|
1823
|
+
make: () => make$2,
|
|
1824
|
+
versionOf: () => versionOf
|
|
1825
|
+
});
|
|
1826
|
+
const jsonApiHeaders = {
|
|
1827
|
+
"Content-Type": "application/vnd.api+json",
|
|
1828
|
+
Accept: "application/vnd.api+json"
|
|
1829
|
+
};
|
|
1830
|
+
/** The extension media type an atomic operations request carries both ways. */
|
|
1831
|
+
const atomicHeaders = {
|
|
1832
|
+
"Content-Type": Atomic.MEDIA_TYPE,
|
|
1833
|
+
Accept: Atomic.MEDIA_TYPE
|
|
1834
|
+
};
|
|
1835
|
+
const trimSlashes = (s) => s.replace(/\/+$/, "");
|
|
1836
|
+
/** Resolve a route template against the base url, substituting the id param. */
|
|
1837
|
+
const urlFor = (baseUrl, path, id) => trimSlashes(baseUrl) + path.replace(/:[^/]+/g, () => encodeURIComponent(id ?? ""));
|
|
1838
|
+
/** A content-hash row version: a plain REST API exposes no version token. */
|
|
1839
|
+
const versionOf = Hash.contentHash;
|
|
1840
|
+
const backendError = (message) => (cause) => new BackendError({
|
|
1841
|
+
message,
|
|
1842
|
+
cause
|
|
1843
|
+
});
|
|
1844
|
+
/** The refusal for a mutation past its client's next expected id (Backend.ts guarantee 1). */
|
|
1845
|
+
const mutationGap = (clientId, expected, got) => new BackendError({ message: `Mutation-id gap for client "${clientId}": expected ${expected}, got ${got}` });
|
|
1846
|
+
/** Append `?`/`&`-joined params to an existing query string. */
|
|
1847
|
+
const appendQuery = (query, extra) => extra === "" ? query : query === "" ? `?${extra}` : `${query}&${extra}`;
|
|
1848
|
+
/**
|
|
1849
|
+
* The set of ids a subset pins via an `id IN (...)` / `id = ...` filter, or
|
|
1850
|
+
* `undefined` if it isn't id-pinned. Used to recognize auto-subscribed
|
|
1851
|
+
* relationship subsets that the include fast-path can serve from cache.
|
|
1852
|
+
*/
|
|
1853
|
+
const pinnedIds = (predicate) => {
|
|
1854
|
+
switch (predicate._tag) {
|
|
1855
|
+
case "In": return predicate.field === "id" ? new Set(predicate.values.map((value) => String(value))) : void 0;
|
|
1856
|
+
case "Compare": return predicate.op === "eq" && predicate.field === "id" ? /* @__PURE__ */ new Set([String(predicate.value)]) : void 0;
|
|
1857
|
+
case "And": return Option.getOrUndefined(Arr.findFirst(predicate.predicates, (inner) => Option.fromUndefinedOr(pinnedIds(inner))));
|
|
1858
|
+
default: return;
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
/**
|
|
1862
|
+
* Fold one applied write's effect into a push's PUT values (mutates `puts`). A
|
|
1863
|
+
* put records the row's value. A delete drops an earlier write's value for the
|
|
1864
|
+
* row, so a later delete in the same push leaves the hint naming it as a
|
|
1865
|
+
* delete (#369).
|
|
1866
|
+
*/
|
|
1867
|
+
const recordEffect = (puts, collection, id, effect) => {
|
|
1868
|
+
if (effect._tag === "delete") puts.get(collection)?.delete(id);
|
|
1869
|
+
else puts.set(collection, (puts.get(collection) ?? /* @__PURE__ */ new Map()).set(id, effect.value));
|
|
1870
|
+
};
|
|
1871
|
+
/** Record a touched id under its collection (mutates `touched`). */
|
|
1872
|
+
const recordTouched = (touched, collection, id) => {
|
|
1873
|
+
touched.set(collection, (touched.get(collection) ?? /* @__PURE__ */ new Set()).add(id));
|
|
1874
|
+
};
|
|
1875
|
+
/** The row entry for (collection, id), if this push has entered the row. */
|
|
1876
|
+
const entryOf = (acc, collection, id) => acc.rows.get(collection)?.get(id);
|
|
1877
|
+
/** Enter a row under (collection, id) (mutates `acc`). */
|
|
1878
|
+
const setEntry = (acc, collection, id, entry) => {
|
|
1879
|
+
acc.rows.set(collection, (acc.rows.get(collection) ?? /* @__PURE__ */ new Map()).set(id, entry));
|
|
1880
|
+
};
|
|
1881
|
+
/**
|
|
1882
|
+
* The pre-push values this push read, as the hints' `olds`: the same snapshot
|
|
1883
|
+
* MemoryBackend takes before it applies a batch.
|
|
1884
|
+
*/
|
|
1885
|
+
const oldsOf = (acc) => {
|
|
1886
|
+
const olds = /* @__PURE__ */ new Map();
|
|
1887
|
+
acc.rows.forEach((entries, collection) => entries.forEach((entry, id) => {
|
|
1888
|
+
if (Option.isSome(entry.before)) olds.set(collection, (olds.get(collection) ?? /* @__PURE__ */ new Map()).set(id, entry.before.value));
|
|
1889
|
+
}));
|
|
1890
|
+
return olds;
|
|
1891
|
+
};
|
|
1892
|
+
/**
|
|
1893
|
+
* Fold one applied write into the push: the row's value after it, and the
|
|
1894
|
+
* write's effect on the hint. A row the push has not entered was absent before
|
|
1895
|
+
* the push wrote it (a create), so it starts with no value.
|
|
1896
|
+
*/
|
|
1897
|
+
const recordApplied = (acc, write, document) => {
|
|
1898
|
+
const entry = entryOf(acc, write.type, write.id) ?? {
|
|
1899
|
+
before: Option.none(),
|
|
1900
|
+
current: Option.none()
|
|
1901
|
+
};
|
|
1902
|
+
setEntry(acc, write.type, write.id, entry);
|
|
1903
|
+
entry.current = write.operation === "delete" ? Option.none() : Option.some(rowAfter(write, entry.current, document));
|
|
1904
|
+
recordTouched(acc.touched, write.type, write.id);
|
|
1905
|
+
recordEffect(acc.puts, write.type, write.id, Option.isSome(entry.current) ? {
|
|
1906
|
+
_tag: "put",
|
|
1907
|
+
value: entry.current.value
|
|
1908
|
+
} : { _tag: "delete" });
|
|
1909
|
+
};
|
|
1910
|
+
/** Build a JSON:API write body (`{ data: { type, id, attributes, ... } }`). */
|
|
1911
|
+
const writeBody = (type, id, attributes, relationships) => ({ data: {
|
|
1912
|
+
type,
|
|
1913
|
+
id,
|
|
1914
|
+
attributes,
|
|
1915
|
+
...relationships !== void 0 ? { relationships } : {}
|
|
1916
|
+
} });
|
|
1917
|
+
/** A write's relationships in the row's linkage shape, as `toRow` gives a server document. */
|
|
1918
|
+
const linkageOf$1 = (write) => resourceToRow({
|
|
1919
|
+
type: write.type,
|
|
1920
|
+
id: write.id,
|
|
1921
|
+
relationships: write.relationships ?? {}
|
|
1922
|
+
}).value.relationships;
|
|
1923
|
+
/**
|
|
1924
|
+
* The value a create/update writes, as the hint carries it — the same shape
|
|
1925
|
+
* `toRow` gives a resource the server returned, so a hint looks alike
|
|
1926
|
+
* whichever path produced it and whatever the server answered.
|
|
1927
|
+
*/
|
|
1928
|
+
const writtenValue = (write) => ({
|
|
1929
|
+
...write.attributes,
|
|
1930
|
+
id: write.id,
|
|
1931
|
+
relationships: linkageOf$1(write)
|
|
1932
|
+
});
|
|
1933
|
+
/** The row after a partial update: the row read before the push, with the write laid over it. */
|
|
1934
|
+
const mergeWrite = (read, write) => ({
|
|
1935
|
+
...read,
|
|
1936
|
+
...write.attributes,
|
|
1937
|
+
id: write.id,
|
|
1938
|
+
relationships: {
|
|
1939
|
+
...read.relationships,
|
|
1940
|
+
...linkageOf$1(write)
|
|
1941
|
+
}
|
|
1942
|
+
});
|
|
1943
|
+
/**
|
|
1944
|
+
* The value a create or update leaves its row with: the server's document when
|
|
1945
|
+
* it returned one; otherwise the row's value so far merged with the write; or
|
|
1946
|
+
* the written value alone when the push does not know the row.
|
|
1947
|
+
*/
|
|
1948
|
+
const rowAfter = (write, current, document) => document ?? (Option.isSome(current) ? mergeWrite(current.value, write) : writtenValue(write));
|
|
1949
|
+
/** Express a derived CRUD mutator's decoded args as one JSON:API write. */
|
|
1950
|
+
const writeOf$1 = (operation, args) => {
|
|
1951
|
+
switch (operation.operation) {
|
|
1952
|
+
case "create": {
|
|
1953
|
+
const a = args;
|
|
1954
|
+
return Effect.succeed({
|
|
1955
|
+
operation: "create",
|
|
1956
|
+
type: operation.type,
|
|
1957
|
+
id: a.id,
|
|
1958
|
+
attributes: a.attributes,
|
|
1959
|
+
...a.relationships !== void 0 ? { relationships: a.relationships } : {}
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
case "update": {
|
|
1963
|
+
const a = args;
|
|
1964
|
+
return Effect.succeed({
|
|
1965
|
+
operation: "update",
|
|
1966
|
+
type: operation.type,
|
|
1967
|
+
id: a.id,
|
|
1968
|
+
attributes: a.attributes
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
case "delete": {
|
|
1972
|
+
const a = args;
|
|
1973
|
+
return Effect.succeed({
|
|
1974
|
+
operation: "delete",
|
|
1975
|
+
type: operation.type,
|
|
1976
|
+
id: a.id
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
default: return Effect.fail(new BackendError({ message: `Operation "${operation.operation}" is not a write` }));
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
/** One write as an atomic operation object (`add` / `update` / `remove`). */
|
|
1983
|
+
const operationOf = (write) => {
|
|
1984
|
+
switch (write.operation) {
|
|
1985
|
+
case "create": return {
|
|
1986
|
+
op: "add",
|
|
1987
|
+
data: {
|
|
1988
|
+
type: write.type,
|
|
1989
|
+
id: write.id,
|
|
1990
|
+
attributes: write.attributes ?? {},
|
|
1991
|
+
...write.relationships !== void 0 ? { relationships: write.relationships } : {}
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
case "update": return {
|
|
1995
|
+
op: "update",
|
|
1996
|
+
data: {
|
|
1997
|
+
type: write.type,
|
|
1998
|
+
id: write.id,
|
|
1999
|
+
attributes: write.attributes ?? {},
|
|
2000
|
+
...write.relationships !== void 0 ? { relationships: write.relationships } : {}
|
|
2001
|
+
}
|
|
2002
|
+
};
|
|
2003
|
+
case "delete": return {
|
|
2004
|
+
op: "remove",
|
|
2005
|
+
ref: {
|
|
2006
|
+
type: write.type,
|
|
2007
|
+
id: write.id
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
/**
|
|
2013
|
+
* The index of the operation an error's `source.pointer` names — the
|
|
2014
|
+
* operation itself (`Atomic.operationPointer(i)`) or a member below it — or
|
|
2015
|
+
* `undefined` when the pointer names none of the `count` operations sent.
|
|
2016
|
+
*/
|
|
2017
|
+
const operationIndex = (pointer, count) => {
|
|
2018
|
+
const match = /^\/atomic:operations\/(\d+)(?:\/|$)/.exec(pointer);
|
|
2019
|
+
if (match === null) return void 0;
|
|
2020
|
+
const index = Number(match[1]);
|
|
2021
|
+
return index < count && Atomic.operationPointer(index) === `/atomic:operations/${index}` ? index : void 0;
|
|
2022
|
+
};
|
|
2023
|
+
/** The per-operation rejections an error document carries, by operation index. */
|
|
2024
|
+
const rejectionsOf = (body, count, fallbackReason) => {
|
|
2025
|
+
const errors = P.isObjectOrArray(body) && Array.isArray(body.errors) ? body.errors : [];
|
|
2026
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
2027
|
+
errors.forEach((error) => {
|
|
2028
|
+
const pointer = error?.source?.pointer;
|
|
2029
|
+
if (!P.isString(pointer)) return;
|
|
2030
|
+
const index = operationIndex(pointer, count);
|
|
2031
|
+
if (index === void 0 || byIndex.has(index)) return;
|
|
2032
|
+
byIndex.set(index, error.detail ?? error.title ?? fallbackReason);
|
|
2033
|
+
});
|
|
2034
|
+
return [...byIndex].map(([index, reason]) => ({
|
|
2035
|
+
index,
|
|
2036
|
+
reason
|
|
2037
|
+
}));
|
|
2038
|
+
};
|
|
2039
|
+
/** The row an `atomic:results` entry carries, if it returned a resource. */
|
|
2040
|
+
const resultRow = (result) => {
|
|
2041
|
+
const data = P.isObjectOrArray(result) ? result.data : void 0;
|
|
2042
|
+
const resource = Schema.decodeUnknownOption(ResourceObject)(data);
|
|
2043
|
+
return Option.isSome(resource) ? toRow(resourceToRow(resource.value).value) : void 0;
|
|
2044
|
+
};
|
|
2045
|
+
/**
|
|
2046
|
+
* Enter a row an atomic batch creates, before the batch's one request. The row
|
|
2047
|
+
* was absent before the push, so it needs no read: a later update or delete of
|
|
2048
|
+
* it in the same batch must not GET it, since that 404 would fail the push. An
|
|
2049
|
+
* entry already present (an earlier delete in the batch read the row) is kept.
|
|
2050
|
+
* A create the server refuses is the one case where the row may exist; the
|
|
2051
|
+
* resend reads it then (`readRefusedCreates`).
|
|
2052
|
+
*/
|
|
2053
|
+
const enterBatchCreate = (acc, write) => {
|
|
2054
|
+
if (entryOf(acc, write.type, write.id) === void 0) setEntry(acc, write.type, write.id, {
|
|
2055
|
+
before: Option.none(),
|
|
2056
|
+
current: Option.none()
|
|
2057
|
+
});
|
|
2058
|
+
};
|
|
2059
|
+
/** A stored group ledger, as the backend holds it. */
|
|
2060
|
+
const groupOf = (ledger) => {
|
|
2061
|
+
const rejections = /* @__PURE__ */ new Map();
|
|
2062
|
+
ledger.rejections.forEach((entry) => {
|
|
2063
|
+
const ids = rejections.get(entry.clientId) ?? /* @__PURE__ */ new Map();
|
|
2064
|
+
ids.set(entry.mutationId, entry.reason);
|
|
2065
|
+
rejections.set(entry.clientId, ids);
|
|
2066
|
+
});
|
|
2067
|
+
return {
|
|
2068
|
+
acknowledged: new Map(Object.entries(ledger.lastMutationIds)),
|
|
2069
|
+
rejections
|
|
2070
|
+
};
|
|
2071
|
+
};
|
|
2072
|
+
/** A group as the store holds it. */
|
|
2073
|
+
const ledgerOf = (group) => ({
|
|
2074
|
+
lastMutationIds: Object.fromEntries(group.acknowledged),
|
|
2075
|
+
rejections: [...group.rejections].flatMap(([clientId, ids]) => [...ids].map(([mutationId, reason]) => ({
|
|
2076
|
+
clientId,
|
|
2077
|
+
mutationId,
|
|
2078
|
+
reason
|
|
2079
|
+
})))
|
|
2080
|
+
});
|
|
2081
|
+
/** Build the JsonApiBackend from its config; requires an `HttpClient`. */
|
|
2082
|
+
const make$2 = (config) => Effect.gen(function* () {
|
|
2083
|
+
const client = yield* HttpClient.HttpClient;
|
|
2084
|
+
const encoder = config.query ?? defaultEncoder;
|
|
2085
|
+
const includes = config.includes ?? {};
|
|
2086
|
+
const cache = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
2087
|
+
const includedIds = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
2088
|
+
const groups = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
2089
|
+
const ledgerStore = config.ledger ?? memory;
|
|
2090
|
+
const ledgerLock = yield* Semaphore.make(1);
|
|
2091
|
+
const getJson = (url) => Effect.scoped(client.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(jsonApiHeaders))));
|
|
2092
|
+
const transientStatuses = /* @__PURE__ */ new Set([408, 429]);
|
|
2093
|
+
const isPermanentStatus = (status) => status >= 400 && status < 500 && !transientStatuses.has(status);
|
|
2094
|
+
/** Send one write; the resource document it answers with, if it answers with one. */
|
|
2095
|
+
const sendWrite = (request) => Effect.scoped(client.execute(request)).pipe(Effect.mapError(backendError("JSON:API write failed")), Effect.flatMap((response) => {
|
|
2096
|
+
if (response.status >= 200 && response.status < 300) return response.json.pipe(Effect.orElseSucceed(() => void 0), Effect.map(resultRow));
|
|
2097
|
+
const message = `JSON:API write returned HTTP ${response.status}`;
|
|
2098
|
+
return isPermanentStatus(response.status) ? Effect.fail(new MutationRejected({ reason: message })) : Effect.fail(new BackendError({ message }));
|
|
2099
|
+
}));
|
|
2100
|
+
/**
|
|
2101
|
+
* The `getOne` route an update or delete reads its row through. A resource
|
|
2102
|
+
* with no such route cannot be written by update or delete, so the write
|
|
2103
|
+
* fails with BackendError before anything is sent.
|
|
2104
|
+
*/
|
|
2105
|
+
const getOneFor = (type) => {
|
|
2106
|
+
const getOne = config.resources.get(type)?.getOne;
|
|
2107
|
+
return getOne === void 0 ? Effect.fail(new BackendError({ message: `"${type}" has no getOne route, so its updates and deletes cannot read the row before writing it` })) : Effect.succeed(getOne);
|
|
2108
|
+
};
|
|
2109
|
+
/**
|
|
2110
|
+
* Read a row before the push writes it: the value the server holds now. Every
|
|
2111
|
+
* failed read fails the push with BackendError, a 404 and any other 4xx
|
|
2112
|
+
* included, and so does a 2xx whose document carries no resource: an update
|
|
2113
|
+
* or delete that went out without its old value would hint no `oldValues`, so
|
|
2114
|
+
* it is not sent instead. On the atomic path every read precedes the one
|
|
2115
|
+
* request, so nothing is written on a read the push cannot complete. On the
|
|
2116
|
+
* per-resource path the writes before the failed read have already applied,
|
|
2117
|
+
* and their hints are lost (see the module header's delivery note).
|
|
2118
|
+
*
|
|
2119
|
+
* The cache is never consulted here: it has no freshness signal for a write
|
|
2120
|
+
* made elsewhere (a version token would be needed), and this backend's own
|
|
2121
|
+
* writes keep it current for `getRows` only (#395). So each push reads the row.
|
|
2122
|
+
*/
|
|
2123
|
+
const readBefore = (type, id) => Effect.gen(function* () {
|
|
2124
|
+
const getOne = yield* getOneFor(type);
|
|
2125
|
+
const response = yield* getJson(urlFor(config.baseUrl, getOne.path, id)).pipe(Effect.mapError(backendError(`Failed to read ${type}/${id} before writing it`)));
|
|
2126
|
+
if (response.status < 200 || response.status >= 300) return yield* new BackendError({ message: `JSON:API read of ${type}/${id} returned HTTP ${response.status}` });
|
|
2127
|
+
const document = yield* parseResponse(response).pipe(Effect.mapError(backendError(`Invalid JSON:API resource ${type}/${id}`)));
|
|
2128
|
+
const resource = documentToRows(document).rows[0]?.value;
|
|
2129
|
+
if (resource === void 0) return yield* new BackendError({ message: `JSON:API read of ${type}/${id} returned no resource` });
|
|
2130
|
+
return toRow(resource);
|
|
2131
|
+
});
|
|
2132
|
+
/**
|
|
2133
|
+
* Enter a row the write touches. An update or delete reads the row on the
|
|
2134
|
+
* push's first write to it, so the pre-push value is kept for the hint (the
|
|
2135
|
+
* read is `readBefore`, where a cache hit would replace it, #395). A row
|
|
2136
|
+
* already entered is not read again. A create reads nothing and enters no
|
|
2137
|
+
* row: its row is entered when the create applies, or up front for the
|
|
2138
|
+
* atomic path's batch (`enterBatchCreate`).
|
|
2139
|
+
*/
|
|
2140
|
+
const enterRow = (acc, write) => {
|
|
2141
|
+
if (write.operation === "create" || entryOf(acc, write.type, write.id) !== void 0) return Effect.void;
|
|
2142
|
+
return readBefore(write.type, write.id).pipe(Effect.map((before) => setEntry(acc, write.type, write.id, {
|
|
2143
|
+
before: Option.some(before),
|
|
2144
|
+
current: Option.some(before)
|
|
2145
|
+
})));
|
|
2146
|
+
};
|
|
2147
|
+
/**
|
|
2148
|
+
* Refuse a write that cannot be read before it is sent: an update or delete
|
|
2149
|
+
* of a resource with no `getOne` route. Checked for a whole push before its
|
|
2150
|
+
* first write, so a push that cannot be read sends nothing on either path.
|
|
2151
|
+
*/
|
|
2152
|
+
const checkReadable = (write) => write.operation === "create" ? Effect.void : getOneFor(write.type).pipe(Effect.asVoid);
|
|
2153
|
+
/** Enter one write of an atomic batch before its request: a create as absent, others by reading the row. */
|
|
2154
|
+
const enterBatchWrite = (acc, write) => write.operation === "create" ? Effect.sync(() => enterBatchCreate(acc, write)) : enterRow(acc, write);
|
|
2155
|
+
/**
|
|
2156
|
+
* Read the rows the batch's refused creates would have made absent (#402).
|
|
2157
|
+
* The batch entered each row a create touches as absent, which is right only
|
|
2158
|
+
* while the create applies. The server refused these creates, so the row may
|
|
2159
|
+
* exist: when a later update or delete of it is still to be sent, read it
|
|
2160
|
+
* now, before the resend, so that write carries its pre-push value. Each row
|
|
2161
|
+
* is read at most once, and a row no remaining update or delete touches is
|
|
2162
|
+
* not read, so a create that applies never makes a read that can fail the push.
|
|
2163
|
+
*/
|
|
2164
|
+
const readRefusedCreates = (acc, refused, remaining) => Effect.forEach(refused.filter((entry) => entry.write.operation === "create"), (create) => {
|
|
2165
|
+
const { type, id } = create.write;
|
|
2166
|
+
const laterWrite = remaining.some((entry) => entry.index > create.index && entry.write.operation !== "create" && entry.write.type === type && entry.write.id === id);
|
|
2167
|
+
const entry = entryOf(acc, type, id);
|
|
2168
|
+
if (!laterWrite || entry !== void 0 && Option.isSome(entry.before)) return Effect.void;
|
|
2169
|
+
return readBefore(type, id).pipe(Effect.map((row) => setEntry(acc, type, id, {
|
|
2170
|
+
before: Option.some(row),
|
|
2171
|
+
current: Option.some(row)
|
|
2172
|
+
})));
|
|
2173
|
+
}, { discard: true });
|
|
2174
|
+
const cacheRows = (collection, rows) => Ref.update(cache, (current) => {
|
|
2175
|
+
const next = new Map(current);
|
|
2176
|
+
const map = new Map(next.get(collection) ?? []);
|
|
2177
|
+
rows.forEach((value) => map.set(value.id, value));
|
|
2178
|
+
next.set(collection, map);
|
|
2179
|
+
return next;
|
|
2180
|
+
});
|
|
2181
|
+
/**
|
|
2182
|
+
* Bring the cache into line with a write the server has applied (#395): the
|
|
2183
|
+
* row's value after the push's writes so far, or no row once a delete has
|
|
2184
|
+
* applied, so `getRows` never serves a value the push has superseded.
|
|
2185
|
+
*/
|
|
2186
|
+
const cacheApplied = (acc, write) => {
|
|
2187
|
+
const current = entryOf(acc, write.type, write.id)?.current ?? Option.none();
|
|
2188
|
+
return Ref.update(cache, (rows) => {
|
|
2189
|
+
const next = new Map(rows);
|
|
2190
|
+
const map = new Map(next.get(write.type) ?? []);
|
|
2191
|
+
if (Option.isSome(current)) map.set(write.id, current.value);
|
|
2192
|
+
else map.delete(write.id);
|
|
2193
|
+
next.set(write.type, map);
|
|
2194
|
+
return next;
|
|
2195
|
+
});
|
|
2196
|
+
};
|
|
2197
|
+
/** Harvest a compound document's `included` rows; record their ids complete. */
|
|
2198
|
+
const harvestIncluded = (included) => Effect.gen(function* () {
|
|
2199
|
+
const byCollection = Arr.groupBy(included, (row) => row.$collection);
|
|
2200
|
+
yield* Effect.forEach(R.toEntries(byCollection), ([collection, rows]) => cacheRows(collection, rows).pipe(Effect.andThen(Ref.update(includedIds, (current) => {
|
|
2201
|
+
const next = new Map(current);
|
|
2202
|
+
const ids = new Set(next.get(collection) ?? []);
|
|
2203
|
+
rows.forEach((row) => ids.add(row.id));
|
|
2204
|
+
next.set(collection, ids);
|
|
2205
|
+
return next;
|
|
2206
|
+
}))), {
|
|
2207
|
+
concurrency: 1,
|
|
2208
|
+
discard: true
|
|
2209
|
+
});
|
|
2210
|
+
});
|
|
2211
|
+
const entriesFrom = (values, subset) => Subset.apply(values.map((value) => ({
|
|
2212
|
+
id: value.id,
|
|
2213
|
+
value
|
|
2214
|
+
})), subset).map((row) => ({
|
|
2215
|
+
id: row.id,
|
|
2216
|
+
version: versionOf(row.value)
|
|
2217
|
+
}));
|
|
2218
|
+
const evaluate = (_context, subset) => Effect.gen(function* () {
|
|
2219
|
+
const list = config.resources.get(subset.collection)?.list;
|
|
2220
|
+
if (list === void 0) return [];
|
|
2221
|
+
if (Predicate.hasExists(subset.where)) return yield* new BackendError({ message: `JSON:API backend does not support Exists predicates on "${subset.collection}"` });
|
|
2222
|
+
const ids = pinnedIds(subset.where);
|
|
2223
|
+
if (ids !== void 0) {
|
|
2224
|
+
const complete = (yield* Ref.get(includedIds)).get(subset.collection);
|
|
2225
|
+
if (complete !== void 0 && [...ids].every((id) => complete.has(id))) {
|
|
2226
|
+
const cached = (yield* Ref.get(cache)).get(subset.collection);
|
|
2227
|
+
if (cached !== void 0 && [...ids].every((id) => cached.has(id))) return entriesFrom([...cached.values()], subset);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const encoded = yield* encoder.encode(subset).pipe(Effect.mapError(backendError(`Subset on "${subset.collection}" has no URL form`)));
|
|
2231
|
+
if (Option.isNone(encoded)) return [];
|
|
2232
|
+
const includeParam = (includes[subset.collection] ?? []).join(",");
|
|
2233
|
+
const query = appendQuery(encoded.value, includeParam === "" ? "" : `include=${encodeURIComponent(includeParam)}`);
|
|
2234
|
+
const url = urlFor(config.baseUrl, list.path) + query;
|
|
2235
|
+
const response = yield* getJson(url).pipe(Effect.mapError(backendError(`Failed to list "${subset.collection}"`)));
|
|
2236
|
+
const document = yield* parseResponse(response).pipe(Effect.mapError(backendError(`Invalid JSON:API listing "${subset.collection}"`)));
|
|
2237
|
+
const loaded = documentToRows(document);
|
|
2238
|
+
const values = loaded.rows.map((row) => toRow(row.value));
|
|
2239
|
+
yield* cacheRows(subset.collection, values);
|
|
2240
|
+
yield* harvestIncluded(loaded.included.map((row) => ({
|
|
2241
|
+
...toRow(row.value),
|
|
2242
|
+
$collection: row.collection
|
|
2243
|
+
})));
|
|
2244
|
+
return entriesFrom(values, subset);
|
|
2245
|
+
});
|
|
2246
|
+
const getRows = (_context, collection, ids) => Effect.gen(function* () {
|
|
2247
|
+
const cached = (yield* Ref.get(cache)).get(collection);
|
|
2248
|
+
const getOne = config.resources.get(collection)?.getOne;
|
|
2249
|
+
return (yield* Effect.forEach(ids, (id) => Effect.gen(function* () {
|
|
2250
|
+
const hit = cached?.get(id);
|
|
2251
|
+
if (hit !== void 0) return [{
|
|
2252
|
+
id,
|
|
2253
|
+
version: versionOf(hit),
|
|
2254
|
+
value: hit
|
|
2255
|
+
}];
|
|
2256
|
+
if (getOne === void 0) return [];
|
|
2257
|
+
const response = yield* getJson(urlFor(config.baseUrl, getOne.path, id)).pipe(Effect.mapError(backendError(`Failed to fetch ${collection}/${id}`)));
|
|
2258
|
+
if (response.status === 404) return [];
|
|
2259
|
+
const document = yield* parseResponse(response).pipe(Effect.mapError(backendError(`Invalid JSON:API resource ${collection}/${id}`)));
|
|
2260
|
+
const resource = documentToRows(document).rows[0]?.value;
|
|
2261
|
+
if (resource === void 0) return [];
|
|
2262
|
+
const value = toRow(resource);
|
|
2263
|
+
return [{
|
|
2264
|
+
id,
|
|
2265
|
+
version: versionOf(value),
|
|
2266
|
+
value
|
|
2267
|
+
}];
|
|
2268
|
+
}), { concurrency: 1 })).flat();
|
|
2269
|
+
});
|
|
2270
|
+
/** Decode a pushed mutation's args and express it as one JSON:API write. */
|
|
2271
|
+
const writeFor = (mutation) => Effect.gen(function* () {
|
|
2272
|
+
const definition = config.mutators.get(mutation.mutator);
|
|
2273
|
+
const custom = config.writes?.get(mutation.mutator);
|
|
2274
|
+
const operation = config.operations.get(mutation.mutator);
|
|
2275
|
+
if (definition === void 0 || custom === void 0 && operation === void 0) return yield* UnknownMutator.make(mutation.mutator);
|
|
2276
|
+
const args = yield* Schema.decodeUnknownEffect(definition.args)(mutation.args).pipe(Effect.mapError(backendError(`Invalid args for mutator "${mutation.mutator}"`)));
|
|
2277
|
+
if (custom !== void 0) return yield* Effect.try({
|
|
2278
|
+
try: () => custom(args),
|
|
2279
|
+
catch: backendError(`Invalid args for mutator "${mutation.mutator}"`)
|
|
2280
|
+
});
|
|
2281
|
+
if (operation === void 0) return yield* UnknownMutator.make(mutation.mutator);
|
|
2282
|
+
return yield* writeOf$1(operation, args);
|
|
2283
|
+
});
|
|
2284
|
+
const applyOne = (write) => Effect.gen(function* () {
|
|
2285
|
+
const resource = config.resources.get(write.type);
|
|
2286
|
+
switch (write.operation) {
|
|
2287
|
+
case "create": {
|
|
2288
|
+
const ref = resource?.create;
|
|
2289
|
+
if (ref === void 0) return yield* new BackendError({ message: `No create endpoint for "${write.type}"` });
|
|
2290
|
+
return yield* sendWrite(HttpClientRequest.post(urlFor(config.baseUrl, ref.path)).pipe(HttpClientRequest.setHeaders(jsonApiHeaders), HttpClientRequest.bodyJsonUnsafe(writeBody(write.type, write.id, write.attributes ?? {}, write.relationships))));
|
|
2291
|
+
}
|
|
2292
|
+
case "update": {
|
|
2293
|
+
const ref = resource?.update;
|
|
2294
|
+
if (ref === void 0) return yield* new BackendError({ message: `No update endpoint for "${write.type}"` });
|
|
2295
|
+
return yield* sendWrite(HttpClientRequest.patch(urlFor(config.baseUrl, ref.path, write.id)).pipe(HttpClientRequest.setHeaders(jsonApiHeaders), HttpClientRequest.bodyJsonUnsafe(writeBody(write.type, write.id, write.attributes ?? {}, write.relationships))));
|
|
2296
|
+
}
|
|
2297
|
+
case "delete": {
|
|
2298
|
+
const ref = resource?.delete;
|
|
2299
|
+
if (ref === void 0) return yield* new BackendError({ message: `No delete endpoint for "${write.type}"` });
|
|
2300
|
+
yield* sendWrite(HttpClientRequest.make("DELETE")(urlFor(config.baseUrl, ref.path, write.id)).pipe(HttpClientRequest.setHeaders(jsonApiHeaders)));
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
/** The group's ledger, filled from the store on first use. Callers hold `ledgerLock`. */
|
|
2306
|
+
const fillGroup = (clientGroupId) => Effect.gen(function* () {
|
|
2307
|
+
const cached = (yield* Ref.get(groups)).get(clientGroupId);
|
|
2308
|
+
if (cached !== void 0) return cached;
|
|
2309
|
+
const group = groupOf(yield* ledgerStore.read(clientGroupId));
|
|
2310
|
+
yield* Ref.update(groups, (current) => new Map(current).set(clientGroupId, group));
|
|
2311
|
+
return group;
|
|
2312
|
+
});
|
|
2313
|
+
/** A client group's ledger, read under `ledgerLock` so a first read cannot race an update. */
|
|
2314
|
+
const readGroup = (clientGroupId) => Semaphore.withPermit(ledgerLock, fillGroup(clientGroupId));
|
|
2315
|
+
/**
|
|
2316
|
+
* Change a client group's ledger. `change` returns the group it becomes, or
|
|
2317
|
+
* the group it was when nothing changes, and then nothing is written. The
|
|
2318
|
+
* store is written before the new group is kept, so a failed write leaves the
|
|
2319
|
+
* store and this instance agreeing on the old group.
|
|
2320
|
+
*/
|
|
2321
|
+
const updateGroup = (clientGroupId, change) => Semaphore.withPermit(ledgerLock, Effect.gen(function* () {
|
|
2322
|
+
const current = yield* fillGroup(clientGroupId);
|
|
2323
|
+
const next = change(current);
|
|
2324
|
+
if (next === current) return;
|
|
2325
|
+
yield* ledgerStore.write(clientGroupId, ledgerOf(next));
|
|
2326
|
+
yield* Ref.update(groups, (all) => new Map(all).set(clientGroupId, next));
|
|
2327
|
+
}));
|
|
2328
|
+
/** Acknowledge mutations in the ledger, in the order given, in one update. */
|
|
2329
|
+
const bumpLedger = (context, mutations) => Arr.isReadonlyArrayEmpty(mutations) ? Effect.void : updateGroup(context.clientGroupId, (group) => {
|
|
2330
|
+
const acknowledged = new Map(group.acknowledged);
|
|
2331
|
+
mutations.forEach((mutation) => acknowledged.set(mutation.clientId, mutation.mutationId));
|
|
2332
|
+
return {
|
|
2333
|
+
...group,
|
|
2334
|
+
acknowledged
|
|
2335
|
+
};
|
|
2336
|
+
});
|
|
2337
|
+
/** Remember permanent rejections, so a replayed copy of them reports the same outcome (#366). */
|
|
2338
|
+
const rememberRejections = (context, rejected) => Arr.isReadonlyArrayEmpty(rejected) ? Effect.void : updateGroup(context.clientGroupId, (group) => {
|
|
2339
|
+
const rejections = new Map(group.rejections);
|
|
2340
|
+
rejected.forEach((entry) => {
|
|
2341
|
+
const ids = new Map(rejections.get(entry.clientId) ?? []);
|
|
2342
|
+
ids.set(entry.mutationId, entry.reason);
|
|
2343
|
+
rejections.set(entry.clientId, ids);
|
|
2344
|
+
});
|
|
2345
|
+
return {
|
|
2346
|
+
...group,
|
|
2347
|
+
rejections
|
|
2348
|
+
};
|
|
2349
|
+
});
|
|
2350
|
+
/**
|
|
2351
|
+
* Forget the rejections this push's floors have moved past (#355), so the
|
|
2352
|
+
* memory is bounded per client, as MemoryBackend's is.
|
|
2353
|
+
*/
|
|
2354
|
+
const forgetRejections = (context, mutations) => updateGroup(context.clientGroupId, (group) => {
|
|
2355
|
+
const rejections = RejectionMemory.forgetBelow(group.rejections, RejectionMemory.floorsOf(mutations));
|
|
2356
|
+
return rejections === group.rejections ? group : {
|
|
2357
|
+
...group,
|
|
2358
|
+
rejections
|
|
2359
|
+
};
|
|
2360
|
+
});
|
|
2361
|
+
/**
|
|
2362
|
+
* The rejections a push's replayed copies were committed with (an id the ledger
|
|
2363
|
+
* already covers, with a remembered rejection). They are reported again (#366).
|
|
2364
|
+
*/
|
|
2365
|
+
const replayedRejections = (context, mutations) => Effect.gen(function* () {
|
|
2366
|
+
const group = yield* readGroup(context.clientGroupId);
|
|
2367
|
+
return mutations.flatMap((mutation) => {
|
|
2368
|
+
if (mutation.mutationId > (group.acknowledged.get(mutation.clientId) ?? 0)) return [];
|
|
2369
|
+
const reason = group.rejections.get(mutation.clientId)?.get(mutation.mutationId);
|
|
2370
|
+
return reason === void 0 ? [] : [{
|
|
2371
|
+
clientId: mutation.clientId,
|
|
2372
|
+
mutationId: mutation.mutationId,
|
|
2373
|
+
reason
|
|
2374
|
+
}];
|
|
2375
|
+
});
|
|
2376
|
+
});
|
|
2377
|
+
const lastMutationIdsOf = (context) => readGroup(context.clientGroupId).pipe(Effect.map((group) => Object.fromEntries(group.acknowledged)));
|
|
2378
|
+
/** The group's remembered rejections, each with its reason (#365). A pull reports those its watermark covers. */
|
|
2379
|
+
const rememberedRejections = (context) => readGroup(context.clientGroupId).pipe(Effect.map((group) => [...group.rejections].flatMap(([clientId, ids]) => [...ids].map(([mutationId, reason]) => ({
|
|
2380
|
+
clientId,
|
|
2381
|
+
mutationId,
|
|
2382
|
+
reason
|
|
2383
|
+
})))));
|
|
2384
|
+
/**
|
|
2385
|
+
* The mutations a push still has to send, in order, each with its write. A
|
|
2386
|
+
* mutation at or below its client's acknowledged id was already applied, so it
|
|
2387
|
+
* is skipped (idempotent, acknowledged). With `strict`, a mutation past the next
|
|
2388
|
+
* expected id is a gap the push cannot apply, so the push is refused.
|
|
2389
|
+
*/
|
|
2390
|
+
const unacknowledged = (context, mutations, strict) => Effect.gen(function* () {
|
|
2391
|
+
const group = yield* readGroup(context.clientGroupId);
|
|
2392
|
+
const expected = new Map(group.acknowledged);
|
|
2393
|
+
const batch = [];
|
|
2394
|
+
yield* Effect.forEach(mutations, (mutation) => {
|
|
2395
|
+
const last = expected.get(mutation.clientId) ?? 0;
|
|
2396
|
+
if (mutation.mutationId <= last) return Effect.void;
|
|
2397
|
+
if (strict && mutation.mutationId > last + 1) return Effect.fail(mutationGap(mutation.clientId, last + 1, mutation.mutationId));
|
|
2398
|
+
expected.set(mutation.clientId, mutation.mutationId);
|
|
2399
|
+
return Effect.map(writeFor(mutation), (write) => {
|
|
2400
|
+
batch.push({
|
|
2401
|
+
mutation,
|
|
2402
|
+
write
|
|
2403
|
+
});
|
|
2404
|
+
});
|
|
2405
|
+
}, {
|
|
2406
|
+
concurrency: 1,
|
|
2407
|
+
discard: true
|
|
2408
|
+
});
|
|
2409
|
+
return batch;
|
|
2410
|
+
});
|
|
2411
|
+
/**
|
|
2412
|
+
* The mutations a per-resource push still has to send, in order. A mutation
|
|
2413
|
+
* at or below its client's acknowledged id was already applied, so it is
|
|
2414
|
+
* skipped (idempotent, acknowledged). A mutation past the next expected id is a
|
|
2415
|
+
* gap, refused with a `BackendError` before any write of the push is sent, as
|
|
2416
|
+
* the atomic path refuses it (`unacknowledged`, strict). Unlike
|
|
2417
|
+
* `unacknowledged`, nothing is decoded here: the send loop decodes each write
|
|
2418
|
+
* in turn, so an undecodable mutation still fails at its own place in the push.
|
|
2419
|
+
*/
|
|
2420
|
+
const pendingEach = (context, mutations) => Effect.gen(function* () {
|
|
2421
|
+
const expected = new Map((yield* readGroup(context.clientGroupId)).acknowledged);
|
|
2422
|
+
const pending = [];
|
|
2423
|
+
yield* Effect.forEach(mutations, (mutation) => {
|
|
2424
|
+
const last = expected.get(mutation.clientId) ?? 0;
|
|
2425
|
+
if (mutation.mutationId <= last) return Effect.void;
|
|
2426
|
+
if (mutation.mutationId > last + 1) return Effect.fail(mutationGap(mutation.clientId, last + 1, mutation.mutationId));
|
|
2427
|
+
expected.set(mutation.clientId, mutation.mutationId);
|
|
2428
|
+
pending.push(mutation);
|
|
2429
|
+
return Effect.void;
|
|
2430
|
+
}, {
|
|
2431
|
+
concurrency: 1,
|
|
2432
|
+
discard: true
|
|
2433
|
+
});
|
|
2434
|
+
return pending;
|
|
2435
|
+
});
|
|
2436
|
+
/**
|
|
2437
|
+
* Replay each write as its own request (at-least-once, in order). Before the
|
|
2438
|
+
* first request, each pending mutation that decodes is checked for a `getOne`
|
|
2439
|
+
* route, so a write that cannot be read sends nothing. A mutation whose args
|
|
2440
|
+
* do not decode is not checked: the loop fails the push at it, after the
|
|
2441
|
+
* writes before it, as it always has.
|
|
2442
|
+
*/
|
|
2443
|
+
const applyEach = (context, mutations) => Effect.gen(function* () {
|
|
2444
|
+
const acc = {
|
|
2445
|
+
touched: /* @__PURE__ */ new Map(),
|
|
2446
|
+
puts: /* @__PURE__ */ new Map(),
|
|
2447
|
+
rejected: [],
|
|
2448
|
+
rows: /* @__PURE__ */ new Map()
|
|
2449
|
+
};
|
|
2450
|
+
const replays = yield* replayedRejections(context, mutations);
|
|
2451
|
+
const pending = yield* pendingEach(context, mutations);
|
|
2452
|
+
const decoded = yield* Effect.forEach(pending, (mutation) => Effect.gen(function* () {
|
|
2453
|
+
const write = yield* writeFor(mutation).pipe(Effect.option);
|
|
2454
|
+
if (Option.isSome(write)) yield* checkReadable(write.value);
|
|
2455
|
+
return {
|
|
2456
|
+
mutation,
|
|
2457
|
+
write
|
|
2458
|
+
};
|
|
2459
|
+
}));
|
|
2460
|
+
yield* Effect.forEach(decoded, ({ mutation, write: decodedWrite }) => Effect.gen(function* () {
|
|
2461
|
+
const write = Option.isSome(decodedWrite) ? decodedWrite.value : yield* writeFor(mutation);
|
|
2462
|
+
yield* enterRow(acc, write);
|
|
2463
|
+
const outcome = yield* applyOne(write).pipe(Effect.map((document) => ({
|
|
2464
|
+
_tag: "applied",
|
|
2465
|
+
document
|
|
2466
|
+
})), Effect.catchTag("MutationRejected", (error) => Effect.succeed({
|
|
2467
|
+
_tag: "rejected",
|
|
2468
|
+
reason: error.reason
|
|
2469
|
+
})));
|
|
2470
|
+
if (outcome._tag === "applied") {
|
|
2471
|
+
recordApplied(acc, write, outcome.document);
|
|
2472
|
+
yield* cacheApplied(acc, write);
|
|
2473
|
+
} else {
|
|
2474
|
+
const rejection = {
|
|
2475
|
+
clientId: mutation.clientId,
|
|
2476
|
+
mutationId: mutation.mutationId,
|
|
2477
|
+
reason: outcome.reason
|
|
2478
|
+
};
|
|
2479
|
+
acc.rejected.push(rejection);
|
|
2480
|
+
yield* rememberRejections(context, [rejection]);
|
|
2481
|
+
}
|
|
2482
|
+
yield* bumpLedger(context, [mutation]);
|
|
2483
|
+
}), {
|
|
2484
|
+
concurrency: 1,
|
|
2485
|
+
discard: true
|
|
2486
|
+
});
|
|
2487
|
+
const rejected = [...replays, ...acc.rejected];
|
|
2488
|
+
return {
|
|
2489
|
+
lastMutationIds: yield* lastMutationIdsOf(context),
|
|
2490
|
+
hints: Backend.buildChangeHints(acc.touched, acc.puts, oldsOf(acc)),
|
|
2491
|
+
...Arr.isReadonlyArrayNonEmpty(rejected) ? { rejected } : {}
|
|
2492
|
+
};
|
|
2493
|
+
});
|
|
2494
|
+
/** Send one `atomic:operations` request and classify the server's answer. */
|
|
2495
|
+
const sendAtomic = (path, operations) => Effect.gen(function* () {
|
|
2496
|
+
const response = yield* Effect.scoped(client.execute(HttpClientRequest.post(urlFor(config.baseUrl, path)).pipe(HttpClientRequest.bodyText(JSON.stringify({ "atomic:operations": operations }), Atomic.MEDIA_TYPE), HttpClientRequest.setHeaders(atomicHeaders)))).pipe(Effect.mapError(backendError("JSON:API atomic:operations request failed")));
|
|
2497
|
+
const status = response.status;
|
|
2498
|
+
const message = `JSON:API atomic:operations returned HTTP ${status}`;
|
|
2499
|
+
const body = yield* response.json.pipe(Effect.orElseSucceed(() => void 0));
|
|
2500
|
+
if (status >= 200 && status < 300) {
|
|
2501
|
+
const results = P.isObjectOrArray(body) ? body["atomic:results"] : void 0;
|
|
2502
|
+
return {
|
|
2503
|
+
_tag: "applied",
|
|
2504
|
+
results: Array.isArray(results) ? results : []
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
if (isPermanentStatus(status)) {
|
|
2508
|
+
const rejections = rejectionsOf(body, operations.length, message);
|
|
2509
|
+
if (Arr.isReadonlyArrayNonEmpty(rejections)) return {
|
|
2510
|
+
_tag: "rejected",
|
|
2511
|
+
rejections
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
return yield* new BackendError({ message });
|
|
2515
|
+
});
|
|
2516
|
+
/**
|
|
2517
|
+
* Push the batch as one `atomic:operations` request. Nothing is
|
|
2518
|
+
* acknowledged until the server has applied the whole (remaining) batch;
|
|
2519
|
+
* permanently rejected operations are dropped and the rest resent.
|
|
2520
|
+
*/
|
|
2521
|
+
const applyAtomic = (context, mutations, atomic) => Effect.gen(function* () {
|
|
2522
|
+
const replays = yield* replayedRejections(context, mutations);
|
|
2523
|
+
const batch = yield* unacknowledged(context, mutations, true);
|
|
2524
|
+
const acc = {
|
|
2525
|
+
touched: /* @__PURE__ */ new Map(),
|
|
2526
|
+
puts: /* @__PURE__ */ new Map(),
|
|
2527
|
+
rejected: [],
|
|
2528
|
+
rows: /* @__PURE__ */ new Map()
|
|
2529
|
+
};
|
|
2530
|
+
yield* Effect.forEach(batch, ({ write }) => enterBatchWrite(acc, write), {
|
|
2531
|
+
concurrency: 1,
|
|
2532
|
+
discard: true
|
|
2533
|
+
});
|
|
2534
|
+
const rejectedAt = /* @__PURE__ */ new Map();
|
|
2535
|
+
const round = (pending) => Effect.gen(function* () {
|
|
2536
|
+
if (Arr.isReadonlyArrayEmpty(pending)) return {
|
|
2537
|
+
pending,
|
|
2538
|
+
results: []
|
|
2539
|
+
};
|
|
2540
|
+
const outcome = yield* sendAtomic(atomic.path, pending.map((entry) => operationOf(entry.write)));
|
|
2541
|
+
if (outcome._tag === "applied") return {
|
|
2542
|
+
pending,
|
|
2543
|
+
results: outcome.results
|
|
2544
|
+
};
|
|
2545
|
+
const refused = outcome.rejections.flatMap(({ index }) => {
|
|
2546
|
+
const entry = pending[index];
|
|
2547
|
+
return entry === void 0 ? [] : [entry];
|
|
2548
|
+
});
|
|
2549
|
+
outcome.rejections.forEach(({ index, reason }) => {
|
|
2550
|
+
const entry = pending[index];
|
|
2551
|
+
if (entry !== void 0) rejectedAt.set(entry.index, reason);
|
|
2552
|
+
});
|
|
2553
|
+
const next = pending.filter((entry) => !rejectedAt.has(entry.index));
|
|
2554
|
+
yield* readRefusedCreates(acc, refused, next);
|
|
2555
|
+
return yield* round(next);
|
|
2556
|
+
});
|
|
2557
|
+
const { pending, results } = yield* round(batch.map((entry, index) => ({
|
|
2558
|
+
...entry,
|
|
2559
|
+
index
|
|
2560
|
+
})));
|
|
2561
|
+
yield* Effect.forEach(pending, ({ write }, position) => {
|
|
2562
|
+
recordApplied(acc, write, resultRow(results[position]));
|
|
2563
|
+
return cacheApplied(acc, write);
|
|
2564
|
+
}, {
|
|
2565
|
+
concurrency: 1,
|
|
2566
|
+
discard: true
|
|
2567
|
+
});
|
|
2568
|
+
const rejected = [...rejectedAt].toSorted(([a], [b]) => a - b).flatMap(([index, reason]) => {
|
|
2569
|
+
const entry = batch[index];
|
|
2570
|
+
return entry === void 0 ? [] : [{
|
|
2571
|
+
clientId: entry.mutation.clientId,
|
|
2572
|
+
mutationId: entry.mutation.mutationId,
|
|
2573
|
+
reason
|
|
2574
|
+
}];
|
|
2575
|
+
});
|
|
2576
|
+
yield* rememberRejections(context, rejected);
|
|
2577
|
+
yield* bumpLedger(context, batch.map(({ mutation }) => mutation));
|
|
2578
|
+
const reported = [...replays, ...rejected];
|
|
2579
|
+
return {
|
|
2580
|
+
lastMutationIds: yield* lastMutationIdsOf(context),
|
|
2581
|
+
hints: Backend.buildChangeHints(acc.touched, acc.puts, oldsOf(acc)),
|
|
2582
|
+
...Arr.isReadonlyArrayNonEmpty(reported) ? { rejected: reported } : {}
|
|
2583
|
+
};
|
|
2584
|
+
});
|
|
2585
|
+
const applyMutations = (context, mutations) => Effect.gen(function* () {
|
|
2586
|
+
yield* forgetRejections(context, mutations);
|
|
2587
|
+
return config.atomic === void 0 ? yield* applyEach(context, mutations) : yield* applyAtomic(context, mutations, config.atomic);
|
|
2588
|
+
});
|
|
2589
|
+
return Backend.Backend.of({
|
|
2590
|
+
applyMutations,
|
|
2591
|
+
lastMutationIds: lastMutationIdsOf,
|
|
2592
|
+
rejections: rememberedRejections,
|
|
2593
|
+
evaluate,
|
|
2594
|
+
getRows
|
|
2595
|
+
});
|
|
2596
|
+
});
|
|
2597
|
+
// src/FacadeWrites.ts
|
|
2598
|
+
/**
|
|
2599
|
+
* FacadeWrites
|
|
2600
|
+
*
|
|
2601
|
+
* The pieces of a façade write (ADR-0030) that do not depend on the fetch
|
|
2602
|
+
* handler: reading the `Prefer` header (RFC 7240), decoding a write's body
|
|
2603
|
+
* **structurally** into a `JsonApiBackend.Write`, and the default **intent
|
|
2604
|
+
* mapping** from a `Write` to the graph-derived CRUD mutation the engine
|
|
2605
|
+
* journals (`CrudMutation`, graph row form). `JsonApiFacade` composes them.
|
|
2606
|
+
*
|
|
2607
|
+
* Structural means exactly ADR-0030 §5's list: `data` is an object whose
|
|
2608
|
+
* `type` is the route's, whose `id` (when present) is the path's for a
|
|
2609
|
+
* `PATCH`, whose `attributes` / `relationships` are objects when present, and
|
|
2610
|
+
* whose relationships are `one` edges of the node with `{ type, id }` or
|
|
2611
|
+
* `null` linkage. Attribute values are never checked: the server rules on
|
|
2612
|
+
* them, and a value it refuses surfaces as a permanent rejection.
|
|
2613
|
+
*/
|
|
2614
|
+
var FacadeWrites_exports = /* @__PURE__ */ __exportAll({
|
|
2615
|
+
InvalidWriteArgs: () => InvalidWriteArgs,
|
|
2616
|
+
decodeWrite: () => decodeWrite,
|
|
2617
|
+
graphIntentOf: () => graphIntentOf,
|
|
2618
|
+
preferencesOf: () => preferencesOf,
|
|
2619
|
+
writesOf: () => writesOf
|
|
2620
|
+
});
|
|
2621
|
+
/** Read the `Prefer` headers of a request (several headers and comma-separated lists alike). */
|
|
2622
|
+
const preferencesOf = (headers) => {
|
|
2623
|
+
const pairs = Arr.map((headers.get("prefer") ?? "").split(","), (part) => {
|
|
2624
|
+
const [token = ""] = part.split(";");
|
|
2625
|
+
const [rawName = "", ...rest] = token.split("=");
|
|
2626
|
+
const value = rest.join("=").trim().replace(/^"(.*)"$/, "$1").toLowerCase();
|
|
2627
|
+
return [rawName.trim().toLowerCase(), value];
|
|
2628
|
+
});
|
|
2629
|
+
const has = (name, value) => Arr.some(pairs, ([n, v]) => n === name && (value === void 0 || v === value));
|
|
2630
|
+
const wait = Arr.findLast(pairs, ([n, v]) => n === "wait" && /^\d+$/.test(v));
|
|
2631
|
+
const preferences = {
|
|
2632
|
+
respondAsync: has("respond-async"),
|
|
2633
|
+
strict: has("handling", "strict")
|
|
2634
|
+
};
|
|
2635
|
+
return Option.match(wait, {
|
|
2636
|
+
onNone: () => preferences,
|
|
2637
|
+
onSome: ([, v]) => ({
|
|
2638
|
+
...preferences,
|
|
2639
|
+
wait: Number(v)
|
|
2640
|
+
})
|
|
2641
|
+
});
|
|
2642
|
+
};
|
|
2643
|
+
const refuse = (refusal) => Effect.fail(refusal);
|
|
2644
|
+
const invalid = (detail, pointer) => refuse({
|
|
2645
|
+
status: 400,
|
|
2646
|
+
code: "invalid_document",
|
|
2647
|
+
title: "Invalid document",
|
|
2648
|
+
detail,
|
|
2649
|
+
pointer
|
|
2650
|
+
});
|
|
2651
|
+
const isRecord = (value) => P.isObject(value) && !Array.isArray(value);
|
|
2652
|
+
const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
2653
|
+
/** The linkage of one relationship: a related id, `null`, or a refusal. */
|
|
2654
|
+
const linkageOf = (name, member, node) => {
|
|
2655
|
+
const pointer = `/data/relationships/${name}`;
|
|
2656
|
+
const edge = node.edges[name];
|
|
2657
|
+
if (edge === void 0 || edge.kind !== "one") return refuse({
|
|
2658
|
+
status: 422,
|
|
2659
|
+
code: "unsupported_relationship",
|
|
2660
|
+
title: "Unsupported relationship",
|
|
2661
|
+
detail: edge === void 0 ? `"${name}" is not a relationship of ${node.type}` : `"${name}" is a to-many relationship; its linkage lives on the related resources, not on ${node.type}`,
|
|
2662
|
+
pointer
|
|
2663
|
+
});
|
|
2664
|
+
if (!isRecord(member) || !("data" in member)) return invalid(`relationship "${name}" must be an object with a "data" member`, pointer);
|
|
2665
|
+
const data = member["data"];
|
|
2666
|
+
if (data === null) return Effect.succeed(null);
|
|
2667
|
+
if (!isRecord(data) || !P.isString(data["id"]) || data["type"] !== edge.target) return invalid(`relationship "${name}" must link one "${edge.target}" by type and id, or be null`, `${pointer}/data`);
|
|
2668
|
+
return Effect.succeed(data["id"]);
|
|
2669
|
+
};
|
|
2670
|
+
/** The body's `data` member as an object, or the refusal for a body that has none. */
|
|
2671
|
+
const dataOf = (body) => Effect.gen(function* () {
|
|
2672
|
+
const text = yield* body.pipe(Effect.catch(() => invalid("the request body could not be read", "")));
|
|
2673
|
+
const document = yield* decodeJson(text).pipe(Effect.catch(() => invalid("the request body is not JSON", "")));
|
|
2674
|
+
if (!isRecord(document) || !("data" in document)) return yield* invalid("a write document needs a \"data\" member", "");
|
|
2675
|
+
const data = document["data"];
|
|
2676
|
+
return isRecord(data) ? data : yield* invalid("\"data\" must be a resource object", "/data");
|
|
2677
|
+
});
|
|
2678
|
+
const clientIdRequired = {
|
|
2679
|
+
status: 503,
|
|
2680
|
+
code: "client_id_required",
|
|
2681
|
+
title: "Client id required",
|
|
2682
|
+
detail: "A create the façade journals offline needs a client-generated id in data.id.",
|
|
2683
|
+
pointer: "/data/id"
|
|
2684
|
+
};
|
|
2685
|
+
/** The resource id a create or an update addresses, checked against the route's type and path. */
|
|
2686
|
+
const idOf = (target, data) => {
|
|
2687
|
+
if (data["type"] !== target.type) return refuse({
|
|
2688
|
+
status: 409,
|
|
2689
|
+
code: "type_mismatch",
|
|
2690
|
+
title: "Type mismatch",
|
|
2691
|
+
detail: `this endpoint writes "${target.type}" resources`,
|
|
2692
|
+
pointer: "/data/type"
|
|
2693
|
+
});
|
|
2694
|
+
const id = data["id"];
|
|
2695
|
+
if (id !== void 0 && !P.isString(id)) return invalid("\"data.id\" must be a string", "/data/id");
|
|
2696
|
+
if (target.operation === "create") return id === void 0 || id === "" ? refuse(clientIdRequired) : Effect.succeed(id);
|
|
2697
|
+
if (id === void 0) return invalid("an update's data needs the id the path names", "/data/id");
|
|
2698
|
+
return id === target.id ? Effect.succeed(id) : refuse({
|
|
2699
|
+
status: 409,
|
|
2700
|
+
code: "id_mismatch",
|
|
2701
|
+
title: "Id mismatch",
|
|
2702
|
+
detail: `"data.id" must be the id the path names ("${target.id}")`,
|
|
2703
|
+
pointer: "/data/id"
|
|
2704
|
+
});
|
|
2705
|
+
};
|
|
2706
|
+
/** An optional object member of `data` (`attributes`, `relationships`): `{}` when absent. */
|
|
2707
|
+
const memberOf = (data, name) => {
|
|
2708
|
+
const member = data[name] ?? {};
|
|
2709
|
+
return isRecord(member) ? Effect.succeed(member) : invalid(`"data.${name}" must be an object`, `/data/${name}`);
|
|
2710
|
+
};
|
|
2711
|
+
/**
|
|
2712
|
+
* Decode a write's body structurally into a `Write` whose `relationships`
|
|
2713
|
+
* are flattened into `one`-edge ids by relationship name (`null` clears).
|
|
2714
|
+
* A `DELETE` reads no body. Fails with the {@link Refusal} the offline façade
|
|
2715
|
+
* answers with; a create without a client id is refused only once the rest
|
|
2716
|
+
* of its body has passed.
|
|
2717
|
+
*/
|
|
2718
|
+
const decodeWrite = (node, target, body) => {
|
|
2719
|
+
if (target.operation === "delete") return Effect.succeed({
|
|
2720
|
+
operation: "delete",
|
|
2721
|
+
type: target.type,
|
|
2722
|
+
id: target.id
|
|
2723
|
+
});
|
|
2724
|
+
return Effect.gen(function* () {
|
|
2725
|
+
const data = yield* dataOf(body);
|
|
2726
|
+
const checked = yield* Effect.result(idOf(target, data));
|
|
2727
|
+
if (Result.isFailure(checked) && checked.failure.code !== "client_id_required") return yield* refuse(checked.failure);
|
|
2728
|
+
const attributes = yield* memberOf(data, "attributes");
|
|
2729
|
+
const relationships = yield* memberOf(data, "relationships");
|
|
2730
|
+
const linkage = yield* Effect.forEach(R.toEntries(relationships), ([name, member]) => Effect.map(linkageOf(name, member, node), (related) => [name, related]), { concurrency: 1 });
|
|
2731
|
+
return {
|
|
2732
|
+
operation: target.operation,
|
|
2733
|
+
type: target.type,
|
|
2734
|
+
id: yield* Result.match(checked, {
|
|
2735
|
+
onFailure: refuse,
|
|
2736
|
+
onSuccess: Effect.succeed
|
|
2737
|
+
}),
|
|
2738
|
+
attributes,
|
|
2739
|
+
relationships: R.fromEntries(linkage)
|
|
2740
|
+
};
|
|
2741
|
+
});
|
|
2742
|
+
};
|
|
2743
|
+
/**
|
|
2744
|
+
* The default intent mapping: the graph-derived CRUD mutations
|
|
2745
|
+
* (`CrudMutation`), args in the graph row form — the attributes plus, per
|
|
2746
|
+
* `one` edge the payload links, its `via` key.
|
|
2747
|
+
*/
|
|
2748
|
+
const graphIntentOf = (nodes) => (write) => {
|
|
2749
|
+
const node = nodes.nodes.get(write.type);
|
|
2750
|
+
if (node === void 0) return void 0;
|
|
2751
|
+
const mutator = CrudMutation.name(write.type, write.operation);
|
|
2752
|
+
if (write.operation === "delete") return {
|
|
2753
|
+
mutator,
|
|
2754
|
+
args: {
|
|
2755
|
+
type: write.type,
|
|
2756
|
+
id: write.id
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
const linked = Arr.map(R.toEntries(write.relationships ?? {}), ([name, related]) => {
|
|
2760
|
+
const edge = node.edges[name];
|
|
2761
|
+
return edge?.kind === "one" ? Option.some([edge.via, related]) : Option.none();
|
|
2762
|
+
});
|
|
2763
|
+
const vias = Option.all(linked);
|
|
2764
|
+
if (Option.isNone(vias)) return void 0;
|
|
2765
|
+
const attributes = {
|
|
2766
|
+
...write.attributes,
|
|
2767
|
+
...R.fromEntries(vias.value)
|
|
2768
|
+
};
|
|
2769
|
+
return {
|
|
2770
|
+
mutator,
|
|
2771
|
+
args: {
|
|
2772
|
+
type: write.type,
|
|
2773
|
+
id: write.id,
|
|
2774
|
+
attributes
|
|
2775
|
+
}
|
|
2776
|
+
};
|
|
2777
|
+
};
|
|
2778
|
+
const decodeWriteArgs = Schema.decodeUnknownSync(CrudMutation.WriteArgs);
|
|
2779
|
+
const decodeDeleteArgs = Schema.decodeUnknownSync(CrudMutation.DeleteArgs);
|
|
2780
|
+
/**
|
|
2781
|
+
* One graph-row write as a `JsonApiBackend` write: each `one` edge's `via` key
|
|
2782
|
+
* moves out of the attributes into `relationships[edge].data` (`null` linkage
|
|
2783
|
+
* when the key is `null`). A key the args do not carry leaves the relationship
|
|
2784
|
+
* out, as a payload that did not link it.
|
|
2785
|
+
*/
|
|
2786
|
+
const writeOf = (node, operation, args) => {
|
|
2787
|
+
const oneEdges = R.toEntries(node.edges).flatMap(([name, edge]) => edge.kind === "one" ? [[name, edge]] : []);
|
|
2788
|
+
const vias = HashSet.fromIterable(oneEdges.map(([, edge]) => edge.via));
|
|
2789
|
+
const relationships = R.fromEntries(oneEdges.flatMap(([name, edge]) => Object.hasOwn(args.attributes, edge.via) ? [[name, { data: P.isString(args.attributes[edge.via]) ? {
|
|
2790
|
+
type: edge.target,
|
|
2791
|
+
id: args.attributes[edge.via]
|
|
2792
|
+
} : null }]] : []));
|
|
2793
|
+
return {
|
|
2794
|
+
operation,
|
|
2795
|
+
type: node.type,
|
|
2796
|
+
id: args.id,
|
|
2797
|
+
attributes: R.filter(args.attributes, (_, field) => !HashSet.has(vias, field)),
|
|
2798
|
+
relationships
|
|
2799
|
+
};
|
|
2800
|
+
};
|
|
2801
|
+
/**
|
|
2802
|
+
* Refuse args whose `type` is not the mutation's own (#529): a `notes/create`
|
|
2803
|
+
* naming `secrets` would otherwise write a type the mutation's name, which the
|
|
2804
|
+
* server authorised, never covered. `JsonApiBackend` reports the refusal as
|
|
2805
|
+
* invalid args, as it does args that do not decode.
|
|
2806
|
+
*/
|
|
2807
|
+
const sameType = (type, mutator) => (args) => {
|
|
2808
|
+
if (args.type !== type) throw new InvalidWriteArgs({ message: `"${mutator}" writes "${type}", but its args name "${args.type}"` });
|
|
2809
|
+
return args;
|
|
2810
|
+
};
|
|
2811
|
+
/** Graph-derived CRUD args that do not fit the mutation they were journaled under. */
|
|
2812
|
+
var InvalidWriteArgs = class extends Schema.TaggedError()("InvalidWriteArgs", { message: Schema.String }) {};
|
|
2813
|
+
/**
|
|
2814
|
+
* The inverse of {@link graphIntentOf} (ADR-0030 §5): for every graph-derived
|
|
2815
|
+
* CRUD mutation (`CrudMutation`, filtered by `options.writes` as the registry
|
|
2816
|
+
* is), how its args replay as one JSON:API write. Pass it as a
|
|
2817
|
+
* `JsonApiBackend`'s `writes`, so a node-graph app's journaled `{type}/create`,
|
|
2818
|
+
* `{type}/update` and `{type}/delete` reach the server as `POST`, `PATCH` and
|
|
2819
|
+
* `DELETE` (or atomic operations), with each `one` edge's `via` key rebuilt as
|
|
2820
|
+
* the relationship's linkage. The write's `type` is always the mutation's own:
|
|
2821
|
+
* args naming another type are refused ({@link InvalidWriteArgs}).
|
|
2822
|
+
*/
|
|
2823
|
+
const writesOf = (nodes, options) => new Map(CrudMutation.entries(nodes, options).flatMap((entry) => {
|
|
2824
|
+
const node = nodes.nodes.get(entry.type);
|
|
2825
|
+
if (node === void 0) return [];
|
|
2826
|
+
const name = entry.mutator.name;
|
|
2827
|
+
const operation = entry.operation;
|
|
2828
|
+
return [[name, operation === "delete" ? flow(decodeDeleteArgs, sameType(entry.type, name), (args) => ({
|
|
2829
|
+
operation,
|
|
2830
|
+
type: entry.type,
|
|
2831
|
+
id: args.id
|
|
2832
|
+
})) : flow(decodeWriteArgs, sameType(entry.type, name), (args) => writeOf(node, operation, args))]];
|
|
2833
|
+
}));
|
|
2834
|
+
// src/MutationStatus.ts
|
|
2835
|
+
var MutationStatus_exports = /* @__PURE__ */ __exportAll({
|
|
2836
|
+
current: () => current,
|
|
2837
|
+
defaultMaxWait: () => defaultMaxWait,
|
|
2838
|
+
document: () => document,
|
|
2839
|
+
parseId: () => parseId,
|
|
2840
|
+
resourceType: () => resourceType,
|
|
2841
|
+
segment: () => segment,
|
|
2842
|
+
settled: () => settled,
|
|
2843
|
+
unknownDocument: () => unknownDocument
|
|
2844
|
+
});
|
|
2845
|
+
/** The JSON:API type of a status resource; `_legation` and this type are never an app's. */
|
|
2846
|
+
const resourceType = "legation-mutations";
|
|
2847
|
+
/** The path segments of the status route after the api prefix: `/_legation/mutations/{id}`. */
|
|
2848
|
+
const segment = "_legation";
|
|
2849
|
+
/** The default cap on `Prefer: wait`: a `respondWith` promise must not outlive the worker's patience. */
|
|
2850
|
+
const defaultMaxWait = "30 seconds";
|
|
2851
|
+
/**
|
|
2852
|
+
* The link to the resource a graph-derived CRUD mutation (`{type}/create|update|delete`,
|
|
2853
|
+
* args `{ type, id, … }`) addresses: `{prefix}/{type}/{id}`. `undefined` for any other mutation.
|
|
2854
|
+
*/
|
|
2855
|
+
const resourceLink = (nodes, prefix, state) => {
|
|
2856
|
+
const parts = state.mutator.split("/");
|
|
2857
|
+
const [type = "", operation = ""] = parts;
|
|
2858
|
+
if (parts.length !== 2 || !nodes.nodes.has(type)) return void 0;
|
|
2859
|
+
if (operation !== "create" && operation !== "update" && operation !== "delete") return void 0;
|
|
2860
|
+
const args = state.args;
|
|
2861
|
+
if (!P.isObject(args) || !("id" in args) || !P.isString(args.id)) return void 0;
|
|
2862
|
+
return `${prefix}/${type}/${encodeURIComponent(args.id)}`;
|
|
2863
|
+
};
|
|
2864
|
+
/** The status document of a tracked mutation; `prefix` is where the api is mounted, as links see it. */
|
|
2865
|
+
const document = (nodes, prefix, state) => {
|
|
2866
|
+
const self = `${prefix}/${segment}/mutations/${state.mutationId}`;
|
|
2867
|
+
const resource = resourceLink(nodes, prefix, state);
|
|
2868
|
+
const base = {
|
|
2869
|
+
phase: state.phase,
|
|
2870
|
+
mutator: state.mutator,
|
|
2871
|
+
journaledAt: state.journaledAt
|
|
2872
|
+
};
|
|
2873
|
+
const settledAt = state.settledAt;
|
|
2874
|
+
const timed = settledAt === void 0 ? base : {
|
|
2875
|
+
...base,
|
|
2876
|
+
settledAt
|
|
2877
|
+
};
|
|
2878
|
+
const reason = state.phase === "rejected" ? state.reason : void 0;
|
|
2879
|
+
return { data: {
|
|
2880
|
+
type: resourceType,
|
|
2881
|
+
id: String(state.mutationId),
|
|
2882
|
+
attributes: reason === void 0 ? timed : {
|
|
2883
|
+
...timed,
|
|
2884
|
+
reason
|
|
2885
|
+
},
|
|
2886
|
+
links: resource === void 0 ? { self } : {
|
|
2887
|
+
self,
|
|
2888
|
+
resource
|
|
2889
|
+
}
|
|
2890
|
+
} };
|
|
2891
|
+
};
|
|
2892
|
+
/** The `404 unknown_mutation` error document. */
|
|
2893
|
+
const unknownDocument = (id) => ({ errors: [{
|
|
2894
|
+
status: "404",
|
|
2895
|
+
code: "unknown_mutation",
|
|
2896
|
+
title: "Unknown mutation",
|
|
2897
|
+
detail: `No mutation ${id} is tracked here. It was never journaled by this worker, or it settled and was evicted beyond the retention window, or it settled before this worker started; the three cannot be told apart.`
|
|
2898
|
+
}] });
|
|
2899
|
+
/** A mutation id as the route carries it: a positive integer, or none. */
|
|
2900
|
+
const parseId = (raw) => /^[1-9]\d*$/.test(raw) ? Option.some(Number(raw)) : Option.none();
|
|
2901
|
+
/** One mutation's state now, from the engine's map stream (it emits the current map first). */
|
|
2902
|
+
const current = (states, mutationId) => Stream.runHead(states).pipe(Effect.map(Option.flatMap((map) => HashMap.get(map, mutationId))));
|
|
2903
|
+
/**
|
|
2904
|
+
* The mutation's state once it leaves `pending` (or is no longer tracked),
|
|
2905
|
+
* or as it is when `wait` elapses first.
|
|
2906
|
+
*/
|
|
2907
|
+
const settled = (states, mutationId, wait) => states.pipe(Stream.map((map) => HashMap.get(map, mutationId)), Stream.filter((state) => Option.isNone(state) || state.value.phase !== "pending"), Stream.runHead, Effect.map(Option.flatten), Effect.timeoutOption(wait), Effect.flatMap(Option.match({
|
|
2908
|
+
onNone: () => current(states, mutationId),
|
|
2909
|
+
onSome: Effect.succeed
|
|
2910
|
+
})));
|
|
2911
|
+
// src/Linkage.ts
|
|
2912
|
+
/**
|
|
2913
|
+
* Linkage
|
|
2914
|
+
*
|
|
2915
|
+
* What a document's relationships are resolved from beside the URL's
|
|
2916
|
+
* includes — shared by the derived REST handlers ({@link RestHandlers}) and
|
|
2917
|
+
* the replica-side writer ({@link ReplicaDocuments}), so both sides resolve
|
|
2918
|
+
* the same edges for the same declarations and the one serialiser
|
|
2919
|
+
* ({@link DocumentWriter}) writes the same bytes (ADR-0029 §6).
|
|
2920
|
+
*
|
|
2921
|
+
* **Linkage follows the resource's declaration.** effect-jsonapi's resource
|
|
2922
|
+
* schemas require every declared relationship on every resource object, so
|
|
2923
|
+
* a read resolves, beside its includes, the rows of every `Relationship.many`
|
|
2924
|
+
* the resource declares — on the primary resource, and on each included
|
|
2925
|
+
* resource's own declaration — and writes them as linkage, not `included`.
|
|
2926
|
+
* A `one` relationship's linkage is the edge's key field and costs nothing.
|
|
2927
|
+
* The primary resource's `many` relationships ride in the read's second
|
|
2928
|
+
* phase (they are selected beside its includes, {@link withLinkage}); the
|
|
2929
|
+
* included resources' ride in one more batch keyed by the included rows
|
|
2930
|
+
* ({@link resolve}). Nothing the resource does not declare is resolved, so a
|
|
2931
|
+
* collection the URL never named is never touched — neither for its cost
|
|
2932
|
+
* nor for its policy verdict. A paginated node edge is served under either
|
|
2933
|
+
* declaration: as `Relationship.many` it carries the spec's `links` and its
|
|
2934
|
+
* rows' linkage (the edge's declared order) and can be `include`d; as
|
|
2935
|
+
* `Relationship.paginated` it carries `links` only.
|
|
2936
|
+
*
|
|
2937
|
+
* A declaration the node graph cannot serve (a relationship that is no
|
|
2938
|
+
* edge, or of the other kind, or targeting another type) is a
|
|
2939
|
+
* definition-time defect ({@link planOf} throws).
|
|
2940
|
+
*
|
|
2941
|
+
* The rows come from a {@link Fetch}: the server's authorized batch
|
|
2942
|
+
* evaluation on the REST side (`SyncServer.readSubsets`), the replica's
|
|
2943
|
+
* local view on the worker side. One plan, one join, whichever source
|
|
2944
|
+
* answers (ADR-0025).
|
|
2945
|
+
*
|
|
2946
|
+
* Package-internal: the two callers import it directly; nothing outside
|
|
2947
|
+
* `@thomasfosterau/legation-jsonapi` derives linkage.
|
|
2948
|
+
*/
|
|
2949
|
+
const declared = (resource, name) => `Linkage: relationship "${name}" of resource "${resource.type}"`;
|
|
2950
|
+
const descriptorsOf$1 = (resource) => Object.entries(resource.relationships);
|
|
2951
|
+
/** One declared relationship checked against the node's edge of that name: same name, same arity, same target. */
|
|
2952
|
+
const checkDeclaration = (resource, node, name, descriptor) => {
|
|
2953
|
+
const edge = node.edges[name];
|
|
2954
|
+
if (edge === void 0) throw new Error(`${declared(resource, name)} is not an edge of the "${node.type}" node`);
|
|
2955
|
+
if ((descriptor.kind === "many" || descriptor.kind === "paginated") === (edge.kind === "one")) throw new Error(`${declared(resource, name)} is "${descriptor.kind}" but the node edge is "${edge.kind}"`);
|
|
2956
|
+
if (descriptor.ref().type !== edge.target) throw new Error(`${declared(resource, name)} targets "${descriptor.ref().type}" but the node edge targets "${edge.target}"`);
|
|
2957
|
+
};
|
|
2958
|
+
/**
|
|
2959
|
+
* The resource's relationships as node edges, each checked against the
|
|
2960
|
+
* node's edge of that name. Returns the `Relationship.many` names — the
|
|
2961
|
+
* edges resolved for linkage.
|
|
2962
|
+
*/
|
|
2963
|
+
const manyEdgesOf = (resource, node) => {
|
|
2964
|
+
const descriptors = descriptorsOf$1(resource);
|
|
2965
|
+
descriptors.forEach(([name, descriptor]) => checkDeclaration(resource, node, name, descriptor));
|
|
2966
|
+
return descriptors.flatMap(([name, descriptor]) => descriptor.kind === "many" ? [name] : []);
|
|
2967
|
+
};
|
|
2968
|
+
/** The linkage plan of a resource over its node (a definition-time check of the whole declaration). */
|
|
2969
|
+
const planOf = (nodes, resource, node) => {
|
|
2970
|
+
return {
|
|
2971
|
+
root: manyEdgesOf(resource, node),
|
|
2972
|
+
included: new Map(descriptorsOf$1(resource).flatMap(([name, descriptor]) => {
|
|
2973
|
+
if (descriptor.kind === "paginated") return [];
|
|
2974
|
+
const target = descriptor.ref();
|
|
2975
|
+
const targetNode = nodes.nodes.get(target.type);
|
|
2976
|
+
return targetNode === void 0 ? [] : [[name, manyEdgesOf(target, targetNode)]];
|
|
2977
|
+
}))
|
|
2978
|
+
};
|
|
2979
|
+
};
|
|
2980
|
+
/** The read to run: the URL's read with the root linkage edges selected beside its includes. */
|
|
2981
|
+
const withLinkage = (plan, read) => {
|
|
2982
|
+
const include = {
|
|
2983
|
+
...R.fromIterableWith(plan.root, (name) => [name, true]),
|
|
2984
|
+
...read.include
|
|
2985
|
+
};
|
|
2986
|
+
return R.isEmptyReadonlyRecord(include) ? read : {
|
|
2987
|
+
...read,
|
|
2988
|
+
include
|
|
2989
|
+
};
|
|
2990
|
+
};
|
|
2991
|
+
/**
|
|
2992
|
+
* The collections the declared linkage is resolved from beyond the read's
|
|
2993
|
+
* own includes: the targets of the root's declared `many` edges the read
|
|
2994
|
+
* does not include, and of every declared `many` edge of each included
|
|
2995
|
+
* type. A replica that holds each of these whole answers the linkage
|
|
2996
|
+
* exactly as the server does — the coverage requirement
|
|
2997
|
+
* `ReplicaDocuments.toCover` states.
|
|
2998
|
+
*/
|
|
2999
|
+
const collectionsOf = (nodes, node, plan, read) => {
|
|
3000
|
+
const included = read.include ?? {};
|
|
3001
|
+
const roots = plan.root.flatMap((name) => {
|
|
3002
|
+
const edge = node.edges[name];
|
|
3003
|
+
return included[name] === void 0 && edge !== void 0 ? [edge.target] : [];
|
|
3004
|
+
});
|
|
3005
|
+
const nested = Object.keys(included).flatMap((name) => includedTargets(nodes, node, plan, name));
|
|
3006
|
+
return [.../* @__PURE__ */ new Set([...roots, ...nested])];
|
|
3007
|
+
};
|
|
3008
|
+
/** The targets of the declared `many` edges of included edge `name`'s type. */
|
|
3009
|
+
const includedTargets = (nodes, node, plan, name) => {
|
|
3010
|
+
const via = node.edges[name];
|
|
3011
|
+
const target = via === void 0 ? void 0 : nodes.nodes.get(via.target);
|
|
3012
|
+
if (target === void 0) return [];
|
|
3013
|
+
return (plan.included.get(name) ?? []).flatMap((edge) => {
|
|
3014
|
+
const next = target.edges[edge];
|
|
3015
|
+
return next === void 0 ? [] : [next.target];
|
|
3016
|
+
});
|
|
3017
|
+
};
|
|
3018
|
+
/** The ids of the rows one included edge resolved across the root rows. */
|
|
3019
|
+
const includedIds = (rows, name) => {
|
|
3020
|
+
return new Set(rows.flatMap((row) => relatedValuesOf(row.related[name]).map(idOf$1)));
|
|
3021
|
+
};
|
|
3022
|
+
/** Add an included type's `many` edge to the batch for a set of parents, merging into the entry the batch already has. */
|
|
3023
|
+
const addLinkageQuery = (nodes, queries, type, edge, parents) => {
|
|
3024
|
+
const key = `${type}\0${edge}`;
|
|
3025
|
+
const existing = queries.get(key);
|
|
3026
|
+
if (existing !== void 0) {
|
|
3027
|
+
parents.forEach((id) => existing.parents.add(id));
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
const relationship = Collection.relationshipsOf(nodes.collections, type).get(edge);
|
|
3031
|
+
if (relationship === void 0) return;
|
|
3032
|
+
queries.set(key, {
|
|
3033
|
+
type,
|
|
3034
|
+
edge,
|
|
3035
|
+
collection: relationship.collection,
|
|
3036
|
+
field: relationship.foreignField,
|
|
3037
|
+
order: relationship.order ?? [],
|
|
3038
|
+
parents: new Set(parents)
|
|
3039
|
+
});
|
|
3040
|
+
};
|
|
3041
|
+
/** The included-linkage batch a read needs: one subset per (included type, `many` edge), parents merged. */
|
|
3042
|
+
const linkageQueries = (nodes, plan, node, read, rows) => {
|
|
3043
|
+
const queries = /* @__PURE__ */ new Map();
|
|
3044
|
+
Object.keys(read.include ?? {}).forEach((name) => {
|
|
3045
|
+
const edges = plan.included.get(name) ?? [];
|
|
3046
|
+
const parents = includedIds(rows, name);
|
|
3047
|
+
if (Arr.isReadonlyArrayEmpty(edges) || parents.size === 0) return;
|
|
3048
|
+
const type = node.edges[name]?.target;
|
|
3049
|
+
if (type === void 0) return;
|
|
3050
|
+
edges.forEach((edge) => addLinkageQuery(nodes, queries, type, edge, parents));
|
|
3051
|
+
});
|
|
3052
|
+
return [...queries.values()];
|
|
3053
|
+
};
|
|
3054
|
+
/** The fetched rows of the batch, assigned to their parents: every parent gets every edge, empty or not. */
|
|
3055
|
+
const assignLinkage = (queries, fetched) => {
|
|
3056
|
+
const linkage = /* @__PURE__ */ new Map();
|
|
3057
|
+
const bucket = (type, parent, edge) => {
|
|
3058
|
+
const byId = linkage.get(type) ?? /* @__PURE__ */ new Map();
|
|
3059
|
+
linkage.set(type, byId);
|
|
3060
|
+
const edges = byId.get(parent) ?? {};
|
|
3061
|
+
byId.set(parent, edges);
|
|
3062
|
+
return edges[edge] ??= [];
|
|
3063
|
+
};
|
|
3064
|
+
queries.forEach((query) => {
|
|
3065
|
+
query.parents.forEach((parent) => bucket(query.type, parent, query.edge));
|
|
3066
|
+
fetched.forEach((row) => {
|
|
3067
|
+
if (row.collection !== query.collection) return;
|
|
3068
|
+
const parent = String(row.value[query.field]);
|
|
3069
|
+
if (query.parents.has(parent)) bucket(query.type, parent, query.edge).push(row.value);
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
return linkage;
|
|
3073
|
+
};
|
|
3074
|
+
/**
|
|
3075
|
+
* Run a read over a row source with the linkage its resource declares:
|
|
3076
|
+
* the read's two phases (`Query.runVia`, the root linkage edges selected
|
|
3077
|
+
* beside the includes), then the included resources' `many` edges in one
|
|
3078
|
+
* more batch, keyed by the included rows. The rows and the linkage are
|
|
3079
|
+
* exactly what {@link DocumentWriter} takes.
|
|
3080
|
+
*/
|
|
3081
|
+
const resolve$1 = (fetchRows, nodes, node, plan, read) => Effect.gen(function* () {
|
|
3082
|
+
const rows = yield* Query$1.runVia(fetchRows, nodes, withLinkage(plan, read));
|
|
3083
|
+
const queries = linkageQueries(nodes, plan, node, read, rows);
|
|
3084
|
+
const fetched = Arr.isReadonlyArrayEmpty(queries) ? [] : yield* fetchRows(queries.map((query) => Subset.make({
|
|
3085
|
+
collection: query.collection,
|
|
3086
|
+
where: Predicate.isIn(query.field, [...query.parents]),
|
|
3087
|
+
orderBy: query.order
|
|
3088
|
+
})));
|
|
3089
|
+
return {
|
|
3090
|
+
rows,
|
|
3091
|
+
linkage: assignLinkage(queries, fetched)
|
|
3092
|
+
};
|
|
3093
|
+
});
|
|
3094
|
+
// src/ReplicaDocuments.ts
|
|
3095
|
+
/**
|
|
3096
|
+
* ReplicaDocuments
|
|
3097
|
+
*
|
|
3098
|
+
* JSON:API documents written from the **replica** (issue #164): the
|
|
3099
|
+
* worker-side caller of {@link DocumentWriter}, the counterpart of the
|
|
3100
|
+
* derived REST handlers ({@link RestHandlers}) that write the same
|
|
3101
|
+
* documents from the backend. Given the node graph and a local view, a URL
|
|
3102
|
+
* (or a parsed read with its `fields[]` projection) runs as a read over the
|
|
3103
|
+
* rows the replica holds — `Query.runVia` over the view, the two-phase
|
|
3104
|
+
* include of ADR-0025 with the plan's edge windows applied — and its rows
|
|
3105
|
+
* are serialised by the one serialiser, so the same URL yields the same
|
|
3106
|
+
* document content on either side (ADR-0029 §6). The document sees the
|
|
3107
|
+
* optimistic overlay exactly as `Client.query` does (ADR-0029 §4); it
|
|
3108
|
+
* depends on nothing but `effect` and the two legation packages, so it runs
|
|
3109
|
+
* where the engine runs — a service worker included.
|
|
3110
|
+
*
|
|
3111
|
+
* **How the replica learns which relationships to emit.** The REST tier
|
|
3112
|
+
* derives its linkage from the endpoint's effect-jsonapi resource: every
|
|
3113
|
+
* `Relationship.many` the resource declares is resolved beside the URL's
|
|
3114
|
+
* includes and written as linkage ({@link Linkage}). The replica takes the
|
|
3115
|
+
* same input — a resource declaration per type ({@link Options.resources})
|
|
3116
|
+
* — and derives its plan through the same function, so for the same
|
|
3117
|
+
* declarations both sides resolve the same edges and write the same bytes
|
|
3118
|
+
* by construction; the façade (#166) is built from the app's `HttpApi` and
|
|
3119
|
+
* has these declarations at hand. An app that has only the node graph
|
|
3120
|
+
* passes nothing and gets `JsonApiQuery.resourcesOf(nodes)`: the synthetic
|
|
3121
|
+
* resources whose relationships are the node's edges — every collection
|
|
3122
|
+
* edge (paginated ones as `many`) resolved for linkage — which a REST
|
|
3123
|
+
* handler built over the same synthetic resources reproduces. A resource
|
|
3124
|
+
* the node graph cannot serve is a definition-time defect here as there.
|
|
3125
|
+
*
|
|
3126
|
+
* **One grammar on both sides.** A URL is parsed with the node-derived
|
|
3127
|
+
* codec (`JsonApiQuery.parseRead`), so parity holds when the endpoint is
|
|
3128
|
+
* declared with the same codec — `JsonApiQuery.querySchema(nodes,
|
|
3129
|
+
* collection)`, or declarations equivalent to it: a filter names the edge's
|
|
3130
|
+
* key field (`filter[categoryId]`), `sort` admits the node's sortable set
|
|
3131
|
+
* and `id`, and collection edges are `many` (a `paginated` declaration
|
|
3132
|
+
* refuses `include=` the node grammar accepts). Where the bound resource
|
|
3133
|
+
* declares less than the node — an edge it leaves out, a type it never
|
|
3134
|
+
* reaches — the endpoint cannot emit what the URL asks for, so the replica
|
|
3135
|
+
* refuses it the same way the codec would: an `include` edge or a
|
|
3136
|
+
* `fields[type]` type the resource does not declare is a `QueryError`
|
|
3137
|
+
* naming the parameter, raised before the read runs.
|
|
3138
|
+
*
|
|
3139
|
+
* **What the replica must hold.** The writer resolves everything from the
|
|
3140
|
+
* local view and cannot tell a missing row from an absent one, so a
|
|
3141
|
+
* document is faithful only when the replica holds every row the read and
|
|
3142
|
+
* its linkage need. That is the caller's coverage check (ADR-0029 §3,
|
|
3143
|
+
* `Client.coverageOf`): {@link ReplicaDocuments.toCover} names the reads a
|
|
3144
|
+
* request needs covered — the URL's read as stated, plus one
|
|
3145
|
+
* whole-collection read per collection the declared linkage resolves from
|
|
3146
|
+
* beyond the read's includes (the root's declared `many` edges the URL did
|
|
3147
|
+
* not include, and every declared `many` edge of each included type). A
|
|
3148
|
+
* replica holding each of those whole (under the same auth filter the
|
|
3149
|
+
* server applies) answers the linkage exactly as the server does. The
|
|
3150
|
+
* requirement is sound and conservative: a pin that holds an edge window
|
|
3151
|
+
* without the whole collection is not credited, and a read that is
|
|
3152
|
+
* uncovered goes to the network unchanged (ADR-0029 §4). The façade serves
|
|
3153
|
+
* a URL only when every read `toCover` lists is covered; this module never
|
|
3154
|
+
* decides that itself.
|
|
3155
|
+
*
|
|
3156
|
+
* A URL is network input, so nothing about it is a defect: a collection no
|
|
3157
|
+
* bound resource declares fails every entry point with a `QueryError`, and
|
|
3158
|
+
* the façade routes the request to the network.
|
|
3159
|
+
*/
|
|
3160
|
+
var ReplicaDocuments_exports = /* @__PURE__ */ __exportAll({
|
|
3161
|
+
fetchOf: () => fetchOf,
|
|
3162
|
+
make: () => make$1
|
|
3163
|
+
});
|
|
3164
|
+
/**
|
|
3165
|
+
* The local view as a row source: each subset evaluated over the replica
|
|
3166
|
+
* (confirmed rows with the optimistic overlay), rows tagged with their
|
|
3167
|
+
* collection, batches concatenated in subset order — the shape
|
|
3168
|
+
* `SyncServer.readSubsets` gives the REST tier. The subsets of a batch are
|
|
3169
|
+
* independent, so they run concurrently. A store failure is a defect, as
|
|
3170
|
+
* `Query.run` treats it.
|
|
3171
|
+
*/
|
|
3172
|
+
const fetchOf = (view) => (subsets) => Effect.forEach(subsets, (subset) => view.query(subset).pipe(Effect.orDie, Effect.map((rows) => rows.map((row) => ({
|
|
3173
|
+
collection: subset.collection,
|
|
3174
|
+
id: row.id,
|
|
3175
|
+
value: row.value
|
|
3176
|
+
})))), { concurrency: "unbounded" }).pipe(Effect.map((batches) => batches.flat()));
|
|
3177
|
+
/** Every resource bound to its node (a definition-time check of each declaration). */
|
|
3178
|
+
const bindAll = (nodes, resources) => {
|
|
3179
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
3180
|
+
resources.forEach((resource) => {
|
|
3181
|
+
const node = nodes.nodes.get(resource.type);
|
|
3182
|
+
if (node === void 0) throw new Error(`ReplicaDocuments: no node "${resource.type}" in the graph for the resource`);
|
|
3183
|
+
bindings.set(resource.type, {
|
|
3184
|
+
node,
|
|
3185
|
+
resource,
|
|
3186
|
+
plan: planOf(nodes, resource, node)
|
|
3187
|
+
});
|
|
3188
|
+
});
|
|
3189
|
+
return bindings;
|
|
3190
|
+
};
|
|
3191
|
+
const descriptorsOf = (resource) => resource.relationships;
|
|
3192
|
+
/** The types a resource's document can carry: its own and its relationships' targets. */
|
|
3193
|
+
const carriedTypes = (resource) => [resource.type, ...Object.values(descriptorsOf(resource)).map((descriptor) => descriptor.ref().type)];
|
|
3194
|
+
/**
|
|
3195
|
+
* The rejection of what the bound resource does not declare, in the codec's
|
|
3196
|
+
* shape: an `include` edge that is no relationship of the resource, or a
|
|
3197
|
+
* `fields[type]` type its document cannot carry. Checked before the read
|
|
3198
|
+
* runs, so a refused request costs no query.
|
|
3199
|
+
*/
|
|
3200
|
+
const undeclared = (binding, parsed) => {
|
|
3201
|
+
const { resource } = binding;
|
|
3202
|
+
const relationships = descriptorsOf(resource);
|
|
3203
|
+
const name = Object.keys(parsed.read.include ?? {}).find((included) => relationships[included] === void 0);
|
|
3204
|
+
if (name !== void 0) return invalidParameter(resource.type, "include", `"${name}" is not a relationship the "${resource.type}" resource declares (relationships: ${Object.keys(relationships).join(", ")})`);
|
|
3205
|
+
const types = carriedTypes(resource);
|
|
3206
|
+
const type = Object.keys(parsed.fields ?? {}).find((projected) => !types.includes(projected));
|
|
3207
|
+
if (type !== void 0) return invalidParameter(resource.type, `fields[${type}]`, `"${type}" is not a type the "${resource.type}" resource can carry (types: ${types.join(", ")})`);
|
|
3208
|
+
};
|
|
3209
|
+
/** Bind the replica-side writer to a graph and its declarations (a definition-time check of every declaration). */
|
|
3210
|
+
const make$1 = (options) => {
|
|
3211
|
+
const { nodes, prefix } = options;
|
|
3212
|
+
const bindings = bindAll(nodes, options.resources ?? resourcesOf(nodes));
|
|
3213
|
+
/** The binding of a read's collection; a type nothing declares is the URL's failure, not a defect. */
|
|
3214
|
+
const bound = (read) => {
|
|
3215
|
+
const binding = bindings.get(read.collection);
|
|
3216
|
+
return binding === void 0 ? Effect.fail(new QueryError({
|
|
3217
|
+
message: `No resource declares "${read.collection}"; the replica cannot serve it`,
|
|
3218
|
+
query: read.collection
|
|
3219
|
+
})) : Effect.succeed(binding);
|
|
3220
|
+
};
|
|
3221
|
+
/** The binding of a parsed read, with what the resource does not declare refused. */
|
|
3222
|
+
const checked = (parsed) => Effect.flatMap(bound(parsed.read), (binding) => {
|
|
3223
|
+
const error = undeclared(binding, parsed);
|
|
3224
|
+
return error === void 0 ? Effect.succeed(binding) : Effect.fail(error);
|
|
3225
|
+
});
|
|
3226
|
+
/** The writer's input for a parsed read's rows off the view. */
|
|
3227
|
+
const input = (view, parsed) => Effect.flatMap(checked(parsed), ({ node, plan }) => resolve$1(fetchOf(view), nodes, node, plan, parsed.read).pipe(Effect.map((resolved) => ({
|
|
3228
|
+
nodes,
|
|
3229
|
+
read: parsed.read,
|
|
3230
|
+
rows: resolved.rows,
|
|
3231
|
+
linkage: resolved.linkage,
|
|
3232
|
+
...parsed.fields === void 0 ? {} : { fields: parsed.fields },
|
|
3233
|
+
...prefix === void 0 ? {} : { prefix }
|
|
3234
|
+
}))));
|
|
3235
|
+
const collection$1 = (view, parsed) => Effect.map(input(view, parsed), collection);
|
|
3236
|
+
const data$1 = (view, parsed) => Effect.map(input(view, parsed), data);
|
|
3237
|
+
const fromUrl = (view, url) => parseRead(nodes, url).pipe(Effect.flatMap((parsed) => parsed.id === void 0 ? collection$1(view, parsed) : data$1(view, parsed)));
|
|
3238
|
+
const toCover = (read) => Effect.map(bound(read), ({ node, plan }) => [read, ...collectionsOf(nodes, node, plan, read).map((name) => ({ collection: name }))]);
|
|
3239
|
+
return {
|
|
3240
|
+
collection: collection$1,
|
|
3241
|
+
data: data$1,
|
|
3242
|
+
fromUrl,
|
|
3243
|
+
toCover
|
|
3244
|
+
};
|
|
3245
|
+
};
|
|
3246
|
+
// src/RestHandlers.ts
|
|
3247
|
+
/**
|
|
3248
|
+
* RestHandlers
|
|
3249
|
+
*
|
|
3250
|
+
* Derived JSON:API `list` / `get` handlers over the sync engine's read path
|
|
3251
|
+
* (issue #163). A JSON:API `GET` is ADR-0025's `read` operation with a
|
|
3252
|
+
* document body: the URL is parsed into a `Query.Read` by the one grammar
|
|
3253
|
+
* (`JsonApiQuery.parseParams`), the request's identity becomes a
|
|
3254
|
+
* `SyncContext`, the read runs through `Query.plan` → the filterable check
|
|
3255
|
+
* and the deny-by-default auth filters (`Authorization.authorize`) →
|
|
3256
|
+
* `Backend.evaluate` + `getRows` with ADR-0025's two-phase include
|
|
3257
|
+
* resolution (`Query.runVia` over `SyncServer.readSubsets` — the very
|
|
3258
|
+
* function the protocol's `handleRead` runs), and the rows are serialised
|
|
3259
|
+
* by {@link DocumentWriter}. Two carriers, one evaluator, one auth path:
|
|
3260
|
+
* what the sync tier syncs for a window is what the REST tier answers for
|
|
3261
|
+
* the same URL, so a façade serving that URL from the replica (ADR-0029 §6)
|
|
3262
|
+
* can match it.
|
|
3263
|
+
*
|
|
3264
|
+
* Scoping is the auth policy's, never the handler's: a request without
|
|
3265
|
+
* identity to a private collection sees the policy's empty allow-set —
|
|
3266
|
+
* an empty collection for `list`, a 404 for `get` — exactly as the sync
|
|
3267
|
+
* tier's `isIn(field, [])` behaves. No `WHERE userId` lives here.
|
|
3268
|
+
*
|
|
3269
|
+
* Sorting and paging are `Subset.apply`'s (ADR-0026 / ADR-0028): code-point
|
|
3270
|
+
* text order, NULLs last, the id tie-break, `page[after]` as the cursor —
|
|
3271
|
+
* the REST tier inherits them by construction.
|
|
3272
|
+
*
|
|
3273
|
+
* **Linkage follows the resource's declaration** ({@link Linkage}, shared
|
|
3274
|
+
* with the replica-side writer): beside the URL's includes, the rows of
|
|
3275
|
+
* every `Relationship.many` the endpoint's resource declares are resolved —
|
|
3276
|
+
* on the primary resource, and on each included resource's own declaration
|
|
3277
|
+
* — and written as linkage, not `included`; nothing the resource does not
|
|
3278
|
+
* declare is touched. A paginated node edge under `Relationship.paginated`
|
|
3279
|
+
* carries `links` only, and the endpoint's own codec refuses `include=`
|
|
3280
|
+
* for it. A declaration the node graph cannot serve is a definition-time
|
|
3281
|
+
* defect, thrown when the handler is built.
|
|
3282
|
+
*
|
|
3283
|
+
* **Outcomes the derivation owns.** A URL the grammar rejects is answered
|
|
3284
|
+
* with the codec's 400 error document, `source.parameter` naming the
|
|
3285
|
+
* offending key, before any backend call; a collection the policy denies
|
|
3286
|
+
* (no auth filter declared) is a 403, and within a batch every subset is
|
|
3287
|
+
* authorized before any is evaluated. Both are written as JSON:API error
|
|
3288
|
+
* responses by the handler — effect-jsonapi's `ApiError` classes carry no
|
|
3289
|
+
* `source` member, and every endpoint already declares the 400. A `get`
|
|
3290
|
+
* that matches no row fails with the error the caller declares on the
|
|
3291
|
+
* endpoint (`notFound`), so the api's own 404 class is what the client
|
|
3292
|
+
* decodes. A backend failure is a defect (500), as the hand-written
|
|
3293
|
+
* handlers' `orDie` made it.
|
|
3294
|
+
*
|
|
3295
|
+
* Derivation is opt-in per endpoint: `handlers.handle("list", rest.list(Product))`
|
|
3296
|
+
* beside hand-written handlers on the same api.
|
|
3297
|
+
*/
|
|
3298
|
+
var RestHandlers_exports = /* @__PURE__ */ __exportAll({
|
|
3299
|
+
make: () => make,
|
|
3300
|
+
queryErrorDocument: () => queryErrorDocument
|
|
3301
|
+
});
|
|
3302
|
+
/**
|
|
3303
|
+
* The 400 error document of a URL the grammar rejects: the codec's own
|
|
3304
|
+
* document (`Middleware.schemaErrorDocument`, one error object per issue,
|
|
3305
|
+
* each with `source.parameter`) when the rejection came from decoding; the
|
|
3306
|
+
* same shape around the `QueryError`'s message and `parameter` when it came
|
|
3307
|
+
* from the graph checks that run before decoding (an undeclared include, a
|
|
3308
|
+
* `fields[]` type the read cannot carry). The façade answers a parse failure
|
|
3309
|
+
* with this document too (ADR-0029 §1).
|
|
3310
|
+
*/
|
|
3311
|
+
const queryErrorDocument = (error) => {
|
|
3312
|
+
if (Schema.isSchemaError(error.cause)) return Middleware.schemaErrorDocument("Query", error.cause);
|
|
3313
|
+
return { errors: [{
|
|
3314
|
+
status: String(ApiError$1.BadRequest.status),
|
|
3315
|
+
code: ApiError$1.BadRequest.code,
|
|
3316
|
+
...ApiError$1.BadRequest.title === void 0 ? {} : { title: ApiError$1.BadRequest.title },
|
|
3317
|
+
detail: error.message,
|
|
3318
|
+
...error.parameter === void 0 ? {} : { source: { parameter: error.parameter } }
|
|
3319
|
+
}] };
|
|
3320
|
+
};
|
|
3321
|
+
/** The 403 error document of a collection the policy denies. */
|
|
3322
|
+
const forbiddenDocument = (error) => ({ errors: [{
|
|
3323
|
+
status: String(ApiError$1.Forbidden.status),
|
|
3324
|
+
code: ApiError$1.Forbidden.code,
|
|
3325
|
+
...ApiError$1.Forbidden.title === void 0 ? {} : { title: ApiError$1.Forbidden.title },
|
|
3326
|
+
detail: error.message
|
|
3327
|
+
}] });
|
|
3328
|
+
/** An error document as a JSON:API response. */
|
|
3329
|
+
const errorResponse = (status, document) => HttpServerResponse.jsonUnsafe(document, {
|
|
3330
|
+
status,
|
|
3331
|
+
contentType: MEDIA_TYPE
|
|
3332
|
+
});
|
|
3333
|
+
/** `HttpServerRequest.url` is path + query; any origin makes it parseable. */
|
|
3334
|
+
const searchParamsOf = (request) => new URL(request.url, "http://localhost").searchParams;
|
|
3335
|
+
/** An outcome the derivation owns, as a response; a backend failure is a defect. */
|
|
3336
|
+
const respondToFailures = (effect) => Effect.catch(effect, (error) => {
|
|
3337
|
+
if (error instanceof QueryError) return Effect.succeed(errorResponse(ApiError$1.BadRequest.status, queryErrorDocument(error)));
|
|
3338
|
+
if (error instanceof AuthorizationError) return Effect.succeed(errorResponse(ApiError$1.Forbidden.status, forbiddenDocument(error)));
|
|
3339
|
+
if (error instanceof BackendError) return Effect.die(error);
|
|
3340
|
+
return Effect.fail(error);
|
|
3341
|
+
});
|
|
3342
|
+
/**
|
|
3343
|
+
* Bind the derived handlers to a graph and a read path. The node of a
|
|
3344
|
+
* resource is looked up by its `type` when the handler is built and the
|
|
3345
|
+
* resource's relationships are checked against the node's edges then, so a
|
|
3346
|
+
* declaration the graph cannot serve is a definition-time defect, never a
|
|
3347
|
+
* 500.
|
|
3348
|
+
*/
|
|
3349
|
+
const make = (options) => {
|
|
3350
|
+
const { nodes, backend, policy, middleware, prefix } = options;
|
|
3351
|
+
const deps = {
|
|
3352
|
+
backend,
|
|
3353
|
+
authPolicy: policy,
|
|
3354
|
+
...middleware && { middleware }
|
|
3355
|
+
};
|
|
3356
|
+
const nodeOf = (resource) => {
|
|
3357
|
+
const node = nodes.nodes.get(resource.type);
|
|
3358
|
+
if (node === void 0) throw new Error(`RestHandlers: no node "${resource.type}" in the graph for the resource`);
|
|
3359
|
+
return node;
|
|
3360
|
+
};
|
|
3361
|
+
const run = (context, node, plan, read) => resolve$1((subsets) => SyncServer.readSubsets(deps, context, {
|
|
3362
|
+
clientGroupId: context.clientGroupId,
|
|
3363
|
+
subsets
|
|
3364
|
+
}), nodes, node, plan, read);
|
|
3365
|
+
/** The writer's input for a read's rows (the projection and prefix only when given). */
|
|
3366
|
+
const input = (read, resolved, fields) => ({
|
|
3367
|
+
nodes,
|
|
3368
|
+
read,
|
|
3369
|
+
rows: resolved.rows,
|
|
3370
|
+
linkage: resolved.linkage,
|
|
3371
|
+
...fields === void 0 ? {} : { fields },
|
|
3372
|
+
...prefix === void 0 ? {} : { prefix }
|
|
3373
|
+
});
|
|
3374
|
+
const list = (resource) => {
|
|
3375
|
+
const node = nodeOf(resource);
|
|
3376
|
+
const plan = planOf(nodes, resource, node);
|
|
3377
|
+
return ({ request }) => respondToFailures(Effect.gen(function* () {
|
|
3378
|
+
const context = yield* options.context(request);
|
|
3379
|
+
const { read, fields } = yield* parseParams(nodes, node.type, searchParamsOf(request));
|
|
3380
|
+
const resolved = yield* run(context, node, plan, read);
|
|
3381
|
+
return collection(input(read, resolved, fields));
|
|
3382
|
+
}));
|
|
3383
|
+
};
|
|
3384
|
+
const get = (resource, { notFound }) => {
|
|
3385
|
+
const node = nodeOf(resource);
|
|
3386
|
+
const plan = planOf(nodes, resource, node);
|
|
3387
|
+
return ({ params, request }) => respondToFailures(Effect.gen(function* () {
|
|
3388
|
+
const context = yield* options.context(request);
|
|
3389
|
+
const single = yield* singleRowParams(node.type, params.id, searchParamsOf(request));
|
|
3390
|
+
const { read, fields } = yield* parseParams(nodes, node.type, single);
|
|
3391
|
+
const resolved = yield* run(context, node, plan, read);
|
|
3392
|
+
if (Arr.isReadonlyArrayEmpty(resolved.rows)) return yield* Effect.fail(notFound());
|
|
3393
|
+
return data(input(read, resolved, fields));
|
|
3394
|
+
}));
|
|
3395
|
+
};
|
|
3396
|
+
return {
|
|
3397
|
+
list,
|
|
3398
|
+
get
|
|
3399
|
+
};
|
|
3400
|
+
};
|
|
3401
|
+
// src/JsonApiFacade.ts
|
|
3402
|
+
/**
|
|
3403
|
+
* JsonApiFacade
|
|
3404
|
+
*
|
|
3405
|
+
* The façade of ADR-0029 (issue #166): a fetch handler that answers a
|
|
3406
|
+
* JSON:API `GET` from the replica when an explicit subscription covers it,
|
|
3407
|
+
* and stays out of the way otherwise. A service worker composes it into its
|
|
3408
|
+
* `fetch` listener **first**, before the HTTP cache and the network, so a
|
|
3409
|
+
* cached copy never becomes a second source of truth for a locally
|
|
3410
|
+
* answerable document (ADR-0029 §1).
|
|
3411
|
+
*
|
|
3412
|
+
* The handler is built from the app's `HttpApi` and the replica it reads:
|
|
3413
|
+
*
|
|
3414
|
+
* 1. **Route reflection.** `Convention.reflectResources(api)` gives the
|
|
3415
|
+
* `list` and `getOne` routes per type; those whose type is a node of the
|
|
3416
|
+
* graph are kept, so a route the replica cannot read (a server-only
|
|
3417
|
+
* resource) is never the façade's. A request is its business iff its
|
|
3418
|
+
* method is `GET`, its `Accept` names `application/vnd.api+json`, and its
|
|
3419
|
+
* path — under {@link Options.baseUrl} — matches one of those route
|
|
3420
|
+
* templates segment for segment. Anything else yields `undefined` and
|
|
3421
|
+
* the caller composes what comes next.
|
|
3422
|
+
* 2. **Parse.** The URL becomes a `Query.Read` through the one grammar
|
|
3423
|
+
* (`JsonApiQuery.parseRead`). A URL the grammar rejects is answered with
|
|
3424
|
+
* the codec's 400 error document (`RestHandlers.queryErrorDocument`),
|
|
3425
|
+
* whether or not the request is covered, because the server rejects it
|
|
3426
|
+
* identically — except an `include` the graph does not declare, which
|
|
3427
|
+
* goes to the network where the server rules on it (ADR-0029 §1), and a
|
|
3428
|
+
* path the graph cannot place, likewise.
|
|
3429
|
+
* 3. **Coverage.** Every read `ReplicaDocuments.toCover` names for the
|
|
3430
|
+
* request — the read itself and the whole collections its declared
|
|
3431
|
+
* linkage resolves from — is checked with {@link Options.coverageOf}, the
|
|
3432
|
+
* ledger's verdict (`Client.coverageOf`, or `Coverage.coverageOf` over
|
|
3433
|
+
* the registrations an `EngineHost` holds on mirrors' behalf). One
|
|
3434
|
+
* uncovered read sends the request to the network unchanged.
|
|
3435
|
+
* 4. **Answer.** A covered read runs over the view — confirmed rows with the
|
|
3436
|
+
* optimistic overlay, exactly what `Client.query` sees for a covered read
|
|
3437
|
+
* — and is serialised by the one writer, so the document's content is
|
|
3438
|
+
* byte-equal to the derived REST handler's for the same declarations
|
|
3439
|
+
* (ADR-0029 §6). The response is `200` `application/vnd.api+json` with
|
|
3440
|
+
* `Cache-Control: no-store` (the replica is the cache) and
|
|
3441
|
+
* `x-legation-served-at: <cookie>`, the replica's CVR cookie at the time
|
|
3442
|
+
* of the answer, so devtools and tests can
|
|
3443
|
+
* tell a local answer from a network one.
|
|
3444
|
+
* 5. **Offline.** An uncovered request while the network is unreachable —
|
|
3445
|
+
* `navigator.onLine === false` here, or a forwarded fetch that rejected
|
|
3446
|
+
* at the transport in the caller ({@link FetchHandler.offline}) — is
|
|
3447
|
+
* answered with the 503 `offline` error document, the one error the
|
|
3448
|
+
* façade synthesises that the server never would (ADR-0029 §4). A status
|
|
3449
|
+
* code from the server is never offline, and a covered request is never
|
|
3450
|
+
* affected by connectivity.
|
|
3451
|
+
*
|
|
3452
|
+
* **The parity assumption.** Answering a parse failure locally, and a
|
|
3453
|
+
* covered request at all, presumes the server rules on the same URL the
|
|
3454
|
+
* same way: its read endpoints are declared with the graph's own codec
|
|
3455
|
+
* (`JsonApiQuery.querySchema` for a `list`, `singleRowSchema` for a `get`)
|
|
3456
|
+
* and served by the derived handlers (`RestHandlers`), as the commerce
|
|
3457
|
+
* example does. A hand-written server with another grammar — a filter key
|
|
3458
|
+
* the node does not declare, a `sort` the codec refuses, a different `page`
|
|
3459
|
+
* vocabulary — must not compose the façade: its 400s and its documents
|
|
3460
|
+
* would diverge from the server's.
|
|
3461
|
+
*
|
|
3462
|
+
* 6. **Writes** (ADR-0030, with {@link Options.mutate} set). A `POST` /
|
|
3463
|
+
* `PATCH` / `DELETE` on a reflected write route of a type the graph
|
|
3464
|
+
* declares (and {@link Options.writes} allows) is forwarded unchanged
|
|
3465
|
+
* while online and unasked; offline, after a forward that rejected at the
|
|
3466
|
+
* transport, or on `Prefer: respond-async`, it is decoded structurally
|
|
3467
|
+
* (`FacadeWrites`), mapped to a mutation by the intent mapping (default:
|
|
3468
|
+
* the graph-derived CRUD mutations, `CrudMutation`), journaled, and
|
|
3469
|
+
* answered with a `202 Accepted` **write receipt**: the resource as
|
|
3470
|
+
* journaled, read back through the one writer, `meta.legation.status:
|
|
3471
|
+
* "pending"` and a `Location` naming the mutation status URL. Never `201`,
|
|
3472
|
+
* `200` or `204`: those are the server's words for "applied". A write it
|
|
3473
|
+
* cannot journal is forwarded online; offline it gets ADR-0030 §3's error
|
|
3474
|
+
* documents: `400 invalid_document`, `409 type_mismatch` / `id_mismatch`
|
|
3475
|
+
* or `422 unsupported_relationship` for a malformed body, and the `503`
|
|
3476
|
+
* `offline` document otherwise, with `client_id_required` appended for a
|
|
3477
|
+
* create without an id and `not_journalable` for a mutation the engine
|
|
3478
|
+
* refuses. Attribute values are never the façade's to refuse.
|
|
3479
|
+
*
|
|
3480
|
+
* 7. **The mutation status URL** (ADR-0030 §2, with
|
|
3481
|
+
* {@link Options.mutationStates} set): `GET {prefix}/_legation/mutations/{id}`
|
|
3482
|
+
* beside every reflected prefix is answered from the engine's
|
|
3483
|
+
* mutation-state map and never forwarded (`MutationStatus`): `200` with
|
|
3484
|
+
* the mutation's phase, `404 unknown_mutation` for an id the map does not
|
|
3485
|
+
* hold, `Prefer: wait=N` holding a pending answer up to
|
|
3486
|
+
* {@link Options.maxWait}.
|
|
3487
|
+
*
|
|
3488
|
+
* What it never does: register a subscription because a request arrived,
|
|
3489
|
+
* journal a write online unless asked, or rewrite a request it forwards. A single-resource
|
|
3490
|
+
* request whose row the covered replica does not hold is forwarded too: the
|
|
3491
|
+
* 404 is the endpoint's own error class, which only the server can write.
|
|
3492
|
+
*
|
|
3493
|
+
* It lives in `@thomasfosterau/legation-jsonapi` rather than `@thomasfosterau/legation-browser` by
|
|
3494
|
+
* ADR-0006's rule: its dependencies are this package's (the codec, the
|
|
3495
|
+
* writer, route reflection) plus the standard `Request` / `Response`
|
|
3496
|
+
* globals, and the one browser-only reading — `navigator.onLine` — is an
|
|
3497
|
+
* option with a guarded default. `@thomasfosterau/legation-browser` therefore stays free of
|
|
3498
|
+
* effect-jsonapi; its `ServiceWorkerHost.start` hands a worker what this
|
|
3499
|
+
* handler needs from the host.
|
|
3500
|
+
*/
|
|
3501
|
+
var JsonApiFacade_exports = /* @__PURE__ */ __exportAll({
|
|
3502
|
+
fetchHandler: () => fetchHandler,
|
|
3503
|
+
offlineDocument: () => offlineDocument,
|
|
3504
|
+
offlineResponse: () => offlineResponse,
|
|
3505
|
+
servedAtHeader: () => servedAtHeader
|
|
3506
|
+
});
|
|
3507
|
+
/** The header a locally served response carries: the replica's CVR cookie at the time of the answer. */
|
|
3508
|
+
const servedAtHeader = "x-legation-served-at";
|
|
3509
|
+
/** The 503 error document of an uncovered request while offline (ADR-0029 §4). */
|
|
3510
|
+
const offlineDocument = { errors: [{
|
|
3511
|
+
status: "503",
|
|
3512
|
+
code: "offline",
|
|
3513
|
+
title: "Service unavailable",
|
|
3514
|
+
detail: "The request is not covered by a local subscription and the network is unreachable."
|
|
3515
|
+
}] };
|
|
3516
|
+
/** Every response the façade synthesises: JSON:API, never cached, marked as served locally. */
|
|
3517
|
+
const respondWith = (status, body, cookie, headers = {}) => new Response(JSON.stringify(body), {
|
|
3518
|
+
status,
|
|
3519
|
+
headers: {
|
|
3520
|
+
...headers,
|
|
3521
|
+
"content-type": MEDIA_TYPE,
|
|
3522
|
+
"cache-control": "no-store",
|
|
3523
|
+
[servedAtHeader]: cookie ?? ""
|
|
3524
|
+
}
|
|
3525
|
+
});
|
|
3526
|
+
/** One error object of a façade-synthesised write error, `source.pointer` when it names one. */
|
|
3527
|
+
const errorObject = (refusal) => {
|
|
3528
|
+
const error = {
|
|
3529
|
+
status: String(refusal.status),
|
|
3530
|
+
code: refusal.code,
|
|
3531
|
+
title: refusal.title,
|
|
3532
|
+
detail: refusal.detail
|
|
3533
|
+
};
|
|
3534
|
+
return refusal.pointer === void 0 ? error : {
|
|
3535
|
+
...error,
|
|
3536
|
+
source: { pointer: refusal.pointer }
|
|
3537
|
+
};
|
|
3538
|
+
};
|
|
3539
|
+
/** The 503 `offline` document with a second error object saying why the write was not journaled. */
|
|
3540
|
+
const offlineWith = (reason) => ({ errors: [...offlineDocument.errors, errorObject(reason)] });
|
|
3541
|
+
/**
|
|
3542
|
+
* The offline answer to a write it could not journal (ADR-0030 §3): a
|
|
3543
|
+
* structural refusal is its own `400` / `409` / `422` document — the statuses
|
|
3544
|
+
* JSON:API prescribes for the same faults, so no divergence from the server —
|
|
3545
|
+
* except a create without a client id, which is the `503` `offline` document
|
|
3546
|
+
* with `client_id_required` appended; an engine refusal is `503` `offline`
|
|
3547
|
+
* with `not_journalable`; anything else the plain `503` `offline`.
|
|
3548
|
+
*/
|
|
3549
|
+
const unjournaledDocument = (failure) => Match.valueTags(failure, {
|
|
3550
|
+
Refused: ({ refusal }) => refusal.status === 503 ? [503, offlineWith(refusal)] : [refusal.status, { errors: [errorObject(refusal)] }],
|
|
3551
|
+
NotJournalable: ({ detail }) => [503, offlineWith({
|
|
3552
|
+
status: 503,
|
|
3553
|
+
code: "not_journalable",
|
|
3554
|
+
title: "Not journalable",
|
|
3555
|
+
detail
|
|
3556
|
+
})],
|
|
3557
|
+
Unmapped: () => [503, offlineDocument]
|
|
3558
|
+
});
|
|
3559
|
+
/** The 503 response of an uncovered request while offline, marked with the cookie when known. */
|
|
3560
|
+
const offlineResponse = (cookie) => respondWith(503, offlineDocument, cookie);
|
|
3561
|
+
const routeOf = (type, template, single) => {
|
|
3562
|
+
const segments = segmentsOf(template);
|
|
3563
|
+
const prefix = segments.slice(0, segments.length - (single ? 2 : 1));
|
|
3564
|
+
return {
|
|
3565
|
+
type,
|
|
3566
|
+
segments,
|
|
3567
|
+
prefix: Arr.isReadonlyArrayEmpty(prefix) ? "" : `/${prefix.join("/")}`
|
|
3568
|
+
};
|
|
3569
|
+
};
|
|
3570
|
+
/** The read routes of the api whose type the graph declares: a `list` and a `getOne` per such resource. */
|
|
3571
|
+
const routesOf = (api, nodes) => {
|
|
3572
|
+
return [...reflectResources(api).values()].flatMap((resource) => {
|
|
3573
|
+
if (!nodes.nodes.has(resource.type)) return [];
|
|
3574
|
+
const list = resource.list === void 0 ? [] : [routeOf(resource.type, resource.list.path, false)];
|
|
3575
|
+
const one = resource.getOne === void 0 ? [] : [routeOf(resource.type, resource.getOne.path, true)];
|
|
3576
|
+
return list.concat(one);
|
|
3577
|
+
});
|
|
3578
|
+
};
|
|
3579
|
+
const writeMethods = {
|
|
3580
|
+
create: "POST",
|
|
3581
|
+
update: "PATCH",
|
|
3582
|
+
delete: "DELETE"
|
|
3583
|
+
};
|
|
3584
|
+
/** The write routes of the api whose type the graph declares and `writes` allows. */
|
|
3585
|
+
const writeRoutesOf = (api, nodes, writes) => Arr.flatMap([...reflectResources(api).values()], (resource) => {
|
|
3586
|
+
const node = nodes.nodes.get(resource.type);
|
|
3587
|
+
if (node === void 0) return [];
|
|
3588
|
+
return Arr.filterMap([
|
|
3589
|
+
"create",
|
|
3590
|
+
"update",
|
|
3591
|
+
"delete"
|
|
3592
|
+
], (operation) => {
|
|
3593
|
+
const endpoint = resource[operation];
|
|
3594
|
+
const method = writeMethods[operation];
|
|
3595
|
+
const allowed = writes === void 0 || (writes[resource.type] ?? []).includes(operation);
|
|
3596
|
+
return endpoint === void 0 || endpoint.method.toUpperCase() !== method || !allowed ? Result.failVoid : Result.succeed({
|
|
3597
|
+
...routeOf(resource.type, endpoint.path, operation !== "create"),
|
|
3598
|
+
operation,
|
|
3599
|
+
node,
|
|
3600
|
+
method
|
|
3601
|
+
});
|
|
3602
|
+
});
|
|
3603
|
+
});
|
|
3604
|
+
const matchesTemplate = (route, segments) => route.segments.length === segments.length && route.segments.every((template, index) => isParam(template) ? true : template === segments[index]);
|
|
3605
|
+
const baseOf = (baseUrl) => {
|
|
3606
|
+
if (baseUrl === "" || baseUrl.startsWith("/")) return { path: baseUrl.replace(/\/+$/, "") };
|
|
3607
|
+
const url = new URL(baseUrl);
|
|
3608
|
+
return {
|
|
3609
|
+
origin: url.origin,
|
|
3610
|
+
path: url.pathname.replace(/\/+$/, "")
|
|
3611
|
+
};
|
|
3612
|
+
};
|
|
3613
|
+
/**
|
|
3614
|
+
* The request path relative to the base, or `undefined` when it lies
|
|
3615
|
+
* elsewhere. A trailing slash is left to the server: the route templates
|
|
3616
|
+
* carry none, and whether `/products/` is `/products` is its ruling.
|
|
3617
|
+
*/
|
|
3618
|
+
const pathUnder = (base, url) => {
|
|
3619
|
+
if (base.origin !== void 0 && url.origin !== base.origin) return void 0;
|
|
3620
|
+
if (!url.pathname.startsWith(base.path) || url.pathname.endsWith("/")) return void 0;
|
|
3621
|
+
const rest = url.pathname.slice(base.path.length);
|
|
3622
|
+
return rest.startsWith("/") ? rest : void 0;
|
|
3623
|
+
};
|
|
3624
|
+
/** Whether an `Accept` header names the JSON:API media type (parameters aside). */
|
|
3625
|
+
const acceptsJsonApi = (header) => header !== null && header.split(",").some((part) => (part.trim().split(";")[0] ?? "").trim().toLowerCase() === MEDIA_TYPE);
|
|
3626
|
+
/**
|
|
3627
|
+
* Whether a `Content-Type` is the JSON:API media type without the atomic
|
|
3628
|
+
* extension: an `ext` parameter naming atomic marks the operations endpoint,
|
|
3629
|
+
* never a façade write (ADR-0030 §5).
|
|
3630
|
+
*/
|
|
3631
|
+
const sendsJsonApi = (header) => {
|
|
3632
|
+
if (header === null) return false;
|
|
3633
|
+
const [type = "", ...parameters] = header.split(";");
|
|
3634
|
+
if (type.trim().toLowerCase() !== MEDIA_TYPE) return false;
|
|
3635
|
+
return !parameters.some((parameter) => {
|
|
3636
|
+
const [name = "", value = ""] = parameter.split("=");
|
|
3637
|
+
return name.trim().toLowerCase() === "ext" && value.toLowerCase().includes("atomic");
|
|
3638
|
+
});
|
|
3639
|
+
};
|
|
3640
|
+
const navigatorOnline = () => {
|
|
3641
|
+
return globalThis.navigator?.onLine !== false;
|
|
3642
|
+
};
|
|
3643
|
+
const forward = { _tag: "Forward" };
|
|
3644
|
+
const respond = (response) => ({
|
|
3645
|
+
_tag: "Respond",
|
|
3646
|
+
response
|
|
3647
|
+
});
|
|
3648
|
+
/**
|
|
3649
|
+
* A grammar rejection as the server would rule on it. An `include` the
|
|
3650
|
+
* graph does not declare is the server's call (ADR-0029 §1), and so is a
|
|
3651
|
+
* path the graph cannot place (no parameter, no decoding issue: the URL
|
|
3652
|
+
* names something the graph does not); anything else is the codec's 400,
|
|
3653
|
+
* which the server returns identically.
|
|
3654
|
+
*/
|
|
3655
|
+
const queryFailure = (error, cookie) => error.parameter === "include" || error.parameter === void 0 && !Schema.isSchemaError(error.cause) ? forward : respond(respondWith(400, queryErrorDocument(error), cookie));
|
|
3656
|
+
/**
|
|
3657
|
+
* Build the façade over an api and a replica. Route reflection and the
|
|
3658
|
+
* writer's declaration checks run here, so a declaration the graph cannot
|
|
3659
|
+
* serve is a definition-time defect, never a response.
|
|
3660
|
+
*/
|
|
3661
|
+
const fetchHandler = (options) => {
|
|
3662
|
+
const { nodes, view, coverageOf } = options;
|
|
3663
|
+
const routes = routesOf(options.api, nodes);
|
|
3664
|
+
const mutate = options.mutate;
|
|
3665
|
+
const writeRoutes = mutate === void 0 ? [] : writeRoutesOf(options.api, nodes, options.writes);
|
|
3666
|
+
const intentOf = options.intentOf ?? graphIntentOf(nodes);
|
|
3667
|
+
const base = baseOf(options.baseUrl ?? "");
|
|
3668
|
+
const online = options.online ?? navigatorOnline;
|
|
3669
|
+
/** The cookie at the time of an answer; read only when a response is synthesised. */
|
|
3670
|
+
const cookie = options.cookie === void 0 ? Effect.succeed(void 0) : Effect.catch(options.cookie, () => Effect.succeed(void 0));
|
|
3671
|
+
const writers = /* @__PURE__ */ new Map();
|
|
3672
|
+
/** The writer for a link prefix: one per prefix, built when a route first needs it. */
|
|
3673
|
+
const writerFor = (prefix) => {
|
|
3674
|
+
const existing = writers.get(prefix);
|
|
3675
|
+
if (existing !== void 0) return existing;
|
|
3676
|
+
const made = make$1({
|
|
3677
|
+
nodes,
|
|
3678
|
+
prefix,
|
|
3679
|
+
...options.resources === void 0 ? {} : { resources: options.resources }
|
|
3680
|
+
});
|
|
3681
|
+
writers.set(prefix, made);
|
|
3682
|
+
return made;
|
|
3683
|
+
};
|
|
3684
|
+
routes.forEach((route) => {
|
|
3685
|
+
writerFor(options.prefix ?? route.prefix);
|
|
3686
|
+
});
|
|
3687
|
+
const candidateOf = (request) => {
|
|
3688
|
+
if (request.method !== "GET" || !acceptsJsonApi(request.headers.get("accept"))) return;
|
|
3689
|
+
const url = new URL(request.url);
|
|
3690
|
+
const path = pathUnder(base, url);
|
|
3691
|
+
if (path === void 0) return void 0;
|
|
3692
|
+
const segments = segmentsOf(path);
|
|
3693
|
+
const route = routes.find((candidate) => matchesTemplate(candidate, segments));
|
|
3694
|
+
return route === void 0 ? void 0 : {
|
|
3695
|
+
route,
|
|
3696
|
+
url
|
|
3697
|
+
};
|
|
3698
|
+
};
|
|
3699
|
+
/** Rules 1 to 4 for every read the request needs, judged over the request's root rows: covered by the ledger, or not the façade's to answer. */
|
|
3700
|
+
const covered = (reads, snapshot) => Effect.forEach(reads, (read) => coverageOf(read, { snapshot })).pipe(Effect.map((verdicts) => verdicts.every((verdict) => verdict._tag === "Covered")));
|
|
3701
|
+
/** The replica with the request's root window answered from `rows`: the rows coverage judged, not a second read. */
|
|
3702
|
+
const pinned = (base, root, rows) => ({
|
|
3703
|
+
query: (subset) => Subset.hash(subset) === Subset.hash(root) ? Effect.succeed(rows) : base.query(subset),
|
|
3704
|
+
observe: base.observe
|
|
3705
|
+
});
|
|
3706
|
+
/** A rejection as an outcome, with the cookie read for the response it may carry. */
|
|
3707
|
+
const rejected = (error) => Effect.flatMap(cookie, (served) => Effect.fail(queryFailure(error, served)));
|
|
3708
|
+
const answer = ({ route, url }) => Effect.gen(function* () {
|
|
3709
|
+
const docs = writers.get(options.prefix ?? route.prefix);
|
|
3710
|
+
if (docs === void 0) return yield* Effect.fail(forward);
|
|
3711
|
+
const parsed = yield* parseRead(nodes, url).pipe(Effect.catch(rejected));
|
|
3712
|
+
const needed = yield* docs.toCover(parsed.read).pipe(Effect.mapError(() => forward));
|
|
3713
|
+
const root = yield* Query$1.tryPlan(nodes, parsed.read).pipe(Effect.mapError(() => forward));
|
|
3714
|
+
const rows = yield* view.query(root.root).pipe(Effect.orElseSucceed(() => void 0));
|
|
3715
|
+
if (rows === void 0 || !(yield* covered(needed, {
|
|
3716
|
+
subset: root.root,
|
|
3717
|
+
rows
|
|
3718
|
+
}))) {
|
|
3719
|
+
if (online()) return yield* Effect.fail(forward);
|
|
3720
|
+
return yield* Effect.flatMap(cookie, (served) => Effect.fail(respond(offlineResponse(served))));
|
|
3721
|
+
}
|
|
3722
|
+
const replica = pinned(view, root.root, rows);
|
|
3723
|
+
const document = yield* (parsed.id === void 0 ? docs.collection(replica, parsed) : docs.data(replica, parsed)).pipe(Effect.catch(rejected));
|
|
3724
|
+
if (parsed.id !== void 0 && document.data === null) return yield* Effect.fail(forward);
|
|
3725
|
+
return respondWith(200, document, yield* cookie);
|
|
3726
|
+
});
|
|
3727
|
+
const readHandle = (request) => {
|
|
3728
|
+
const candidate = candidateOf(request);
|
|
3729
|
+
if (candidate === void 0) return Effect.succeed(void 0);
|
|
3730
|
+
return answer(candidate).pipe(Effect.catchTags({
|
|
3731
|
+
Forward: () => Effect.succeed(void 0),
|
|
3732
|
+
Respond: (outcome) => Effect.succeed(outcome.response)
|
|
3733
|
+
}));
|
|
3734
|
+
};
|
|
3735
|
+
const writeCandidateOf = (request) => {
|
|
3736
|
+
const method = request.method.toUpperCase();
|
|
3737
|
+
if (!acceptsJsonApi(request.headers.get("accept"))) return void 0;
|
|
3738
|
+
if (method !== "DELETE" && !sendsJsonApi(request.headers.get("content-type"))) return;
|
|
3739
|
+
const path = pathUnder(base, new URL(request.url));
|
|
3740
|
+
if (path === void 0) return void 0;
|
|
3741
|
+
const segments = segmentsOf(path);
|
|
3742
|
+
const route = writeRoutes.find((candidate) => candidate.method === method && matchesTemplate(candidate, segments));
|
|
3743
|
+
if (route === void 0) return void 0;
|
|
3744
|
+
return {
|
|
3745
|
+
route,
|
|
3746
|
+
target: route.operation === "create" ? {
|
|
3747
|
+
operation: "create",
|
|
3748
|
+
type: route.type
|
|
3749
|
+
} : {
|
|
3750
|
+
operation: route.operation,
|
|
3751
|
+
type: route.type,
|
|
3752
|
+
id: decodeURIComponent(segments.at(-1) ?? "")
|
|
3753
|
+
}
|
|
3754
|
+
};
|
|
3755
|
+
};
|
|
3756
|
+
/** The mutation status URL of a journaled mutation: beside the api it belongs to (ADR-0030 §2). */
|
|
3757
|
+
const statusUrl = (route, mutationId) => `${base.origin ?? ""}${base.path}${route.prefix}/_legation/mutations/${mutationId}`;
|
|
3758
|
+
/**
|
|
3759
|
+
* The receipt's `data`: the resource read back through the one writer
|
|
3760
|
+
* after journaling (ADR-0030 §1), or — when the replica does not hold the
|
|
3761
|
+
* row (a patch of a row it never pulled) — the request's own members.
|
|
3762
|
+
*/
|
|
3763
|
+
const receiptData = ({ route }, write) => {
|
|
3764
|
+
const prefix = options.prefix ?? route.prefix;
|
|
3765
|
+
const docs = writerFor(prefix);
|
|
3766
|
+
const sent = {
|
|
3767
|
+
type: write.type,
|
|
3768
|
+
id: write.id,
|
|
3769
|
+
attributes: write.attributes ?? {}
|
|
3770
|
+
};
|
|
3771
|
+
return docs.fromUrl(view, `${prefix}/${write.type}/${encodeURIComponent(write.id)}`).pipe(Effect.map((document) => "data" in document && document.data !== null && !Array.isArray(document.data) ? document.data : sent), Effect.orElseSucceed(() => sent));
|
|
3772
|
+
};
|
|
3773
|
+
/**
|
|
3774
|
+
* Journal a write and answer its `202` receipt, or fail with `undefined`
|
|
3775
|
+
* when it cannot be journaled (the caller forwards it online, or answers
|
|
3776
|
+
* offline). The body is read from a clone.
|
|
3777
|
+
*/
|
|
3778
|
+
const journal = (request, candidate, preferences) => Effect.gen(function* () {
|
|
3779
|
+
const body = Effect.tryPromise(() => request.clone().text());
|
|
3780
|
+
const write = yield* decodeWrite(candidate.route.node, candidate.target, body).pipe(Effect.mapError((refusal) => ({
|
|
3781
|
+
_tag: "Refused",
|
|
3782
|
+
refusal
|
|
3783
|
+
})));
|
|
3784
|
+
const intent = intentOf(write);
|
|
3785
|
+
if (intent === void 0 || mutate === void 0) return yield* Effect.fail({ _tag: "Unmapped" });
|
|
3786
|
+
const handle = yield* mutate(intent.mutator, intent.args).pipe(Effect.mapError((error) => P.isTagged(error, "MutatorError") ? {
|
|
3787
|
+
_tag: "NotJournalable",
|
|
3788
|
+
detail: error.message
|
|
3789
|
+
} : { _tag: "Unmapped" }));
|
|
3790
|
+
const location = statusUrl(candidate.route, handle.mutationId);
|
|
3791
|
+
const meta = { legation: {
|
|
3792
|
+
status: "pending",
|
|
3793
|
+
mutationId: handle.mutationId,
|
|
3794
|
+
mutation: location
|
|
3795
|
+
} };
|
|
3796
|
+
const document = write.operation === "delete" ? { meta } : {
|
|
3797
|
+
data: yield* receiptData(candidate, write),
|
|
3798
|
+
meta
|
|
3799
|
+
};
|
|
3800
|
+
const headers = preferences.respondAsync ? {
|
|
3801
|
+
location,
|
|
3802
|
+
"preference-applied": "respond-async"
|
|
3803
|
+
} : { location };
|
|
3804
|
+
return respondWith(202, document, yield* cookie, headers);
|
|
3805
|
+
});
|
|
3806
|
+
const offlineNow = Effect.map(cookie, offlineResponse);
|
|
3807
|
+
/** The offline answer to a write it could not journal: ADR-0030 §3's error documents. */
|
|
3808
|
+
const unjournaled = (failure) => Effect.map(cookie, (served) => {
|
|
3809
|
+
const [status, document] = unjournaledDocument(failure);
|
|
3810
|
+
return respondWith(status, document, served);
|
|
3811
|
+
});
|
|
3812
|
+
/** ADR-0030 §3's decision table for a write candidate, offline meaning `navigator.onLine` false. */
|
|
3813
|
+
const writeHandle = (request, candidate) => {
|
|
3814
|
+
const preferences = preferencesOf(request.headers);
|
|
3815
|
+
const reachable = online();
|
|
3816
|
+
if (preferences.strict) return reachable ? Effect.succeed(void 0) : offlineNow;
|
|
3817
|
+
if (reachable && !preferences.respondAsync) return Effect.succeed(void 0);
|
|
3818
|
+
return journal(request, candidate, preferences).pipe(Effect.catch((failure) => reachable ? Effect.succeed(void 0) : unjournaled(failure)));
|
|
3819
|
+
};
|
|
3820
|
+
const mutationStates = options.mutationStates;
|
|
3821
|
+
const maxWait = Duration.fromInputUnsafe(options.maxWait ?? "30 seconds");
|
|
3822
|
+
/** Every prefix the api's reflected routes sit under: the status route sits beside each. */
|
|
3823
|
+
const prefixes = Arr.dedupe(Arr.map([...routes, ...writeRoutes], (route) => route.prefix));
|
|
3824
|
+
/** A status request: the route prefix it names and the id segment, or `undefined`. */
|
|
3825
|
+
const statusCandidateOf = (request) => {
|
|
3826
|
+
if (mutationStates === void 0 || request.method !== "GET") return void 0;
|
|
3827
|
+
if (!acceptsJsonApi(request.headers.get("accept"))) return void 0;
|
|
3828
|
+
const path = pathUnder(base, new URL(request.url));
|
|
3829
|
+
if (path === void 0) return void 0;
|
|
3830
|
+
const segments = segmentsOf(path);
|
|
3831
|
+
const [id = "", mutations = "", legation = ""] = segments.toReversed();
|
|
3832
|
+
if (legation !== "_legation" || mutations !== "mutations") return void 0;
|
|
3833
|
+
const prefix = segments.slice(0, -3);
|
|
3834
|
+
const joined = Arr.isReadonlyArrayEmpty(prefix) ? "" : `/${prefix.join("/")}`;
|
|
3835
|
+
return prefixes.includes(joined) ? {
|
|
3836
|
+
prefix: joined,
|
|
3837
|
+
id: decodeURIComponent(id)
|
|
3838
|
+
} : void 0;
|
|
3839
|
+
};
|
|
3840
|
+
const statusHandle = (request, states, candidate) => Effect.gen(function* () {
|
|
3841
|
+
const links = `${base.origin ?? ""}${base.path}${candidate.prefix}`;
|
|
3842
|
+
const mutationId = parseId(candidate.id);
|
|
3843
|
+
if (Option.isNone(mutationId)) return respondWith(404, unknownDocument(candidate.id), yield* cookie);
|
|
3844
|
+
const now = yield* current(states, mutationId.value);
|
|
3845
|
+
const asked = preferencesOf(request.headers).wait;
|
|
3846
|
+
const wait = asked === void 0 || Option.isNone(now) || now.value.phase !== "pending" ? void 0 : Duration.min(Duration.seconds(asked), maxWait);
|
|
3847
|
+
const state = wait === void 0 ? now : yield* settled(states, mutationId.value, wait);
|
|
3848
|
+
const served = yield* cookie;
|
|
3849
|
+
if (Option.isNone(state)) return respondWith(404, unknownDocument(candidate.id), served);
|
|
3850
|
+
const document$1 = document(nodes, links, state.value);
|
|
3851
|
+
return wait === void 0 ? respondWith(200, document$1, served) : respondWith(200, document$1, served, { "preference-applied": `wait=${Math.floor(Duration.toSeconds(wait))}` });
|
|
3852
|
+
});
|
|
3853
|
+
const handle = (request) => {
|
|
3854
|
+
const status = statusCandidateOf(request);
|
|
3855
|
+
if (status !== void 0 && mutationStates !== void 0) return statusHandle(request, mutationStates, status);
|
|
3856
|
+
const write = writeCandidateOf(request);
|
|
3857
|
+
return write === void 0 ? readHandle(request) : writeHandle(request, write);
|
|
3858
|
+
};
|
|
3859
|
+
const offline = (request) => {
|
|
3860
|
+
const write = writeCandidateOf(request);
|
|
3861
|
+
if (write === void 0) return offlineNow;
|
|
3862
|
+
const preferences = preferencesOf(request.headers);
|
|
3863
|
+
if (preferences.strict) return offlineNow;
|
|
3864
|
+
return journal(request, write, preferences).pipe(Effect.catch(unjournaled));
|
|
3865
|
+
};
|
|
3866
|
+
const forwarded = (request, response) => options.pull !== void 0 && response.status >= 200 && response.status < 300 && writeCandidateOf(request) !== void 0 ? Effect.ignore(options.pull) : Effect.void;
|
|
3867
|
+
return {
|
|
3868
|
+
matches: (request) => candidateOf(request) !== void 0 || writeCandidateOf(request) !== void 0 || statusCandidateOf(request) !== void 0,
|
|
3869
|
+
handle,
|
|
3870
|
+
offline,
|
|
3871
|
+
forwarded
|
|
3872
|
+
};
|
|
3873
|
+
};
|
|
3874
|
+
// src/JsonApiSync.ts
|
|
3875
|
+
/**
|
|
3876
|
+
* JsonApiSync
|
|
3877
|
+
*
|
|
3878
|
+
* The happy path: hand it an app's JSON:API `HttpApi` and get a fully wired,
|
|
3879
|
+
* offline-capable sync engine. It reflects the api into resources
|
|
3880
|
+
* (Convention.ts), derives collections + CRUD mutators (Derive.ts), and
|
|
3881
|
+
* composes the JsonApiBackend with an in-process sync room and the client
|
|
3882
|
+
* engine — so the app's existing REST API gains optimistic writes, a durable
|
|
3883
|
+
* journal, rebase, and live queries with no extra server to deploy
|
|
3884
|
+
* (client-direct deployment; "realtime" is periodic pull).
|
|
3885
|
+
*
|
|
3886
|
+
* The same JsonApiBackend satisfies the BYO `Backend` contract, so a later
|
|
3887
|
+
* server-side deployment for CVR fan-out reuses it unchanged.
|
|
3888
|
+
*
|
|
3889
|
+
* When the api advertises the JSON:API Atomic Operations extension (an
|
|
3890
|
+
* endpoint taking an `atomic:operations` document), pushes go to it as one
|
|
3891
|
+
* all-or-nothing request per batch; otherwise each mutation is replayed as
|
|
3892
|
+
* its own request (`JsonApiSyncOptions.atomic`).
|
|
3893
|
+
*/
|
|
3894
|
+
var JsonApiSync_exports = /* @__PURE__ */ __exportAll({
|
|
3895
|
+
inspect: () => inspect,
|
|
3896
|
+
layer: () => layer,
|
|
3897
|
+
start: () => start
|
|
3898
|
+
});
|
|
3899
|
+
/**
|
|
3900
|
+
* Reflect + derive an api's resources, collections, mutators, operations —
|
|
3901
|
+
* and its atomic operations endpoint, when it declares one.
|
|
3902
|
+
*/
|
|
3903
|
+
const inspect = (api) => {
|
|
3904
|
+
const resources = reflectResources(api);
|
|
3905
|
+
const atomic = reflectAtomic(api);
|
|
3906
|
+
return {
|
|
3907
|
+
resources,
|
|
3908
|
+
...atomic !== void 0 ? { atomic } : {},
|
|
3909
|
+
...derive(resources)
|
|
3910
|
+
};
|
|
3911
|
+
};
|
|
3912
|
+
/** Every resource collection is readable; the REST API enforces real auth. */
|
|
3913
|
+
const authPolicy = (resources) => Authorization.policy(Object.fromEntries([...resources.keys()].map((type) => [type, Authorization.allowAll])));
|
|
3914
|
+
/**
|
|
3915
|
+
* The full client-direct stack as a layer: provide an `HttpClient`, get a
|
|
3916
|
+
* ready `ClientEngine`. CVR store and invalidation bus are in-process; the
|
|
3917
|
+
* client store defaults to in-memory, or to the durable store over
|
|
3918
|
+
* `keyValueStore` (pass it for offline persistence and a durable ledger across
|
|
3919
|
+
* reloads, or pass `store` and `ledger` for another durable store).
|
|
3920
|
+
*/
|
|
3921
|
+
const layer = (api, options) => {
|
|
3922
|
+
const { resources, registry, operations, atomic } = inspect(api);
|
|
3923
|
+
const atomicPath = options.atomic === false ? void 0 : options.atomic?.path ?? atomic?.path;
|
|
3924
|
+
const ledger = options.ledger ?? (options.keyValueStore !== void 0 ? keyValue(options.keyValueStore) : void 0);
|
|
3925
|
+
const clientStore = options.store ?? (options.keyValueStore !== void 0 ? ClientStore.layerKeyValue().pipe(Layer.provide(Layer.succeed(KeyValueStore.KeyValueStore)(options.keyValueStore))) : ClientStore.layerMemory);
|
|
3926
|
+
const backendLayer = Layer.effect(Backend.Backend)(make$2({
|
|
3927
|
+
baseUrl: options.baseUrl,
|
|
3928
|
+
resources,
|
|
3929
|
+
mutators: registry.mutators,
|
|
3930
|
+
operations,
|
|
3931
|
+
...atomicPath !== void 0 ? { atomic: { path: atomicPath } } : {},
|
|
3932
|
+
...options.queryEncoder !== void 0 ? { query: options.queryEncoder } : {},
|
|
3933
|
+
...options.includes !== void 0 ? { includes: options.includes } : {},
|
|
3934
|
+
...ledger !== void 0 ? { ledger } : {}
|
|
3935
|
+
}));
|
|
3936
|
+
const authLayer = Layer.succeed(Authorization.AuthPolicy)(authPolicy(resources));
|
|
3937
|
+
const serverLayer = SyncRoom.layer.pipe(Layer.provide(backendLayer), Layer.provide(authLayer));
|
|
3938
|
+
const transportLayer = Transport.layerInMemory(options.clientGroupId).pipe(Layer.provide(serverLayer));
|
|
3939
|
+
const pullInterval = options.pullInterval === null ? void 0 : options.pullInterval ?? Duration.seconds(5);
|
|
3940
|
+
const syncLoop = {
|
|
3941
|
+
...pullInterval !== void 0 ? { pullInterval } : {},
|
|
3942
|
+
...options.pushDebounce !== void 0 ? { pushDebounce: options.pushDebounce } : {}
|
|
3943
|
+
};
|
|
3944
|
+
return ClientEngine.layer({
|
|
3945
|
+
clientId: options.clientId,
|
|
3946
|
+
clientGroupId: options.clientGroupId,
|
|
3947
|
+
mutators: registry,
|
|
3948
|
+
syncLoop
|
|
3949
|
+
}).pipe(Layer.provide(transportLayer), Layer.provide(clientStore));
|
|
3950
|
+
};
|
|
3951
|
+
/**
|
|
3952
|
+
* Start syncing: run the engine's sync loop. Interval pull, push-on-mutate,
|
|
3953
|
+
* pull-on-poke and push+pull-on-reconnect are all scheduled by the core loop
|
|
3954
|
+
* (configure the interval/debounce on `layer`'s options). Requires a `Scope` —
|
|
3955
|
+
* fork it into the app's runtime and the fibers live until the scope closes.
|
|
3956
|
+
*/
|
|
3957
|
+
const start = Effect.gen(function* () {
|
|
3958
|
+
yield* (yield* ClientEngine.ClientEngine).runSync.pipe(Effect.orDie);
|
|
3959
|
+
});
|
|
3960
|
+
// src/NodeBridge.ts
|
|
3961
|
+
/**
|
|
3962
|
+
* NodeBridge
|
|
3963
|
+
*
|
|
3964
|
+
* The first-class integration ADR-0022 promised: a JSON:API resource
|
|
3965
|
+
* definition (`Resource.make` from `@thomasfosterau/effect-jsonapi`) **is**
|
|
3966
|
+
* a node definition — same type name, same attributes, and relationships
|
|
3967
|
+
* that map one-for-one onto edges. This module derives {@link Node} graphs
|
|
3968
|
+
* from resource definitions so an app declares its resources once and gets
|
|
3969
|
+
* the sync engine's replica, queries, joins, and fan-out topics from the
|
|
3970
|
+
* same source of truth as its HTTP API.
|
|
3971
|
+
*
|
|
3972
|
+
* The mapping:
|
|
3973
|
+
*
|
|
3974
|
+
* - `Relationship.one` / `Relationship.optional` → `Edge.one`, stored in
|
|
3975
|
+
* the foreign-key field `` `${name}Id` `` (override per relationship with
|
|
3976
|
+
* `via`). JSON:API keeps relationship linkage out of `attributes`, so the
|
|
3977
|
+
* engine-side row gains the key field the edge declares.
|
|
3978
|
+
* - `Relationship.many` / `Relationship.paginated` → `Edge.many` /
|
|
3979
|
+
* `Edge.paginated`. The **inverse** — the one-edge on the target holding
|
|
3980
|
+
* the key — is discovered when it is unambiguous, and named explicitly
|
|
3981
|
+
* when it is not: on the resource, with the `@legation/inverses`
|
|
3982
|
+
* annotation ({@link syncAnnotations}), or in the `inverses` option.
|
|
3983
|
+
* - `paginated` edges need an `order` (cursor pagination requires a stable
|
|
3984
|
+
* order, and it is part of the edge's identity — ADR-0022). It is read off
|
|
3985
|
+
* the relationship's own declaration, `Relationship.paginated(ref, { order })`
|
|
3986
|
+
* (#220), or given in the `order` option.
|
|
3987
|
+
*
|
|
3988
|
+
* **Sync metadata on the resource (#220).** What only the sync engine needs —
|
|
3989
|
+
* an inverse name, the literal kind of a field no schema can tell (an
|
|
3990
|
+
* `instant` stored as text) — is declared on the resource as
|
|
3991
|
+
* `@legation/`-namespaced annotations: `Resource.make(…, { annotations:
|
|
3992
|
+
* syncAnnotations({ inverses, kinds }) })`, or `Resource.annotate`, which
|
|
3993
|
+
* `Resource.extend` keeps. The {@link FromResourceOptions} remain, as
|
|
3994
|
+
* explicit overrides: an option entry wins over the resource's annotation or
|
|
3995
|
+
* declaration for the same relationship or field. A to-one relationship's
|
|
3996
|
+
* key field is still `` `${name}Id` `` or the `via` option until
|
|
3997
|
+
* effect-jsonapi's storage-key slot (effect-jsonapi#509) reaches this
|
|
3998
|
+
* package.
|
|
3999
|
+
*
|
|
4000
|
+
* **Acyclic resource graphs.** The library's resource types expand eagerly,
|
|
4001
|
+
* so mutually-referencing definitions (`Product ⇄ Review`) cannot be
|
|
4002
|
+
* authored; the idiomatic JSON:API graph points parent → child and the
|
|
4003
|
+
* child carries its foreign key as a plain attribute (`productId`). The
|
|
4004
|
+
* `edges` option closes that gap: it promotes such an attribute to a
|
|
4005
|
+
* declared one-edge on the bridged node, and {@link registry} discovers
|
|
4006
|
+
* collection-valued inverses across **bridged** one-edges — declared and
|
|
4007
|
+
* relationship-derived alike — so the acyclic graph bridges without ever
|
|
4008
|
+
* naming an inverse by hand.
|
|
4009
|
+
*
|
|
4010
|
+
* **The filterable set** (ADR-0028 rule d, #154) is read off the resource's
|
|
4011
|
+
* own declaration (effect-jsonapi, since 0.13: `Filter.able` / `Sort.able` piped
|
|
4012
|
+
* onto an attribute schema, `Resource.attribute(schema, { filter, sort })`,
|
|
4013
|
+
* `Relationship.one(ref, { filter })`) through `Resource.filterable` /
|
|
4014
|
+
* `Resource.sortable`. An attribute keeps its name and derives its literal
|
|
4015
|
+
* kind from its schema exactly as `Node.make` does; a to-one relationship's
|
|
4016
|
+
* declaration lands on the edge's **key field** (`author` → `authorId`,
|
|
4017
|
+
* kind `text`), which is the field the engine filters by. The
|
|
4018
|
+
* `filterable` / `sortable` options remain as overrides: an option entry
|
|
4019
|
+
* replaces the resource's declaration for that field (the way to give an
|
|
4020
|
+
* `instant` its kind, which no schema can tell), and adds fields the
|
|
4021
|
+
* resource does not declare.
|
|
4022
|
+
*
|
|
4023
|
+
* All mismatches are definition-time defects (a thrown
|
|
4024
|
+
* {@link ResourceBridgeError}), never runtime failures: a resource graph
|
|
4025
|
+
* either bridges completely or not at all.
|
|
4026
|
+
*
|
|
4027
|
+
* **Identity** is always `id` here: a JSON:API resource is identified by the
|
|
4028
|
+
* spec's `id` member, so a bridged node declares no identity attribute of its
|
|
4029
|
+
* own (`Node.make`'s `id` option, #144). The JSON:API surface — `/{type}/{id}`
|
|
4030
|
+
* widening to `filter[id]`, linkage and `included` reading `id` off a row
|
|
4031
|
+
* value — assumes that, so a hand-built node naming a natural key is not yet
|
|
4032
|
+
* servable through these handlers.
|
|
4033
|
+
*/
|
|
4034
|
+
var NodeBridge_exports = /* @__PURE__ */ __exportAll({
|
|
4035
|
+
InversesAnnotation: () => InversesAnnotation,
|
|
4036
|
+
KindsAnnotation: () => KindsAnnotation,
|
|
4037
|
+
ResourceBridgeError: () => ResourceBridgeError,
|
|
4038
|
+
fromResource: () => fromResource,
|
|
4039
|
+
registry: () => registry,
|
|
4040
|
+
syncAnnotations: () => syncAnnotations
|
|
4041
|
+
});
|
|
4042
|
+
/** A definition-time mismatch between a resource graph and the node model. */
|
|
4043
|
+
var ResourceBridgeError = class extends Schema.TaggedError()("ResourceBridgeError", {
|
|
4044
|
+
resource: Schema.String,
|
|
4045
|
+
relationship: Schema.String,
|
|
4046
|
+
message: Schema.String
|
|
4047
|
+
}) {};
|
|
4048
|
+
/** The annotation key naming the inverse edge of collection relationships, by relationship name. */
|
|
4049
|
+
const InversesAnnotation = "@legation/inverses";
|
|
4050
|
+
/** The annotation key naming the literal kind of filterable fields, by row field. */
|
|
4051
|
+
const KindsAnnotation = "@legation/kinds";
|
|
4052
|
+
/**
|
|
4053
|
+
* The resource annotations that declare `sync`, to pass as (or merge into)
|
|
4054
|
+
* `Resource.make`'s `annotations` or `Resource.annotate`'s.
|
|
4055
|
+
*/
|
|
4056
|
+
const syncAnnotations = (sync) => {
|
|
4057
|
+
let record = {};
|
|
4058
|
+
if (sync.inverses !== void 0) record = {
|
|
4059
|
+
...record,
|
|
4060
|
+
[InversesAnnotation]: sync.inverses
|
|
4061
|
+
};
|
|
4062
|
+
if (sync.kinds !== void 0) record = {
|
|
4063
|
+
...record,
|
|
4064
|
+
[KindsAnnotation]: sync.kinds
|
|
4065
|
+
};
|
|
4066
|
+
return record;
|
|
4067
|
+
};
|
|
4068
|
+
const Inverses = Schema.Record(Schema.String, Schema.String);
|
|
4069
|
+
const Kinds = Schema.Record(Schema.String, Schema.Literals([
|
|
4070
|
+
"text",
|
|
4071
|
+
"integer",
|
|
4072
|
+
"double",
|
|
4073
|
+
"boolean",
|
|
4074
|
+
"instant"
|
|
4075
|
+
]));
|
|
4076
|
+
/**
|
|
4077
|
+
* Read one of the bridge's annotations off a resource: absent reads as empty,
|
|
4078
|
+
* and a value of the wrong shape is a definition-time defect.
|
|
4079
|
+
*/
|
|
4080
|
+
const annotationOf = (resource, key, schema) => Option.map(Option.fromUndefinedOr(Resource.annotations(resource)[key]), (value) => Option.getOrElse(Schema.decodeUnknownOption(schema)(value), () => fail(resource.type, key, `the "${key}" annotation does not have the shape the bridge reads`)));
|
|
4081
|
+
const fail = (resource, relationship, message) => {
|
|
4082
|
+
throw new ResourceBridgeError({
|
|
4083
|
+
resource,
|
|
4084
|
+
relationship,
|
|
4085
|
+
message
|
|
4086
|
+
});
|
|
4087
|
+
};
|
|
4088
|
+
/**
|
|
4089
|
+
* The one-edges a resource bridges to: its `one`/`optional` relationships
|
|
4090
|
+
* plus the option-declared extras. This is the map inverse discovery
|
|
4091
|
+
* scans, so declared and relationship-derived edges resolve identically.
|
|
4092
|
+
*/
|
|
4093
|
+
const oneEdgesOf = (resource, options) => {
|
|
4094
|
+
const related = R.filterMap(resource.relationships, (descriptor, name) => descriptor.kind === "one" || descriptor.kind === "optional" ? Result.succeed(Edge.one(descriptor.ref().type, { via: options.via?.[name] ?? `${name}Id` })) : Result.failVoid);
|
|
4095
|
+
const declared = R.map(options.edges ?? {}, (edge) => Edge.one(edge.target, { via: edge.via }));
|
|
4096
|
+
return {
|
|
4097
|
+
...related,
|
|
4098
|
+
...declared
|
|
4099
|
+
};
|
|
4100
|
+
};
|
|
4101
|
+
/**
|
|
4102
|
+
* Discover the inverse of a collection-valued relationship: the unique
|
|
4103
|
+
* one-edge on the (bridged) target that points back at `source`.
|
|
4104
|
+
*/
|
|
4105
|
+
const discoverInverse = (source, name, target, targetOneEdges) => {
|
|
4106
|
+
const candidates = Object.entries(targetOneEdges).filter(([, edge]) => edge.target === source);
|
|
4107
|
+
const only = candidates[0];
|
|
4108
|
+
if (candidates.length === 1 && only !== void 0) return only[0];
|
|
4109
|
+
return fail(source, name, Arr.isReadonlyArrayEmpty(candidates) ? `no inverse found: "${target}" bridges no one-edge back to "${source}" — declare one (a relationship, or options.edges over its key attribute), or name it in options.inverses` : `ambiguous inverse: "${target}" bridges ${candidates.length} one-edges back to "${source}" (${candidates.map(([candidate]) => `"${candidate}"`).join(", ")}) — name the right one in options.inverses`);
|
|
4110
|
+
};
|
|
4111
|
+
/**
|
|
4112
|
+
* A paginated relationship's own canonical order (effect-jsonapi's
|
|
4113
|
+
* `Relationship.paginated(ref, { order })`), as the engine spells an order:
|
|
4114
|
+
* field names, `-` prefixed for descending.
|
|
4115
|
+
*/
|
|
4116
|
+
const declaredOrder = (descriptor) => descriptor.kind === "paginated" ? Option.map(Option.fromUndefinedOr(descriptor.order), (order) => order.map((term) => term.direction === "desc" ? `-${term.field}` : term.field)) : Option.none();
|
|
4117
|
+
/**
|
|
4118
|
+
* Bridge one collection-valued relationship to its edge: the inverse is
|
|
4119
|
+
* named in the options or discovered on the target; a paginated one also
|
|
4120
|
+
* needs its declared order.
|
|
4121
|
+
*/
|
|
4122
|
+
const collectionEdge = (resource, name, descriptor, options, oneEdgesOfTarget) => {
|
|
4123
|
+
const target = descriptor.ref();
|
|
4124
|
+
const inverse = options.inverses?.[name] ?? Option.getOrUndefined(Option.flatMapNullishOr(annotationOf(resource, "@legation/inverses", Inverses), (inverses) => inverses[name])) ?? discoverInverse(resource.type, name, target.type, oneEdgesOfTarget(target));
|
|
4125
|
+
if (descriptor.kind === "many") return Edge.many(target.type, { inverse });
|
|
4126
|
+
const order = options.order?.[name] ?? Option.getOrUndefined(declaredOrder(descriptor));
|
|
4127
|
+
if (order === void 0) return fail(resource.type, name, `a paginated relationship needs an edge order (cursor pagination requires a stable order) — declare it on the relationship, Relationship.paginated(ref, { order }), or in options.order`);
|
|
4128
|
+
return Edge.paginated(target.type, {
|
|
4129
|
+
inverse,
|
|
4130
|
+
order
|
|
4131
|
+
});
|
|
4132
|
+
};
|
|
4133
|
+
/** The resource's edges: its one-edges plus every collection-valued relationship's. */
|
|
4134
|
+
const edgesOf = (resource, options, oneEdgesOfTarget) => {
|
|
4135
|
+
const collections = R.filterMap(resource.relationships, (descriptor, name) => descriptor.kind === "many" || descriptor.kind === "paginated" ? Result.succeed(collectionEdge(resource, name, descriptor, options, oneEdgesOfTarget)) : Result.failVoid);
|
|
4136
|
+
return {
|
|
4137
|
+
...oneEdgesOf(resource, options),
|
|
4138
|
+
...collections
|
|
4139
|
+
};
|
|
4140
|
+
};
|
|
4141
|
+
/**
|
|
4142
|
+
* The resource's attributes minus every one-edge's key field: a one-edge's
|
|
4143
|
+
* key replaces any attribute of the same name (the promoted-attribute
|
|
4144
|
+
* case); the edge's branded id carries the field.
|
|
4145
|
+
*/
|
|
4146
|
+
const attributesOf = (resource, edges) => {
|
|
4147
|
+
const keys = new Set(Object.values(edges).flatMap((edge) => edge.kind === "one" ? [edge.via] : []));
|
|
4148
|
+
return R.filter(resource.fields.attributes.fields, (_, field) => !keys.has(field));
|
|
4149
|
+
};
|
|
4150
|
+
/**
|
|
4151
|
+
* The filterable set the resource declares, keyed by **row field**: an
|
|
4152
|
+
* attribute under its own name, with the kind left to `Node.make` to derive
|
|
4153
|
+
* from its schema; a to-one relationship under its edge's key field, kind
|
|
4154
|
+
* `text` (an id). A key field declared twice — the relationship and the
|
|
4155
|
+
* attribute it is stored in both filterable — is a definition-time defect:
|
|
4156
|
+
* neither declaration may silently win.
|
|
4157
|
+
*/
|
|
4158
|
+
const declaredFilterable = (resource, edges) => {
|
|
4159
|
+
const declared = {};
|
|
4160
|
+
const attributes = resource.fields.attributes.fields;
|
|
4161
|
+
Object.entries(Resource.filterable(resource)).forEach(([key, entry]) => {
|
|
4162
|
+
const edge = edges[key];
|
|
4163
|
+
const viaRelationship = attributes[key] === void 0 && edge?.kind === "one";
|
|
4164
|
+
const field = viaRelationship ? edge.via : key;
|
|
4165
|
+
if (declared[field] !== void 0) fail(resource.type, key, `key field "${field}" is declared filterable twice (by relationship "${key}" and by the attribute it is stored in) — declare it once`);
|
|
4166
|
+
declared[field] = viaRelationship ? {
|
|
4167
|
+
operators: entry.operators,
|
|
4168
|
+
kind: "text"
|
|
4169
|
+
} : entry.operators;
|
|
4170
|
+
});
|
|
4171
|
+
return declared;
|
|
4172
|
+
};
|
|
4173
|
+
/**
|
|
4174
|
+
* The declared filterable set with the resource's `@legation/kinds`
|
|
4175
|
+
* annotation applied: each named field keeps its operators and takes the
|
|
4176
|
+
* annotated kind. Naming a field the resource does not declare filterable is a
|
|
4177
|
+
* definition-time defect.
|
|
4178
|
+
*/
|
|
4179
|
+
const withKinds = (resource, declared) => R.reduce(Option.getOrElse(annotationOf(resource, KindsAnnotation, Kinds), () => ({})), declared, (current, kind, field) => {
|
|
4180
|
+
const entry = current[field];
|
|
4181
|
+
if (entry === void 0) return fail(resource.type, KindsAnnotation, `"${field}" has a kind in "${KindsAnnotation}" but is not declared filterable on the resource`);
|
|
4182
|
+
const operators = "operators" in entry ? entry.operators : entry;
|
|
4183
|
+
return {
|
|
4184
|
+
...current,
|
|
4185
|
+
[field]: {
|
|
4186
|
+
operators,
|
|
4187
|
+
kind
|
|
4188
|
+
}
|
|
4189
|
+
};
|
|
4190
|
+
});
|
|
4191
|
+
/**
|
|
4192
|
+
* The per-node options: hint keys from the bridge options; the filterable
|
|
4193
|
+
* set and sortable fields from the resource's declaration, each option
|
|
4194
|
+
* entry replacing the resource's for its field.
|
|
4195
|
+
*/
|
|
4196
|
+
const nodeOptionsOf = (resource, edges, options) => ({
|
|
4197
|
+
...options.hintKeys === void 0 ? {} : { hintKeys: options.hintKeys },
|
|
4198
|
+
filterable: {
|
|
4199
|
+
...withKinds(resource, declaredFilterable(resource, edges)),
|
|
4200
|
+
...options.filterable
|
|
4201
|
+
},
|
|
4202
|
+
sortable: [...Resource.sortable(resource), ...options.sortable ?? []]
|
|
4203
|
+
});
|
|
4204
|
+
/** Bridge one resource, resolving inverses through `oneEdgesOfTarget`. */
|
|
4205
|
+
const bridge = (resource, options, oneEdgesOfTarget) => {
|
|
4206
|
+
const edges = edgesOf(resource, options, oneEdgesOfTarget);
|
|
4207
|
+
return Node.make(resource.type, {
|
|
4208
|
+
attributes: attributesOf(resource, edges),
|
|
4209
|
+
edges,
|
|
4210
|
+
...nodeOptionsOf(resource, edges, options)
|
|
4211
|
+
});
|
|
4212
|
+
};
|
|
4213
|
+
/**
|
|
4214
|
+
* Derive a {@link Node} definition from a single JSON:API resource
|
|
4215
|
+
* definition. Inverse discovery here sees only the target resource's own
|
|
4216
|
+
* relationships — bridge a whole graph with {@link registry} to discover
|
|
4217
|
+
* across option-declared edges too.
|
|
4218
|
+
*/
|
|
4219
|
+
const fromResource = (resource, options = {}) => bridge(resource, options, (target) => oneEdgesOf(target, {}));
|
|
4220
|
+
/**
|
|
4221
|
+
* Bridge a whole resource graph and close it as a validated
|
|
4222
|
+
* {@link Node.NodeRegistry} — nodes plus the derived engine
|
|
4223
|
+
* `CollectionRegistry`, ready for `Client.make` / `Server.make`.
|
|
4224
|
+
* Options are keyed by resource type.
|
|
4225
|
+
*/
|
|
4226
|
+
const registry = (resources, options = {}) => Node.registry(resources.map((resource) => bridge(resource, options[resource.type] ?? {}, (target) => oneEdgesOf(target, options[target.type] ?? {}))));
|
|
4227
|
+
// src/Relationships.ts
|
|
4228
|
+
var Relationships_exports = /* @__PURE__ */ __exportAll({
|
|
4229
|
+
hydrate: () => hydrate,
|
|
4230
|
+
observe: () => observe,
|
|
4231
|
+
query: () => query,
|
|
4232
|
+
related: () => related,
|
|
4233
|
+
resolve: () => resolve
|
|
4234
|
+
});
|
|
4235
|
+
const refOf = (value, name) => value.relationships?.[name] ?? null;
|
|
4236
|
+
const isLink = (value) => P.isObjectOrArray(value) && "type" in value && "id" in value;
|
|
4237
|
+
/** The `{ type, id }` links a row's named relationship points at. */
|
|
4238
|
+
const linksOf = (value, name) => {
|
|
4239
|
+
const ref = refOf(value, name);
|
|
4240
|
+
if (ref === null || ref === void 0) return [];
|
|
4241
|
+
return (Array.isArray(ref) ? ref : [ref]).filter(isLink);
|
|
4242
|
+
};
|
|
4243
|
+
/** Resolve one relationship of a row against a lookup (linkage → value[s]). */
|
|
4244
|
+
const resolve = (lookup, value, name) => {
|
|
4245
|
+
const ref = refOf(value, name);
|
|
4246
|
+
if (ref === null || ref === void 0) return ref;
|
|
4247
|
+
if (Array.isArray(ref)) return ref.flatMap((link) => {
|
|
4248
|
+
const row = lookup(link.type, link.id);
|
|
4249
|
+
return row === void 0 ? [] : [row];
|
|
4250
|
+
});
|
|
4251
|
+
return isLink(ref) ? lookup(ref.type, ref.id) : void 0;
|
|
4252
|
+
};
|
|
4253
|
+
/** Resolve several named relationships of a row. */
|
|
4254
|
+
const hydrate = (lookup, value, names) => {
|
|
4255
|
+
return R.fromIterableWith(names, (name) => [name, resolve(lookup, value, name)]);
|
|
4256
|
+
};
|
|
4257
|
+
/**
|
|
4258
|
+
* The linkage strategy: `targets` pins each referenced collection by `id` and
|
|
4259
|
+
* the linked ids; `resolveRow` reads the referenced rows out of the index by id
|
|
4260
|
+
* and hydrates via {@link hydrate}, so resolution is byte-for-byte the previous
|
|
4261
|
+
* behaviour.
|
|
4262
|
+
*/
|
|
4263
|
+
const linkageStrategy = (names) => ({
|
|
4264
|
+
targets: (baseRows) => {
|
|
4265
|
+
const map = /* @__PURE__ */ new Map();
|
|
4266
|
+
baseRows.flatMap((row) => names.flatMap((name) => linksOf(row.value, name))).forEach((link) => {
|
|
4267
|
+
const entry = map.get(link.type) ?? {
|
|
4268
|
+
field: "id",
|
|
4269
|
+
ids: /* @__PURE__ */ new Set()
|
|
4270
|
+
};
|
|
4271
|
+
entry.ids.add(link.id);
|
|
4272
|
+
map.set(link.type, entry);
|
|
4273
|
+
});
|
|
4274
|
+
return map;
|
|
4275
|
+
},
|
|
4276
|
+
resolveRow: (index, baseRow) => {
|
|
4277
|
+
const lookup = (collection, id) => index.get(collection)?.get(id)?.[0]?.value;
|
|
4278
|
+
return hydrate(lookup, baseRow.value, names);
|
|
4279
|
+
}
|
|
4280
|
+
});
|
|
4281
|
+
/**
|
|
4282
|
+
* Resolve a subset's rows together with their named relationships, reading the
|
|
4283
|
+
* referenced collections from the engine's local view. Delegates to
|
|
4284
|
+
* {@link Related.queryWith} with the linkage strategy.
|
|
4285
|
+
*/
|
|
4286
|
+
const query = (engine, subset, names) => Related.queryWith(engine, linkageStrategy(names), subset);
|
|
4287
|
+
/**
|
|
4288
|
+
* Live version of {@link query} as an incrementally-maintained join. Delegates
|
|
4289
|
+
* to {@link Related.observeWith}: a write to a referenced row re-hydrates the
|
|
4290
|
+
* join without re-querying the base; a base change that shifts referenced
|
|
4291
|
+
* collections re-establishes the observers.
|
|
4292
|
+
*/
|
|
4293
|
+
const observe = (engine, subset, names) => Related.observeWith(engine, linkageStrategy(names), subset);
|
|
4294
|
+
/**
|
|
4295
|
+
* Auto-subscribing relational read: subscribes the base subset and the narrowed
|
|
4296
|
+
* `id IN (...)` subsets of each referenced collection, tracks the live linkage,
|
|
4297
|
+
* and tears the subscriptions down when the scope closes. Delegates to
|
|
4298
|
+
* {@link Related.relatedWith}. Pair with the backend's `includes` config to
|
|
4299
|
+
* deliver the related rows in the parent's response (no extra round trip).
|
|
4300
|
+
*/
|
|
4301
|
+
const related = (engine, subset, names) => Related.relatedWith(engine, linkageStrategy(names), subset);
|
|
4302
|
+
export { ApiError, CollectionLoader_exports as CollectionLoader, ConflictError, Convention_exports as Convention, Derive_exports as Derive, DocumentWriter_exports as DocumentWriter, FacadeWrites_exports as FacadeWrites, JsonApiBackend_exports as JsonApiBackend, JsonApiFacade_exports as JsonApiFacade, JsonApiQuery_exports as JsonApiQuery, schema_exports as JsonApiSchema, JsonApiSync_exports as JsonApiSync, Ledger_exports as Ledger, MutationStatus_exports as MutationStatus, NetworkError, NodeBridge_exports as NodeBridge, ParseError, Relationships_exports as Relationships, ReplicaDocuments_exports as ReplicaDocuments, RestHandlers_exports as RestHandlers, SyncError };
|
|
4303
|
+
|
|
4304
|
+
//# sourceMappingURL=index.js.map
|