@formancy/data-client 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/LICENSE.md +156 -0
- package/NOTICE +9 -0
- package/README.md +246 -2
- package/dist/index.d.mts +232 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +487 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -3
package/LICENSE.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Formancy Data Source-Available Licence
|
|
2
|
+
|
|
3
|
+
**Version 1.1 — in force from 11 October 2026. Not reviewed by a lawyer.**
|
|
4
|
+
|
|
5
|
+
This text states the terms Formancy Data is offered under. Version 1.1 is in
|
|
6
|
+
force from 11 October 2026 and replaces Version 1 of the same day, which named
|
|
7
|
+
no address for the agreement Redistribution requires, and Version 0, the draft
|
|
8
|
+
of 8 October 2026. **This version has not been reviewed by a lawyer.** It is
|
|
9
|
+
published in the repository so that the terms are visible to everyone who
|
|
10
|
+
reads the source. Production licences, which section 3 describes, can be had
|
|
11
|
+
as section 9 says.
|
|
12
|
+
|
|
13
|
+
This is not an open-source licence. It is not Apache-2.0, and it is not the
|
|
14
|
+
licence of [formancy](https://github.com/sharkysan/formancy.ai), on which this
|
|
15
|
+
software builds and whose packages keep their own Apache-2.0 terms.
|
|
16
|
+
|
|
17
|
+
## The terms in one table
|
|
18
|
+
|
|
19
|
+
| Use | Terms |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Read the source and fork the repository | Free |
|
|
22
|
+
| Evaluate, develop and test — including in companies | Free |
|
|
23
|
+
| Run in production, internally or customer-facing | Paid licence |
|
|
24
|
+
| Redistribute inside another software product | Separate commercial agreement |
|
|
25
|
+
| Customer data and generated form definitions | Remain the customer's |
|
|
26
|
+
|
|
27
|
+
The rest of this document says what each row means.
|
|
28
|
+
|
|
29
|
+
## 1. Definitions
|
|
30
|
+
|
|
31
|
+
**The Software** means the source code, declarations, documentation and
|
|
32
|
+
packages in this repository and the packages published from it under the
|
|
33
|
+
`@formancy/data-*` names, in any version, including modified versions.
|
|
34
|
+
|
|
35
|
+
**The Rightsholder** means Daniel Bacher, or whoever the Rightsholder later
|
|
36
|
+
designates in a revision of this document.
|
|
37
|
+
|
|
38
|
+
**Production Use** means running the Software, or anything that incorporates
|
|
39
|
+
it, in a way that serves real end users or real business operations: an
|
|
40
|
+
application people other than its developers and testers rely on, whether
|
|
41
|
+
inside one organisation or offered to others, whether or not it earns money.
|
|
42
|
+
Production is defined by operational use, not by revenue.
|
|
43
|
+
|
|
44
|
+
**Development Use** means any use that is not Production Use: reading,
|
|
45
|
+
evaluating, building, modifying, testing, demonstrating and running in
|
|
46
|
+
development, test, staging and continuous-integration environments, including
|
|
47
|
+
by and within a commercial organisation. A staging environment that real
|
|
48
|
+
users depend on is Production Use whatever it is called.
|
|
49
|
+
|
|
50
|
+
**Redistribution** means delivering the Software, or anything that
|
|
51
|
+
incorporates it, to a third party as part of a software product, a managed
|
|
52
|
+
service, a platform or a toolkit, such that the third party runs it rather
|
|
53
|
+
than you.
|
|
54
|
+
|
|
55
|
+
**Customer Data** means the contents of any database the Software connects to,
|
|
56
|
+
and anything the Software reads from or writes to it.
|
|
57
|
+
|
|
58
|
+
**Generated Definitions** means the form documents, bindings, layouts,
|
|
59
|
+
policies and other configuration the Software generates or that a person
|
|
60
|
+
creates with it.
|
|
61
|
+
|
|
62
|
+
## 2. What is free
|
|
63
|
+
|
|
64
|
+
Subject to section 4, the Rightsholder grants you a worldwide, royalty-free,
|
|
65
|
+
non-exclusive licence to:
|
|
66
|
+
|
|
67
|
+
1. read, copy, fork and modify the Software;
|
|
68
|
+
2. use the Software and your modifications for Development Use, without limit
|
|
69
|
+
of time, number of people or size of organisation;
|
|
70
|
+
3. share the Software and your modifications with others for their
|
|
71
|
+
Development Use, under this licence and with this document attached.
|
|
72
|
+
|
|
73
|
+
No production licence is required to evaluate the Software for as long as you
|
|
74
|
+
like, and no evaluation period expires.
|
|
75
|
+
|
|
76
|
+
## 3. What is paid
|
|
77
|
+
|
|
78
|
+
**Production Use requires a production licence from the Rightsholder.** A
|
|
79
|
+
production licence is a separate written agreement. It is expected to be
|
|
80
|
+
granted per application, to cover that application's development, test and
|
|
81
|
+
replica environments without further charge, to specify support and update
|
|
82
|
+
entitlements, and to leave you the right to keep running the versions you
|
|
83
|
+
licensed if you later choose not to renew. Those are intentions; the agreement
|
|
84
|
+
you sign is what governs.
|
|
85
|
+
|
|
86
|
+
**Redistribution requires a separate commercial agreement.** Shipping the
|
|
87
|
+
Software inside a product, a platform or a managed service that other parties
|
|
88
|
+
run is not covered by a production licence for your own applications.
|
|
89
|
+
|
|
90
|
+
Running the Software in production without a production licence is a breach of
|
|
91
|
+
this licence.
|
|
92
|
+
|
|
93
|
+
## 4. Conditions
|
|
94
|
+
|
|
95
|
+
1. **Notices travel.** A copy of this document, and the NOTICE file, stays
|
|
96
|
+
with every copy and every modified version of the Software.
|
|
97
|
+
2. **Modified versions carry these terms.** A modified version of the Software
|
|
98
|
+
is the Software for the purposes of this licence. You may not remove or
|
|
99
|
+
circumvent the terms by modifying it.
|
|
100
|
+
3. **No trademark grant.** This licence grants no right to use the names
|
|
101
|
+
"Formancy", "Formancy Data", "formancy.ai" or any associated logo, beyond
|
|
102
|
+
what is needed to identify the Software truthfully.
|
|
103
|
+
4. **Third-party components keep their own licences.** The Software depends on
|
|
104
|
+
packages licensed under the Apache License, Version 2.0, among others. Those
|
|
105
|
+
licences continue to govern those components, their notices are preserved
|
|
106
|
+
in NOTICE, and nothing in this document narrows the rights they grant.
|
|
107
|
+
|
|
108
|
+
## 5. What stays yours
|
|
109
|
+
|
|
110
|
+
**Customer Data remains the customer's.** This licence grants the Rightsholder
|
|
111
|
+
no right of any kind over the contents of any database the Software connects
|
|
112
|
+
to, and the Software is designed not to send any of it anywhere.
|
|
113
|
+
|
|
114
|
+
**Generated Definitions remain yours.** Form documents, bindings, layouts and
|
|
115
|
+
policies you generate or author with the Software are yours, under whatever
|
|
116
|
+
terms you choose. They are not the Software, and this licence does not
|
|
117
|
+
restrict them — including after a production licence ends.
|
|
118
|
+
|
|
119
|
+
## 6. Contributions
|
|
120
|
+
|
|
121
|
+
Contributions to the Software are accepted under the Contributor License
|
|
122
|
+
Agreement in `CLA.md`, which grants the Rightsholder the right to distribute
|
|
123
|
+
them under this licence and under commercial agreements, and transfers no
|
|
124
|
+
ownership.
|
|
125
|
+
|
|
126
|
+
## 7. Termination
|
|
127
|
+
|
|
128
|
+
Your rights under this licence end automatically if you breach it and do not
|
|
129
|
+
cure the breach within thirty days of becoming aware of it. The rights in
|
|
130
|
+
section 5 do not end: your data and your generated definitions stay yours in
|
|
131
|
+
every case.
|
|
132
|
+
|
|
133
|
+
## 8. No warranty, limited liability
|
|
134
|
+
|
|
135
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
136
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
137
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
|
138
|
+
RIGHTSHOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
139
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
140
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
141
|
+
|
|
142
|
+
A production licence may provide warranties and support; this document does
|
|
143
|
+
not.
|
|
144
|
+
|
|
145
|
+
## 9. Obtaining a production licence
|
|
146
|
+
|
|
147
|
+
Production licences are requested from the Rightsholder at info@formancy.ai.
|
|
148
|
+
The separate commercial agreement that Redistribution requires, which
|
|
149
|
+
section 3 describes, is requested at the same address. The Software is new
|
|
150
|
+
and has no published price; the plan is to set the price after the first
|
|
151
|
+
design partners have established what the Software is worth to them.
|
|
152
|
+
|
|
153
|
+
## 10. Governing law and place of jurisdiction
|
|
154
|
+
|
|
155
|
+
This licence is governed by Swiss law, excluding its conflict-of-laws rules.
|
|
156
|
+
The place of jurisdiction is the Rightsholder's domicile in Switzerland.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Formancy Data
|
|
2
|
+
Copyright 2026 Daniel Bacher
|
|
3
|
+
|
|
4
|
+
This product is distributed under the Formancy Data Source-Available Licence
|
|
5
|
+
(LICENSE.md). It is not open source.
|
|
6
|
+
|
|
7
|
+
This product includes software developed by the formancy project
|
|
8
|
+
(https://github.com/sharkysan/formancy.ai), licensed under the Apache License,
|
|
9
|
+
Version 2.0. Those packages keep their licence; nothing here changes it.
|
package/README.md
CHANGED
|
@@ -1,3 +1,247 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- Part of Formancy Data: https://github.com/sharkysan/formancy.ai-data -->
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# @formancy/data-client
|
|
4
|
+
|
|
5
|
+
The browser's side of Formancy Data's runtime plane: one function per route
|
|
6
|
+
for the forms a person may read, a published form's definition, records and
|
|
7
|
+
lookups, option sources that
|
|
8
|
+
both formancy renderers take, and a refusal's field problems in the shape a
|
|
9
|
+
form engine applies them
|
|
10
|
+
([0029](../../docs/decisions/0029-a-host-renders-a-published-form-through-one-client.md)).
|
|
11
|
+
|
|
12
|
+
Framework-neutral and fetch-based. It uses no Node API — the package's own
|
|
13
|
+
type check has no Node types, so a `node:` import does not compile — and runs
|
|
14
|
+
wherever `fetch` and `AbortSignal` exist.
|
|
15
|
+
|
|
16
|
+
**Licence.** Source-available under the Formancy Data licence in
|
|
17
|
+
[`LICENSE.md`](LICENSE.md), like every package in this repository; see the
|
|
18
|
+
licence table in the [repository README](../../README.md). It depends on the
|
|
19
|
+
Apache-2.0 `@formancy/spec` for types only.
|
|
20
|
+
|
|
21
|
+
## What it exports
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
createDataClient({ token, base?, fetch? }) // → { forms, form, read, list, create, update, delete, reconcile, query, resolve }
|
|
25
|
+
isUnknownWrite(outcome) // → whether a create, update or delete's outcome is an UnknownWrite
|
|
26
|
+
lookupSources(client, formId, form, operation | () => operation) // → { [source]: { resolve } }
|
|
27
|
+
sourceNames(form) // → every optionsSource the document names, once each, in document order
|
|
28
|
+
fieldProblems(refusal) // → { [field]: [sentence, …] }, for engine.applyServerErrors
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every client function resolves to an `Outcome`: `{ ok: true, value }` or a
|
|
32
|
+
`Refusal` — `{ ok: false, status, code, message, fieldErrors? }`. It rejects
|
|
33
|
+
only for an abort the caller asked for, with the AbortError it raised.
|
|
34
|
+
`create` and `update` resolve to a `WriteOutcome`, which is that or an
|
|
35
|
+
`UnknownWrite` — see below; `delete` to a `DeleteOutcome`, the server's
|
|
36
|
+
`{ record, deleted: true }` or a refusal, or an `UnknownWrite`.
|
|
37
|
+
|
|
38
|
+
| Function | Route |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `forms(signal?)` | `GET /v1/forms` → `{ forms: [{ id, title, operations, children }] }` |
|
|
41
|
+
| `form(formId)` | `GET /v1/forms/:id` → `{ form, operations, readable, list, deleting }` |
|
|
42
|
+
| `read(formId, record)` | `POST /v1/forms/:id/records/read` |
|
|
43
|
+
| `list(formId, query?, signal?)` | `POST /v1/forms/:id/records/list` → `{ rows, hasMore }` |
|
|
44
|
+
| `create(formId, answers)` | `POST /v1/forms/:id/records/create` |
|
|
45
|
+
| `update(formId, { record, version, answers })` | `POST /v1/forms/:id/records/update` |
|
|
46
|
+
| `delete(formId, { record, version })` | `POST /v1/forms/:id/records/delete` → `{ record, deleted: true }` |
|
|
47
|
+
| `reconcile(formId, unknown)` | `POST /v1/forms/:id/records/read`, or nothing when there is no token |
|
|
48
|
+
| `query(formId, source, { operation, search, offset?, limit? }, signal?)` | `POST /v1/forms/:id/lookups/:source/query` |
|
|
49
|
+
| `resolve(formId, source, { operation, tokens }, signal?)` | `POST /v1/forms/:id/lookups/:source/resolve` |
|
|
50
|
+
|
|
51
|
+
The reply types — `FormMenu`, `FormMenuEntry`, `FormMenuChild`,
|
|
52
|
+
`PublishedForm`, `FormRecord`, `RecordList`, `RecordListQuery`,
|
|
53
|
+
`ListSortTerm`, `ListColumns`, `LookupResult`, `LookupRow`, `FieldError` —
|
|
54
|
+
and `LIST_PAGE_SIZE` are re-exported from
|
|
55
|
+
`@formancy/data-core`, where the server's handlers are checked against the
|
|
56
|
+
same declarations. A form's `list`
|
|
57
|
+
is `null` when the server would refuse this person a page, and otherwise
|
|
58
|
+
says which fields a page may be searched and sorted by
|
|
59
|
+
([0044](../../docs/decisions/0044-a-forms-records-are-listed-a-page-at-a-time-under-its-read-policy.md));
|
|
60
|
+
a `form` reply without one is `unexpected`, as from a server older than
|
|
61
|
+
lists. `list` sends exactly `search`, `sort`, `parent`, `offset` and `limit`
|
|
62
|
+
in a POST body, each left out the server's default, so the search is in no
|
|
63
|
+
URL. A form's `deleting` says what a delete also does -- whether it
|
|
64
|
+
cascades, clears references, or may be refused while other rows refer to
|
|
65
|
+
the record -- for a page to word its confirmation by, and is `null`
|
|
66
|
+
exactly when `operations` holds no `delete`; a reply that breaks that is
|
|
67
|
+
`unexpected`, as is one without `deleting` from a server older than
|
|
68
|
+
deletes, so a host upgrades its server first
|
|
69
|
+
([0050](../../docs/decisions/0050-a-record-is-deleted-one-at-a-time-by-its-token-and-the-version-its-person-read.md)).
|
|
70
|
+
`delete` sends exactly `record` and `version`, with a write id of its own,
|
|
71
|
+
and is done only on the server's `{ record, deleted: true }`, whose token
|
|
72
|
+
is the row the statement deleted.
|
|
73
|
+
|
|
74
|
+
`forms` is the menu
|
|
75
|
+
([0045](../../docs/decisions/0045-the-runtime-plane-lists-the-forms-a-person-may-read-and-nothing-about-the-others.md)):
|
|
76
|
+
the forms the server says this person may read, each with its title, the
|
|
77
|
+
operations its policy grants them as published, and its children -- a
|
|
78
|
+
child form and the lookup field its rows name this form's records by, so a
|
|
79
|
+
host lists one record's children with
|
|
80
|
+
`list(child.form, { parent: { field: child.field, record } })`. The server
|
|
81
|
+
asks no database for it: `form(formId)` says what the database allows now,
|
|
82
|
+
and can still refuse a form the menu named. An answer whose entries lack
|
|
83
|
+
a title, operations or children is `unexpected`.
|
|
84
|
+
|
|
85
|
+
## The rules it keeps for a host
|
|
86
|
+
|
|
87
|
+
- **The token is the host's, per request.** `token()` is called once for every
|
|
88
|
+
request and its answer goes in `Authorization: Bearer …` and nowhere else —
|
|
89
|
+
not a URL, not a body, not storage. A host whose session renews the token is
|
|
90
|
+
never answered for the old one.
|
|
91
|
+
- **Same origin, or a listed one.** `base` defaults to `''`, the page's own
|
|
92
|
+
origin: served from the server's origin, or through a proxy on the page's
|
|
93
|
+
own ([0024](../../docs/decisions/0024-the-studio-speaks-only-the-admin-plane.md)).
|
|
94
|
+
A page on another origin sets `base` to the server's, and is answered only
|
|
95
|
+
when the server's operator lists the page's origin exactly in
|
|
96
|
+
`FORMANCY_DATA_CORS_ORIGINS`
|
|
97
|
+
([0060](../../docs/decisions/0060-the-runtime-plane-answers-the-origins-an-operator-lists-and-the-administrators-plane-none.md));
|
|
98
|
+
otherwise every call is a refused preflight, a read `unreachable` and a
|
|
99
|
+
write unknown though nothing was sent, and only the server's log says why.
|
|
100
|
+
Across origins the client sends only headers the server grants --
|
|
101
|
+
`authorization`, `content-type` and `formancy-write-id`, and `accept`,
|
|
102
|
+
which a browser sends without asking -- and asks for no credentials: a
|
|
103
|
+
`fetch` the host passes must not add a header or set `credentials:
|
|
104
|
+
'include'`, which the server never allows.
|
|
105
|
+
- **A name is one path segment.** A form id or a source name is
|
|
106
|
+
`encodeURIComponent`'d into one segment under the form's routes. Empty, `.`
|
|
107
|
+
and `..` cannot be one — `fetch` would resolve them to another route — and
|
|
108
|
+
are refused as `invalid-name` before any request or token is made. A
|
|
109
|
+
document decides which names exist, and never a URL, a header, a token, a
|
|
110
|
+
tenant or an operation (formancy.ai 0077,
|
|
111
|
+
[0012](../../docs/decisions/0012-a-lookup-token-is-a-reference-not-a-permission.md)).
|
|
112
|
+
- **Refusals are the server's, verbatim.** The client adds words only when
|
|
113
|
+
the server said none: `unreachable` (status 0) when nothing answered,
|
|
114
|
+
`unexpected` when what answered was not the data server's shape — a proxy's
|
|
115
|
+
HTML page, a redirect, a captive portal's `200`. A refusal with a sentence
|
|
116
|
+
and no code is `http-<status>`.
|
|
117
|
+
- **An abort stops the lookup in the database.** The signal a renderer
|
|
118
|
+
passes a source, or a host passes `query` and `resolve`, aborts the
|
|
119
|
+
request; the server notices its client left and stops the statement it
|
|
120
|
+
started, on both engines, and the call rejects with the AbortError
|
|
121
|
+
([0061](../../docs/decisions/0061-a-lookup-statement-stops-when-its-request-is-abandoned-or-its-deadline-passes.md)).
|
|
122
|
+
A search that runs past the server's deadline is refused 503
|
|
123
|
+
`lookup-timeout`, whose sentence says the same search will take as long
|
|
124
|
+
again, where `unavailable` invites another try; a resolve past it is
|
|
125
|
+
`unavailable`, since trying again helps a read by key. A source rejects
|
|
126
|
+
with either, and a renderer shows "could not be loaded". The 499 `abandoned` the server
|
|
127
|
+
answers an abandoned lookup reaches nobody: it is the audit trail's.
|
|
128
|
+
- **A write is known only when the data server says what happened**
|
|
129
|
+
([0031](../../docs/decisions/0031-an-answer-lost-after-a-write-is-unknown.md)).
|
|
130
|
+
A record, the server's 502 `unknown-outcome`, a 4xx with a sentence, or a
|
|
131
|
+
503 `unavailable`. Anything else — a rejected `fetch`, a body that cannot be
|
|
132
|
+
read or is not JSON, a proxy's 502 or 504, a 500, a 2xx that is not a
|
|
133
|
+
record — may have reached the database, so it is an `UnknownWrite`:
|
|
134
|
+
`{ ok: false, status, code: 'unknown-outcome', message, operation, record,
|
|
135
|
+
version, origin }`, `origin` being `database` (the server's 502, whose
|
|
136
|
+
`record` and `version` it carries) or `transport` (the update's or the
|
|
137
|
+
delete's own, or `null` for a create). `ok` is false, so a host that
|
|
138
|
+
checks only `ok` fails closed. Nothing is ever sent again.
|
|
139
|
+
`reconcile(formId, unknown)` reads what it addressed and says `unchanged`
|
|
140
|
+
(an update's or a deletion's record still at the version sent: saving or
|
|
141
|
+
deleting again with it is done at most once), `changed`, `present`,
|
|
142
|
+
`absent` (the read's 404, through this person's read filter: not proof the
|
|
143
|
+
row is not stored, but creating again is safe, because the key stops a
|
|
144
|
+
second row; for a deletion, the record is gone, by it or another) or
|
|
145
|
+
`unverifiable` (no token, and no request) — or passes on the read's own
|
|
146
|
+
refusal. On a read, 0029's rules stand: a proxy's page is
|
|
147
|
+
`unexpected`.
|
|
148
|
+
- **Every write carries a write id of its own**, in the `formancy-write-id`
|
|
149
|
+
header: 128 random bits from `crypto.getRandomValues`, new for every
|
|
150
|
+
`create`, `update` and `delete` call. Over HTTP/1.1, when a connection the
|
|
151
|
+
browser reused closes after the server read a write and before any byte of
|
|
152
|
+
an answer, Chromium and Firefox send it again on their own, below the page;
|
|
153
|
+
one server process answers that copy with the first one's answer instead of
|
|
154
|
+
applying it twice (0031). WebKit on Linux sends every write on a new
|
|
155
|
+
connection and never sends one twice, so there the write is unknown
|
|
156
|
+
([0058](../../docs/decisions/0058-the-browser-gates-run-in-chromium-firefox-and-webkit-and-each-fact-the-browsers-disagree-on-is-named-and-held.md)). The client itself still never sends anything again.
|
|
157
|
+
- **Field problems are sentences.** The renderers print an error entry as
|
|
158
|
+
it is, beside the field and in the error summary, as observed at 0.3.0 on
|
|
159
|
+
2026-10-09, at 0.4.0 on 2026-10-10 and at 0.5.0 on 2026-10-11, so
|
|
160
|
+
`fieldProblems` hands them the server's sentence, which never echoes a
|
|
161
|
+
value. The code stays on the
|
|
162
|
+
refusal for a program.
|
|
163
|
+
|
|
164
|
+
## In React
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
import { createFormEngine } from '@formancy/core'
|
|
168
|
+
import { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider } from '@formancy/react'
|
|
169
|
+
import { createDataClient, fieldProblems, isUnknownWrite, lookupSources } from '@formancy/data-client'
|
|
170
|
+
|
|
171
|
+
const client = createDataClient({ token: () => session.token() })
|
|
172
|
+
const published = await client.form('order') // check .ok
|
|
173
|
+
const { form } = published.value
|
|
174
|
+
let held = { record: undefined as string | undefined, version: '', operation: 'create' as 'create' | 'update' }
|
|
175
|
+
const sources = lookupSources(client, 'order', form, () => held.operation)
|
|
176
|
+
const engine = createFormEngine({ schema: form, formId: 'order-react', capabilities })
|
|
177
|
+
|
|
178
|
+
async function save(outcome) {
|
|
179
|
+
if (!outcome.ok) return // the engine's own errors are showing
|
|
180
|
+
const saved = held.record === undefined
|
|
181
|
+
? await client.create('order', outcome.data)
|
|
182
|
+
: await client.update('order', { record: held.record, version: held.version, answers: outcome.data })
|
|
183
|
+
if (isUnknownWrite(saved)) return showUnknown(saved) // it may have been saved: client.reconcile, never a resend
|
|
184
|
+
if (!saved.ok) {
|
|
185
|
+
engine.applyServerErrors(fieldProblems(saved)) // {} when the refusal concerns no field
|
|
186
|
+
return show(saved.message) // 409 stale: keep the draft and say so
|
|
187
|
+
}
|
|
188
|
+
held = { record: saved.value.record ?? undefined, version: saved.value.version ?? '', operation: 'update' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
<OptionsSourcesProvider value={sources}>
|
|
192
|
+
<FormancyProvider engine={engine}>
|
|
193
|
+
<ErrorSummary />
|
|
194
|
+
<FormancyForm submitLabel="Save" onSubmit={save} />
|
|
195
|
+
</FormancyProvider>
|
|
196
|
+
</OptionsSourcesProvider>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## In Angular
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { provideFormancy, provideFormancyOptionsSources } from '@formancy/angular'
|
|
203
|
+
|
|
204
|
+
const app = await createApplication({
|
|
205
|
+
providers: [provideZonelessChangeDetection(), provideFormancy(engine), provideFormancyOptionsSources(sources)],
|
|
206
|
+
})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The same `sources` object serves both renderers: each declares its own
|
|
210
|
+
`OptionsSource` on purpose, and a `LookupSource` is assignable to both, which
|
|
211
|
+
a host's type check proves. Angular's providers are fixed once its injector
|
|
212
|
+
exists, so the operation is passed as a getter: a form that moves from create
|
|
213
|
+
to update asks under update's filter without a new application.
|
|
214
|
+
|
|
215
|
+
## What it does not do
|
|
216
|
+
|
|
217
|
+
- **No cache and no retry.** The server asks the policy on every request
|
|
218
|
+
([0022](../../docs/decisions/0022-the-runtime-plane-asks-the-policy-every-time.md)),
|
|
219
|
+
and a write whose outcome is unknown — `502 unknown-outcome`, "may have been
|
|
220
|
+
saved" — is the host's to reconcile, never the client's to send again
|
|
221
|
+
([0015](../../docs/decisions/0015-a-record-operation-is-one-guarded-statement.md)).
|
|
222
|
+
What the browser sends again on its own is answered by the server once per
|
|
223
|
+
write id, in its process only: a copy that reaches another replica is
|
|
224
|
+
applied again.
|
|
225
|
+
- **`hasMore` and `omitted` do not reach the person.** A renderer's option
|
|
226
|
+
contract, as read at 0.3.0 on 2026-10-09 and unchanged at 0.4.0 and 0.5.0,
|
|
227
|
+
is a list of `{ value, label }`, so a list of exactly one page looks
|
|
228
|
+
complete. A typeahead keeps narrowing; a plain select does not.
|
|
229
|
+
- **Its types are `@formancy/spec` 0.5.0's.** `PublishedForm.form` is the
|
|
230
|
+
`FormSchema` of the `@formancy/spec` this package pins exactly (0002), and
|
|
231
|
+
there `specVersion` may be `"4"`, so a host whose engine is
|
|
232
|
+
`@formancy/core` 0.3.0 does not compile the React snippet above: TS2322 at
|
|
233
|
+
`createFormEngine`, measured with TypeScript 6.0.3 and `skipLibCheck` off
|
|
234
|
+
on 2026-10-10, and again against 0.5.0's types on 2026-10-11, when a host
|
|
235
|
+
whose engine is at 0.4.0 compiled it. The document itself is spec 3
|
|
236
|
+
([0042](../../docs/decisions/0042-generated-forms-stay-on-spec-3-after-spec-4-is-released.md)),
|
|
237
|
+
so such a host casts it to its own `@formancy/spec`'s `FormSchema`, or
|
|
238
|
+
moves its renderer to 0.4.0 or later. Nothing here type-checks a 0.3.0 or
|
|
239
|
+
a 0.4.0 host.
|
|
240
|
+
- **Records are addressed by token only.** The definition does not name the
|
|
241
|
+
identity columns, so a host keeps the tokens that create, read and lookups
|
|
242
|
+
return, or encodes its own key with data-core's `encodeKeyToken`.
|
|
243
|
+
- **Error sentences are the server's English.** The codes stay on the refusal
|
|
244
|
+
for a host that translates.
|
|
245
|
+
- **A source answers for every name the document carries.** A host with its
|
|
246
|
+
own named lists merges maps; a name the server does not know fails closed,
|
|
247
|
+
as "could not be loaded".
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Deleting, FieldError, FieldError as FieldError$1, FormMenu, FormMenu as FormMenu$1, FormMenuChild, FormMenuEntry, FormRecord, FormRecord as FormRecord$1, LIST_PAGE_SIZE, ListColumns, ListSortTerm, LookupResult, LookupResult as LookupResult$1, LookupRow, LookupRow as LookupRow$1, PublishedForm, PublishedForm as PublishedForm$1, RecordDeleted, RecordDeleted as RecordDeleted$1, RecordList, RecordList as RecordList$1, RecordListQuery, RecordListQuery as RecordListQuery$1 } from "@formancy/data-core";
|
|
2
|
+
import { FormSchema, RemoteOption } from "@formancy/spec";
|
|
3
|
+
//#region src/writes.d.ts
|
|
4
|
+
/** A write that was sent and whose outcome nobody here can know: the host reconciles it, nothing retries it. */
|
|
5
|
+
interface UnknownWrite {
|
|
6
|
+
/** False, so a host that only checks `ok` treats it as not done, and fails closed. */
|
|
7
|
+
ok: false;
|
|
8
|
+
/** The status that answered, or 0 when nothing did. */
|
|
9
|
+
status: number;
|
|
10
|
+
code: 'unknown-outcome';
|
|
11
|
+
/** The server's sentence when the database lost the answer; the client's own when the page lost it. */
|
|
12
|
+
message: string;
|
|
13
|
+
/** `delete` since 0050. */
|
|
14
|
+
operation: 'create' | 'update' | 'delete';
|
|
15
|
+
/** Update and delete: the token sent. Create: the token the server says it would have, or null. */
|
|
16
|
+
record: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Update and delete: the version sent -- saving again with exactly it is
|
|
19
|
+
* stored at most once, and deleting again with it deletes nothing else.
|
|
20
|
+
* Create: null.
|
|
21
|
+
*/
|
|
22
|
+
version: string | null;
|
|
23
|
+
/** `database`: the data server said so (its 502). `transport`: the answer was lost between page and server. */
|
|
24
|
+
origin: 'database' | 'transport';
|
|
25
|
+
}
|
|
26
|
+
/** What `create` and `update` resolve to. */
|
|
27
|
+
type WriteOutcome = Outcome<FormRecord$1> | UnknownWrite;
|
|
28
|
+
/** What `delete` resolves to (0050): the server's deletion answer, a refusal, or a deletion nobody here can say happened. */
|
|
29
|
+
type DeleteOutcome = Outcome<RecordDeleted$1> | UnknownWrite;
|
|
30
|
+
/** Whether an outcome is an unknown write, rather than a success or a refusal. */
|
|
31
|
+
export declare function isUnknownWrite(outcome: {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
}): outcome is UnknownWrite;
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/reconcile.d.ts
|
|
36
|
+
/**
|
|
37
|
+
* What one read says about an unknown write (0031). Never a resend: the
|
|
38
|
+
* person decides what happens next, with this in front of them.
|
|
39
|
+
*
|
|
40
|
+
* - `unchanged` -- an update's or a deletion's record is still at the
|
|
41
|
+
* version it sent: for a deletion, deleting again with it is safe. Not
|
|
42
|
+
* visible yet, which is not "not stored": on PostgreSQL a write orphaned by
|
|
43
|
+
* a cut can still commit. Saving again with that same version is safe; the
|
|
44
|
+
* database stores it at most once, and whichever comes second is stale.
|
|
45
|
+
* - `changed` -- an update's or a deletion's record has moved on: this save, or another's.
|
|
46
|
+
* - `present` -- a create whose token was known is there -- or, where records
|
|
47
|
+
* are deleted, one created again since with the same key, which only a
|
|
48
|
+
* key people type allows, and only on a rowversion table (0050).
|
|
49
|
+
* - `absent` -- it is not (the read's 404). Creating again is safe: the key
|
|
50
|
+
* stops a second row, whichever write lands first. A deletion's record
|
|
51
|
+
* is gone: this deletion's work, or another's, which the version a record
|
|
52
|
+
* cannot share with another makes one and the same (0050).
|
|
53
|
+
* - `unverifiable` -- a create with no token: the database numbers the key,
|
|
54
|
+
* or the answer was lost before the server could name one. No request is
|
|
55
|
+
* made; only the person can tell.
|
|
56
|
+
* - a refusal -- the read's own, which says nothing about the write.
|
|
57
|
+
*/
|
|
58
|
+
type Reconciliation = {
|
|
59
|
+
ok: true;
|
|
60
|
+
state: 'unchanged';
|
|
61
|
+
current: FormRecord$1;
|
|
62
|
+
} | {
|
|
63
|
+
ok: true;
|
|
64
|
+
state: 'changed';
|
|
65
|
+
current: FormRecord$1;
|
|
66
|
+
} | {
|
|
67
|
+
ok: true;
|
|
68
|
+
state: 'present';
|
|
69
|
+
current: FormRecord$1;
|
|
70
|
+
} | {
|
|
71
|
+
ok: true;
|
|
72
|
+
state: 'absent';
|
|
73
|
+
} | {
|
|
74
|
+
ok: true;
|
|
75
|
+
state: 'unverifiable';
|
|
76
|
+
} | Refusal;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/client.d.ts
|
|
79
|
+
interface DataClientOptions {
|
|
80
|
+
/** Called once per request. The host's session owns it; it is never stored, and never in a URL or a body. */
|
|
81
|
+
token: () => string | Promise<string>;
|
|
82
|
+
/**
|
|
83
|
+
* Prefixed to every route. Default `''`: the page's own origin. Another
|
|
84
|
+
* origin is answered only when the server's operator lists the page's
|
|
85
|
+
* origin in `FORMANCY_DATA_CORS_ORIGINS`; otherwise every call is a refused
|
|
86
|
+
* preflight, a network error to the page: a read is `unreachable`, a write
|
|
87
|
+
* unknown though nothing was sent (0060).
|
|
88
|
+
*/
|
|
89
|
+
base?: string;
|
|
90
|
+
/** Default the global `fetch`, bound. */
|
|
91
|
+
fetch?: typeof fetch;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Why a call did not succeed. `status` is the HTTP status, or 0 when nothing
|
|
95
|
+
* answered or nothing was sent. `code` and `message` are the server's when it
|
|
96
|
+
* gave them; `fieldErrors` is present when the refusal concerns fields.
|
|
97
|
+
*/
|
|
98
|
+
type Refusal = {
|
|
99
|
+
ok: false;
|
|
100
|
+
status: number;
|
|
101
|
+
code: string;
|
|
102
|
+
message: string;
|
|
103
|
+
fieldErrors?: FieldError$1[];
|
|
104
|
+
};
|
|
105
|
+
type Outcome<T> = {
|
|
106
|
+
ok: true;
|
|
107
|
+
value: T;
|
|
108
|
+
} | Refusal;
|
|
109
|
+
/** The operation a form is open for, which decides the policy's lookup filter. Never the document's to say. */
|
|
110
|
+
type LookupOperation = 'read' | 'create' | 'update';
|
|
111
|
+
/**
|
|
112
|
+
* A client of one data server's runtime plane. Every function resolves to an
|
|
113
|
+
* outcome and never rejects for an answer -- only for an abort the caller
|
|
114
|
+
* asked for, which reaches it as the AbortError it raised.
|
|
115
|
+
*/
|
|
116
|
+
export declare function createDataClient(options: DataClientOptions): {
|
|
117
|
+
/**
|
|
118
|
+
* `GET /v1/forms` (0045): the published forms this person may read, each
|
|
119
|
+
* with its title, the operations the policy grants them and the children
|
|
120
|
+
* a page may list under one of its records -- and nothing about any other
|
|
121
|
+
* form. Opening one with `form` says what the database allows now.
|
|
122
|
+
*/
|
|
123
|
+
forms: (signal?: AbortSignal) => Promise<Outcome<FormMenu$1>>;
|
|
124
|
+
/** `GET /v1/forms/:id`: the published document, the operations this person may use, and the fields they may read. */
|
|
125
|
+
form: (formId: string) => Promise<Outcome<PublishedForm$1>>;
|
|
126
|
+
/** `POST …/records/read`: one record by its token, with the version an update sends back. */
|
|
127
|
+
read: (formId: string, record: string) => Promise<Outcome<FormRecord$1>>;
|
|
128
|
+
/**
|
|
129
|
+
* `POST …/records/list` (0044): one page of the records this person may
|
|
130
|
+
* read, each what `read` returns for its token, and whether more follow.
|
|
131
|
+
* A property left out is the server's default -- no search, the key's
|
|
132
|
+
* order, no parent, the first page, the largest page -- and exactly the
|
|
133
|
+
* route's keys are sent, whatever else the object carries. A POST, so the
|
|
134
|
+
* search is never in a URL.
|
|
135
|
+
*/
|
|
136
|
+
list: (formId: string, query?: Partial<RecordListQuery$1>, signal?: AbortSignal) => Promise<Outcome<RecordList$1>>;
|
|
137
|
+
/**
|
|
138
|
+
* `POST …/records/create`: a new record from the answers; the reply is what
|
|
139
|
+
* was stored, in the stored spelling -- or an `UnknownWrite` when nobody
|
|
140
|
+
* can say whether it was stored.
|
|
141
|
+
*/
|
|
142
|
+
create: (formId: string, answers: Readonly<Record<string, unknown>>) => Promise<WriteOutcome>;
|
|
143
|
+
/**
|
|
144
|
+
* `POST …/records/update`: saved only if the record is still at `version`,
|
|
145
|
+
* otherwise 409 `stale`; an `UnknownWrite` when nobody can say whether it
|
|
146
|
+
* was saved. Exactly the route's keys are sent, whatever else the object
|
|
147
|
+
* passed carries.
|
|
148
|
+
*/
|
|
149
|
+
update: (formId: string, change: {
|
|
150
|
+
record: string;
|
|
151
|
+
version: string;
|
|
152
|
+
answers: Readonly<Record<string, unknown>>;
|
|
153
|
+
}) => Promise<WriteOutcome>;
|
|
154
|
+
/**
|
|
155
|
+
* `POST …/records/delete` (0050): the record, only if it is still at
|
|
156
|
+
* `version`, otherwise 409 `stale`; the answer is the deleted row's
|
|
157
|
+
* token and `deleted: true`, and nothing else is done. An `UnknownWrite`
|
|
158
|
+
* when nobody can say whether it was deleted -- deleting again with the
|
|
159
|
+
* same version then deletes nothing else. Exactly the route's keys are
|
|
160
|
+
* sent, with a write id of its own.
|
|
161
|
+
*/
|
|
162
|
+
delete: (formId: string, removal: {
|
|
163
|
+
record: string;
|
|
164
|
+
version: string;
|
|
165
|
+
}) => Promise<DeleteOutcome>;
|
|
166
|
+
/** Reads what an unknown write addressed and says what that shows; never sends the write again (0031). */
|
|
167
|
+
reconcile: (formId: string, unknown: UnknownWrite) => Promise<Reconciliation>;
|
|
168
|
+
/** `POST …/lookups/:source/query`: one page of the options this person may pick for this operation. */
|
|
169
|
+
query: (formId: string, source: string, q: {
|
|
170
|
+
operation: LookupOperation;
|
|
171
|
+
search: string;
|
|
172
|
+
offset?: number;
|
|
173
|
+
limit?: number;
|
|
174
|
+
}, signal?: AbortSignal) => Promise<Outcome<LookupResult$1>>;
|
|
175
|
+
/** `POST …/lookups/:source/resolve`: the labels of the tokens this person may see; any other is absent, not an error. */
|
|
176
|
+
resolve: (formId: string, source: string, r: {
|
|
177
|
+
operation: LookupOperation;
|
|
178
|
+
tokens: readonly string[];
|
|
179
|
+
}, signal?: AbortSignal) => Promise<Outcome<LookupRow$1[]>>;
|
|
180
|
+
};
|
|
181
|
+
/** A client of one data server's runtime plane: what `createDataClient` returns. */
|
|
182
|
+
type DataClient = ReturnType<typeof createDataClient>;
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/lookups.d.ts
|
|
185
|
+
/** What a source reads of a renderer's request: the kind, the query or the stored values, the limit, and the signal. */
|
|
186
|
+
type LookupRequest = {
|
|
187
|
+
kind: 'search' | 'labels';
|
|
188
|
+
query: string;
|
|
189
|
+
values: readonly string[];
|
|
190
|
+
limit: number;
|
|
191
|
+
signal: AbortSignal;
|
|
192
|
+
};
|
|
193
|
+
/** One named list, answering through the runtime plane. */
|
|
194
|
+
type LookupSource = {
|
|
195
|
+
resolve(request: LookupRequest): Promise<readonly RemoteOption[]>;
|
|
196
|
+
};
|
|
197
|
+
/** Every optionsSource the document names -- in groups and repeaters too -- once each, in document order. */
|
|
198
|
+
export declare function sourceNames(form: FormSchema): string[];
|
|
199
|
+
/**
|
|
200
|
+
* One source per name the document carries, each answering through this
|
|
201
|
+
* form's lookup routes.
|
|
202
|
+
*
|
|
203
|
+
* `operation` may be a getter: it is read on every request, so a form that
|
|
204
|
+
* moves from create to update after its first save asks under update's filter
|
|
205
|
+
* without a new map -- which, in Angular, would mean a new application.
|
|
206
|
+
*
|
|
207
|
+
* A search sends the renderer's query and limit; a label request resolves the
|
|
208
|
+
* stored tokens, and asks nothing when there are none. A token the person may
|
|
209
|
+
* not see is left out, not an error. A refusal rejects with the server's
|
|
210
|
+
* sentence, which the renderer shows as "could not be loaded"; an abort
|
|
211
|
+
* rejects with the AbortError. `hasMore` and `omitted` do not reach the
|
|
212
|
+
* renderer, whose contract, at 0.5.0 as at 0.3.0, has nowhere to put them
|
|
213
|
+
* (0029).
|
|
214
|
+
*/
|
|
215
|
+
export declare function lookupSources(client: DataClient, formId: string, form: FormSchema, operation: LookupOperation | (() => LookupOperation)): Readonly<Record<string, LookupSource>>;
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/problems.d.ts
|
|
218
|
+
/**
|
|
219
|
+
* A refusal's field problems as `engine.applyServerErrors` takes them: the
|
|
220
|
+
* server's sentences, grouped by field, in the order the server gave them,
|
|
221
|
+
* each once. `{}` when the refusal concerns no field.
|
|
222
|
+
*
|
|
223
|
+
* Sentences and not codes, because the renderers print an entry verbatim, at
|
|
224
|
+
* 0.3.0, 0.4.0 and 0.5.0, beside the field and in the error summary: a code would
|
|
225
|
+
* put `not-an-option` in front of a person. The server promises its sentence is
|
|
226
|
+
* showable and never echoes a value (0011); the code stays on the Refusal for a
|
|
227
|
+
* program to read.
|
|
228
|
+
*/
|
|
229
|
+
export declare function fieldProblems(refusal: Refusal): Record<string, string[]>;
|
|
230
|
+
//#endregion
|
|
231
|
+
export { type DataClient, type DataClientOptions, type DeleteOutcome, type Deleting, type FieldError, type FormMenu, type FormMenuChild, type FormMenuEntry, type FormRecord, LIST_PAGE_SIZE, type ListColumns, type ListSortTerm, type LookupOperation, type LookupRequest, type LookupResult, type LookupRow, type LookupSource, type Outcome, type PublishedForm, type Reconciliation, type RecordDeleted, type RecordList, type RecordListQuery, type Refusal, type UnknownWrite, type WriteOutcome };
|
|
232
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":["FormRecord","RecordDeleted","FormRecord","FieldError","FormMenu","PublishedForm","FormRecord","RecordListQuery","RecordList","LookupResult","LookupRow"],"sources":["../src/writes.ts","../src/reconcile.ts","../src/client.ts","../src/lookups.ts","../src/problems.ts"],"mappings":";;;;UAciB;;EAEf;;EAEA;EACA;;EAEA;;EAEA;;EAEA;;;;;;EAMA;;EAEA;;;KAIU,eAAe,QAAQA,gBAAc;;KAGrC,gBAAgB,QAAQC,mBAAiB;;wBAGrC,eAAe;EAAW;IAAgB,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;KCjBzD;EACN;EAAU;EAAoB,SAASC;;EACvC;EAAU;EAAkB,SAASA;;EACrC;EAAU;EAAkB,SAASA;;EACrC;EAAU;;EACV;EAAU;IACZ;;;UCGa;;EAEf,sBAAsB;;;;;;;;EAQtB;;EAEA,eAAe;;;;;;;KAQL;EAAY;EAAW;EAAgB;EAAc;EAAiB,cAAcC;;KACpF,QAAQ;EAAO;EAAU,OAAO;IAAM;;KAGtC;;;;;;wBAsEI,iBAAiB,SAAS;;;;;;;EAqErB,QAAA,SAAA,gBAAc,QAAQ,QAAQC;;EAIhC,OAAA,mBAAS,QAAQ,QAAQC;;EAhBpB,OAAA,gBAAM,mBAAmB,QAAQ,QAAQC;;;;;;;;;EA8B9C,OAAA,gBAAM,QAAS,QAAQC,oBAAgB,SAAgB,gBAAc,QAAQ,QAAQC;;;;;;EAenF,SAAA,gBAAM,SAAW,SAAS,6BAA2B,QAAQ;;;;;;;EAS7D,SAAA,gBAAM;IAAY;IAAgB;IAAiB,SAAS,SAAS;QAA6B,QAAQ;;;;;;;;;EAWpG,SAAA,gBAAM;IAAa;IAAgB;QAAoB,QAAQ;;EAOlE,YAAA,gBAAM,SAAW,iBAAe,QAAQ;;EAIlD,QAAA,gBAAM,gBACA;IACT,WAAW;IAAiB;IAAgB;IAAiB;KAAgB,SACzE,gBACR,QAAQ,QAAQC;;EAID,UAAA,gBAAM,gBAAgB;IAAO,WAAW;IAAiB;KAA2B,SAAW,gBAAc,QAAQ,QAAQC;;;KAMvI,aAAa,kBAAkB;;;;KChQ/B;EAAkB;EAA2B;EAAe;EAA2B;EAAe,QAAQ;;;KAG9G;EAAiB,QAAQ,SAAS,gBAAgB,iBAAiB;;;wBAU/D,YAAY,MAAM;;;;;;;;;;;;;;;;;wBAsBlB,cACd,QAAQ,YACR,gBACA,MAAM,YACN,WAAW,yBAAyB,mBACnC,SAAS,eAAe;;;;;;;;;;;;;;wBC/CX,cAAc,SAAS,UAAU"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { LIST_PAGE_SIZE, WRITE_ID_HEADER } from "@formancy/data-core";
|
|
2
|
+
//#region src/reconcile.ts
|
|
3
|
+
/** Reads the record an unknown write addressed, through `read`, and says what that shows. */
|
|
4
|
+
async function reconcile(read, formId, unknown) {
|
|
5
|
+
if (unknown.record === null) return {
|
|
6
|
+
ok: true,
|
|
7
|
+
state: "unverifiable"
|
|
8
|
+
};
|
|
9
|
+
const found = await read(formId, unknown.record);
|
|
10
|
+
if (unknown.operation === "create") {
|
|
11
|
+
if (found.ok) return {
|
|
12
|
+
ok: true,
|
|
13
|
+
state: "present",
|
|
14
|
+
current: found.value
|
|
15
|
+
};
|
|
16
|
+
return found.status === 404 && found.code === "not-found" ? {
|
|
17
|
+
ok: true,
|
|
18
|
+
state: "absent"
|
|
19
|
+
} : found;
|
|
20
|
+
}
|
|
21
|
+
if (unknown.operation === "delete" && !found.ok && found.status === 404 && found.code === "not-found") return {
|
|
22
|
+
ok: true,
|
|
23
|
+
state: "absent"
|
|
24
|
+
};
|
|
25
|
+
if (!found.ok) return found;
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
state: found.value.version === unknown.version ? "unchanged" : "changed",
|
|
29
|
+
current: found.value
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/shapes.ts
|
|
34
|
+
const UNEXPECTED = "The data server did not answer in a way this client understands.";
|
|
35
|
+
function isRecord(value) {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
function isNullableString(value) {
|
|
39
|
+
return value === null || typeof value === "string";
|
|
40
|
+
}
|
|
41
|
+
function isFieldError(value) {
|
|
42
|
+
return isRecord(value) && typeof value["field"] === "string" && typeof value["code"] === "string" && typeof value["message"] === "string";
|
|
43
|
+
}
|
|
44
|
+
function isRows(value) {
|
|
45
|
+
return Array.isArray(value) && value.every((row) => isRecord(row) && typeof row["token"] === "string" && typeof row["label"] === "string");
|
|
46
|
+
}
|
|
47
|
+
function isStrings(value) {
|
|
48
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
49
|
+
}
|
|
50
|
+
/** What a form's list may be searched and sorted by, or `null` for no list (0044): a reply without one is from no server that has lists. */
|
|
51
|
+
function isListColumns(value) {
|
|
52
|
+
return value === null || isRecord(value) && isStrings(value["searchable"]) && isStrings(value["sortable"]);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* What a delete also does (0050): `null` exactly when the form offers no
|
|
56
|
+
* delete, otherwise its three flags. A reply that says delete without them,
|
|
57
|
+
* or them without delete -- or neither, a server older than delete -- is no
|
|
58
|
+
* form this server sent, and a page that worded a confirmation from it
|
|
59
|
+
* would say what nobody decided.
|
|
60
|
+
*/
|
|
61
|
+
function isDeleting(value, operations) {
|
|
62
|
+
if (!operations.includes("delete")) return value === null;
|
|
63
|
+
return isRecord(value) && typeof value["cascades"] === "boolean" && typeof value["clears"] === "boolean" && typeof value["restricted"] === "boolean";
|
|
64
|
+
}
|
|
65
|
+
function isPublishedForm(body) {
|
|
66
|
+
return isRecord(body) && isRecord(body["form"]) && isRecord(body["form"]["model"]) && Array.isArray(body["operations"]) && Array.isArray(body["readable"]) && isListColumns(body["list"]) && isDeleting(body["deleting"], body["operations"]);
|
|
67
|
+
}
|
|
68
|
+
/** A child in a menu: the child form, and the field its list is narrowed by. */
|
|
69
|
+
function isMenuChild(value) {
|
|
70
|
+
return isRecord(value) && typeof value["form"] === "string" && typeof value["field"] === "string";
|
|
71
|
+
}
|
|
72
|
+
/** One menu entry: a page draws a link from it, and a grid per child. */
|
|
73
|
+
function isMenuEntry(value) {
|
|
74
|
+
return isRecord(value) && typeof value["id"] === "string" && typeof value["title"] === "string" && isStrings(value["operations"]) && Array.isArray(value["children"]) && value["children"].every(isMenuChild);
|
|
75
|
+
}
|
|
76
|
+
/** The menu (0045): every entry in the server's shape, children included, so an entry of a server with no children is not drawn as a form with none. */
|
|
77
|
+
function isFormMenu(body) {
|
|
78
|
+
return isRecord(body) && Array.isArray(body["forms"]) && body["forms"].every(isMenuEntry);
|
|
79
|
+
}
|
|
80
|
+
/** A delete's answer (0050): the token of the row deleted, and `deleted: true`, nothing else that a 2xx could be mistaken for. */
|
|
81
|
+
function isRecordDeleted(body) {
|
|
82
|
+
return isRecord(body) && typeof body["record"] === "string" && body["deleted"] === true;
|
|
83
|
+
}
|
|
84
|
+
function isFormRecord(body) {
|
|
85
|
+
return isRecord(body) && isNullableString(body["record"]) && isNullableString(body["version"]) && isRecord(body["answers"]);
|
|
86
|
+
}
|
|
87
|
+
/** A page of records (0044): every row a record, and whether more follow. */
|
|
88
|
+
function isRecordList(body) {
|
|
89
|
+
return isRecord(body) && Array.isArray(body["rows"]) && body["rows"].every(isFormRecord) && typeof body["hasMore"] === "boolean";
|
|
90
|
+
}
|
|
91
|
+
function isLookupResult(body) {
|
|
92
|
+
return isRecord(body) && isRows(body["rows"]) && typeof body["hasMore"] === "boolean" && typeof body["omitted"] === "number";
|
|
93
|
+
}
|
|
94
|
+
function isResolved(body) {
|
|
95
|
+
return isRecord(body) && isRows(body["rows"]);
|
|
96
|
+
}
|
|
97
|
+
/** A non-2xx answer: the server's refusal when it carries a sentence, otherwise not the server's to have said. */
|
|
98
|
+
function refusalOf(status, body) {
|
|
99
|
+
if (!isRecord(body) || typeof body["message"] !== "string") return {
|
|
100
|
+
ok: false,
|
|
101
|
+
status,
|
|
102
|
+
code: "unexpected",
|
|
103
|
+
message: UNEXPECTED
|
|
104
|
+
};
|
|
105
|
+
const refusal = {
|
|
106
|
+
ok: false,
|
|
107
|
+
status,
|
|
108
|
+
code: typeof body["code"] === "string" ? body["code"] : `http-${String(status)}`,
|
|
109
|
+
message: body["message"]
|
|
110
|
+
};
|
|
111
|
+
if (Array.isArray(body["fieldErrors"])) refusal.fieldErrors = body["fieldErrors"].filter(isFieldError);
|
|
112
|
+
return refusal;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/writes.ts
|
|
116
|
+
/** Whether an outcome is an unknown write, rather than a success or a refusal. */
|
|
117
|
+
function isUnknownWrite(outcome) {
|
|
118
|
+
if (outcome.ok) return false;
|
|
119
|
+
const candidate = outcome;
|
|
120
|
+
return candidate.code === "unknown-outcome" && (candidate.origin === "database" || candidate.origin === "transport");
|
|
121
|
+
}
|
|
122
|
+
const LOST_ON_THE_WAY = "The answer to this save was lost between this page and the data server. It may have been saved.";
|
|
123
|
+
const DELETION_LOST_ON_THE_WAY = "The answer to this deletion was lost between this page and the data server. It may have been deleted.";
|
|
124
|
+
function lost(intent, status) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
status,
|
|
128
|
+
code: "unknown-outcome",
|
|
129
|
+
message: intent.operation === "delete" ? DELETION_LOST_ON_THE_WAY : LOST_ON_THE_WAY,
|
|
130
|
+
...intent,
|
|
131
|
+
origin: "transport"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function writeOutcome(answer, intent) {
|
|
135
|
+
if (!answer.answered) return lost(intent, 0);
|
|
136
|
+
const { status, body } = answer;
|
|
137
|
+
if (!isRecord(body)) return lost(intent, status);
|
|
138
|
+
if (status >= 200 && status <= 299) {
|
|
139
|
+
if (intent.operation === "delete") return isRecordDeleted(body) ? {
|
|
140
|
+
ok: true,
|
|
141
|
+
value: body
|
|
142
|
+
} : lost(intent, status);
|
|
143
|
+
return isFormRecord(body) ? {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: body
|
|
146
|
+
} : lost(intent, status);
|
|
147
|
+
}
|
|
148
|
+
const message = body["message"];
|
|
149
|
+
if (typeof message !== "string") return lost(intent, status);
|
|
150
|
+
if (status === 502 && body["code"] === "unknown-outcome") {
|
|
151
|
+
const record = isNullableString(body["record"]) ? body["record"] : intent.record;
|
|
152
|
+
const version = isNullableString(body["version"]) ? body["version"] : intent.version;
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
status,
|
|
156
|
+
code: "unknown-outcome",
|
|
157
|
+
message,
|
|
158
|
+
operation: intent.operation,
|
|
159
|
+
record,
|
|
160
|
+
version,
|
|
161
|
+
origin: "database"
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (status >= 400 && status <= 499) return refusalOf(status, body);
|
|
165
|
+
if (status === 503 && body["code"] === "unavailable") return refusalOf(status, body);
|
|
166
|
+
return lost(intent, status);
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/client.ts
|
|
170
|
+
const UNREACHABLE = "The data server could not be reached.";
|
|
171
|
+
const INVALID_NAME = "A form or lookup name that is empty, \".\" or \"..\" cannot address a route.";
|
|
172
|
+
/**
|
|
173
|
+
* `name` as one path segment, or undefined when it cannot be one. `fetch`
|
|
174
|
+
* resolves `.` and `..` before sending, so they would address a different
|
|
175
|
+
* route; an empty segment would address the route above.
|
|
176
|
+
*/
|
|
177
|
+
function pathSegment(name) {
|
|
178
|
+
if (name === "" || name === "." || name === "..") return void 0;
|
|
179
|
+
return encodeURIComponent(name);
|
|
180
|
+
}
|
|
181
|
+
/** The body as JSON, or undefined when it is not JSON or could not be read. An abort while reading rethrows. */
|
|
182
|
+
async function bodyOf(response, signal) {
|
|
183
|
+
let text;
|
|
184
|
+
try {
|
|
185
|
+
text = await response.text();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (signal?.aborted === true) throw error;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(text);
|
|
192
|
+
} catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The base without the slashes it ends in, by walking back from the end.
|
|
198
|
+
* `/\/+$/` did the same and CodeQL flagged it (js/polynomial-redos): on a
|
|
199
|
+
* base of many slashes not at the end, the anchored pattern backtracks.
|
|
200
|
+
*/
|
|
201
|
+
function withoutTrailingSlashes(base) {
|
|
202
|
+
let end = base.length;
|
|
203
|
+
while (end > 0 && base[end - 1] === "/") end -= 1;
|
|
204
|
+
return base.slice(0, end);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A new write id: 128 random bits as hex, from `crypto.getRandomValues`,
|
|
208
|
+
* which a page served over plain HTTP has too, unlike `randomUUID`.
|
|
209
|
+
*/
|
|
210
|
+
function newWriteId() {
|
|
211
|
+
return Array.from(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A client of one data server's runtime plane. Every function resolves to an
|
|
215
|
+
* outcome and never rejects for an answer -- only for an abort the caller
|
|
216
|
+
* asked for, which reaches it as the AbortError it raised.
|
|
217
|
+
*/
|
|
218
|
+
function createDataClient(options) {
|
|
219
|
+
const base = withoutTrailingSlashes(options.base ?? "");
|
|
220
|
+
const send = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
221
|
+
/** The transport: the request sent once, and what came back. Classifying it is the caller's. */
|
|
222
|
+
async function exchange(request) {
|
|
223
|
+
const segments = request.names.map(pathSegment);
|
|
224
|
+
if (segments.some((segment) => segment === void 0)) return { sent: false };
|
|
225
|
+
const headers = {
|
|
226
|
+
...request.headers,
|
|
227
|
+
authorization: `Bearer ${await options.token()}`,
|
|
228
|
+
accept: "application/json"
|
|
229
|
+
};
|
|
230
|
+
if (request.body !== void 0) headers["content-type"] = "application/json";
|
|
231
|
+
let response;
|
|
232
|
+
try {
|
|
233
|
+
response = await send(`${base}${request.route(segments)}`, {
|
|
234
|
+
method: request.method,
|
|
235
|
+
headers,
|
|
236
|
+
redirect: "manual",
|
|
237
|
+
...request.body === void 0 ? {} : { body: JSON.stringify(request.body) },
|
|
238
|
+
...request.signal === void 0 ? {} : { signal: request.signal }
|
|
239
|
+
});
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (request.signal?.aborted === true) throw error;
|
|
242
|
+
return { answered: false };
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
answered: true,
|
|
246
|
+
status: response.status,
|
|
247
|
+
body: await bodyOf(response, request.signal)
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const invalidName = {
|
|
251
|
+
ok: false,
|
|
252
|
+
status: 0,
|
|
253
|
+
code: "invalid-name",
|
|
254
|
+
message: INVALID_NAME
|
|
255
|
+
};
|
|
256
|
+
/** A read: changes nothing, so an answer that went wrong is a refusal, and asking again is safe (0029). */
|
|
257
|
+
async function call(request) {
|
|
258
|
+
const answer = await exchange(request);
|
|
259
|
+
if ("sent" in answer) return invalidName;
|
|
260
|
+
if (!answer.answered) return {
|
|
261
|
+
ok: false,
|
|
262
|
+
status: 0,
|
|
263
|
+
code: "unreachable",
|
|
264
|
+
message: UNREACHABLE
|
|
265
|
+
};
|
|
266
|
+
if (answer.status < 200 || answer.status > 299) return refusalOf(answer.status, answer.body);
|
|
267
|
+
const value = request.read(answer.body);
|
|
268
|
+
return value === void 0 ? {
|
|
269
|
+
ok: false,
|
|
270
|
+
status: answer.status,
|
|
271
|
+
code: "unexpected",
|
|
272
|
+
message: UNEXPECTED
|
|
273
|
+
} : {
|
|
274
|
+
ok: true,
|
|
275
|
+
value
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** A write: known only when the data server says what happened; otherwise unknown, and never sent again (0031). */
|
|
279
|
+
async function sendWrite(formId, intent, body) {
|
|
280
|
+
return exchange({
|
|
281
|
+
method: "POST",
|
|
282
|
+
names: [formId],
|
|
283
|
+
route: ([form]) => `/v1/forms/${String(form)}/records/${intent.operation}`,
|
|
284
|
+
body,
|
|
285
|
+
headers: { [WRITE_ID_HEADER]: newWriteId() }
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async function write(formId, intent, body) {
|
|
289
|
+
const answer = await sendWrite(formId, intent, body);
|
|
290
|
+
return "sent" in answer ? invalidName : writeOutcome(answer, intent);
|
|
291
|
+
}
|
|
292
|
+
const read = (formId, record) => call({
|
|
293
|
+
method: "POST",
|
|
294
|
+
names: [formId],
|
|
295
|
+
route: ([form]) => `/v1/forms/${String(form)}/records/read`,
|
|
296
|
+
body: { record },
|
|
297
|
+
read: (body) => isFormRecord(body) ? body : void 0
|
|
298
|
+
});
|
|
299
|
+
const lookups = (formId, source, action, body, read, signal) => call({
|
|
300
|
+
method: "POST",
|
|
301
|
+
names: [formId, source],
|
|
302
|
+
route: ([form, name]) => `/v1/forms/${String(form)}/lookups/${String(name)}/${action}`,
|
|
303
|
+
body,
|
|
304
|
+
read,
|
|
305
|
+
signal
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
/**
|
|
309
|
+
* `GET /v1/forms` (0045): the published forms this person may read, each
|
|
310
|
+
* with its title, the operations the policy grants them and the children
|
|
311
|
+
* a page may list under one of its records -- and nothing about any other
|
|
312
|
+
* form. Opening one with `form` says what the database allows now.
|
|
313
|
+
*/
|
|
314
|
+
forms: (signal) => call({
|
|
315
|
+
method: "GET",
|
|
316
|
+
names: [],
|
|
317
|
+
route: () => "/v1/forms",
|
|
318
|
+
read: (body) => isFormMenu(body) ? body : void 0,
|
|
319
|
+
signal
|
|
320
|
+
}),
|
|
321
|
+
/** `GET /v1/forms/:id`: the published document, the operations this person may use, and the fields they may read. */
|
|
322
|
+
form: (formId) => call({
|
|
323
|
+
method: "GET",
|
|
324
|
+
names: [formId],
|
|
325
|
+
route: ([form]) => `/v1/forms/${String(form)}`,
|
|
326
|
+
read: (body) => isPublishedForm(body) ? body : void 0
|
|
327
|
+
}),
|
|
328
|
+
/** `POST …/records/read`: one record by its token, with the version an update sends back. */
|
|
329
|
+
read,
|
|
330
|
+
/**
|
|
331
|
+
* `POST …/records/list` (0044): one page of the records this person may
|
|
332
|
+
* read, each what `read` returns for its token, and whether more follow.
|
|
333
|
+
* A property left out is the server's default -- no search, the key's
|
|
334
|
+
* order, no parent, the first page, the largest page -- and exactly the
|
|
335
|
+
* route's keys are sent, whatever else the object carries. A POST, so the
|
|
336
|
+
* search is never in a URL.
|
|
337
|
+
*/
|
|
338
|
+
list: (formId, query = {}, signal) => call({
|
|
339
|
+
method: "POST",
|
|
340
|
+
names: [formId],
|
|
341
|
+
route: ([form]) => `/v1/forms/${String(form)}/records/list`,
|
|
342
|
+
body: {
|
|
343
|
+
search: query.search,
|
|
344
|
+
sort: query.sort,
|
|
345
|
+
parent: query.parent,
|
|
346
|
+
offset: query.offset,
|
|
347
|
+
limit: query.limit
|
|
348
|
+
},
|
|
349
|
+
read: (body) => isRecordList(body) ? body : void 0,
|
|
350
|
+
signal
|
|
351
|
+
}),
|
|
352
|
+
/**
|
|
353
|
+
* `POST …/records/create`: a new record from the answers; the reply is what
|
|
354
|
+
* was stored, in the stored spelling -- or an `UnknownWrite` when nobody
|
|
355
|
+
* can say whether it was stored.
|
|
356
|
+
*/
|
|
357
|
+
create: (formId, answers) => write(formId, {
|
|
358
|
+
operation: "create",
|
|
359
|
+
record: null,
|
|
360
|
+
version: null
|
|
361
|
+
}, { answers }),
|
|
362
|
+
/**
|
|
363
|
+
* `POST …/records/update`: saved only if the record is still at `version`,
|
|
364
|
+
* otherwise 409 `stale`; an `UnknownWrite` when nobody can say whether it
|
|
365
|
+
* was saved. Exactly the route's keys are sent, whatever else the object
|
|
366
|
+
* passed carries.
|
|
367
|
+
*/
|
|
368
|
+
update: (formId, change) => write(formId, {
|
|
369
|
+
operation: "update",
|
|
370
|
+
record: change.record,
|
|
371
|
+
version: change.version
|
|
372
|
+
}, {
|
|
373
|
+
record: change.record,
|
|
374
|
+
version: change.version,
|
|
375
|
+
answers: change.answers
|
|
376
|
+
}),
|
|
377
|
+
/**
|
|
378
|
+
* `POST …/records/delete` (0050): the record, only if it is still at
|
|
379
|
+
* `version`, otherwise 409 `stale`; the answer is the deleted row's
|
|
380
|
+
* token and `deleted: true`, and nothing else is done. An `UnknownWrite`
|
|
381
|
+
* when nobody can say whether it was deleted -- deleting again with the
|
|
382
|
+
* same version then deletes nothing else. Exactly the route's keys are
|
|
383
|
+
* sent, with a write id of its own.
|
|
384
|
+
*/
|
|
385
|
+
delete: async (formId, removal) => {
|
|
386
|
+
const intent = {
|
|
387
|
+
operation: "delete",
|
|
388
|
+
record: removal.record,
|
|
389
|
+
version: removal.version
|
|
390
|
+
};
|
|
391
|
+
const answer = await sendWrite(formId, intent, {
|
|
392
|
+
record: removal.record,
|
|
393
|
+
version: removal.version
|
|
394
|
+
});
|
|
395
|
+
return "sent" in answer ? invalidName : writeOutcome(answer, intent);
|
|
396
|
+
},
|
|
397
|
+
/** Reads what an unknown write addressed and says what that shows; never sends the write again (0031). */
|
|
398
|
+
reconcile: (formId, unknown) => reconcile(read, formId, unknown),
|
|
399
|
+
/** `POST …/lookups/:source/query`: one page of the options this person may pick for this operation. */
|
|
400
|
+
query: (formId, source, q, signal) => lookups(formId, source, "query", {
|
|
401
|
+
operation: q.operation,
|
|
402
|
+
search: q.search,
|
|
403
|
+
offset: q.offset,
|
|
404
|
+
limit: q.limit
|
|
405
|
+
}, (body) => isLookupResult(body) ? body : void 0, signal),
|
|
406
|
+
/** `POST …/lookups/:source/resolve`: the labels of the tokens this person may see; any other is absent, not an error. */
|
|
407
|
+
resolve: (formId, source, r, signal) => lookups(formId, source, "resolve", {
|
|
408
|
+
operation: r.operation,
|
|
409
|
+
tokens: r.tokens
|
|
410
|
+
}, (body) => isResolved(body) ? body.rows : void 0, signal)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/lookups.ts
|
|
415
|
+
function collect(fields, into) {
|
|
416
|
+
for (const field of fields) {
|
|
417
|
+
if (field.optionsSource !== void 0) into.add(field.optionsSource);
|
|
418
|
+
if (field.fields !== void 0) collect(field.fields, into);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** Every optionsSource the document names -- in groups and repeaters too -- once each, in document order. */
|
|
422
|
+
function sourceNames(form) {
|
|
423
|
+
const names = /* @__PURE__ */ new Set();
|
|
424
|
+
collect(form.model.fields, names);
|
|
425
|
+
return [...names];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* One source per name the document carries, each answering through this
|
|
429
|
+
* form's lookup routes.
|
|
430
|
+
*
|
|
431
|
+
* `operation` may be a getter: it is read on every request, so a form that
|
|
432
|
+
* moves from create to update after its first save asks under update's filter
|
|
433
|
+
* without a new map -- which, in Angular, would mean a new application.
|
|
434
|
+
*
|
|
435
|
+
* A search sends the renderer's query and limit; a label request resolves the
|
|
436
|
+
* stored tokens, and asks nothing when there are none. A token the person may
|
|
437
|
+
* not see is left out, not an error. A refusal rejects with the server's
|
|
438
|
+
* sentence, which the renderer shows as "could not be loaded"; an abort
|
|
439
|
+
* rejects with the AbortError. `hasMore` and `omitted` do not reach the
|
|
440
|
+
* renderer, whose contract, at 0.5.0 as at 0.3.0, has nowhere to put them
|
|
441
|
+
* (0029).
|
|
442
|
+
*/
|
|
443
|
+
function lookupSources(client, formId, form, operation) {
|
|
444
|
+
const current = typeof operation === "function" ? operation : () => operation;
|
|
445
|
+
const source = (name) => ({ async resolve(request) {
|
|
446
|
+
if (request.kind === "labels" && request.values.length === 0) return [];
|
|
447
|
+
const outcome = request.kind === "search" ? await client.query(formId, name, {
|
|
448
|
+
operation: current(),
|
|
449
|
+
search: request.query,
|
|
450
|
+
limit: request.limit
|
|
451
|
+
}, request.signal) : await client.resolve(formId, name, {
|
|
452
|
+
operation: current(),
|
|
453
|
+
tokens: request.values
|
|
454
|
+
}, request.signal);
|
|
455
|
+
if (!outcome.ok) throw new Error(outcome.message);
|
|
456
|
+
return (Array.isArray(outcome.value) ? outcome.value : outcome.value.rows).map((row) => ({
|
|
457
|
+
value: row.token,
|
|
458
|
+
label: row.label
|
|
459
|
+
}));
|
|
460
|
+
} });
|
|
461
|
+
return Object.fromEntries(sourceNames(form).map((name) => [name, source(name)]));
|
|
462
|
+
}
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/problems.ts
|
|
465
|
+
/**
|
|
466
|
+
* A refusal's field problems as `engine.applyServerErrors` takes them: the
|
|
467
|
+
* server's sentences, grouped by field, in the order the server gave them,
|
|
468
|
+
* each once. `{}` when the refusal concerns no field.
|
|
469
|
+
*
|
|
470
|
+
* Sentences and not codes, because the renderers print an entry verbatim, at
|
|
471
|
+
* 0.3.0, 0.4.0 and 0.5.0, beside the field and in the error summary: a code would
|
|
472
|
+
* put `not-an-option` in front of a person. The server promises its sentence is
|
|
473
|
+
* showable and never echoes a value (0011); the code stays on the Refusal for a
|
|
474
|
+
* program to read.
|
|
475
|
+
*/
|
|
476
|
+
function fieldProblems(refusal) {
|
|
477
|
+
const problems = {};
|
|
478
|
+
for (const { field, message } of refusal.fieldErrors ?? []) {
|
|
479
|
+
const sentences = problems[field] ??= [];
|
|
480
|
+
if (!sentences.includes(message)) sentences.push(message);
|
|
481
|
+
}
|
|
482
|
+
return problems;
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
export { LIST_PAGE_SIZE, createDataClient, fieldProblems, isUnknownWrite, lookupSources, sourceNames };
|
|
486
|
+
|
|
487
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/reconcile.ts","../src/shapes.ts","../src/writes.ts","../src/client.ts","../src/lookups.ts","../src/problems.ts"],"sourcesContent":["import type { FormRecord } from '@formancy/data-core'\nimport type { Outcome, Refusal } from './client.js'\nimport type { UnknownWrite } from './writes.js'\n\n/**\n * What one read says about an unknown write (0031). Never a resend: the\n * person decides what happens next, with this in front of them.\n *\n * - `unchanged` -- an update's or a deletion's record is still at the\n * version it sent: for a deletion, deleting again with it is safe. Not\n * visible yet, which is not \"not stored\": on PostgreSQL a write orphaned by\n * a cut can still commit. Saving again with that same version is safe; the\n * database stores it at most once, and whichever comes second is stale.\n * - `changed` -- an update's or a deletion's record has moved on: this save, or another's.\n * - `present` -- a create whose token was known is there -- or, where records\n * are deleted, one created again since with the same key, which only a\n * key people type allows, and only on a rowversion table (0050).\n * - `absent` -- it is not (the read's 404). Creating again is safe: the key\n * stops a second row, whichever write lands first. A deletion's record\n * is gone: this deletion's work, or another's, which the version a record\n * cannot share with another makes one and the same (0050).\n * - `unverifiable` -- a create with no token: the database numbers the key,\n * or the answer was lost before the server could name one. No request is\n * made; only the person can tell.\n * - a refusal -- the read's own, which says nothing about the write.\n */\nexport type Reconciliation =\n | { ok: true; state: 'unchanged'; current: FormRecord }\n | { ok: true; state: 'changed'; current: FormRecord }\n | { ok: true; state: 'present'; current: FormRecord }\n | { ok: true; state: 'absent' }\n | { ok: true; state: 'unverifiable' }\n | Refusal\n\n/** Reads the record an unknown write addressed, through `read`, and says what that shows. */\nexport async function reconcile(\n read: (formId: string, record: string) => Promise<Outcome<FormRecord>>,\n formId: string,\n unknown: UnknownWrite,\n): Promise<Reconciliation> {\n if (unknown.record === null) return { ok: true, state: 'unverifiable' }\n const found = await read(formId, unknown.record)\n if (unknown.operation === 'create') {\n if (found.ok) return { ok: true, state: 'present', current: found.value }\n return found.status === 404 && found.code === 'not-found' ? { ok: true, state: 'absent' } : found\n }\n if (unknown.operation === 'delete' && !found.ok && found.status === 404 && found.code === 'not-found') return { ok: true, state: 'absent' }\n if (!found.ok) return found\n return { ok: true, state: found.value.version === unknown.version ? 'unchanged' : 'changed', current: found.value }\n}\n","import type { FieldError, FormMenu, FormRecord, LookupResult, LookupRow, PublishedForm, RecordDeleted, RecordList } from '@formancy/data-core'\nimport type { Refusal } from './client.js'\n\n/*\n * The shape each runtime route answers with, and a refusal's. Shallow on\n * purpose: enough that a captive portal's `{}` or a proxy's page is not taken\n * for the data server's answer, without restating the server's validation of\n * a document. One place, because a read and a write must agree on what a\n * record and a refusal look like (0029, 0031).\n */\n\nexport const UNEXPECTED = 'The data server did not answer in a way this client understands.'\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nexport function isNullableString(value: unknown): value is string | null {\n return value === null || typeof value === 'string'\n}\n\nfunction isFieldError(value: unknown): value is FieldError {\n return isRecord(value) && typeof value['field'] === 'string' && typeof value['code'] === 'string' && typeof value['message'] === 'string'\n}\n\nfunction isRows(value: unknown): value is LookupRow[] {\n return Array.isArray(value) && value.every((row) => isRecord(row) && typeof row['token'] === 'string' && typeof row['label'] === 'string')\n}\n\nfunction isStrings(value: unknown): value is string[] {\n return Array.isArray(value) && value.every((entry) => typeof entry === 'string')\n}\n\n/** What a form's list may be searched and sorted by, or `null` for no list (0044): a reply without one is from no server that has lists. */\nfunction isListColumns(value: unknown): value is PublishedForm['list'] {\n return value === null || (isRecord(value) && isStrings(value['searchable']) && isStrings(value['sortable']))\n}\n\n/**\n * What a delete also does (0050): `null` exactly when the form offers no\n * delete, otherwise its three flags. A reply that says delete without them,\n * or them without delete -- or neither, a server older than delete -- is no\n * form this server sent, and a page that worded a confirmation from it\n * would say what nobody decided.\n */\nfunction isDeleting(value: unknown, operations: readonly unknown[]): value is PublishedForm['deleting'] {\n if (!operations.includes('delete')) return value === null\n return isRecord(value) && typeof value['cascades'] === 'boolean' && typeof value['clears'] === 'boolean' && typeof value['restricted'] === 'boolean'\n}\n\nexport function isPublishedForm(body: unknown): body is PublishedForm {\n return (\n isRecord(body) &&\n isRecord(body['form']) &&\n isRecord(body['form']['model']) &&\n Array.isArray(body['operations']) &&\n Array.isArray(body['readable']) &&\n isListColumns(body['list']) &&\n isDeleting(body['deleting'], body['operations'] as unknown[])\n )\n}\n\n/** A child in a menu: the child form, and the field its list is narrowed by. */\nfunction isMenuChild(value: unknown): boolean {\n return isRecord(value) && typeof value['form'] === 'string' && typeof value['field'] === 'string'\n}\n\n/** One menu entry: a page draws a link from it, and a grid per child. */\nfunction isMenuEntry(value: unknown): boolean {\n return (\n isRecord(value) &&\n typeof value['id'] === 'string' &&\n typeof value['title'] === 'string' &&\n isStrings(value['operations']) &&\n Array.isArray(value['children']) &&\n value['children'].every(isMenuChild)\n )\n}\n\n/** The menu (0045): every entry in the server's shape, children included, so an entry of a server with no children is not drawn as a form with none. */\nexport function isFormMenu(body: unknown): body is FormMenu {\n return isRecord(body) && Array.isArray(body['forms']) && body['forms'].every(isMenuEntry)\n}\n\n/** A delete's answer (0050): the token of the row deleted, and `deleted: true`, nothing else that a 2xx could be mistaken for. */\nexport function isRecordDeleted(body: unknown): body is RecordDeleted {\n return isRecord(body) && typeof body['record'] === 'string' && body['deleted'] === true\n}\n\nexport function isFormRecord(body: unknown): body is FormRecord {\n return isRecord(body) && isNullableString(body['record']) && isNullableString(body['version']) && isRecord(body['answers'])\n}\n\n/** A page of records (0044): every row a record, and whether more follow. */\nexport function isRecordList(body: unknown): body is RecordList {\n return isRecord(body) && Array.isArray(body['rows']) && body['rows'].every(isFormRecord) && typeof body['hasMore'] === 'boolean'\n}\n\nexport function isLookupResult(body: unknown): body is LookupResult {\n return isRecord(body) && isRows(body['rows']) && typeof body['hasMore'] === 'boolean' && typeof body['omitted'] === 'number'\n}\n\nexport function isResolved(body: unknown): body is { rows: LookupRow[] } {\n return isRecord(body) && isRows(body['rows'])\n}\n\n/** A non-2xx answer: the server's refusal when it carries a sentence, otherwise not the server's to have said. */\nexport function refusalOf(status: number, body: unknown): Refusal {\n if (!isRecord(body) || typeof body['message'] !== 'string') return { ok: false, status, code: 'unexpected', message: UNEXPECTED }\n const code = typeof body['code'] === 'string' ? body['code'] : `http-${String(status)}`\n const refusal: Refusal = { ok: false, status, code, message: body['message'] }\n if (Array.isArray(body['fieldErrors'])) refusal.fieldErrors = body['fieldErrors'].filter(isFieldError)\n return refusal\n}\n","import type { FormRecord, RecordDeleted } from '@formancy/data-core'\nimport type { Outcome } from './client.js'\nimport { isFormRecord, isNullableString, isRecord, isRecordDeleted, refusalOf } from './shapes.js'\n\n/*\n * What the answer to a write says happened (0031). A read that went wrong\n * changed nothing and may be asked again; a write may have been stored by\n * the time anything went wrong, and sending it again may store it twice. So\n * a write's answer is known -- stored, or refused -- only when the data\n * server says which; everything else is an unknown write, never a refusal,\n * which a host would show as \"not saved\". Nothing here sends anything again.\n */\n\n/** A write that was sent and whose outcome nobody here can know: the host reconciles it, nothing retries it. */\nexport interface UnknownWrite {\n /** False, so a host that only checks `ok` treats it as not done, and fails closed. */\n ok: false\n /** The status that answered, or 0 when nothing did. */\n status: number\n code: 'unknown-outcome'\n /** The server's sentence when the database lost the answer; the client's own when the page lost it. */\n message: string\n /** `delete` since 0050. */\n operation: 'create' | 'update' | 'delete'\n /** Update and delete: the token sent. Create: the token the server says it would have, or null. */\n record: string | null\n /**\n * Update and delete: the version sent -- saving again with exactly it is\n * stored at most once, and deleting again with it deletes nothing else.\n * Create: null.\n */\n version: string | null\n /** `database`: the data server said so (its 502). `transport`: the answer was lost between page and server. */\n origin: 'database' | 'transport'\n}\n\n/** What `create` and `update` resolve to. */\nexport type WriteOutcome = Outcome<FormRecord> | UnknownWrite\n\n/** What `delete` resolves to (0050): the server's deletion answer, a refusal, or a deletion nobody here can say happened. */\nexport type DeleteOutcome = Outcome<RecordDeleted> | UnknownWrite\n\n/** Whether an outcome is an unknown write, rather than a success or a refusal. */\nexport function isUnknownWrite(outcome: { ok: boolean }): outcome is UnknownWrite {\n if (outcome.ok) return false\n const candidate = outcome as Partial<UnknownWrite>\n return candidate.code === 'unknown-outcome' && (candidate.origin === 'database' || candidate.origin === 'transport')\n}\n\n/** What a write addressed, as the call knows it: the transport can lose an answer, never the call's own arguments. */\nexport interface WriteIntent {\n operation: 'create' | 'update' | 'delete'\n record: string | null\n version: string | null\n}\n\n/** What arrived for a write: nothing, or a status and the body as JSON (undefined when unreadable or not JSON). */\nexport type WriteAnswer = { answered: false } | { answered: true; status: number; body: unknown }\n\nconst LOST_ON_THE_WAY = 'The answer to this save was lost between this page and the data server. It may have been saved.'\nconst DELETION_LOST_ON_THE_WAY = 'The answer to this deletion was lost between this page and the data server. It may have been deleted.'\n\nfunction lost(intent: WriteIntent, status: number): UnknownWrite {\n return { ok: false, status, code: 'unknown-outcome', message: intent.operation === 'delete' ? DELETION_LOST_ON_THE_WAY : LOST_ON_THE_WAY, ...intent, origin: 'transport' }\n}\n\n/**\n * The outcome of a write, from what arrived, in this order:\n *\n * 1. Nothing arrived -- `fetch` rejected -- or the body is unreadable or not\n * JSON, at any status: lost on the way. A proxy's page is not the data\n * server's answer, and the request may have reached it.\n * 2. A 2xx with a record is the record stored -- for a delete, a 2xx with\n * the deleted row's token and `deleted: true` is the record deleted\n * (0050); any other 2xx was lost.\n * 3. The data server's 502 `unknown-outcome` with a sentence: the database\n * lost the answer. What was addressed comes from the body, or the call.\n * 4. A 4xx with a sentence is the server's refusal: nothing was written.\n * This is 0028's 422 `refused`, 409 `stale`, 401 and 403 -- and a rate\n * limit's 429, which carries a sentence and no code.\n * 5. A 503 `unavailable` with a sentence: the database was not reached.\n * 6. Anything else -- a 500, another 502 or 504 -- was lost.\n */\nexport function writeOutcome(answer: WriteAnswer, intent: WriteIntent & { operation: 'delete' }): DeleteOutcome\nexport function writeOutcome(answer: WriteAnswer, intent: WriteIntent & { operation: 'create' | 'update' }): WriteOutcome\nexport function writeOutcome(answer: WriteAnswer, intent: WriteIntent): WriteOutcome | DeleteOutcome {\n if (!answer.answered) return lost(intent, 0)\n const { status, body } = answer\n if (!isRecord(body)) return lost(intent, status)\n if (status >= 200 && status <= 299) {\n if (intent.operation === 'delete') return isRecordDeleted(body) ? { ok: true, value: body } : lost(intent, status)\n return isFormRecord(body) ? { ok: true, value: body } : lost(intent, status)\n }\n const message = body['message']\n if (typeof message !== 'string') return lost(intent, status)\n if (status === 502 && body['code'] === 'unknown-outcome') {\n const record = isNullableString(body['record']) ? body['record'] : intent.record\n const version = isNullableString(body['version']) ? body['version'] : intent.version\n return { ok: false, status, code: 'unknown-outcome', message, operation: intent.operation, record, version, origin: 'database' }\n }\n if (status >= 400 && status <= 499) return refusalOf(status, body)\n if (status === 503 && body['code'] === 'unavailable') return refusalOf(status, body)\n return lost(intent, status)\n}\n","import { WRITE_ID_HEADER } from '@formancy/data-core'\nimport type { FieldError, FormMenu, FormRecord, LookupResult, LookupRow, PublishedForm, RecordDeleted, RecordList, RecordListQuery } from '@formancy/data-core'\nimport { reconcile } from './reconcile.js'\nimport type { Reconciliation } from './reconcile.js'\nimport { isFormMenu, isFormRecord, isLookupResult, isPublishedForm, isRecordList, isResolved, refusalOf, UNEXPECTED } from './shapes.js'\nimport { writeOutcome } from './writes.js'\nimport type { DeleteOutcome, UnknownWrite, WriteAnswer, WriteIntent, WriteOutcome } from './writes.js'\n\n/*\n * One function per runtime route (0029), and the decisions every host would\n * otherwise make again: where the token goes, how a name becomes a path, what\n * an answer that is not a success says, and what a write's lost answer is.\n *\n * - **The token** is asked of the host once per request and sent in the\n * Authorization header and nowhere else: never a query string, which is in\n * every proxy's log, never a body, which is in every request dump.\n * - **A name** -- a form id, a source -- is one encoded path segment. `.` and\n * `..` survive `encodeURIComponent` and are resolved away by `fetch`, so they\n * are refused before anything is sent: `form('..')` would be `GET /v1/`.\n * - **A refusal** is the server's code and sentence, verbatim. The client adds\n * words only when the server said none: nothing answered (`unreachable`), or\n * what answered was not the data server's shape (`unexpected`).\n * - **A write** is known only when the data server says what happened to it.\n * Its answer lost -- by the database, said in the server's 502, or between\n * the page and the server -- it is an `UnknownWrite`, not a refusal, and\n * `reconcile` reads what it addressed (0031). Each write carries a new\n * write id, so a copy the browser itself sends again -- Chromium does, on a\n * reused connection that closed before any answer -- is answered by the\n * server with the first one's answer instead of being applied twice.\n *\n * Nothing is cached and nothing is retried. The server asks the policy on\n * every request (0022), and a write whose outcome is unknown is the host's to\n * reconcile, never the client's to send again (0015, 0031).\n */\n\nexport interface DataClientOptions {\n /** Called once per request. The host's session owns it; it is never stored, and never in a URL or a body. */\n token: () => string | Promise<string>\n /**\n * Prefixed to every route. Default `''`: the page's own origin. Another\n * origin is answered only when the server's operator lists the page's\n * origin in `FORMANCY_DATA_CORS_ORIGINS`; otherwise every call is a refused\n * preflight, a network error to the page: a read is `unreachable`, a write\n * unknown though nothing was sent (0060).\n */\n base?: string\n /** Default the global `fetch`, bound. */\n fetch?: typeof fetch\n}\n\n/**\n * Why a call did not succeed. `status` is the HTTP status, or 0 when nothing\n * answered or nothing was sent. `code` and `message` are the server's when it\n * gave them; `fieldErrors` is present when the refusal concerns fields.\n */\nexport type Refusal = { ok: false; status: number; code: string; message: string; fieldErrors?: FieldError[] }\nexport type Outcome<T> = { ok: true; value: T } | Refusal\n\n/** The operation a form is open for, which decides the policy's lookup filter. Never the document's to say. */\nexport type LookupOperation = 'read' | 'create' | 'update'\n\nconst UNREACHABLE = 'The data server could not be reached.'\nconst INVALID_NAME = 'A form or lookup name that is empty, \".\" or \"..\" cannot address a route.'\n\n/**\n * `name` as one path segment, or undefined when it cannot be one. `fetch`\n * resolves `.` and `..` before sending, so they would address a different\n * route; an empty segment would address the route above.\n */\nfunction pathSegment(name: string): string | undefined {\n if (name === '' || name === '.' || name === '..') return undefined\n return encodeURIComponent(name)\n}\n\n/** The body as JSON, or undefined when it is not JSON or could not be read. An abort while reading rethrows. */\nasync function bodyOf(response: Response, signal: AbortSignal | undefined): Promise<unknown> {\n let text: string\n try {\n text = await response.text()\n } catch (error) {\n if (signal?.aborted === true) throw error\n return undefined\n }\n try {\n return JSON.parse(text) as unknown\n } catch {\n return undefined\n }\n}\n\ninterface Request {\n method: 'GET' | 'POST'\n /** The names that become one segment each; the route is built only when every one can. */\n names: readonly string[]\n /** The route, from the names as segments, in order. */\n route: (segments: readonly string[]) => string\n body?: Record<string, unknown>\n signal?: AbortSignal | undefined\n /** Sent beside the token and the content type: a write's id. */\n headers?: Record<string, string>\n}\n\n/**\n * The base without the slashes it ends in, by walking back from the end.\n * `/\\/+$/` did the same and CodeQL flagged it (js/polynomial-redos): on a\n * base of many slashes not at the end, the anchored pattern backtracks.\n */\nfunction withoutTrailingSlashes(base: string): string {\n let end = base.length\n while (end > 0 && base[end - 1] === '/') end -= 1\n return base.slice(0, end)\n}\n\n/**\n * A new write id: 128 random bits as hex, from `crypto.getRandomValues`,\n * which a page served over plain HTTP has too, unlike `randomUUID`.\n */\nfunction newWriteId(): string {\n return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/** What one request came to: a name that is no segment (nothing sent), nothing answered, or a status and its JSON body. */\ntype Exchange = { sent: false } | WriteAnswer\n\n/**\n * A client of one data server's runtime plane. Every function resolves to an\n * outcome and never rejects for an answer -- only for an abort the caller\n * asked for, which reaches it as the AbortError it raised.\n */\nexport function createDataClient(options: DataClientOptions) {\n const base = withoutTrailingSlashes(options.base ?? '')\n const send = options.fetch ?? globalThis.fetch.bind(globalThis)\n\n /** The transport: the request sent once, and what came back. Classifying it is the caller's. */\n async function exchange(request: Request): Promise<Exchange> {\n const segments = request.names.map(pathSegment)\n if (segments.some((segment) => segment === undefined)) return { sent: false }\n const headers: Record<string, string> = { ...request.headers, authorization: `Bearer ${await options.token()}`, accept: 'application/json' }\n if (request.body !== undefined) headers['content-type'] = 'application/json'\n let response: Response\n try {\n response = await send(`${base}${request.route(segments as string[])}`, {\n method: request.method,\n headers,\n // The runtime plane never redirects; following one would carry the token to another route, or another host.\n redirect: 'manual',\n ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),\n ...(request.signal === undefined ? {} : { signal: request.signal }),\n })\n } catch (error) {\n if (request.signal?.aborted === true) throw error\n return { answered: false }\n }\n return { answered: true, status: response.status, body: await bodyOf(response, request.signal) }\n }\n\n const invalidName: Refusal = { ok: false, status: 0, code: 'invalid-name', message: INVALID_NAME }\n\n /** A read: changes nothing, so an answer that went wrong is a refusal, and asking again is safe (0029). */\n async function call<T>(request: Request & { read: (body: unknown) => T | undefined }): Promise<Outcome<T>> {\n const answer = await exchange(request)\n if ('sent' in answer) return invalidName\n if (!answer.answered) return { ok: false, status: 0, code: 'unreachable', message: UNREACHABLE }\n if (answer.status < 200 || answer.status > 299) return refusalOf(answer.status, answer.body)\n const value = request.read(answer.body)\n return value === undefined ? { ok: false, status: answer.status, code: 'unexpected', message: UNEXPECTED } : { ok: true, value }\n }\n\n /** A write: known only when the data server says what happened; otherwise unknown, and never sent again (0031). */\n async function sendWrite(formId: string, intent: WriteIntent, body: Record<string, unknown>): Promise<Exchange> {\n return exchange({\n method: 'POST',\n names: [formId],\n route: ([form]) => `/v1/forms/${String(form)}/records/${intent.operation}`,\n body,\n // New for every call: one id on two saves would answer the second with the first's answer.\n headers: { [WRITE_ID_HEADER]: newWriteId() },\n })\n }\n\n async function write(formId: string, intent: WriteIntent & { operation: 'create' | 'update' }, body: Record<string, unknown>): Promise<WriteOutcome> {\n const answer = await sendWrite(formId, intent, body)\n // Nothing was sent: a refusal like any other.\n return 'sent' in answer ? invalidName : writeOutcome(answer, intent)\n }\n\n const read = (formId: string, record: string): Promise<Outcome<FormRecord>> =>\n call({ method: 'POST', names: [formId], route: ([form]) => `/v1/forms/${String(form)}/records/read`, body: { record }, read: (body) => (isFormRecord(body) ? body : undefined) })\n const lookups = <T>(formId: string, source: string, action: 'query' | 'resolve', body: Record<string, unknown>, read: (body: unknown) => T | undefined, signal?: AbortSignal) =>\n call({ method: 'POST', names: [formId, source], route: ([form, name]) => `/v1/forms/${String(form)}/lookups/${String(name)}/${action}`, body, read, signal })\n\n return {\n /**\n * `GET /v1/forms` (0045): the published forms this person may read, each\n * with its title, the operations the policy grants them and the children\n * a page may list under one of its records -- and nothing about any other\n * form. Opening one with `form` says what the database allows now.\n */\n forms: (signal?: AbortSignal): Promise<Outcome<FormMenu>> =>\n call({ method: 'GET', names: [], route: () => '/v1/forms', read: (body) => (isFormMenu(body) ? body : undefined), signal }),\n\n /** `GET /v1/forms/:id`: the published document, the operations this person may use, and the fields they may read. */\n form: (formId: string): Promise<Outcome<PublishedForm>> =>\n call({ method: 'GET', names: [formId], route: ([form]) => `/v1/forms/${String(form)}`, read: (body) => (isPublishedForm(body) ? body : undefined) }),\n\n /** `POST …/records/read`: one record by its token, with the version an update sends back. */\n read,\n\n /**\n * `POST …/records/list` (0044): one page of the records this person may\n * read, each what `read` returns for its token, and whether more follow.\n * A property left out is the server's default -- no search, the key's\n * order, no parent, the first page, the largest page -- and exactly the\n * route's keys are sent, whatever else the object carries. A POST, so the\n * search is never in a URL.\n */\n list: (formId: string, query: Partial<RecordListQuery> = {}, signal?: AbortSignal): Promise<Outcome<RecordList>> =>\n call({\n method: 'POST',\n names: [formId],\n route: ([form]) => `/v1/forms/${String(form)}/records/list`,\n body: { search: query.search, sort: query.sort, parent: query.parent, offset: query.offset, limit: query.limit },\n read: (body) => (isRecordList(body) ? body : undefined),\n signal,\n }),\n\n /**\n * `POST …/records/create`: a new record from the answers; the reply is what\n * was stored, in the stored spelling -- or an `UnknownWrite` when nobody\n * can say whether it was stored.\n */\n create: (formId: string, answers: Readonly<Record<string, unknown>>): Promise<WriteOutcome> =>\n write(formId, { operation: 'create', record: null, version: null }, { answers }),\n\n /**\n * `POST …/records/update`: saved only if the record is still at `version`,\n * otherwise 409 `stale`; an `UnknownWrite` when nobody can say whether it\n * was saved. Exactly the route's keys are sent, whatever else the object\n * passed carries.\n */\n update: (formId: string, change: { record: string; version: string; answers: Readonly<Record<string, unknown>> }): Promise<WriteOutcome> =>\n write(formId, { operation: 'update', record: change.record, version: change.version }, { record: change.record, version: change.version, answers: change.answers }),\n\n /**\n * `POST …/records/delete` (0050): the record, only if it is still at\n * `version`, otherwise 409 `stale`; the answer is the deleted row's\n * token and `deleted: true`, and nothing else is done. An `UnknownWrite`\n * when nobody can say whether it was deleted -- deleting again with the\n * same version then deletes nothing else. Exactly the route's keys are\n * sent, with a write id of its own.\n */\n delete: async (formId: string, removal: { record: string; version: string }): Promise<DeleteOutcome> => {\n const intent = { operation: 'delete', record: removal.record, version: removal.version } as const\n const answer = await sendWrite(formId, intent, { record: removal.record, version: removal.version })\n return 'sent' in answer ? invalidName : writeOutcome(answer, intent)\n },\n\n /** Reads what an unknown write addressed and says what that shows; never sends the write again (0031). */\n reconcile: (formId: string, unknown: UnknownWrite): Promise<Reconciliation> => reconcile(read, formId, unknown),\n\n /** `POST …/lookups/:source/query`: one page of the options this person may pick for this operation. */\n query: (\n formId: string,\n source: string,\n q: { operation: LookupOperation; search: string; offset?: number; limit?: number },\n signal?: AbortSignal,\n ): Promise<Outcome<LookupResult>> =>\n lookups(formId, source, 'query', { operation: q.operation, search: q.search, offset: q.offset, limit: q.limit }, (body) => (isLookupResult(body) ? body : undefined), signal),\n\n /** `POST …/lookups/:source/resolve`: the labels of the tokens this person may see; any other is absent, not an error. */\n resolve: (formId: string, source: string, r: { operation: LookupOperation; tokens: readonly string[] }, signal?: AbortSignal): Promise<Outcome<LookupRow[]>> =>\n lookups(formId, source, 'resolve', { operation: r.operation, tokens: r.tokens }, (body) => (isResolved(body) ? body.rows : undefined), signal),\n }\n}\n\n/** A client of one data server's runtime plane: what `createDataClient` returns. */\nexport type DataClient = ReturnType<typeof createDataClient>\n","import type { FieldDef, FormSchema, RemoteOption } from '@formancy/spec'\nimport type { DataClient, LookupOperation } from './client.js'\n\nexport type { LookupOperation } from './client.js'\n\n/*\n * The option sources a host hands either renderer (formancy.ai 0077). The\n * document names a list; the deployment says where it comes from, and here\n * that is always the runtime plane, under the same form, for the operation\n * the host has the form open for. A document decides which names exist and\n * nothing else: not a URL, a header, a token, a tenant or an operation (0012).\n *\n * `@formancy/react` and `@formancy/angular` each declare their own\n * `OptionsSource`, on purpose. A `LookupSource` declares only what it reads of\n * a request, and is assignable to both; a host's type check of\n * `OptionsSourcesProvider` and `provideFormancyOptionsSources` is what fails\n * when either contract moves.\n */\n\n/** What a source reads of a renderer's request: the kind, the query or the stored values, the limit, and the signal. */\nexport type LookupRequest = { kind: 'search' | 'labels'; query: string; values: readonly string[]; limit: number; signal: AbortSignal }\n\n/** One named list, answering through the runtime plane. */\nexport type LookupSource = { resolve(request: LookupRequest): Promise<readonly RemoteOption[]> }\n\nfunction collect(fields: readonly FieldDef[], into: Set<string>): void {\n for (const field of fields) {\n if (field.optionsSource !== undefined) into.add(field.optionsSource)\n if (field.fields !== undefined) collect(field.fields, into)\n }\n}\n\n/** Every optionsSource the document names -- in groups and repeaters too -- once each, in document order. */\nexport function sourceNames(form: FormSchema): string[] {\n const names = new Set<string>()\n collect(form.model.fields, names)\n return [...names]\n}\n\n/**\n * One source per name the document carries, each answering through this\n * form's lookup routes.\n *\n * `operation` may be a getter: it is read on every request, so a form that\n * moves from create to update after its first save asks under update's filter\n * without a new map -- which, in Angular, would mean a new application.\n *\n * A search sends the renderer's query and limit; a label request resolves the\n * stored tokens, and asks nothing when there are none. A token the person may\n * not see is left out, not an error. A refusal rejects with the server's\n * sentence, which the renderer shows as \"could not be loaded\"; an abort\n * rejects with the AbortError. `hasMore` and `omitted` do not reach the\n * renderer, whose contract, at 0.5.0 as at 0.3.0, has nowhere to put them\n * (0029).\n */\nexport function lookupSources(\n client: DataClient,\n formId: string,\n form: FormSchema,\n operation: LookupOperation | (() => LookupOperation),\n): Readonly<Record<string, LookupSource>> {\n const current = typeof operation === 'function' ? operation : () => operation\n const source = (name: string): LookupSource => ({\n async resolve(request) {\n if (request.kind === 'labels' && request.values.length === 0) return []\n const outcome =\n request.kind === 'search'\n ? await client.query(formId, name, { operation: current(), search: request.query, limit: request.limit }, request.signal)\n : await client.resolve(formId, name, { operation: current(), tokens: request.values }, request.signal)\n if (!outcome.ok) throw new Error(outcome.message)\n const rows = Array.isArray(outcome.value) ? outcome.value : outcome.value.rows\n return rows.map((row) => ({ value: row.token, label: row.label }))\n },\n })\n return Object.fromEntries(sourceNames(form).map((name) => [name, source(name)]))\n}\n","import type { Refusal } from './client.js'\n\n/**\n * A refusal's field problems as `engine.applyServerErrors` takes them: the\n * server's sentences, grouped by field, in the order the server gave them,\n * each once. `{}` when the refusal concerns no field.\n *\n * Sentences and not codes, because the renderers print an entry verbatim, at\n * 0.3.0, 0.4.0 and 0.5.0, beside the field and in the error summary: a code would\n * put `not-an-option` in front of a person. The server promises its sentence is\n * showable and never echoes a value (0011); the code stays on the Refusal for a\n * program to read.\n */\nexport function fieldProblems(refusal: Refusal): Record<string, string[]> {\n const problems: Record<string, string[]> = {}\n for (const { field, message } of refusal.fieldErrors ?? []) {\n const sentences = (problems[field] ??= [])\n if (!sentences.includes(message)) sentences.push(message)\n }\n return problems\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formancy/data-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The browser's side of Formancy Data's runtime plane: a fetch-based client for a published form's records and lookups, and option sources both formancy renderers take. Framework-neutral, no Node.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"default": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"LICENSE.md",
|
|
17
|
+
"NOTICE"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@formancy/data-core": "0.1.0",
|
|
21
|
+
"@formancy/spec": "0.5.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
25
|
+
"@formancy/core": "0.5.0",
|
|
26
|
+
"@formancy/data-fixtures": "0.1.0",
|
|
27
|
+
"@formancy/data-server": "0.1.0",
|
|
28
|
+
"@types/mssql": "^12.3.0",
|
|
29
|
+
"@types/node": "^22.10.0",
|
|
30
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
31
|
+
"jose": "^6.2.12",
|
|
32
|
+
"mssql": "^12.0.0",
|
|
33
|
+
"postgres": "^3.4.9",
|
|
34
|
+
"publint": "^0.3.24",
|
|
35
|
+
"tsdown": "^0.23.0",
|
|
36
|
+
"typescript": "~6.0.3",
|
|
37
|
+
"unrun": "^0.3.1",
|
|
38
|
+
"vitest": "^5.0.1"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/sharkysan/formancy.ai-data.git",
|
|
46
|
+
"directory": "packages/data-client"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/sharkysan/formancy.ai-data",
|
|
49
|
+
"bugs": "https://github.com/sharkysan/formancy.ai-data/issues",
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"test:coverage": "vitest run --coverage",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
55
|
+
"check:pkg": "publint && attw --pack . --profile esm-only"
|
|
56
|
+
}
|
|
6
57
|
}
|