foldkit-remote 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +266 -6
- package/dist/index.d.mts +452 -70
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +709 -365
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -145,9 +145,9 @@ Start with one server entity and one selection:
|
|
|
145
145
|
|
|
146
146
|
```ts
|
|
147
147
|
import { Schema } from 'effect'
|
|
148
|
-
import { Entity } from 'foldkit-
|
|
148
|
+
import { Entity } from 'foldkit-entity'
|
|
149
149
|
|
|
150
|
-
const Project = Entity.
|
|
150
|
+
const Project = Entity.define(
|
|
151
151
|
'Project',
|
|
152
152
|
Schema.Struct({
|
|
153
153
|
id: Schema.String,
|
|
@@ -157,12 +157,20 @@ const Project = Entity.make(
|
|
|
157
157
|
)
|
|
158
158
|
|
|
159
159
|
// A Selection is the exact server-owned shape this consumer needs.
|
|
160
|
-
const ProjectSummary =
|
|
160
|
+
const ProjectSummary = Entity.select(Project, {
|
|
161
161
|
id: true,
|
|
162
162
|
name: true,
|
|
163
163
|
})
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
+
The entity comes from [`foldkit-entity`](../entity), which declares a domain
|
|
167
|
+
without Remote in it. Remote also has an `Entity.make` of its own, and accepts
|
|
168
|
+
either — but only a `foldkit-entity` entity has addressable `fields`, so only
|
|
169
|
+
that one can be given [relations](#entities-declared-with-foldkit-entity),
|
|
170
|
+
derived members, or a [query body](#queries-and-pagination). Reach for
|
|
171
|
+
`Entity.make` when a domain is small enough that Remote is all it will ever
|
|
172
|
+
need; reach for `Entity.define` otherwise, which is most of the time.
|
|
173
|
+
|
|
166
174
|
Embed Remote's Submodel in the application and bind the domain to that field:
|
|
167
175
|
|
|
168
176
|
```ts
|
|
@@ -293,6 +301,32 @@ const clientLayer = Remote.clientLayer(rpcClient)
|
|
|
293
301
|
`RemoteClient` service used by subscriptions and Commands. The transport itself
|
|
294
302
|
is not owned by Remote.
|
|
295
303
|
|
|
304
|
+
## One list per application with `Data.wiring`
|
|
305
|
+
|
|
306
|
+
The integration steps above — spread `Remote.messages`, reduce by tag, derive
|
|
307
|
+
`Data.subscriptions`, provide the client — are one value when the application
|
|
308
|
+
uses `foldkit-bundle`:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { Bundle } from 'foldkit-bundle'
|
|
312
|
+
|
|
313
|
+
const Page = Bundle.parent({ Model, Message })
|
|
314
|
+
const wiring = Page.assemble(
|
|
315
|
+
Data.wiring({
|
|
316
|
+
project: Surface.at(ProjectPage, model =>
|
|
317
|
+
model.route._tag === 'project' ? { projectId: model.route.projectId } : undefined,
|
|
318
|
+
),
|
|
319
|
+
}),
|
|
320
|
+
)
|
|
321
|
+
const update = wiring.update(model => ({ model }))
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`Data.wiring` routes Remote's Messages into `Data.reduce`, brings the active
|
|
325
|
+
Surfaces' Subscriptions and the domain's contract, and requires `RemoteClient`
|
|
326
|
+
from the runtime's resources — the same client layer from above. One assembly
|
|
327
|
+
holds at most one Remote domain: every domain claims the same Message tags,
|
|
328
|
+
and the assembly refuses a second claimant at startup, naming both.
|
|
329
|
+
|
|
296
330
|
## `RemoteData`: what does the Model know right now?
|
|
297
331
|
|
|
298
332
|
A Remote Projection never lies by pretending an absent value is present. It
|
|
@@ -329,7 +363,13 @@ The states are:
|
|
|
329
363
|
- **`Ready`** — every selected field is present and decodes.
|
|
330
364
|
- **`Refreshing`** — the current value remains visible while it is being refetched.
|
|
331
365
|
- **`Failed`** — stored server data does not decode against the Selection.
|
|
332
|
-
- **`NotFound`** — the entity is represented by a tombstone
|
|
366
|
+
- **`NotFound`** — the entity is represented by a tombstone: a live event
|
|
367
|
+
deleted it, or the server was asked for it by id and answered without it. That
|
|
368
|
+
id is then known absent, whether it never existed, is gone, or is not this
|
|
369
|
+
principal's to see. It is not planned again until `Data.refresh` forces it or a
|
|
370
|
+
write brings it back. The target of a returned ref is not marked this way: a
|
|
371
|
+
server need not expand a relation that rides on a request, and the planner asks
|
|
372
|
+
for such a target by id next, which is when its absence is learned.
|
|
333
373
|
|
|
334
374
|
`Initial` is intentionally different from `Loading`. A Projection belonging to
|
|
335
375
|
no active Surface may remain `Initial` forever. Rendering a spinner for
|
|
@@ -339,6 +379,46 @@ state that actually means "wait for this request."
|
|
|
339
379
|
`RemoteData.match` is exhaustive, so adding or omitting a state is visible at
|
|
340
380
|
compile time.
|
|
341
381
|
|
|
382
|
+
### Drawing one: `RemoteData.render`
|
|
383
|
+
|
|
384
|
+
Most views draw these six states three ways, under one policy: useful data
|
|
385
|
+
stays on screen. `RemoteData.render` is that fold.
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
RemoteData.render(project, {
|
|
389
|
+
loading: () => ProjectSkeleton(),
|
|
390
|
+
notFound: () => NoSuchProject(),
|
|
391
|
+
failed: error => ErrorView(error),
|
|
392
|
+
data: (value, freshness) =>
|
|
393
|
+
ProjectView({ project: value, dimmed: freshness._tag !== 'Fresh' }),
|
|
394
|
+
})
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`Initial` and `Loading` reach `loading`; `Ready` and `Refreshing` reach `data`;
|
|
398
|
+
a `Failed` that still carries the value it had reaches `data` too, so a read
|
|
399
|
+
that failed does not throw away what the reader was already looking at. Only a
|
|
400
|
+
`Failed` with nothing to show reaches `failed`.
|
|
401
|
+
|
|
402
|
+
The `data` branch is told which it got, as a `Freshness` — exported, so a view
|
|
403
|
+
helper that takes one can name it:
|
|
404
|
+
|
|
405
|
+
| `Freshness` | What it means |
|
|
406
|
+
| --- | --- |
|
|
407
|
+
| `Fresh` | This is the current answer. |
|
|
408
|
+
| `Refreshing` | A newer answer is on its way; this one is still good. |
|
|
409
|
+
| `Stale` | A read failed; this is what was last known good, with its `error`. |
|
|
410
|
+
|
|
411
|
+
It is one tag rather than a pair of booleans because a value cannot be both
|
|
412
|
+
refreshing and stale, and a type that can say so invites a view to handle a
|
|
413
|
+
state that never arrives.
|
|
414
|
+
|
|
415
|
+
`notFound` is its own branch and not optional. A row the server answered for
|
|
416
|
+
and does not have is neither loading nor a failure; drawing it as either is a
|
|
417
|
+
spinner that never ends or an error nobody can act on.
|
|
418
|
+
|
|
419
|
+
Reach for `match` instead when the six states really do draw differently — it
|
|
420
|
+
stays the exhaustive fold, and `render` does not replace it.
|
|
421
|
+
|
|
342
422
|
## Normalized entities and selections
|
|
343
423
|
|
|
344
424
|
Remote stores an entity once by identity, regardless of how many Surfaces read
|
|
@@ -384,6 +464,57 @@ An unknown field or a nested Selection for the wrong entity is a type error.
|
|
|
384
464
|
Recursive relations remain finite because the entity schema stores references
|
|
385
465
|
(`Entity.ref(User)` / `Entity.refTo('Node')`), not recursively inlined schemas.
|
|
386
466
|
|
|
467
|
+
### Entities declared with `foldkit-entity`
|
|
468
|
+
|
|
469
|
+
[`foldkit-entity`](../entity/README.md) declares a domain without Remote in it:
|
|
470
|
+
fields, relations as navigation edges, derived members. `Remote.make` registers
|
|
471
|
+
those Entities, and `Data.get`, `Data.live`, and a query's `select` take their
|
|
472
|
+
Selections. Remote compiles both into the descriptor and Selection above, so
|
|
473
|
+
the store, the planner, and the server see nothing new.
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
import { Schema } from 'effect'
|
|
477
|
+
import { Entity, Relation } from 'foldkit-entity'
|
|
478
|
+
import { Remote } from 'foldkit-remote'
|
|
479
|
+
|
|
480
|
+
const User = Entity.define('User', Schema.Struct({ id: Schema.String, name: Schema.String }))
|
|
481
|
+
const Project = Entity.define('Project', Schema.Struct({ id: Schema.String, name: Schema.String }))
|
|
482
|
+
const Work = Entity.relate({ User, Project }, { Project: { owner: Relation.one(User) } })
|
|
483
|
+
|
|
484
|
+
const ProjectCard = Entity.select(Work.Project, {
|
|
485
|
+
name: true,
|
|
486
|
+
owner: Entity.select(Work.User, { name: true }),
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const Data = Remote.make({ model: App.model.remote, entities: [Work.User, Work.Project] })
|
|
490
|
+
const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name; owner: { name } }>>
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
- A relation becomes a ref field: `one` a ref, an optional `one` a nullable ref,
|
|
494
|
+
`many` an array of refs. A derived member becomes a field the server supplies.
|
|
495
|
+
- The Entity needs an `id` field; Remote keys the store by it. When that field
|
|
496
|
+
is branded, `Data.get` and `Data.live` take that type for an Entity Selection:
|
|
497
|
+
another Entity's id, or plain text, does not compile.
|
|
498
|
+
- Register the Entities of one `Entity.relate` result and select from that same
|
|
499
|
+
result; `Object.values(Work)` registers them all.
|
|
500
|
+
- `Entity.from(entity)` and `Selection.from(selection)` are the compile steps,
|
|
501
|
+
for when you need the descriptor itself: `patch` and `ref` in a mutation
|
|
502
|
+
handler.
|
|
503
|
+
- `Entity.page(selection, window)` in an Entity Selection is Remote's
|
|
504
|
+
`Selection.connection`: the window travels with the read, and the server
|
|
505
|
+
answers with a page of refs. A page of a whole list is read under a name of
|
|
506
|
+
its own, `comments@first=10`, so one view can show every comment while another
|
|
507
|
+
shows the first ten of the same post: they are two fields to the store, fetched
|
|
508
|
+
in one batch, merged and refreshed each on its own. A write to the list (a
|
|
509
|
+
mutation's patch, a live change) marks its pages stale, so they are read again.
|
|
510
|
+
A cursor is not part of the name: a page read from a cursor continues its page.
|
|
511
|
+
The server refuses a request that pages one relation more than four ways, and
|
|
512
|
+
a field's own name may not contain `@`.
|
|
513
|
+
|
|
514
|
+
`Entity.make` with `Entity.ref` keeps working, and both kinds can share one
|
|
515
|
+
domain. Both packages export `Entity`; a module that needs `Entity.from` beside
|
|
516
|
+
`Entity.define` aliases one of them.
|
|
517
|
+
|
|
387
518
|
Presence is tracked separately from the JavaScript value. These are distinct:
|
|
388
519
|
|
|
389
520
|
```text
|
|
@@ -433,6 +564,24 @@ Projection itself.
|
|
|
433
564
|
|
|
434
565
|
### Refreshing from `update`
|
|
435
566
|
|
|
567
|
+
### Showing a change nobody has made
|
|
568
|
+
|
|
569
|
+
A mutation's `optimistic` operations show over the store while it is in flight.
|
|
570
|
+
`Data.overlay` shows operations the same way with no request behind them, until
|
|
571
|
+
`Data.lift`: a preview, in every Selection and view, of something not yet sent.
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
const previewed = Data.overlay(model, 'post-preview', [Project.patch(id, { name: draft })])
|
|
575
|
+
const back = Data.lift(previewed, 'post-preview')
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
- Both are called from `update`, and neither performs I/O or touches what the
|
|
579
|
+
server said: the store beneath is as it was.
|
|
580
|
+
- Showing an id again replaces what it showed. Lifting what was never shown
|
|
581
|
+
returns the same Model.
|
|
582
|
+
- An overlay's id is apart from every request's, so a mutation that settles does
|
|
583
|
+
not take a preview with it.
|
|
584
|
+
|
|
436
585
|
To revalidate what a screen already declares — a refresh button, a focus
|
|
437
586
|
regained — hand its Projection (or a Surface without params) to `Data.refresh`:
|
|
438
587
|
|
|
@@ -445,8 +594,9 @@ case 'ClickedRefresh': {
|
|
|
445
594
|
```
|
|
446
595
|
|
|
447
596
|
`Data.refresh` performs no I/O and restates no request. It returns the Model
|
|
448
|
-
with every selected field the store holds reading `Refreshing
|
|
449
|
-
|
|
597
|
+
with every selected field the store holds reading `Refreshing`, every entity it
|
|
598
|
+
knew to be absent forgotten, so `NotFound` reads `Loading` and is asked for
|
|
599
|
+
again, and every loaded connection invalidated; the `Data.subscriptions` read entries then refetch it,
|
|
450
600
|
since stale data is planned again under every policy, so the page is requested
|
|
451
601
|
once. The Projection must be observed, as it is while it is on screen; for data
|
|
452
602
|
nothing observes, use `Data.prefetch` with `RemotePolicy.networkOnly`.
|
|
@@ -479,6 +629,52 @@ const Data = Remote.make({
|
|
|
479
629
|
})
|
|
480
630
|
```
|
|
481
631
|
|
|
632
|
+
`Query.make` names a query and says what it returns. Where it *means*
|
|
633
|
+
something the client should be able to state, declare it with a body instead:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
import { Entity, Expr, Order, Query } from 'foldkit-entity'
|
|
637
|
+
|
|
638
|
+
const Task = Entity.define(
|
|
639
|
+
'Task',
|
|
640
|
+
Schema.Struct({ id: Schema.String, ownerId: Schema.String, updatedAt: Schema.Number }),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
const TasksByOwner = Query.define(
|
|
644
|
+
'TasksByOwner',
|
|
645
|
+
{ ownerId: Schema.String },
|
|
646
|
+
({ input }) =>
|
|
647
|
+
Query.from(Task).pipe(
|
|
648
|
+
Query.where(Expr.eq(Task.fields.ownerId, input.ownerId)),
|
|
649
|
+
Query.orderBy(Order.desc(Task.fields.updatedAt), Order.asc(Task.fields.id)),
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
A body is written over a [`foldkit-entity`](../entity) Entity, because that is
|
|
655
|
+
what has addressable fields: `Task.fields.ownerId` is a reference an `Expr` can
|
|
656
|
+
be built from, where this package's own `Entity.make` describes a field as the
|
|
657
|
+
schema of its value and has nothing to point at. An entity declared either way
|
|
658
|
+
still reads, selects and normalizes the same; only a query body needs the
|
|
659
|
+
richer declaration.
|
|
660
|
+
|
|
661
|
+
What comes back is an ordinary descriptor — the same name, `Input`, `ref` and
|
|
662
|
+
connection identity — carrying its `body` besides, and the result is a
|
|
663
|
+
connection over the Entity the body reads, so it is not named twice. A server
|
|
664
|
+
can compile that body rather than be told the same thing again in its own
|
|
665
|
+
dialect; one that would rather answer the query its own way still can, and a
|
|
666
|
+
descriptor from `Query.make` has no body at all.
|
|
667
|
+
|
|
668
|
+
The body is built **once**, when the query is declared. Inside it `input.ownerId`
|
|
669
|
+
is a placeholder for the value the query will be given, not the value — there is
|
|
670
|
+
nothing there yet to branch on. A condition that depends on what was passed is a
|
|
671
|
+
comparison over the placeholder, never a `?:` around it.
|
|
672
|
+
|
|
673
|
+
`Input` must be fields or a plain `Schema.Struct`, because that is what the
|
|
674
|
+
placeholders are made from. A codec that exposes no keys is refused where the
|
|
675
|
+
query is declared rather than handing the body an empty object, which would
|
|
676
|
+
compare a column to nothing while every type agreed.
|
|
677
|
+
|
|
482
678
|
Then a query is still just a Projection:
|
|
483
679
|
|
|
484
680
|
```ts
|
|
@@ -552,6 +748,17 @@ Those settlement Messages go through `Data.reduce` like every other Remote fact.
|
|
|
552
748
|
The request id comes from the Remote Model's sequence, so `update` stays pure.
|
|
553
749
|
Settling is idempotent per `requestId`.
|
|
554
750
|
|
|
751
|
+
A mutation that deletes says so. The server's outcome carries
|
|
752
|
+
`deleted: [{ entity, id }]`, and settling it tombstones those entities: they read
|
|
753
|
+
`NotFound`, and they leave every connection and relation they were in, so the
|
|
754
|
+
server names no list. Patches apply first, so an entity named both ways is
|
|
755
|
+
deleted; a retry of the same request deletes nothing again.
|
|
756
|
+
|
|
757
|
+
`Data.mutation(model, requestId)` reads what became of it from the Model:
|
|
758
|
+
`Pending`, `Applied`, `Failed` with the error the server or transport gave, or
|
|
759
|
+
`Unknown` for an id never started here (or settled so long ago it left the
|
|
760
|
+
bounded ledger). A retry reuses its id, and the latest outcome wins.
|
|
761
|
+
|
|
555
762
|
Optimistic entity patches are **layers over the base store**, not inverse
|
|
556
763
|
patches. If multiple mutations overlap, the visible cache is recomputed as base
|
|
557
764
|
plus the still-pending layers, so settling one does not require trying to undo
|
|
@@ -576,6 +783,41 @@ const { model: started, command } = Data.mutate(model, AddComment, input, {
|
|
|
576
783
|
A successful server result can replace the request's temporary connection
|
|
577
784
|
overlays with confirmed ones in place.
|
|
578
785
|
|
|
786
|
+
### Reading past what is only pending
|
|
787
|
+
|
|
788
|
+
Every projection reads the **visible** cache: the server-derived store under
|
|
789
|
+
the layers still pending, which is why an optimistic change shows at once. A
|
|
790
|
+
reader that must not believe a change until the server has agreed to it asks
|
|
791
|
+
for the confirmed read instead:
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
Data.confirmed(Data.get(ProjectSummary, projectId))
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
It is the same projection — the same requirements, planned the same way, so
|
|
798
|
+
observing it fetches exactly what observing the original fetches — reading the
|
|
799
|
+
server-derived store alone, with the pending layers and connection overlays
|
|
800
|
+
left off. An optimistically inserted edge is not in its page; an
|
|
801
|
+
optimistically patched field reads as the server last said.
|
|
802
|
+
|
|
803
|
+
A view almost always wants the projection itself. This is for the reader that
|
|
804
|
+
reports on the world rather than drawing it, which in practice is an Agent
|
|
805
|
+
capability that must not claim success before the server reflects it:
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
Agent.when({
|
|
809
|
+
projection: Data.confirmed(Data.get(ProjectSummary, projectId)),
|
|
810
|
+
predicate: (project, request) => project.name === request.name,
|
|
811
|
+
})
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
There is deliberately no `Data.visible`: a projection already is the visible
|
|
815
|
+
read, and a second name for it would be a wrapper that only forwards.
|
|
816
|
+
|
|
817
|
+
Sync draws the same line over its replica, with the same words and a different
|
|
818
|
+
mechanism: see [what a reader sees while a change is in
|
|
819
|
+
flight](../../docs/state-model.md#what-a-reader-sees-while-a-change-is-in-flight).
|
|
820
|
+
|
|
579
821
|
### Remote mutation vs Sync operation
|
|
580
822
|
|
|
581
823
|
Do not use Remote mutation as a durable offline-write mechanism:
|
|
@@ -741,6 +983,15 @@ useful question is: **"Why is this Projection still Initial?"** `Data.plan`
|
|
|
741
983
|
shows whether Remote believes anything is actually missing; active Surface
|
|
742
984
|
wiring determines whether that plan is being executed.
|
|
743
985
|
|
|
986
|
+
`Data.inspect(model).loading` answers the other one — **"what is Remote doing
|
|
987
|
+
right now?"** — with the `entity\0id\0field` marks of the reads in flight,
|
|
988
|
+
beside `mutations.pending` for the writes. Both are read from the Model, not
|
|
989
|
+
from the fibers doing the work: a tool that shows them shows something a
|
|
990
|
+
recorded Model can be replayed to, and nothing that needs the runtime to be
|
|
991
|
+
asked. That is deliberate. Runtime activity is not a second source of truth
|
|
992
|
+
here, and a view that rendered from it would stop being reproducible from the
|
|
993
|
+
Model.
|
|
994
|
+
|
|
744
995
|
## Advanced: the kernel
|
|
745
996
|
|
|
746
997
|
Everything above compiles to the lower-level exports below; there is not a
|
|
@@ -911,6 +1162,15 @@ Remote does **not** own:
|
|
|
911
1162
|
- authentication; server-side authorization belongs at the Source boundary;
|
|
912
1163
|
- a background scheduler or general-purpose database/query engine.
|
|
913
1164
|
|
|
1165
|
+
## How state changes here
|
|
1166
|
+
|
|
1167
|
+
Local application state uses `evo` inside `update`, like anywhere else.
|
|
1168
|
+
Remote Messages go through `Data.reduce` into the embedded `Remote.Model`
|
|
1169
|
+
submodel — Remote owns that reducer, and it is the only writer of the
|
|
1170
|
+
cache. Never install cache state with a ref `set`; the requirements,
|
|
1171
|
+
staleness, and tombstones only stay coherent when every fact arrives as a
|
|
1172
|
+
Remote Message.
|
|
1173
|
+
|
|
914
1174
|
## Limits
|
|
915
1175
|
|
|
916
1176
|
- Effect RPC is the wire contract; the application supplies deployment layers.
|