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 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-remote'
148
+ import { Entity } from 'foldkit-entity'
149
149
 
150
- const Project = Entity.make(
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 = Project.select({
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` and every loaded
449
- connection invalidated; the `Data.subscriptions` read entries then refetch it,
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.