foldkit-remote 0.13.0 → 0.14.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 +263 -11
- package/dist/{index-Dh4qdXfE.d.mts → index-CXAGo7Dz.d.mts} +479 -50
- package/dist/index-CXAGo7Dz.d.mts.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/port.d.mts +1 -1
- package/dist/port.mjs +1 -1
- package/dist/{src-C53_fLA2.mjs → src-CcPPtNuH.mjs} +1109 -131
- package/dist/src-CcPPtNuH.mjs.map +1 -0
- package/package.json +4 -4
- package/dist/index-Dh4qdXfE.d.mts.map +0 -1
- package/dist/src-C53_fLA2.mjs.map +0 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaAST, SchemaGetter, Stream } from "effect";
|
|
1
|
+
import { Context, Data, Deferred, Duration, Effect, Layer, Match, Option, Predicate, Record, Request, RequestResolver, Result, Schema, SchemaAST, SchemaGetter, Stream } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
2
3
|
import * as Domain from "foldkit-entity";
|
|
3
|
-
import { Entity, Expr, Query, SchemaShape, SelectionTypeId, evaluate } from "foldkit-entity";
|
|
4
|
+
import { Entity, Expr, Query, QueryEvaluateError, ReadFilter, SchemaShape, SelectionTypeId, Write, compareRows, dependenciesOf, evaluate } from "foldkit-entity";
|
|
4
5
|
import { mapMessage } from "foldkit/command";
|
|
5
6
|
import { defineMessageUnion } from "foldkit/message";
|
|
6
7
|
import * as Subscription from "foldkit/subscription";
|
|
7
8
|
import { Metadata } from "foldkit-surface";
|
|
8
9
|
import { Rpc, RpcGroup } from "effect/rpc";
|
|
9
|
-
import {
|
|
10
|
+
import { defineTaggedUnion } from "foldkit/schema";
|
|
10
11
|
//#region src/requirement.ts
|
|
11
12
|
/**
|
|
12
13
|
* What a Remote Projection needs from the server: entity slices and query
|
|
@@ -485,18 +486,89 @@ const reconcileMutation = (store, state, requestId, answer) => {
|
|
|
485
486
|
}
|
|
486
487
|
};
|
|
487
488
|
};
|
|
489
|
+
const conflict = Schema.TaggedStruct("Conflict", {});
|
|
490
|
+
const anyField = Schema.TaggedStruct("Field", {
|
|
491
|
+
key: Schema.String,
|
|
492
|
+
reason: Schema.Unknown
|
|
493
|
+
});
|
|
494
|
+
/** Refusals a form or an editor knows how to show. */
|
|
495
|
+
const Refusal = {
|
|
496
|
+
/** One input key was refused, for `reason`, which a form shows beside that key. */
|
|
497
|
+
field: (key, reason) => Schema.TaggedStruct("Field", {
|
|
498
|
+
key: Schema.Literal(key),
|
|
499
|
+
reason
|
|
500
|
+
}),
|
|
501
|
+
/** The row moved on since the client read it; the author decides what wins. */
|
|
502
|
+
conflict,
|
|
503
|
+
/** Whether a refusal, of whatever mutation, is a `Refusal.field`. */
|
|
504
|
+
isField: Schema.is(anyField),
|
|
505
|
+
/** Whether a refusal, of whatever mutation, is `Refusal.conflict`. */
|
|
506
|
+
isConflict: Schema.is(conflict)
|
|
507
|
+
};
|
|
488
508
|
/** The codec itself, or a `Schema.Struct` over the fields. */
|
|
489
509
|
const schemaOf = (shape) => Schema.isSchema(shape) ? shape : Schema.Struct(shape);
|
|
490
|
-
const Mutation = {
|
|
491
510
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
511
|
+
* A bound write as the store patches it: its fields as they are, and each
|
|
512
|
+
* relation it points as the ref key the store holds (`'User:u1'`), or null.
|
|
513
|
+
*/
|
|
514
|
+
const patchOfWrite = (bound) => ({
|
|
515
|
+
entity: bound.entity,
|
|
516
|
+
id: bound.id,
|
|
517
|
+
values: {
|
|
518
|
+
...bound.values,
|
|
519
|
+
...Record.map(bound.links, (link) => Option.match(link, {
|
|
520
|
+
onNone: () => null,
|
|
521
|
+
onSome: (ref) => entityKey(ref.entity, ref.id)
|
|
522
|
+
}))
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
/**
|
|
526
|
+
* What a declared write shows before the server answers: an update's or an
|
|
527
|
+
* insert's row as a patch (an update's only for the keys named, when some
|
|
528
|
+
* are). A delete shows nothing, since no operation removes a row from every
|
|
529
|
+
* list it is in; its answer does.
|
|
494
530
|
*/
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
531
|
+
const optimisticOfWrite = (write, input, keys) => Match.value(write).pipe(Match.tagsExhaustive({
|
|
532
|
+
Update: () => [patchOfWrite(Write.bind(write, input, keys))],
|
|
533
|
+
Insert: () => {
|
|
534
|
+
const patch = patchOfWrite(Write.bind(write, input));
|
|
535
|
+
return [{
|
|
536
|
+
...patch,
|
|
537
|
+
values: {
|
|
538
|
+
id: patch.id,
|
|
539
|
+
...patch.values
|
|
540
|
+
}
|
|
541
|
+
}];
|
|
542
|
+
},
|
|
543
|
+
Delete: () => []
|
|
544
|
+
}));
|
|
545
|
+
const Mutation = {
|
|
546
|
+
/**
|
|
547
|
+
* Declares a mutation. `Input` and `Output` are codecs, or the fields of the
|
|
548
|
+
* `Schema.Struct` they would be (`{ id: ProjectId, name: Schema.String }`).
|
|
549
|
+
* `Refusal`, when given, is what the server may refuse with: a codec, usually
|
|
550
|
+
* a union of `Refusal.field(...)` and `Refusal.conflict`.
|
|
551
|
+
*/
|
|
552
|
+
/**
|
|
553
|
+
* A mutation that is a declared `Write` (`update`, `insert` or `delete`): its
|
|
554
|
+
* input is the write's, and it answers nothing but the row it changed, as a
|
|
555
|
+
* patch, or its deletion. It may be refused as a conflict, for a write that
|
|
556
|
+
* `expect`s a revision the row has moved past.
|
|
557
|
+
*/
|
|
558
|
+
write: (name, write) => ({
|
|
559
|
+
name,
|
|
560
|
+
Input: write.input.schema,
|
|
561
|
+
Output: Schema.Struct({}),
|
|
562
|
+
Refusal: Refusal.conflict,
|
|
563
|
+
write
|
|
564
|
+
}),
|
|
565
|
+
make: (name, config) => ({
|
|
566
|
+
name,
|
|
567
|
+
Input: schemaOf(config.Input),
|
|
568
|
+
Output: schemaOf(config.Output),
|
|
569
|
+
Refusal: config.Refusal ?? Schema.Never
|
|
570
|
+
})
|
|
571
|
+
};
|
|
500
572
|
//#endregion
|
|
501
573
|
//#region src/query.ts
|
|
502
574
|
/**
|
|
@@ -654,7 +726,20 @@ const Query$1 = {
|
|
|
654
726
|
*/
|
|
655
727
|
const REMOTE_PROTOCOL_VERSION = 4;
|
|
656
728
|
var RemoteReadError = class extends Schema.TaggedError()("RemoteReadError", { message: Schema.String }) {};
|
|
657
|
-
var RemoteMutationError = class extends Schema.TaggedError()("RemoteMutationError", {
|
|
729
|
+
var RemoteMutationError = class extends Schema.TaggedError()("RemoteMutationError", {
|
|
730
|
+
message: Schema.String,
|
|
731
|
+
/** The mutation's own `Refusal`, encoded, when the server refused it as one. */
|
|
732
|
+
refusal: Schema.optional(Schema.Unknown)
|
|
733
|
+
}) {};
|
|
734
|
+
/**
|
|
735
|
+
* The server refused who is asking: they may not do this, and asking again
|
|
736
|
+
* as the same principal is refused again. `reason` is the application's own
|
|
737
|
+
* words for the person who asked; without it the refusal says nothing of why.
|
|
738
|
+
*/
|
|
739
|
+
var RemoteForbidden = class extends Schema.TaggedError()("Forbidden", {
|
|
740
|
+
message: Schema.String,
|
|
741
|
+
reason: Schema.optional(Schema.String)
|
|
742
|
+
}) {};
|
|
658
743
|
var RemoteLiveError = class extends Schema.TaggedError()("RemoteLiveError", { message: Schema.String }) {};
|
|
659
744
|
/** The peer speaks another protocol version; nothing was read. */
|
|
660
745
|
var RemoteProtocolError = class extends Schema.TaggedError()("RemoteProtocolError", {
|
|
@@ -731,7 +816,9 @@ const MutationRequest = Schema.Struct({
|
|
|
731
816
|
/** Stable across transport retries so a mutation is not applied twice. */
|
|
732
817
|
requestId: Schema.String,
|
|
733
818
|
mutation: Schema.String,
|
|
734
|
-
input: Schema.Unknown
|
|
819
|
+
input: Schema.Unknown,
|
|
820
|
+
/** For a mutation that is a `Write`: the input keys to write, when not all of them. */
|
|
821
|
+
keys: Schema.optional(Schema.Array(Schema.String))
|
|
735
822
|
});
|
|
736
823
|
const LiveEdge = Schema.Struct({
|
|
737
824
|
entity: Schema.String,
|
|
@@ -812,7 +899,7 @@ const Read = Rpc.make("FoldkitRemoteRead", {
|
|
|
812
899
|
const Mutate = Rpc.make("FoldkitRemoteMutate", {
|
|
813
900
|
payload: MutationRequest,
|
|
814
901
|
success: MutationResult,
|
|
815
|
-
error: RemoteMutationError
|
|
902
|
+
error: Schema.Union([RemoteMutationError, RemoteForbidden])
|
|
816
903
|
});
|
|
817
904
|
const Live = Rpc.make("FoldkitRemoteLive", {
|
|
818
905
|
payload: LiveRequirement,
|
|
@@ -1074,7 +1161,9 @@ const liveEventOf = (change) => {
|
|
|
1074
1161
|
};
|
|
1075
1162
|
const remoteError = (error) => ({
|
|
1076
1163
|
_tag: error._tag,
|
|
1077
|
-
message: error.message
|
|
1164
|
+
message: error.message,
|
|
1165
|
+
...error.refusal === void 0 ? {} : { refusal: error.refusal },
|
|
1166
|
+
...error.reason === void 0 ? {} : { reason: error.reason }
|
|
1078
1167
|
});
|
|
1079
1168
|
const coalescedLayer = (layer, options = {}) => Layer.effect(RemoteClient, Effect.gen(function* () {
|
|
1080
1169
|
const client = yield* RemoteClient;
|
|
@@ -1090,7 +1179,7 @@ const coalescedLayer = (layer, options = {}) => Layer.effect(RemoteClient, Effec
|
|
|
1090
1179
|
* `Remote.mutate` as a standalone effect, so `Remote.mutateInto` can reuse it
|
|
1091
1180
|
* without the object literal referencing itself.
|
|
1092
1181
|
*/
|
|
1093
|
-
const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requestId) {
|
|
1182
|
+
const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requestId, keys) {
|
|
1094
1183
|
yield* Effect.annotateCurrentSpan({
|
|
1095
1184
|
mutation: mutation.name,
|
|
1096
1185
|
requestId
|
|
@@ -1100,7 +1189,8 @@ const mutateRemote = Effect.fn("Remote.mutate")(function* (mutation, input, requ
|
|
|
1100
1189
|
const result = yield* client.mutate({
|
|
1101
1190
|
requestId,
|
|
1102
1191
|
mutation: mutation.name,
|
|
1103
|
-
input: encoded
|
|
1192
|
+
input: encoded,
|
|
1193
|
+
...keys === void 0 ? {} : { keys }
|
|
1104
1194
|
});
|
|
1105
1195
|
return {
|
|
1106
1196
|
output: yield* Schema.decodeUnknownEffect(mutation.Output)(result.output).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new RemoteMutationError({ message: error.message })))),
|
|
@@ -1546,6 +1636,116 @@ const Entity$1 = {
|
|
|
1546
1636
|
})
|
|
1547
1637
|
};
|
|
1548
1638
|
//#endregion
|
|
1639
|
+
//#region src/placement.ts
|
|
1640
|
+
/**
|
|
1641
|
+
* Where one row falls in a loaded list, by the list's own declared order:
|
|
1642
|
+
* the decidable half of placing a row instead of fetching the list again.
|
|
1643
|
+
*
|
|
1644
|
+
* A list is segments of edges between boundaries, and only a `Terminal`
|
|
1645
|
+
* boundary says nothing lies beyond it. So a row is placed between two loaded
|
|
1646
|
+
* edges, or before a segment's first edge when that segment starts the list,
|
|
1647
|
+
* or at its end when it ends it; a row that sorts past a `Cursor` or into a
|
|
1648
|
+
* gap belongs to rows the client has not read, and is `Outside`. Anything the
|
|
1649
|
+
* client cannot judge exactly (a body it cannot place, a field a row lacks,
|
|
1650
|
+
* keys it cannot compare) is `Unknown`, with why, never a guess.
|
|
1651
|
+
*
|
|
1652
|
+
* Pure: it reads the store and the list it is given.
|
|
1653
|
+
*/
|
|
1654
|
+
const RowPlace = Data.taggedEnum();
|
|
1655
|
+
/**
|
|
1656
|
+
* The values a row holds of the fields an order reads, or none when it lacks
|
|
1657
|
+
* one. An `id` no Selection stored is its key's, when the id is stored as
|
|
1658
|
+
* text as a key holds it, so the id tie-break every order ends on does not
|
|
1659
|
+
* make such a row unplaceable; a number id is never compared as text.
|
|
1660
|
+
*/
|
|
1661
|
+
const keysOf = (store, key, id, idIsText, terms) => {
|
|
1662
|
+
const values = Object.create(null);
|
|
1663
|
+
for (const term of terms) {
|
|
1664
|
+
if (term.expr._tag !== "Field") return Option.none();
|
|
1665
|
+
const field = term.expr.key;
|
|
1666
|
+
const stored = readField(store, key, field);
|
|
1667
|
+
const value = field === "id" && Option.isNone(stored) && idIsText ? id : stored;
|
|
1668
|
+
if (Option.isNone(value)) return Option.none();
|
|
1669
|
+
values[field] = value.value;
|
|
1670
|
+
}
|
|
1671
|
+
return Option.some(values);
|
|
1672
|
+
};
|
|
1673
|
+
/**
|
|
1674
|
+
* Where the row stored under `key` falls in `connection`, by `body`'s order
|
|
1675
|
+
* for `input` (encoded, as a connection's identity carries it). The row's own
|
|
1676
|
+
* edge, if the list holds it, is left out: placing a row that moved asks
|
|
1677
|
+
* where it goes now.
|
|
1678
|
+
*/
|
|
1679
|
+
const placeIn = (store, connection, body, input, key) => {
|
|
1680
|
+
const placement = Query.placement(body, input);
|
|
1681
|
+
if (placement._tag === "NotPlaceable") return RowPlace.Unknown({ reason: placement.reason });
|
|
1682
|
+
const terms = Query.orderFor(body, input);
|
|
1683
|
+
const prefix = `${body.entity.name}:`;
|
|
1684
|
+
const id = key.startsWith(prefix) ? Option.some(key.slice(prefix.length)) : Option.none();
|
|
1685
|
+
const idField = body.entity.fields.id;
|
|
1686
|
+
const idIsText = idField !== void 0 && SchemaAST.isString(Schema.toEncoded(idField.schema).ast);
|
|
1687
|
+
const row = keysOf(store, key, id, idIsText, terms);
|
|
1688
|
+
if (Option.isNone(row)) return RowPlace.Unknown({ reason: `${key} lacks a field the order reads` });
|
|
1689
|
+
/** Negative when the row sorts before the edge; none when the edge cannot be compared. */
|
|
1690
|
+
const against = (edge) => Option.flatMap(keysOf(store, entityKey(edge.ref.entity, edge.ref.id), Option.some(edge.ref.id), idIsText, terms), (values) => {
|
|
1691
|
+
try {
|
|
1692
|
+
return Option.some(compareRows(terms, row.value, values, body.entity.name));
|
|
1693
|
+
} catch {
|
|
1694
|
+
return Option.none();
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
const boundary = RowPlace.Unknown({ reason: `${key} was where its page met rows not loaded` });
|
|
1698
|
+
const { segments } = connection;
|
|
1699
|
+
for (let index = 0; index < segments.length; index++) {
|
|
1700
|
+
const segment = segments[index];
|
|
1701
|
+
const held = segment.edges.findIndex((edge) => edge.key === key);
|
|
1702
|
+
const edges = segment.edges.filter((edge) => edge.key !== key);
|
|
1703
|
+
for (let position = 0; position < edges.length; position++) {
|
|
1704
|
+
const edge = edges[position];
|
|
1705
|
+
const sign = against(edge);
|
|
1706
|
+
if (Option.isNone(sign)) return RowPlace.Unknown({ reason: `${edge.key} lacks a field the order reads` });
|
|
1707
|
+
if (sign.value < 0) {
|
|
1708
|
+
const before = RowPlace.Before({
|
|
1709
|
+
segment: index,
|
|
1710
|
+
edge: edge.key
|
|
1711
|
+
});
|
|
1712
|
+
if (position > 0) return before;
|
|
1713
|
+
return Match.value(segment.start).pipe(Match.tag("Terminal", () => before), Match.orElse(() => held === 0 ? boundary : RowPlace.Outside()));
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (Match.value(segment.end).pipe(Match.tag("Terminal", () => true), Match.orElse(() => false))) return RowPlace.End({ segment: index });
|
|
1717
|
+
if (held >= 0 && held === segment.edges.length - 1) return boundary;
|
|
1718
|
+
}
|
|
1719
|
+
return RowPlace.Outside();
|
|
1720
|
+
};
|
|
1721
|
+
/** The connection without the edge of `key`, or the connection itself when it holds none. */
|
|
1722
|
+
const withoutEdge = (connection, key) => connection.segments.some((segment) => segment.edges.some((edge) => edge.key === key)) ? {
|
|
1723
|
+
...connection,
|
|
1724
|
+
segments: connection.segments.map((segment) => ({
|
|
1725
|
+
...segment,
|
|
1726
|
+
edges: segment.edges.filter((edge) => edge.key !== key)
|
|
1727
|
+
}))
|
|
1728
|
+
} : connection;
|
|
1729
|
+
/** The connection with `edge` where `place` says: before an edge, or at a segment's end. */
|
|
1730
|
+
const withEdge = (connection, added, place) => ({
|
|
1731
|
+
...connection,
|
|
1732
|
+
segments: connection.segments.map((segment, index) => {
|
|
1733
|
+
if (index !== place.segment) return segment;
|
|
1734
|
+
const at = Match.value(place).pipe(Match.tagsExhaustive({
|
|
1735
|
+
Before: ({ edge }) => segment.edges.findIndex((held) => held.key === edge),
|
|
1736
|
+
End: () => segment.edges.length
|
|
1737
|
+
}));
|
|
1738
|
+
return {
|
|
1739
|
+
...segment,
|
|
1740
|
+
edges: [
|
|
1741
|
+
...segment.edges.slice(0, at),
|
|
1742
|
+
added,
|
|
1743
|
+
...segment.edges.slice(at)
|
|
1744
|
+
]
|
|
1745
|
+
};
|
|
1746
|
+
})
|
|
1747
|
+
});
|
|
1748
|
+
//#endregion
|
|
1549
1749
|
//#region src/matching.ts
|
|
1550
1750
|
/**
|
|
1551
1751
|
* Running a query body against the rows the client already holds.
|
|
@@ -1652,12 +1852,132 @@ const belongsEncoded = (store, descriptor, encoded, key) => {
|
|
|
1652
1852
|
if (entry.tombstone) return "no";
|
|
1653
1853
|
const body = descriptor.body;
|
|
1654
1854
|
if (body !== void 0 && decidableOn(body).some((field) => entry.stale.has(field))) return "unknown";
|
|
1655
|
-
|
|
1855
|
+
let judged;
|
|
1856
|
+
try {
|
|
1857
|
+
judged = matchingEncoded(store, descriptor, encoded, { among: [key] });
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
if (error instanceof QueryEvaluateError) return "unknown";
|
|
1860
|
+
throw error;
|
|
1861
|
+
}
|
|
1656
1862
|
if (judged.matched.includes(key)) return "yes";
|
|
1657
1863
|
if (judged.skipped.includes(key)) return "unknown";
|
|
1658
1864
|
return "no";
|
|
1659
1865
|
};
|
|
1660
1866
|
//#endregion
|
|
1867
|
+
//#region src/impact.ts
|
|
1868
|
+
/**
|
|
1869
|
+
* What a write did to the lists the client holds: given one change and one
|
|
1870
|
+
* loaded connection, whether the connection is still right as it stands.
|
|
1871
|
+
*
|
|
1872
|
+
* Pure, and asked once per connection a write could touch. The answer is never
|
|
1873
|
+
* a guess: a connection kept must be provably unchanged in which rows it holds
|
|
1874
|
+
* and in what order, a connection placed is the client's exact deduction of
|
|
1875
|
+
* what the server now lists, and everything else is invalidated, with the
|
|
1876
|
+
* reason, for the read entry to fetch again.
|
|
1877
|
+
*
|
|
1878
|
+
* Membership is judged by the query's body, as a live insert is
|
|
1879
|
+
* (`belongsEncoded`), and position by its declared order (`placeIn`), when
|
|
1880
|
+
* the order is placeable. A body that names no order leaves it to the server,
|
|
1881
|
+
* by fields the client cannot see, so any change to a row it holds
|
|
1882
|
+
* invalidates it.
|
|
1883
|
+
*/
|
|
1884
|
+
/**
|
|
1885
|
+
* One row a write changed: the fields it wrote, and whether the client held
|
|
1886
|
+
* no values of the row before (`created`), or its deletion.
|
|
1887
|
+
*/
|
|
1888
|
+
const EntityChange = defineTaggedUnion({
|
|
1889
|
+
Changed: {
|
|
1890
|
+
entity: Schema.String,
|
|
1891
|
+
id: Schema.String,
|
|
1892
|
+
fields: Schema.Array(Schema.String),
|
|
1893
|
+
created: Schema.Boolean
|
|
1894
|
+
},
|
|
1895
|
+
Deleted: {
|
|
1896
|
+
entity: Schema.String,
|
|
1897
|
+
id: Schema.String
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
/**
|
|
1901
|
+
* What a mutation's answer changed, against `before`, the store as the server
|
|
1902
|
+
* last said it: each patch's fields whose value is new, and each deletion. An
|
|
1903
|
+
* answer returns more than it changed (a whole row, say), and counting an
|
|
1904
|
+
* unchanged ordering field would refetch every list on every save.
|
|
1905
|
+
*/
|
|
1906
|
+
const changesOf = (answer, before) => [...answer.entities.flatMap((patch) => {
|
|
1907
|
+
const held = before[entityKey(patch.entity, patch.id)]?.values;
|
|
1908
|
+
const fields = Object.keys(patch.values).filter((field) => held === void 0 || !Object.hasOwn(held, field) || !sameData(held[field], patch.values[field]));
|
|
1909
|
+
return fields.length === 0 ? [] : [EntityChange.Changed({
|
|
1910
|
+
entity: patch.entity,
|
|
1911
|
+
id: patch.id,
|
|
1912
|
+
fields,
|
|
1913
|
+
created: held === void 0
|
|
1914
|
+
})];
|
|
1915
|
+
}), ...(answer.deleted ?? []).map((gone) => EntityChange.Deleted({
|
|
1916
|
+
entity: gone.entity,
|
|
1917
|
+
id: gone.id
|
|
1918
|
+
}))];
|
|
1919
|
+
const Impact = Data.taggedEnum();
|
|
1920
|
+
const kept = Impact.Kept();
|
|
1921
|
+
/** Whether two versions of a list hold the same rows in the same order, segment by segment. */
|
|
1922
|
+
const sameOrder = (left, right) => left.segments.length === right.segments.length && left.segments.every((segment, index) => {
|
|
1923
|
+
const other = right.segments[index].edges;
|
|
1924
|
+
return segment.edges.length === other.length && segment.edges.every((edge, position) => edge.key === other[position].key);
|
|
1925
|
+
});
|
|
1926
|
+
/**
|
|
1927
|
+
* What `change` does to `connection`, judged against `store`, the store after
|
|
1928
|
+
* the write.
|
|
1929
|
+
*/
|
|
1930
|
+
const impactOn = (connection, store, change) => {
|
|
1931
|
+
const { descriptor } = connection;
|
|
1932
|
+
const body = descriptor.body;
|
|
1933
|
+
if ((body?.entity.name ?? descriptor.Result.entity) !== change.entity) return kept;
|
|
1934
|
+
return EntityChange.match(change, {
|
|
1935
|
+
Deleted: () => kept,
|
|
1936
|
+
Changed: ({ fields, created }) => {
|
|
1937
|
+
if (body === void 0) return Impact.Invalidated({ reason: `query "${descriptor.name}" declares no body, so what it reads is unknown` });
|
|
1938
|
+
const key = entityKey(change.entity, change.id);
|
|
1939
|
+
const held = connection.holds.has(key);
|
|
1940
|
+
const ruled = fields.find((field) => connection.filtered.includes(field));
|
|
1941
|
+
if (!held && ruled !== void 0) return Impact.Invalidated({ reason: `${key} changed "${ruled}", which the server's read rule for ${change.entity} filters on` });
|
|
1942
|
+
const roles = Query.dependencies(body);
|
|
1943
|
+
const touches = (keys) => keys.some((field) => fields.includes(field.key));
|
|
1944
|
+
let order;
|
|
1945
|
+
try {
|
|
1946
|
+
order = Query.orderFor(body, connection.encoded);
|
|
1947
|
+
} catch {
|
|
1948
|
+
return Impact.Invalidated({ reason: `"${descriptor.name}" was read in an order its body does not offer` });
|
|
1949
|
+
}
|
|
1950
|
+
if (held && body.orderBy.length === 0) return Impact.Invalidated({ reason: `${key} changed, and "${descriptor.name}" is ordered by the server, by fields its body does not name` });
|
|
1951
|
+
const pages = connection.connection;
|
|
1952
|
+
const reorders = touches(dependenciesOf(...order).fields);
|
|
1953
|
+
const refilters = touches(roles.predicate);
|
|
1954
|
+
const unloaded = hasPrevious(pages) || hasNext(pages) || isGapped(pages);
|
|
1955
|
+
if (!(held && reorders) && !refilters && !(!held && (created || reorders && unloaded))) return kept;
|
|
1956
|
+
const belongs = held && !refilters ? "yes" : belongsEncoded(store, descriptor, connection.encoded, key);
|
|
1957
|
+
if (belongs === "unknown") return Impact.Invalidated({ reason: `${key} changed a field "${descriptor.name}" filters on, and the client cannot judge it` });
|
|
1958
|
+
if (!held && belongs === "no") return kept;
|
|
1959
|
+
if (held && belongs === "yes" && !reorders) return kept;
|
|
1960
|
+
const inPages = pages.segments.some((segment) => segment.edges.some((edge) => edge.key === key));
|
|
1961
|
+
if (held && !inPages) return Impact.Invalidated({ reason: `${key} is shown in "${descriptor.name}" by an overlay, which a placement does not move` });
|
|
1962
|
+
if (belongs === "no") return Impact.Placed({ connection: withoutEdge(pages, key) });
|
|
1963
|
+
/** The row where `place` says; the list as it was when that is where it already stood. */
|
|
1964
|
+
const placedAt = (place) => {
|
|
1965
|
+
const next = withEdge(withoutEdge(pages, key), edge({
|
|
1966
|
+
entity: change.entity,
|
|
1967
|
+
id: change.id
|
|
1968
|
+
}), place);
|
|
1969
|
+
return sameOrder(pages, next) ? kept : Impact.Placed({ connection: next });
|
|
1970
|
+
};
|
|
1971
|
+
return RowPlace.$match(placeIn(store, pages, body, connection.encoded, key), {
|
|
1972
|
+
Before: placedAt,
|
|
1973
|
+
End: placedAt,
|
|
1974
|
+
Outside: () => held ? Impact.Placed({ connection: withoutEdge(pages, key) }) : kept,
|
|
1975
|
+
Unknown: ({ reason }) => Impact.Invalidated({ reason: `${key} ${held ? "moved in" : "joined"} "${descriptor.name}", and cannot be placed: ${reason}` })
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
};
|
|
1980
|
+
//#endregion
|
|
1661
1981
|
//#region src/inspect.ts
|
|
1662
1982
|
const inspectEntry = (key, entry) => ({
|
|
1663
1983
|
key,
|
|
@@ -1674,6 +1994,7 @@ const inspectRemote = (model) => ({
|
|
|
1674
1994
|
connections: Object.keys(model.connections),
|
|
1675
1995
|
live: Object.keys(model.live),
|
|
1676
1996
|
gaps: [...model.gaps],
|
|
1997
|
+
placementMismatches: model.placementMismatches,
|
|
1677
1998
|
loading: [...model.loading],
|
|
1678
1999
|
failures: model.failures,
|
|
1679
2000
|
mutations: {
|
|
@@ -2369,7 +2690,9 @@ const fresh = { _tag: "Fresh" };
|
|
|
2369
2690
|
const refreshing = { _tag: "Refreshing" };
|
|
2370
2691
|
const remoteErrorSchema = Schema.Struct({
|
|
2371
2692
|
_tag: Schema.String,
|
|
2372
|
-
message: Schema.String
|
|
2693
|
+
message: Schema.String,
|
|
2694
|
+
refusal: Schema.optional(Schema.Unknown),
|
|
2695
|
+
reason: Schema.optional(Schema.String)
|
|
2373
2696
|
});
|
|
2374
2697
|
/** A `RemoteData` schema, so a projection that reads remote state is typed. */
|
|
2375
2698
|
const remoteDataSchemas = /* @__PURE__ */ new WeakMap();
|
|
@@ -2536,9 +2859,13 @@ const initialRemoteModel = {
|
|
|
2536
2859
|
live: {},
|
|
2537
2860
|
mutations: emptyMutationState,
|
|
2538
2861
|
gaps: /* @__PURE__ */ new Set(),
|
|
2862
|
+
streams: {},
|
|
2539
2863
|
loading: /* @__PURE__ */ new Set(),
|
|
2540
2864
|
refresh: emptyRefresh,
|
|
2541
|
-
failures: noFailures
|
|
2865
|
+
failures: noFailures,
|
|
2866
|
+
restoredFrom: Option.none(),
|
|
2867
|
+
placed: {},
|
|
2868
|
+
placementMismatches: []
|
|
2542
2869
|
};
|
|
2543
2870
|
/** The entity store and the mutation ledger are runtime values, not wire shapes. */
|
|
2544
2871
|
const runtimeSchema = Schema.Unknown;
|
|
@@ -2550,12 +2877,16 @@ const remoteModelSchema = () => Schema.Struct({
|
|
|
2550
2877
|
live: Schema.Record(Schema.String, runtimeSchema),
|
|
2551
2878
|
mutations: runtimeSchema,
|
|
2552
2879
|
gaps: runtimeSchema,
|
|
2880
|
+
streams: Schema.Record(Schema.String, runtimeSchema),
|
|
2553
2881
|
loading: runtimeSchema,
|
|
2554
2882
|
refresh: runtimeSchema,
|
|
2555
2883
|
failures: Schema.Struct({
|
|
2556
2884
|
connections: Schema.Record(Schema.String, remoteErrorSchema),
|
|
2557
2885
|
fields: Schema.Record(Schema.String, remoteErrorSchema)
|
|
2558
|
-
})
|
|
2886
|
+
}),
|
|
2887
|
+
restoredFrom: runtimeSchema,
|
|
2888
|
+
placed: runtimeSchema,
|
|
2889
|
+
placementMismatches: runtimeSchema
|
|
2559
2890
|
});
|
|
2560
2891
|
const retentionRootsSchema = Schema.Struct({
|
|
2561
2892
|
requirements: Schema.Array(ReadRequest),
|
|
@@ -2588,6 +2919,7 @@ const remoteMessageCases = {
|
|
|
2588
2919
|
RetentionChanged: { roots: retentionRootsSchema },
|
|
2589
2920
|
Hydrated: {
|
|
2590
2921
|
connections: Schema.optional(Schema.Unknown),
|
|
2922
|
+
from: Schema.optional(Schema.String),
|
|
2591
2923
|
entities: runtimeSchema,
|
|
2592
2924
|
merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
|
|
2593
2925
|
},
|
|
@@ -2651,7 +2983,10 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
|
|
|
2651
2983
|
...fields
|
|
2652
2984
|
})));
|
|
2653
2985
|
/** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
|
|
2654
|
-
const
|
|
2986
|
+
const OVERLAY = "overlay:";
|
|
2987
|
+
const overlayLayer = (id) => `${OVERLAY}${id}`;
|
|
2988
|
+
/** The ids `OverlayShown` shows now, each once, in the order they are drawn. */
|
|
2989
|
+
const overlaysOf = (model) => [...new Set([...model.optimistic.layers, ...model.optimistic.overlays].flatMap(({ id }) => id.startsWith(OVERLAY) ? [id.slice(8)] : []))];
|
|
2655
2990
|
/** The key a grown window is kept under: its connection and the window first asked for. */
|
|
2656
2991
|
const windowGrowthKey = (identity, window) => `${identity}\u0000${window}`;
|
|
2657
2992
|
/**
|
|
@@ -2772,6 +3107,22 @@ const withRefreshRequested = (refresh, requests, connections = []) => {
|
|
|
2772
3107
|
};
|
|
2773
3108
|
};
|
|
2774
3109
|
/**
|
|
3110
|
+
* Marks connections as needing to be asked again, and restarts a read of them
|
|
3111
|
+
* already in flight: a page requested before what invalidated it could land
|
|
3112
|
+
* after it, saying what is no longer true. `Remote.refresh` marks the same way.
|
|
3113
|
+
*/
|
|
3114
|
+
const invalidateConnections = (model, connections) => {
|
|
3115
|
+
if (connections.length === 0) return model;
|
|
3116
|
+
const marked = connections.reduce((current, connection) => updateRemote(current, {
|
|
3117
|
+
_tag: "ConnectionInvalidated",
|
|
3118
|
+
connection
|
|
3119
|
+
}), model);
|
|
3120
|
+
return {
|
|
3121
|
+
...marked,
|
|
3122
|
+
refresh: withRefreshRequested(marked.refresh, [], connections)
|
|
3123
|
+
};
|
|
3124
|
+
};
|
|
3125
|
+
/**
|
|
2775
3126
|
* Records that a read of `requests` began under `generation`. A read that
|
|
2776
3127
|
* carries no generation began under none, and leaves the marks as they are.
|
|
2777
3128
|
*/
|
|
@@ -2822,9 +3173,49 @@ const withLiveState = (live, stream, state) => live[stream] === state ? live : {
|
|
|
2822
3173
|
...live,
|
|
2823
3174
|
[stream]: state
|
|
2824
3175
|
};
|
|
2825
|
-
const
|
|
2826
|
-
|
|
2827
|
-
|
|
3176
|
+
const noHealth = {
|
|
3177
|
+
restarts: 0,
|
|
3178
|
+
failures: 0,
|
|
3179
|
+
error: Option.none()
|
|
3180
|
+
};
|
|
3181
|
+
/**
|
|
3182
|
+
* A stream broke: it is marked a gap and its restart count moves, which
|
|
3183
|
+
* restarts its live entry. A stream already broken and not yet restarted
|
|
3184
|
+
* changes nothing, so the events a dying stream still delivers restart it once.
|
|
3185
|
+
*/
|
|
3186
|
+
const broke = (model, stream, error) => {
|
|
3187
|
+
if (model.gaps.has(stream)) return model;
|
|
3188
|
+
const health = model.streams[stream] ?? noHealth;
|
|
3189
|
+
return {
|
|
3190
|
+
...model,
|
|
3191
|
+
gaps: /* @__PURE__ */ new Set([...model.gaps, stream]),
|
|
3192
|
+
streams: {
|
|
3193
|
+
...model.streams,
|
|
3194
|
+
[stream]: {
|
|
3195
|
+
restarts: health.restarts + 1,
|
|
3196
|
+
failures: health.failures + 1,
|
|
3197
|
+
error
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
};
|
|
3201
|
+
};
|
|
3202
|
+
/**
|
|
3203
|
+
* An event applied on a stream that resubscribed: it works, so its next break
|
|
3204
|
+
* backs off from the start. A broken stream's last events prove nothing.
|
|
3205
|
+
*/
|
|
3206
|
+
const healthy = (model, stream) => {
|
|
3207
|
+
const health = model.streams[stream];
|
|
3208
|
+
return health === void 0 || health.failures === 0 || model.gaps.has(stream) ? model : {
|
|
3209
|
+
...model,
|
|
3210
|
+
streams: {
|
|
3211
|
+
...model.streams,
|
|
3212
|
+
[stream]: {
|
|
3213
|
+
...health,
|
|
3214
|
+
failures: 0,
|
|
3215
|
+
error: Option.none()
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
};
|
|
2828
3219
|
};
|
|
2829
3220
|
const clearGap = (model, stream) => model.gaps.has(stream) ? {
|
|
2830
3221
|
...model,
|
|
@@ -2832,8 +3223,8 @@ const clearGap = (model, stream) => model.gaps.has(stream) ? {
|
|
|
2832
3223
|
} : model;
|
|
2833
3224
|
/**
|
|
2834
3225
|
* The pure reducer all four producers share. A live event that arrives ahead of
|
|
2835
|
-
* its cursor is a gap: it is not applied, and the stream is recorded so
|
|
2836
|
-
*
|
|
3226
|
+
* its cursor is a gap: it is not applied, and the stream is recorded so its
|
|
3227
|
+
* live entry restarts it rather than silently missing facts. When every field a Message
|
|
2837
3228
|
* touches keeps its identity, `model` itself is returned, so the application's
|
|
2838
3229
|
* root keeps its identity and Foldkit does not render. The common no-ops do (a
|
|
2839
3230
|
* duplicate live event, a repeated `ReadStarted`); a repeated failure, or a
|
|
@@ -2849,6 +3240,32 @@ const sameFields = (model, next) => {
|
|
|
2849
3240
|
const keys = Object.keys(next);
|
|
2850
3241
|
return keys.length === Object.keys(model).length && keys.every((key) => Object.is(model[key], next[key]));
|
|
2851
3242
|
};
|
|
3243
|
+
/**
|
|
3244
|
+
* A placement compared with the server's first page of the list it placed
|
|
3245
|
+
* in, under `verifyPlacement`: the keys as far as both reach must agree, and
|
|
3246
|
+
* no row placing took out may be one the page still lists. The record goes
|
|
3247
|
+
* once compared; a disagreement is kept.
|
|
3248
|
+
*/
|
|
3249
|
+
const checkedPlacement = (model, connection, page, refreshes) => {
|
|
3250
|
+
const placed = model.placed[connection];
|
|
3251
|
+
if (placed === void 0 || !refreshes || page.start._tag !== "Terminal") return {
|
|
3252
|
+
placed: model.placed,
|
|
3253
|
+
placementMismatches: model.placementMismatches
|
|
3254
|
+
};
|
|
3255
|
+
const { [connection]: _, ...rest } = model.placed;
|
|
3256
|
+
const reach = Math.min(placed.order.length, page.edges.length);
|
|
3257
|
+
const served = page.edges.slice(0, reach).map((edge) => edge.key);
|
|
3258
|
+
const asPlaced = placed.order.slice(0, reach);
|
|
3259
|
+
const listed = new Set(page.edges.map((edge) => edge.key));
|
|
3260
|
+
return {
|
|
3261
|
+
placed: rest,
|
|
3262
|
+
placementMismatches: served.every((key, index) => key === asPlaced[index]) && !placed.dropped.some((key) => listed.has(key)) ? model.placementMismatches : [...model.placementMismatches, {
|
|
3263
|
+
connection,
|
|
3264
|
+
placed: asPlaced,
|
|
3265
|
+
served
|
|
3266
|
+
}]
|
|
3267
|
+
};
|
|
3268
|
+
};
|
|
2852
3269
|
const reduceRemote = (model, message) => {
|
|
2853
3270
|
switch (message._tag) {
|
|
2854
3271
|
case "ReadReceived": return {
|
|
@@ -2858,7 +3275,7 @@ const reduceRemote = (model, message) => {
|
|
|
2858
3275
|
failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
|
|
2859
3276
|
};
|
|
2860
3277
|
case "ReadFailed":
|
|
2861
|
-
if (message.stream !== void 0) return
|
|
3278
|
+
if (message.stream !== void 0) return broke(model, message.stream, Option.some(message.error));
|
|
2862
3279
|
return {
|
|
2863
3280
|
...model,
|
|
2864
3281
|
entities: setStale(model.entities, marksOf(message.requests), false),
|
|
@@ -2898,7 +3315,8 @@ const reduceRemote = (model, message) => {
|
|
|
2898
3315
|
} : {
|
|
2899
3316
|
...Object.fromEntries(restored),
|
|
2900
3317
|
...model.connections
|
|
2901
|
-
}
|
|
3318
|
+
},
|
|
3319
|
+
restoredFrom: message.from === void 0 ? model.restoredFrom : Option.some(message.from)
|
|
2902
3320
|
};
|
|
2903
3321
|
}
|
|
2904
3322
|
case "RefreshStarted": {
|
|
@@ -2968,29 +3386,28 @@ const reduceRemote = (model, message) => {
|
|
|
2968
3386
|
if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
|
|
2969
3387
|
const event = message.event;
|
|
2970
3388
|
const applied = applyEntityEvent(state, model.entities, event, message.now);
|
|
2971
|
-
if (applied.outcome === "gap") return
|
|
3389
|
+
if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
|
|
2972
3390
|
const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
|
|
2973
3391
|
...event.ref,
|
|
2974
3392
|
values: event.values
|
|
2975
3393
|
}])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
|
|
2976
|
-
|
|
3394
|
+
const next = {
|
|
2977
3395
|
...model,
|
|
2978
3396
|
entities: applied.store,
|
|
2979
3397
|
live: withLiveState(model.live, message.stream, applied.state),
|
|
2980
3398
|
failures: withoutFieldFailures(model.failures, settled)
|
|
2981
|
-
}
|
|
3399
|
+
};
|
|
3400
|
+
return applied.outcome === "applied" ? healthy(next, message.stream) : next;
|
|
2982
3401
|
}
|
|
2983
3402
|
const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
|
|
2984
|
-
if (applied.outcome === "gap") return
|
|
2985
|
-
const
|
|
3403
|
+
if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
|
|
3404
|
+
const written = {
|
|
2986
3405
|
...model,
|
|
2987
3406
|
optimistic: applied.optimistic,
|
|
2988
3407
|
live: withLiveState(model.live, message.stream, applied.state)
|
|
2989
|
-
}
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
connection: applied.invalidated
|
|
2993
|
-
});
|
|
3408
|
+
};
|
|
3409
|
+
const next = applied.outcome === "applied" ? healthy(written, message.stream) : written;
|
|
3410
|
+
return applied.invalidated === void 0 ? next : invalidateConnections(next, [applied.invalidated]);
|
|
2994
3411
|
}
|
|
2995
3412
|
case "GapCleared": return clearGap(model, message.stream);
|
|
2996
3413
|
case "ConnectionMerged": {
|
|
@@ -3011,6 +3428,7 @@ const reduceRemote = (model, message) => {
|
|
|
3011
3428
|
const failures = withoutFieldFailures(withoutConnectionFailure(model.failures, message.connection), [...fieldMarks(requests), ...patchedMarks(payload)]);
|
|
3012
3429
|
return {
|
|
3013
3430
|
...model,
|
|
3431
|
+
...checkedPlacement(model, message.connection, message.page, message.refreshes === true),
|
|
3014
3432
|
entities,
|
|
3015
3433
|
connections: {
|
|
3016
3434
|
...model.connections,
|
|
@@ -3541,29 +3959,46 @@ const RemoteJsonRequest = Schema.Struct({
|
|
|
3541
3959
|
payload: Schema.Unknown
|
|
3542
3960
|
});
|
|
3543
3961
|
/** An answer: the call's result, or why there is none. */
|
|
3544
|
-
const RemoteJsonAnswer = Schema.Union([Schema.Struct({ result: Schema.Unknown }), Schema.Struct({
|
|
3962
|
+
const RemoteJsonAnswer = Schema.Union([Schema.Struct({ result: Schema.Unknown }), Schema.Struct({
|
|
3963
|
+
error: Schema.String,
|
|
3964
|
+
refusal: Schema.optional(Schema.Unknown),
|
|
3965
|
+
/** Who is asking was refused, with the application's reason when it gave one. */
|
|
3966
|
+
forbidden: Schema.optional(Schema.Struct({ reason: Schema.optional(Schema.String) }))
|
|
3967
|
+
})]);
|
|
3545
3968
|
const decodeAnswer = Schema.decodeUnknownEffect(RemoteJsonAnswer);
|
|
3546
3969
|
const call = (send, operation, payload, fail) => Effect.tryPromise({
|
|
3547
3970
|
try: () => send(JSON.stringify({
|
|
3548
3971
|
operation,
|
|
3549
3972
|
payload
|
|
3550
3973
|
})),
|
|
3551
|
-
catch: (error) => fail(error instanceof Error ? error.message : String(error))
|
|
3552
|
-
}).pipe(Effect.flatMap((answer) => decodeAnswer(answer).pipe(Effect.mapError(() => fail("The server answered with something that is not an answer")))), Effect.flatMap((answer) => "error" in answer ? Effect.fail(fail(answer
|
|
3974
|
+
catch: (error) => fail({ error: error instanceof Error ? error.message : String(error) })
|
|
3975
|
+
}).pipe(Effect.flatMap((answer) => decodeAnswer(answer).pipe(Effect.mapError(() => fail({ error: "The server answered with something that is not an answer" })))), Effect.flatMap((answer) => "error" in answer ? Effect.fail(fail(answer)) : Effect.succeed(answer.result)));
|
|
3553
3976
|
/** Remote's client over `send`, for `Remote.clientLayer`. */
|
|
3554
3977
|
const json = (send) => ({
|
|
3555
|
-
FoldkitRemoteRead: (payload) => call(send, "read", payload, (
|
|
3556
|
-
FoldkitRemoteQuery: (payload) => call(send, "query", payload, (
|
|
3557
|
-
FoldkitRemoteMutate: (payload) => call(send, "mutate", payload, (
|
|
3978
|
+
FoldkitRemoteRead: (payload) => call(send, "read", payload, ({ error }) => new RemoteReadError({ message: error })),
|
|
3979
|
+
FoldkitRemoteQuery: (payload) => call(send, "query", payload, ({ error }) => new RemoteQueryError({ message: error })),
|
|
3980
|
+
FoldkitRemoteMutate: (payload) => call(send, "mutate", payload, ({ error, refusal, forbidden }) => forbidden === void 0 ? new RemoteMutationError({
|
|
3981
|
+
message: error,
|
|
3982
|
+
refusal
|
|
3983
|
+
}) : new RemoteForbidden({
|
|
3984
|
+
message: error,
|
|
3985
|
+
...forbidden.reason === void 0 ? {} : { reason: forbidden.reason }
|
|
3986
|
+
})),
|
|
3558
3987
|
FoldkitRemoteLive: () => Stream.empty
|
|
3559
3988
|
});
|
|
3989
|
+
/** A body that did not parse, told apart from every value one can parse to, `null` included. */
|
|
3990
|
+
const notJson = Symbol("not JSON");
|
|
3560
3991
|
/**
|
|
3561
3992
|
* Remote's client over HTTP: each call a `POST` of the request to `url`, the
|
|
3562
|
-
* answer its JSON body whatever its status.
|
|
3563
|
-
*
|
|
3993
|
+
* answer its JSON body whatever its status. A 403 is answered as forbidden
|
|
3994
|
+
* unless its body says so itself, as the server's own does: one from
|
|
3995
|
+
* middleware in front of Remote (a refused session) may say only `{ error }`.
|
|
3996
|
+
* `headers` is read per request, so a token that changes reaches the next
|
|
3997
|
+
* call. `fetch` defaults to the global one; pass it to route calls through
|
|
3998
|
+
* another implementation in tests.
|
|
3564
3999
|
*/
|
|
3565
4000
|
const http = (url, options = {}) => json(async (request) => {
|
|
3566
|
-
const response = await fetch(url, {
|
|
4001
|
+
const response = await (options.fetch ?? fetch)(url, {
|
|
3567
4002
|
method: "POST",
|
|
3568
4003
|
headers: {
|
|
3569
4004
|
"content-type": "application/json",
|
|
@@ -3571,9 +4006,120 @@ const http = (url, options = {}) => json(async (request) => {
|
|
|
3571
4006
|
},
|
|
3572
4007
|
body: request
|
|
3573
4008
|
});
|
|
3574
|
-
|
|
3575
|
-
|
|
4009
|
+
const status = `${response.status} ${response.statusText}`;
|
|
4010
|
+
const body = await response.json().catch(() => notJson);
|
|
4011
|
+
const saysForbidden = typeof body === "object" && body !== null && Object.hasOwn(body, "forbidden");
|
|
4012
|
+
if (response.status === 403 && !saysForbidden) return {
|
|
4013
|
+
error: status,
|
|
4014
|
+
forbidden: {}
|
|
4015
|
+
};
|
|
4016
|
+
if (body === notJson) throw new Error(status);
|
|
4017
|
+
return body;
|
|
4018
|
+
});
|
|
4019
|
+
//#endregion
|
|
4020
|
+
//#region src/sse.ts
|
|
4021
|
+
/**
|
|
4022
|
+
* Remote live over fetch with SSE framing: the other end of
|
|
4023
|
+
* `foldkit-remote-server/fetch`'s live open. One `POST` carries the live
|
|
4024
|
+
* requirement; the answer is a `text/event-stream` of `LiveChange` frames,
|
|
4025
|
+
* one `data:` frame per change, closing when the server has nothing more or
|
|
4026
|
+
* failing on a terminal `event: error` frame. Abandoning the stream aborts
|
|
4027
|
+
* the request, which interrupts the subscription server-side.
|
|
4028
|
+
*
|
|
4029
|
+
* `httpWithLive` is this live stream beside `http`'s reads, queries, and
|
|
4030
|
+
* mutations, for `Remote.clientLayer`.
|
|
4031
|
+
*/
|
|
4032
|
+
const decodeChange = Schema.decodeUnknownSync(LiveChange);
|
|
4033
|
+
const parseFrame = (text) => {
|
|
4034
|
+
let event = "message";
|
|
4035
|
+
const data = [];
|
|
4036
|
+
for (const line of text.split(/\r\n|\n/)) {
|
|
4037
|
+
if (line.startsWith(":") || line === "") continue;
|
|
4038
|
+
const colon = line.indexOf(":");
|
|
4039
|
+
if (colon < 0) continue;
|
|
4040
|
+
const field = line.slice(0, colon);
|
|
4041
|
+
const value = line.slice(colon + 1).startsWith(" ") ? line.slice(colon + 2) : line.slice(colon + 1);
|
|
4042
|
+
if (field === "event") event = value;
|
|
4043
|
+
else if (field === "data") data.push(value);
|
|
4044
|
+
}
|
|
4045
|
+
return {
|
|
4046
|
+
event,
|
|
4047
|
+
data
|
|
4048
|
+
};
|
|
4049
|
+
};
|
|
4050
|
+
/** The frames of a body, split liberally: a blank line ends a frame. */
|
|
4051
|
+
async function* frames(body) {
|
|
4052
|
+
const reader = body.getReader();
|
|
4053
|
+
const decoder = new TextDecoder();
|
|
4054
|
+
let buffer = "";
|
|
4055
|
+
try {
|
|
4056
|
+
for (;;) {
|
|
4057
|
+
const next = await reader.read();
|
|
4058
|
+
if (next.done) break;
|
|
4059
|
+
buffer += decoder.decode(next.value, { stream: true });
|
|
4060
|
+
for (;;) {
|
|
4061
|
+
const found = /(?:\r\n|\n){2}/.exec(buffer);
|
|
4062
|
+
if (found === null) break;
|
|
4063
|
+
yield parseFrame(buffer.slice(0, found.index));
|
|
4064
|
+
buffer = buffer.slice(found.index + found[0].length);
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
buffer += decoder.decode();
|
|
4068
|
+
if (buffer.trim() !== "") yield parseFrame(buffer);
|
|
4069
|
+
} finally {
|
|
4070
|
+
await reader.cancel().catch(() => {});
|
|
4071
|
+
reader.releaseLock();
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
/** The wire changes of a live body: data frames decoded, error frames failed. */
|
|
4075
|
+
async function* changes(body) {
|
|
4076
|
+
for await (const frame of frames(body)) {
|
|
4077
|
+
if (frame.data.length === 0) continue;
|
|
4078
|
+
const text = frame.data.join("\n");
|
|
4079
|
+
if (frame.event === "error") {
|
|
4080
|
+
const message = JSON.parse(text).message;
|
|
4081
|
+
throw new RemoteLiveError({ message: typeof message === "string" ? message : text });
|
|
4082
|
+
}
|
|
4083
|
+
yield decodeChange(JSON.parse(text));
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
const liveError = (error) => error instanceof RemoteLiveError ? error : new RemoteLiveError({ message: error instanceof Error ? error.message : String(error) });
|
|
4087
|
+
/** Remote's live stream over fetch: `serveFetch`'s live open as a `Stream`. */
|
|
4088
|
+
const liveFetch = (url, options = {}) => (payload) => Stream.unwrap(Effect.gen(function* () {
|
|
4089
|
+
const via = options.fetch ?? fetch;
|
|
4090
|
+
const controller = yield* Effect.acquireRelease(Effect.sync(() => new AbortController()), (open) => Effect.sync(() => open.abort()));
|
|
4091
|
+
const response = yield* Effect.tryPromise({
|
|
4092
|
+
try: () => via(url, {
|
|
4093
|
+
method: "POST",
|
|
4094
|
+
headers: {
|
|
4095
|
+
"content-type": "application/json",
|
|
4096
|
+
...options.headers?.()
|
|
4097
|
+
},
|
|
4098
|
+
body: JSON.stringify({
|
|
4099
|
+
operation: "live",
|
|
4100
|
+
payload
|
|
4101
|
+
}),
|
|
4102
|
+
signal: controller.signal
|
|
4103
|
+
}),
|
|
4104
|
+
catch: liveError
|
|
3576
4105
|
});
|
|
4106
|
+
if (!response.ok) {
|
|
4107
|
+
const answered = yield* Effect.tryPromise({
|
|
4108
|
+
try: () => response.json().catch(() => null),
|
|
4109
|
+
catch: liveError
|
|
4110
|
+
});
|
|
4111
|
+
return yield* new RemoteLiveError({ message: Predicate.isObject(answered) && "error" in answered ? String(answered.error) : `HTTP ${response.status} ${response.statusText}` });
|
|
4112
|
+
}
|
|
4113
|
+
if (response.body === null) return yield* new RemoteLiveError({ message: "The server answered live with no body" });
|
|
4114
|
+
return Stream.fromAsyncIterable(changes(response.body), liveError);
|
|
4115
|
+
}));
|
|
4116
|
+
/**
|
|
4117
|
+
* Remote's client over HTTP with live: reads, queries, and mutations as
|
|
4118
|
+
* `http` sends them, live as `liveFetch` streams it, for `clientLayer`.
|
|
4119
|
+
*/
|
|
4120
|
+
const httpWithLive = (url, options = {}) => ({
|
|
4121
|
+
...http(url, options),
|
|
4122
|
+
FoldkitRemoteLive: liveFetch(url, options)
|
|
3577
4123
|
});
|
|
3578
4124
|
//#endregion
|
|
3579
4125
|
//#region src/index.ts
|
|
@@ -3612,6 +4158,30 @@ const readContract = (source, shape, window) => {
|
|
|
3612
4158
|
}
|
|
3613
4159
|
};
|
|
3614
4160
|
};
|
|
4161
|
+
/**
|
|
4162
|
+
* A read contract whose connection, when a client can place rows in it, also
|
|
4163
|
+
* reads every field its body filters and orders by: a changed row is then
|
|
4164
|
+
* compared with neighbours that hold those fields. What the view reads, and so
|
|
4165
|
+
* decodes, stays its Selection.
|
|
4166
|
+
*/
|
|
4167
|
+
const placeableReadContract = (contract, body) => {
|
|
4168
|
+
if (body === void 0) return contract;
|
|
4169
|
+
const { requirement, ref } = contract;
|
|
4170
|
+
const encoded = Schema.encodeSync(ref.Input)(ref.input);
|
|
4171
|
+
if (Query.placement(body, encoded)._tag !== "Placeable") return contract;
|
|
4172
|
+
const missing = Query.dependencies(body).fields.filter((field) => !requirement.select.fields.includes(field.key));
|
|
4173
|
+
if (missing.length === 0) return contract;
|
|
4174
|
+
return {
|
|
4175
|
+
...contract,
|
|
4176
|
+
requirement: {
|
|
4177
|
+
...requirement,
|
|
4178
|
+
select: {
|
|
4179
|
+
...requirement.select,
|
|
4180
|
+
fields: [...requirement.select.fields, ...missing.map((field) => field.key)]
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
};
|
|
4184
|
+
};
|
|
3615
4185
|
const metaOf = (remote, asked) => {
|
|
3616
4186
|
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
3617
4187
|
let updatedAt;
|
|
@@ -3793,7 +4363,8 @@ const defineRemote = (config) => {
|
|
|
3793
4363
|
registry: {
|
|
3794
4364
|
entities: new Map(entities.map((entity) => [entity.name, entity])),
|
|
3795
4365
|
queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
|
|
3796
|
-
mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation]))
|
|
4366
|
+
mutations: new Map((config.mutations ?? []).map((mutation) => [mutation.name, mutation])),
|
|
4367
|
+
filtered: new Map(config.entities.flatMap((entity) => Entity.is(entity) ? [[entity.name, ReadFilter.members(entity.metadata)]] : []))
|
|
3797
4368
|
}
|
|
3798
4369
|
};
|
|
3799
4370
|
};
|
|
@@ -3815,19 +4386,10 @@ const bindRemote = (definition, store) => ({
|
|
|
3815
4386
|
const assertRegistered = (bound, kind, registered, name) => {
|
|
3816
4387
|
if (!registered.has(name)) throw new Error(`Remote: ${kind} "${name}" is not registered with domain "${bound.contract.name}"`);
|
|
3817
4388
|
};
|
|
3818
|
-
const nothingAsked = {
|
|
3819
|
-
requirements: [],
|
|
3820
|
-
connections: []
|
|
3821
|
-
};
|
|
3822
4389
|
const askedOf = (projection) => ({
|
|
3823
4390
|
requirements: requirementsOf(projection),
|
|
3824
4391
|
connections: connectionsOf(projection)
|
|
3825
4392
|
});
|
|
3826
|
-
/** What an active Surface asks for: nothing while it is inactive. */
|
|
3827
|
-
const askedWhile = (projection) => Option.match(projection, {
|
|
3828
|
-
onNone: () => nothingAsked,
|
|
3829
|
-
onSome: askedOf
|
|
3830
|
-
});
|
|
3831
4393
|
/**
|
|
3832
4394
|
* Whether `outer` asks for everything `inner` does of one entity: each field,
|
|
3833
4395
|
* each relation's own slice of its target, and each page window alike.
|
|
@@ -4174,58 +4736,121 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
|
4174
4736
|
};
|
|
4175
4737
|
};
|
|
4176
4738
|
const resumeCursor = Schema.Number.pipe(Schema.overrideToEquivalence(() => () => true));
|
|
4739
|
+
/** The wait before a stream's restart after `failures` breaks in a row: Sync's transport backoff. */
|
|
4740
|
+
const restartDelay = (options, failures) => {
|
|
4741
|
+
const base = Duration.toMillis(Duration.fromInputUnsafe(options.retryBase ?? "50 millis"));
|
|
4742
|
+
const max = Duration.toMillis(Duration.fromInputUnsafe(options.maxRetryDelay ?? "5 seconds"));
|
|
4743
|
+
const backoff = Math.min(max, base * 2 ** Math.min(Math.max(failures, 1) - 1, 30));
|
|
4744
|
+
return Duration.millis(backoff * (.8 + Math.random() * .4));
|
|
4745
|
+
};
|
|
4177
4746
|
/** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
|
|
4178
4747
|
const liveEntry = (bound, requirementsOf, toMessage, options) => ({
|
|
4179
4748
|
dependenciesSchema: Schema.Struct({
|
|
4180
4749
|
requirements: Schema.Array(ReadRequest),
|
|
4181
4750
|
cursor: resumeCursor,
|
|
4182
|
-
floor: Schema.Number
|
|
4751
|
+
floor: Schema.Number,
|
|
4752
|
+
restarts: Schema.Number,
|
|
4753
|
+
failures: resumeCursor
|
|
4183
4754
|
}),
|
|
4184
4755
|
modelToDependencies: (model) => {
|
|
4185
4756
|
const requirements = requirementsOf(model);
|
|
4186
4757
|
const stream = liveStreamKey(requirements);
|
|
4187
4758
|
const remote = bound.store.get(model);
|
|
4759
|
+
const health = remote.streams[stream];
|
|
4188
4760
|
return {
|
|
4189
4761
|
requirements,
|
|
4190
4762
|
cursor: remote.live[stream]?.cursor ?? 0,
|
|
4191
|
-
floor: remote.refresh.floor
|
|
4763
|
+
floor: remote.refresh.floor,
|
|
4764
|
+
restarts: health?.restarts ?? 0,
|
|
4765
|
+
failures: health?.failures ?? 0
|
|
4192
4766
|
};
|
|
4193
4767
|
},
|
|
4194
|
-
dependenciesToStream: ({ requirements, cursor }) =>
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
event
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4768
|
+
dependenciesToStream: ({ requirements, cursor, restarts, failures }) => {
|
|
4769
|
+
if (requirements.length === 0) return Stream.empty;
|
|
4770
|
+
const stream = liveStreamKey(requirements);
|
|
4771
|
+
const live = Stream.unwrap(Effect.gen(function* () {
|
|
4772
|
+
return (yield* RemoteClient).live({
|
|
4773
|
+
requirements,
|
|
4774
|
+
after: cursor
|
|
4775
|
+
});
|
|
4776
|
+
})).pipe(Stream.map((event) => toMessage({
|
|
4777
|
+
_tag: "LiveReceived",
|
|
4778
|
+
stream: liveStreamKey(requirements),
|
|
4779
|
+
event,
|
|
4780
|
+
now: (options.now ?? wallClock)()
|
|
4781
|
+
})), Stream.catchIf((_error) => true, (error) => Stream.succeed(toMessage({
|
|
4782
|
+
_tag: "ReadFailed",
|
|
4783
|
+
requests: requirements,
|
|
4784
|
+
error: remoteError(error),
|
|
4785
|
+
stream: liveStreamKey(requirements)
|
|
4786
|
+
}))));
|
|
4787
|
+
if (restarts === 0) return live;
|
|
4788
|
+
return Stream.fromEffect(Effect.sleep(restartDelay(options, failures))).pipe(Stream.drain, Stream.concat(Stream.make(toMessage({
|
|
4789
|
+
_tag: "RefreshStarted",
|
|
4790
|
+
requests: requirements
|
|
4791
|
+
}), toMessage({
|
|
4792
|
+
_tag: "GapCleared",
|
|
4793
|
+
stream
|
|
4794
|
+
}))), Stream.concat(live));
|
|
4795
|
+
}
|
|
4796
|
+
});
|
|
4797
|
+
/** The projection an active entry has for this Model; none while it is inactive. */
|
|
4798
|
+
const projectionOf = (entry, model) => {
|
|
4799
|
+
if ("projectionOf" in entry) return entry.projectionOf(model);
|
|
4800
|
+
if ("projection" in entry) return Option.some(entry.projection());
|
|
4801
|
+
return Option.none();
|
|
4802
|
+
};
|
|
4803
|
+
/**
|
|
4804
|
+
* Every projection an entry resolves to for a Model: the lone one of a
|
|
4805
|
+
* `Surface.at`/`Surface.when` value or a bare Surface, or a family's parent
|
|
4806
|
+
* first and then each instance. The parent joins so an unloaded parent is
|
|
4807
|
+
* fetched before what it reveals; `Data.satisfy` reaches the instances in a
|
|
4808
|
+
* later pass. A caller that also passes the parent separately double-covers
|
|
4809
|
+
* its requirements, as overlapping Surfaces already do.
|
|
4810
|
+
*/
|
|
4811
|
+
const resolvedOf = (entry, model) => {
|
|
4812
|
+
const source = entry;
|
|
4813
|
+
if (typeof source.instancesOf === "function") {
|
|
4814
|
+
const instances = source.instancesOf(model);
|
|
4815
|
+
const from = entry.from;
|
|
4816
|
+
if (from === void 0) return instances.map((instance) => ({
|
|
4817
|
+
name: entry.name,
|
|
4818
|
+
projection: instance.projection
|
|
4819
|
+
}));
|
|
4820
|
+
return [...Option.toArray(from.projectionOf(model)).map((projection) => ({
|
|
4821
|
+
name: from.name,
|
|
4822
|
+
projection
|
|
4823
|
+
})), ...instances.map((instance) => ({
|
|
4824
|
+
name: `${entry.name}[${instance.key}]`,
|
|
4825
|
+
projection: instance.projection
|
|
4826
|
+
}))];
|
|
4827
|
+
}
|
|
4828
|
+
return Option.toArray(projectionOf(entry, model)).map((projection) => ({
|
|
4829
|
+
name: entry.name,
|
|
4830
|
+
projection
|
|
4831
|
+
}));
|
|
4832
|
+
};
|
|
4833
|
+
/** What some projections ask for together: their requirements and connections unioned. */
|
|
4834
|
+
const askedUnion = (projections) => ({
|
|
4835
|
+
requirements: projections.flatMap(requirementsOf),
|
|
4836
|
+
connections: projections.flatMap(connectionsOf)
|
|
4210
4837
|
});
|
|
4211
|
-
/** The projection an active Surface has for this Model; none while it is inactive. */
|
|
4212
|
-
const projectionOf = (entry, model) => "projectionOf" in entry ? entry.projectionOf(model) : Option.some(entry.projection());
|
|
4213
4838
|
/**
|
|
4214
|
-
* `
|
|
4215
|
-
* all derive their dependencies from the same
|
|
4839
|
+
* `resolvedOf` computed once per Model: the read, live, and retain entries
|
|
4840
|
+
* all derive their dependencies from the same projections on the same Model
|
|
4216
4841
|
* change, and a Surface's `model` callback (which lifts and builds schemas)
|
|
4217
4842
|
* need not run three times for it. Models are objects, so the memo is weak.
|
|
4218
4843
|
*/
|
|
4219
|
-
const
|
|
4844
|
+
const memoizedResolved = (entry) => {
|
|
4220
4845
|
const cache = /* @__PURE__ */ new WeakMap();
|
|
4221
4846
|
return (model) => {
|
|
4222
4847
|
const key = model;
|
|
4223
|
-
if (typeof key !== "object" || key === null) return
|
|
4848
|
+
if (typeof key !== "object" || key === null) return resolvedOf(entry, model);
|
|
4224
4849
|
const known = cache.get(key);
|
|
4225
4850
|
if (known !== void 0) return known;
|
|
4226
|
-
const
|
|
4227
|
-
cache.set(key,
|
|
4228
|
-
return
|
|
4851
|
+
const resolved = resolvedOf(entry, model);
|
|
4852
|
+
cache.set(key, resolved);
|
|
4853
|
+
return resolved;
|
|
4229
4854
|
};
|
|
4230
4855
|
};
|
|
4231
4856
|
const Remote = {
|
|
@@ -4306,7 +4931,7 @@ const Remote = {
|
|
|
4306
4931
|
* in one step; the result carries the application-facing operations. The
|
|
4307
4932
|
* descriptor alone is `Remote.define`, the binding alone `Remote.at`.
|
|
4308
4933
|
*/
|
|
4309
|
-
make: (config) => bindDomain(defineRemote(config), config.model),
|
|
4934
|
+
make: (config) => bindDomain(defineRemote(config), config.model, { verifyPlacement: config.verifyPlacement }),
|
|
4310
4935
|
/**
|
|
4311
4936
|
* Declares a Remote domain without binding it: its entities, queries, and
|
|
4312
4937
|
* mutations, plus the submodel. For a domain reused across applications or
|
|
@@ -4528,6 +5153,13 @@ const Remote = {
|
|
|
4528
5153
|
* request to `url`. `headers` is read per request (a session token, say).
|
|
4529
5154
|
*/
|
|
4530
5155
|
http,
|
|
5156
|
+
/**
|
|
5157
|
+
* Remote's client over HTTP with live, for `clientLayer`: reads, queries,
|
|
5158
|
+
* and mutations as `http` sends them, live requirements as one `POST`
|
|
5159
|
+
* answered with a `text/event-stream` of changes.
|
|
5160
|
+
* `foldkit-remote-server/fetch` is the other end.
|
|
5161
|
+
*/
|
|
5162
|
+
httpWithLive,
|
|
4531
5163
|
clientLayer: (client, options = {}) => coalescedLayer(Layer.effect(RemoteClient, Effect.gen(function* () {
|
|
4532
5164
|
const context = yield* Effect.context();
|
|
4533
5165
|
return {
|
|
@@ -4583,15 +5215,6 @@ const Remote = {
|
|
|
4583
5215
|
}),
|
|
4584
5216
|
/** The `RemoteMessage` that merges a query page into its connection. */
|
|
4585
5217
|
queryMessage: (ref, result, select, now) => pageMessage(ref.identity, result, false, ref.window, select, now),
|
|
4586
|
-
/**
|
|
4587
|
-
* The edges a connection shows: its server-known region with pending and
|
|
4588
|
-
* confirmed overlays placed around it, minus removed edges and edges whose
|
|
4589
|
-
* target is a tombstone.
|
|
4590
|
-
*/
|
|
4591
|
-
visibleItems: (model, connection) => {
|
|
4592
|
-
const identity = connectionIdentity(connection);
|
|
4593
|
-
return visibleItems(model.connections[identity] ?? emptyConnection, identity, model.optimistic.overlays, model.entities);
|
|
4594
|
-
},
|
|
4595
5218
|
/** A pure, serializable view of the whole cache. */
|
|
4596
5219
|
inspect: inspectRemote,
|
|
4597
5220
|
/** A pure, serializable view of one entity, or `undefined` if unknown. */
|
|
@@ -4643,7 +5266,8 @@ const Remote = {
|
|
|
4643
5266
|
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
4644
5267
|
* carrying its `stream` when the stream breaks (including
|
|
4645
5268
|
* `ResumeUnavailable`). That records a gap on the stream rather than a failed
|
|
4646
|
-
* read, since nothing was being read
|
|
5269
|
+
* read, since nothing was being read, and restarts the entry after a
|
|
5270
|
+
* backoff; the restart refetches what it covers. The resume cursor is read
|
|
4647
5271
|
* from `RemoteModel.live`, so the application tracks no cursor of its own.
|
|
4648
5272
|
*/
|
|
4649
5273
|
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
|
|
@@ -4659,7 +5283,7 @@ const failedReadWords = (error) => {
|
|
|
4659
5283
|
}
|
|
4660
5284
|
};
|
|
4661
5285
|
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
4662
|
-
const bindDomain = (definition, store) => {
|
|
5286
|
+
const bindDomain = (definition, store, options = {}) => {
|
|
4663
5287
|
const bound = bindRemote(definition, store);
|
|
4664
5288
|
const readers = /* @__PURE__ */ new Map();
|
|
4665
5289
|
const keptConnections = /* @__PURE__ */ new Set();
|
|
@@ -4704,11 +5328,248 @@ const bindDomain = (definition, store) => {
|
|
|
4704
5328
|
policy: livePolicyFor(model, live.event)
|
|
4705
5329
|
};
|
|
4706
5330
|
const remote = store.get(model);
|
|
4707
|
-
const
|
|
5331
|
+
const reduced = updateRemote(remote, resolved);
|
|
5332
|
+
const answer = message._tag === "MutationSucceeded" ? message : void 0;
|
|
5333
|
+
const next = answer === void 0 ? placedNews(reduced, newsOf(message), remote.entities) : impactOf(reduced, answer, remote.entities);
|
|
4708
5334
|
return next === remote ? model : store.set(model, next);
|
|
4709
5335
|
};
|
|
5336
|
+
/** The registered query a connection identity names, and its encoded input. */
|
|
5337
|
+
const queryOf = (identity) => {
|
|
5338
|
+
const separator = identity.indexOf("\0");
|
|
5339
|
+
const descriptor = definition.registry.queries.get(identity.slice(0, separator));
|
|
5340
|
+
if (descriptor === void 0) return Option.none();
|
|
5341
|
+
const encoded = JSON.parse(identity.slice(separator + 1));
|
|
5342
|
+
const over = descriptor.body?.entity.name;
|
|
5343
|
+
const filtered = (over === void 0 ? void 0 : definition.registry.filtered.get(over)) ?? [];
|
|
5344
|
+
return Option.some({
|
|
5345
|
+
descriptor,
|
|
5346
|
+
encoded,
|
|
5347
|
+
filtered
|
|
5348
|
+
});
|
|
5349
|
+
};
|
|
5350
|
+
/**
|
|
5351
|
+
* `pages` with each change to a row they hold placed, judged against
|
|
5352
|
+
* `store`: a row that moved goes where the body's order puts it, and one
|
|
5353
|
+
* that stopped matching leaves. A row a change brings in is not added,
|
|
5354
|
+
* since what the list selects of it may not be loaded, and a change the
|
|
5355
|
+
* client cannot place leaves the row where it is.
|
|
5356
|
+
*/
|
|
5357
|
+
const placedHeld = (identity, pages, changes, store) => {
|
|
5358
|
+
const query = queryOf(identity);
|
|
5359
|
+
if (Option.isNone(query)) return pages;
|
|
5360
|
+
const holds = new Set(items(pages).map((edge) => entityKey(edge.ref.entity, edge.ref.id)));
|
|
5361
|
+
let placed = pages;
|
|
5362
|
+
for (const change of changes) {
|
|
5363
|
+
if (!holds.has(entityKey(change.entity, change.id))) continue;
|
|
5364
|
+
placed = Impact.$match(impactOn({
|
|
5365
|
+
...query.value,
|
|
5366
|
+
connection: placed,
|
|
5367
|
+
holds
|
|
5368
|
+
}, store, change), {
|
|
5369
|
+
Kept: () => placed,
|
|
5370
|
+
Placed: ({ connection }) => connection,
|
|
5371
|
+
Invalidated: () => placed
|
|
5372
|
+
});
|
|
5373
|
+
}
|
|
5374
|
+
return placed;
|
|
5375
|
+
};
|
|
5376
|
+
/**
|
|
5377
|
+
* A loaded list with the pending layers placed in its pages, so a row a
|
|
5378
|
+
* pending edit moves, or takes out of the list, shows where its answer
|
|
5379
|
+
* will be placed until the edit settles.
|
|
5380
|
+
*/
|
|
5381
|
+
const pendingPlaced = (remote, identity, pages) => {
|
|
5382
|
+
const { layers } = remote.optimistic;
|
|
5383
|
+
if (layers.length === 0) return pages;
|
|
5384
|
+
return memoRead([
|
|
5385
|
+
pages,
|
|
5386
|
+
layers,
|
|
5387
|
+
remote.entities
|
|
5388
|
+
], `placed:${identity}`, () => placedHeld(identity, pages, changesOf({ entities: layers.flatMap((layer) => layer.patches) }, remote.entities), visibleStoreOf(remote.entities, remote.optimistic)));
|
|
5389
|
+
};
|
|
5390
|
+
/**
|
|
5391
|
+
* The edges a loaded list shows: its pages with pending edits placed, and
|
|
5392
|
+
* each row an insert overlay shows (a pending create, a confirmed or a live
|
|
5393
|
+
* insert) where the body's order puts it, or left out when it sorts past
|
|
5394
|
+
* what is loaded. An overlay says a row is in the list; in a list the
|
|
5395
|
+
* client can place, the order says where. A row it cannot place stays at
|
|
5396
|
+
* the end its overlay names.
|
|
5397
|
+
*/
|
|
5398
|
+
const shownEdges = (remote, identity, connection) => {
|
|
5399
|
+
const pages = pendingPlaced(remote, identity, connection);
|
|
5400
|
+
const shown = remote.optimistic.overlays.some((overlay) => overlay.connection === identity && overlay.position !== "remove") ? insertsPlaced(remote, identity, pages) : {
|
|
5401
|
+
pages,
|
|
5402
|
+
overlays: remote.optimistic.overlays
|
|
5403
|
+
};
|
|
5404
|
+
return visibleItems(shown.pages, identity, shown.overlays, remote.entities);
|
|
5405
|
+
};
|
|
5406
|
+
const insertsPlaced = (remote, identity, pages) => {
|
|
5407
|
+
const { overlays } = remote.optimistic;
|
|
5408
|
+
const store = visibleStoreOf(remote.entities, remote.optimistic);
|
|
5409
|
+
return memoRead([
|
|
5410
|
+
pages,
|
|
5411
|
+
overlays,
|
|
5412
|
+
store
|
|
5413
|
+
], `inserted:${identity}`, () => {
|
|
5414
|
+
const query = queryOf(identity);
|
|
5415
|
+
const body = Option.isSome(query) ? query.value.descriptor.body : void 0;
|
|
5416
|
+
if (Option.isNone(query) || body === void 0) return {
|
|
5417
|
+
pages,
|
|
5418
|
+
overlays
|
|
5419
|
+
};
|
|
5420
|
+
const inserted = /* @__PURE__ */ new Map();
|
|
5421
|
+
for (const overlay of overlays) {
|
|
5422
|
+
if (overlay.connection !== identity || overlay.position === "remove") continue;
|
|
5423
|
+
for (const edge of overlay.edges) inserted.set(edge.key, edge);
|
|
5424
|
+
}
|
|
5425
|
+
const held = new Set(items(pages).map((edge) => edge.key));
|
|
5426
|
+
let placed = pages;
|
|
5427
|
+
const outside = [];
|
|
5428
|
+
for (const edge of inserted.values()) {
|
|
5429
|
+
if (held.has(edge.key)) continue;
|
|
5430
|
+
const key = entityKey(edge.ref.entity, edge.ref.id);
|
|
5431
|
+
RowPlace.$match(placeIn(store, placed, body, query.value.encoded, key), {
|
|
5432
|
+
Before: (place) => void (placed = withEdge(placed, edge, place)),
|
|
5433
|
+
End: (place) => void (placed = withEdge(placed, edge, place)),
|
|
5434
|
+
Outside: () => void outside.push(edge),
|
|
5435
|
+
Unknown: () => void 0
|
|
5436
|
+
});
|
|
5437
|
+
}
|
|
5438
|
+
return {
|
|
5439
|
+
pages: placed,
|
|
5440
|
+
overlays: outside.length === 0 ? overlays : [...overlays, {
|
|
5441
|
+
id: "placed:outside",
|
|
5442
|
+
connection: identity,
|
|
5443
|
+
edges: outside,
|
|
5444
|
+
position: "remove"
|
|
5445
|
+
}]
|
|
5446
|
+
};
|
|
5447
|
+
});
|
|
5448
|
+
};
|
|
5449
|
+
/**
|
|
5450
|
+
* The loaded lists with what the server said of rows they hold placed: a
|
|
5451
|
+
* read or a live patch that changes a held row's ordering or filtered field
|
|
5452
|
+
* moves it, or takes it out, as a mutation's answer does. A list it cannot
|
|
5453
|
+
* place stands as it is, as a read is no write: its own refetch or live
|
|
5454
|
+
* stream says otherwise. Only a value the client held before counts, so a
|
|
5455
|
+
* row's first read judges nothing.
|
|
5456
|
+
*/
|
|
5457
|
+
const placedNews = (remote, news, before) => {
|
|
5458
|
+
if (news.length === 0) return remote;
|
|
5459
|
+
const held = news.map((patch) => {
|
|
5460
|
+
const values = before[entityKey(patch.entity, patch.id)]?.values ?? {};
|
|
5461
|
+
return {
|
|
5462
|
+
...patch,
|
|
5463
|
+
values: Object.fromEntries(Object.entries(patch.values).filter(([field]) => Object.hasOwn(values, field)))
|
|
5464
|
+
};
|
|
5465
|
+
});
|
|
5466
|
+
const changes = changesOf({ entities: held }, before);
|
|
5467
|
+
if (changes.length === 0) return remote;
|
|
5468
|
+
const placed = {};
|
|
5469
|
+
for (const [identity, pages] of Object.entries(remote.connections)) {
|
|
5470
|
+
const after = placedHeld(identity, pages, changes, remote.entities);
|
|
5471
|
+
if (after !== pages) placed[identity] = after;
|
|
5472
|
+
}
|
|
5473
|
+
return withPlacements(remote, placed, []);
|
|
5474
|
+
};
|
|
5475
|
+
/** The rows a read or a live patch says the server holds now. */
|
|
5476
|
+
const newsOf = (message) => {
|
|
5477
|
+
if (message._tag === "ReadReceived") return message.result.entities;
|
|
5478
|
+
if (message._tag !== "LiveReceived") return [];
|
|
5479
|
+
const event = message.event;
|
|
5480
|
+
if (event._tag !== "EntityPatched") return [];
|
|
5481
|
+
return [{
|
|
5482
|
+
entity: event.ref.entity,
|
|
5483
|
+
id: event.ref.id,
|
|
5484
|
+
values: event.values
|
|
5485
|
+
}];
|
|
5486
|
+
};
|
|
5487
|
+
/**
|
|
5488
|
+
* The loaded connections as a write left them: each judged by its body
|
|
5489
|
+
* against the store as the write left it, a change it can place placed in
|
|
5490
|
+
* its pages, and one it cannot invalidated for its read entry to fetch.
|
|
5491
|
+
*/
|
|
5492
|
+
const impactOf = (remote, answer, before) => {
|
|
5493
|
+
const changes = changesOf(answer, before);
|
|
5494
|
+
if (changes.length === 0) return remote;
|
|
5495
|
+
const named = /* @__PURE__ */ new Map();
|
|
5496
|
+
for (const change of answer.connections ?? []) {
|
|
5497
|
+
const keys = named.get(change.connection) ?? /* @__PURE__ */ new Set();
|
|
5498
|
+
keys.add(change.edge.key);
|
|
5499
|
+
named.set(change.connection, keys);
|
|
5500
|
+
}
|
|
5501
|
+
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
5502
|
+
const placed = {};
|
|
5503
|
+
const invalidated = [];
|
|
5504
|
+
for (const identity of Object.keys(remote.connections)) {
|
|
5505
|
+
const answered = named.get(identity);
|
|
5506
|
+
const judged = answered === void 0 ? changes : changes.filter((change) => !answered.has(entityKey(change.entity, change.id)));
|
|
5507
|
+
if (judged.length === 0) continue;
|
|
5508
|
+
const query = queryOf(identity);
|
|
5509
|
+
if (Option.isNone(query)) continue;
|
|
5510
|
+
const original = remote.connections[identity];
|
|
5511
|
+
const holds = new Set(visibleItems(original, identity, remote.optimistic.overlays, remote.entities).map((edge) => entityKey(edge.ref.entity, edge.ref.id)));
|
|
5512
|
+
let pages = original;
|
|
5513
|
+
let invalid = false;
|
|
5514
|
+
for (const change of judged) {
|
|
5515
|
+
const held = {
|
|
5516
|
+
...query.value,
|
|
5517
|
+
connection: pages,
|
|
5518
|
+
holds
|
|
5519
|
+
};
|
|
5520
|
+
const after = Impact.$match(impactOn(held, visible, change), {
|
|
5521
|
+
Kept: () => Option.some(pages),
|
|
5522
|
+
Placed: ({ connection }) => Option.some(connection),
|
|
5523
|
+
Invalidated: () => Option.none()
|
|
5524
|
+
});
|
|
5525
|
+
if (Option.isNone(after)) {
|
|
5526
|
+
invalid = true;
|
|
5527
|
+
break;
|
|
5528
|
+
}
|
|
5529
|
+
pages = after.value;
|
|
5530
|
+
}
|
|
5531
|
+
if (invalid) invalidated.push(identity);
|
|
5532
|
+
else if (pages !== original) placed[identity] = pages;
|
|
5533
|
+
}
|
|
5534
|
+
return withPlacements(remote, placed, invalidated);
|
|
5535
|
+
};
|
|
5536
|
+
/**
|
|
5537
|
+
* The lists placed and invalidated. Under `verifyPlacement`, each placed
|
|
5538
|
+
* list is fetched too, and the order it was placed in kept to compare with
|
|
5539
|
+
* the server's page.
|
|
5540
|
+
*/
|
|
5541
|
+
const withPlacements = (remote, placed, invalidated) => {
|
|
5542
|
+
const identities = Object.keys(placed);
|
|
5543
|
+
if (identities.length === 0) return invalidateConnections(remote, invalidated);
|
|
5544
|
+
const withPlaced = {
|
|
5545
|
+
...remote,
|
|
5546
|
+
connections: {
|
|
5547
|
+
...remote.connections,
|
|
5548
|
+
...placed
|
|
5549
|
+
}
|
|
5550
|
+
};
|
|
5551
|
+
if (options.verifyPlacement !== true) return invalidateConnections(withPlaced, invalidated);
|
|
5552
|
+
const firstPage = (connection) => (connection?.segments[0]?.edges ?? []).map((edge) => edge.key);
|
|
5553
|
+
const orders = Object.fromEntries(identities.map((identity) => {
|
|
5554
|
+
const order = firstPage(placed[identity]);
|
|
5555
|
+
const kept = new Set(order);
|
|
5556
|
+
return [identity, {
|
|
5557
|
+
order,
|
|
5558
|
+
dropped: firstPage(remote.connections[identity]).filter((key) => !kept.has(key))
|
|
5559
|
+
}];
|
|
5560
|
+
}));
|
|
5561
|
+
return invalidateConnections({
|
|
5562
|
+
...withPlaced,
|
|
5563
|
+
placed: {
|
|
5564
|
+
...remote.placed,
|
|
5565
|
+
...orders
|
|
5566
|
+
}
|
|
5567
|
+
}, [...invalidated, ...identities]);
|
|
5568
|
+
};
|
|
4710
5569
|
const assertOwned = (entry) => {
|
|
4711
5570
|
if (bound.contract.owner !== void 0 && entry.owner !== bound.contract.owner) throw new Error(`Remote: Surface "${entry.name}" belongs to another application than domain "${bound.contract.name}"`);
|
|
5571
|
+
const from = entry.from;
|
|
5572
|
+
if (from !== void 0 && bound.contract.owner !== void 0 && from.owner !== bound.contract.owner) throw new Error(`Remote: Surface "${from.name}" belongs to another application than domain "${bound.contract.name}"`);
|
|
4712
5573
|
};
|
|
4713
5574
|
const domain = {
|
|
4714
5575
|
...definition,
|
|
@@ -4738,16 +5599,16 @@ const bindDomain = (definition, store) => {
|
|
|
4738
5599
|
const entries = {};
|
|
4739
5600
|
for (const entry of Object.values(active)) assertOwned(entry);
|
|
4740
5601
|
for (const [key, entry] of Object.entries(active)) {
|
|
4741
|
-
const
|
|
4742
|
-
readers.set(entry,
|
|
4743
|
-
const asked = (model) =>
|
|
5602
|
+
const resolvedAt = readers.get(entry) ?? memoizedResolved(entry);
|
|
5603
|
+
readers.set(entry, resolvedAt);
|
|
5604
|
+
const asked = (model) => askedUnion(resolvedAt(model).map(({ projection }) => projection));
|
|
4744
5605
|
entries[`${key}.read`] = observeEntry(bound, asked, identityMessage, options);
|
|
4745
5606
|
entries[`${key}.live`] = liveEntry(bound, (model) => asked(model).requirements.filter((requirement) => requirement.live === true), identityMessage, options);
|
|
4746
5607
|
}
|
|
4747
5608
|
for (const identity of options.connections ?? []) keptConnections.add(connectionIdentity(identity));
|
|
4748
5609
|
entries.retain = {
|
|
4749
5610
|
dependenciesSchema: retentionRootsSchema,
|
|
4750
|
-
modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((
|
|
5611
|
+
modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((resolvedAt) => resolvedAt(model).map(({ projection }) => projection)), { connections: [...keptConnections] }),
|
|
4751
5612
|
dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed({
|
|
4752
5613
|
_tag: "RetentionChanged",
|
|
4753
5614
|
roots: current
|
|
@@ -4788,22 +5649,19 @@ const bindDomain = (definition, store) => {
|
|
|
4788
5649
|
const entries = Object.values(active);
|
|
4789
5650
|
entries.forEach(assertOwned);
|
|
4790
5651
|
const planOptions = RemotePolicy.toPlan(RemotePolicy.cacheFirst, now());
|
|
4791
|
-
const plans = (current,
|
|
4792
|
-
const planned = planAsked(store.get(current),
|
|
5652
|
+
const plans = (current, projections) => {
|
|
5653
|
+
const planned = planAsked(store.get(current), askedUnion(projections), planOptions);
|
|
4793
5654
|
return planned.queries.length > 0 || planned.requirements.length > 0;
|
|
4794
5655
|
};
|
|
4795
5656
|
let current = model;
|
|
4796
5657
|
for (let pass = 0; pass < passes; pass++) {
|
|
4797
|
-
if (!entries.some((entry) => plans(current, entry))) return current;
|
|
4798
|
-
for (const entry of entries) {
|
|
4799
|
-
const projection = projectionOf(entry, current);
|
|
4800
|
-
if (Option.isSome(projection)) current = yield* domain.prefetch(current, projection.value, { now });
|
|
4801
|
-
}
|
|
5658
|
+
if (!entries.some((entry) => plans(current, resolvedOf(entry, current).map(({ projection }) => projection)))) return current;
|
|
5659
|
+
for (const entry of entries) for (const { projection } of resolvedOf(entry, current)) current = yield* domain.prefetch(current, projection, { now });
|
|
4802
5660
|
}
|
|
4803
|
-
const reading = entries.
|
|
5661
|
+
const reading = entries.flatMap((entry) => resolvedOf(entry, current).filter(({ projection }) => plans(current, [projection])));
|
|
4804
5662
|
if (reading.length === 0) return current;
|
|
4805
5663
|
return yield* new RemoteUnsatisfied({
|
|
4806
|
-
surfaces: reading.map((
|
|
5664
|
+
surfaces: reading.map(({ name }) => name),
|
|
4807
5665
|
passes
|
|
4808
5666
|
});
|
|
4809
5667
|
}),
|
|
@@ -4815,10 +5673,10 @@ const bindDomain = (definition, store) => {
|
|
|
4815
5673
|
const listed = query.Result.entity;
|
|
4816
5674
|
if (listed === void 0) throw new Error(`Remote: query "${query.name}" is not a connection over an entity, so it has no page to select`);
|
|
4817
5675
|
if (select.entity !== listed) throw new Error(`Remote: the selection is of "${select.entity}", but query "${query.name}" lists "${listed}"`);
|
|
4818
|
-
const { ref, relation, requirement } = readContract(query.ref(input), select, window);
|
|
5676
|
+
const { ref, relation, requirement } = placeableReadContract(readContract(query.ref(input), select, window), query.body);
|
|
4819
5677
|
const relationKey = stableStringify(relation);
|
|
4820
5678
|
const sizeOf = (remote) => windowSize(remote, ref);
|
|
4821
|
-
const shown = (remote, connection) => cutToWindow(
|
|
5679
|
+
const shown = (remote, connection) => cutToWindow(shownEdges(remote, ref.identity, connection), ref.window, sizeOf(remote));
|
|
4822
5680
|
const failedItem = (remote, connection) => {
|
|
4823
5681
|
if (Object.keys(remote.failures.fields).length === 0) return void 0;
|
|
4824
5682
|
for (const edge of shown(remote, connection).edges) {
|
|
@@ -4936,8 +5794,13 @@ const bindDomain = (definition, store) => {
|
|
|
4936
5794
|
},
|
|
4937
5795
|
explain: (model, projection, options) => {
|
|
4938
5796
|
const { ref } = projection;
|
|
4939
|
-
const reading = Object.values(options?.surfaces ?? {}).
|
|
4940
|
-
|
|
5797
|
+
const reading = Object.values(options?.surfaces ?? {}).flatMap((source) => {
|
|
5798
|
+
const matched = resolvedOf(source, model).filter(({ projection: shown }) => connectionsOf(shown).some((connection) => connection.identity === ref.identity));
|
|
5799
|
+
if (matched.length === 0) return [];
|
|
5800
|
+
return [{
|
|
5801
|
+
source,
|
|
5802
|
+
names: matched.map(({ name }) => name)
|
|
5803
|
+
}];
|
|
4941
5804
|
});
|
|
4942
5805
|
const body = definition.registry.queries.get(ref.query)?.body;
|
|
4943
5806
|
const [connection] = RemoteConnections.get(projection.metadata);
|
|
@@ -4954,20 +5817,21 @@ const bindDomain = (definition, store) => {
|
|
|
4954
5817
|
},
|
|
4955
5818
|
state: projection.read(model)._tag,
|
|
4956
5819
|
...options?.surfaces === void 0 ? {} : {
|
|
4957
|
-
surfaces: reading.
|
|
4958
|
-
activation: reading.flatMap((
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
5820
|
+
surfaces: reading.flatMap((read) => read.names),
|
|
5821
|
+
activation: reading.flatMap((read) => {
|
|
5822
|
+
const activation = "activation" in read.source ? read.source.activation : void 0;
|
|
5823
|
+
return activation === void 0 ? [] : [{
|
|
5824
|
+
surface: read.source.name,
|
|
5825
|
+
...activation
|
|
5826
|
+
}];
|
|
5827
|
+
})
|
|
4962
5828
|
}
|
|
4963
5829
|
};
|
|
4964
5830
|
},
|
|
4965
5831
|
why: (model, projection, options) => {
|
|
4966
5832
|
const state = projection.read(model);
|
|
4967
5833
|
const asked = askedOf(projection);
|
|
4968
|
-
const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).filter((
|
|
4969
|
-
return Option.exists(active.projectionOf(model), (shown) => covers(askedOf(shown), asked));
|
|
4970
|
-
}).map((active) => active.name);
|
|
5834
|
+
const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).flatMap((source) => resolvedOf(source, model).filter(({ projection: shown }) => covers(askedOf(shown), asked)).map(({ name }) => name));
|
|
4971
5835
|
const withSurfaces = reading === void 0 ? {} : { surfaces: reading };
|
|
4972
5836
|
switch (state._tag) {
|
|
4973
5837
|
case "Ready": return {
|
|
@@ -5021,8 +5885,17 @@ const bindDomain = (definition, store) => {
|
|
|
5021
5885
|
const remote = store.get(model);
|
|
5022
5886
|
const connection = remote.connections[over.ref.identity];
|
|
5023
5887
|
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
5024
|
-
const among = (connection === void 0 ? [] :
|
|
5025
|
-
|
|
5888
|
+
const among = (connection === void 0 ? [] : shownEdges(remote, over.ref.identity, connection)).map((edge) => entityKey(edge.ref.entity, edge.ref.id));
|
|
5889
|
+
let judged;
|
|
5890
|
+
try {
|
|
5891
|
+
judged = matching(visible, by, input, { among });
|
|
5892
|
+
} catch (error) {
|
|
5893
|
+
if (error instanceof QueryEvaluateError) return {
|
|
5894
|
+
items: [],
|
|
5895
|
+
complete: false
|
|
5896
|
+
};
|
|
5897
|
+
throw error;
|
|
5898
|
+
}
|
|
5026
5899
|
const relation = relationOf(over.selection);
|
|
5027
5900
|
const items = [];
|
|
5028
5901
|
let assembledAll = true;
|
|
@@ -5055,6 +5928,11 @@ const bindDomain = (definition, store) => {
|
|
|
5055
5928
|
_tag: "OverlayLifted",
|
|
5056
5929
|
id
|
|
5057
5930
|
}),
|
|
5931
|
+
overlays: (model) => overlaysOf(store.get(model)),
|
|
5932
|
+
visibleItems: (remote, connection) => {
|
|
5933
|
+
const identity = connectionIdentity(connection);
|
|
5934
|
+
return shownEdges(remote, identity, remote.connections[identity] ?? emptyConnection);
|
|
5935
|
+
},
|
|
5058
5936
|
mutate: (model, mutation, input, options = {}) => {
|
|
5059
5937
|
assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
|
|
5060
5938
|
const remote = store.get(model);
|
|
@@ -5063,7 +5941,7 @@ const bindDomain = (definition, store) => {
|
|
|
5063
5941
|
const optimistic = typeof options.optimistic === "function" ? options.optimistic({
|
|
5064
5942
|
requestId,
|
|
5065
5943
|
tempId
|
|
5066
|
-
}) : options.optimistic;
|
|
5944
|
+
}) : options.optimistic ?? (mutation.write === void 0 ? void 0 : optimisticOfWrite(mutation.write, input, options.keys));
|
|
5067
5945
|
const started = updateRemote(remote, {
|
|
5068
5946
|
_tag: "MutationStarted",
|
|
5069
5947
|
requestId,
|
|
@@ -5076,7 +5954,7 @@ const bindDomain = (definition, store) => {
|
|
|
5076
5954
|
command: {
|
|
5077
5955
|
name: `Remote.mutate(${mutation.name})`,
|
|
5078
5956
|
args: { requestId },
|
|
5079
|
-
effect: mutateRemote(mutation, input, requestId).pipe(Effect.match({
|
|
5957
|
+
effect: mutateRemote(mutation, input, requestId, options.keys).pipe(Effect.match({
|
|
5080
5958
|
onFailure: (error) => ({
|
|
5081
5959
|
_tag: "MutationFailed",
|
|
5082
5960
|
requestId,
|
|
@@ -5095,8 +5973,108 @@ const bindDomain = (definition, store) => {
|
|
|
5095
5973
|
};
|
|
5096
5974
|
},
|
|
5097
5975
|
mutation: (model, requestId) => mutationStatus(store.get(model).mutations, requestId),
|
|
5976
|
+
refusal: (model, requestId, mutation) => {
|
|
5977
|
+
const status = mutationStatus(store.get(model).mutations, requestId);
|
|
5978
|
+
if (status._tag !== "Failed") return Option.none();
|
|
5979
|
+
const { refusal } = status.error;
|
|
5980
|
+
return refusal === void 0 ? Option.none() : Schema.decodeUnknownOption(mutation.Refusal)(refusal);
|
|
5981
|
+
},
|
|
5982
|
+
liveStatus: (model, active) => {
|
|
5983
|
+
assertOwned(active);
|
|
5984
|
+
const resolvedAt = readers.get(active) ?? memoizedResolved(active);
|
|
5985
|
+
const requirements = askedUnion(resolvedAt(model).map(({ projection }) => projection)).requirements.filter((requirement) => requirement.live === true);
|
|
5986
|
+
if (requirements.length === 0) return { _tag: "Idle" };
|
|
5987
|
+
const remote = store.get(model);
|
|
5988
|
+
const stream = liveStreamKey(requirements);
|
|
5989
|
+
const health = remote.streams[stream];
|
|
5990
|
+
return health === void 0 || !remote.gaps.has(stream) ? { _tag: "Live" } : {
|
|
5991
|
+
_tag: "Reconnecting",
|
|
5992
|
+
attempt: health.failures,
|
|
5993
|
+
error: health.error
|
|
5994
|
+
};
|
|
5995
|
+
},
|
|
5098
5996
|
reduce,
|
|
5099
5997
|
inspect: (model) => inspectRemote(store.get(model)),
|
|
5998
|
+
persistence: (options) => {
|
|
5999
|
+
const connections = (options.connections ?? []).map(connectionIdentity);
|
|
6000
|
+
const snapshotOf = options.snapshot ?? ((remote) => RemotePersistence.snapshotOf(remote, { connections }));
|
|
6001
|
+
const debounce = options.debounce ?? "250 millis";
|
|
6002
|
+
let last = {
|
|
6003
|
+
entities: emptyStore,
|
|
6004
|
+
connections: {},
|
|
6005
|
+
scope: "",
|
|
6006
|
+
text: Option.none()
|
|
6007
|
+
};
|
|
6008
|
+
const textOf = (remote, scope) => {
|
|
6009
|
+
if (last.entities !== remote.entities || last.connections !== remote.connections || last.scope !== scope) last = {
|
|
6010
|
+
entities: remote.entities,
|
|
6011
|
+
connections: remote.connections,
|
|
6012
|
+
scope,
|
|
6013
|
+
text: Option.fromUndefinedOr(RemotePersistence.dehydrate(snapshotOf(remote), {
|
|
6014
|
+
scope,
|
|
6015
|
+
maxBytes: options.maxBytes
|
|
6016
|
+
}))
|
|
6017
|
+
};
|
|
6018
|
+
return last.text;
|
|
6019
|
+
};
|
|
6020
|
+
const restore = {
|
|
6021
|
+
dependenciesSchema: Schema.Struct({
|
|
6022
|
+
key: Schema.String,
|
|
6023
|
+
scope: Schema.String
|
|
6024
|
+
}),
|
|
6025
|
+
modelToDependencies: (model) => ({
|
|
6026
|
+
key: options.key(model),
|
|
6027
|
+
scope: options.scope(model)
|
|
6028
|
+
}),
|
|
6029
|
+
dependenciesToStream: ({ key, scope }) => Stream.fromEffect(RemotePersistence.restore({
|
|
6030
|
+
key,
|
|
6031
|
+
scope,
|
|
6032
|
+
maxBytes: options.maxBytes
|
|
6033
|
+
}).pipe(Effect.option)).pipe(Stream.flatMap((read) => Option.match(read, {
|
|
6034
|
+
onNone: () => Stream.empty,
|
|
6035
|
+
onSome: (snapshot) => Stream.make({
|
|
6036
|
+
_tag: "Hydrated",
|
|
6037
|
+
entities: snapshot.entities,
|
|
6038
|
+
connections: snapshot.connections,
|
|
6039
|
+
merge: "preserve-existing",
|
|
6040
|
+
from: key
|
|
6041
|
+
})
|
|
6042
|
+
})))
|
|
6043
|
+
};
|
|
6044
|
+
const save = {
|
|
6045
|
+
dependenciesSchema: Schema.Struct({
|
|
6046
|
+
key: Schema.String,
|
|
6047
|
+
restored: Schema.Boolean,
|
|
6048
|
+
text: Schema.OptionFromNullOr(Schema.String)
|
|
6049
|
+
}),
|
|
6050
|
+
modelToDependencies: (model) => {
|
|
6051
|
+
const remote = store.get(model);
|
|
6052
|
+
const key = options.key(model);
|
|
6053
|
+
const restored = Option.contains(remote.restoredFrom, key);
|
|
6054
|
+
return {
|
|
6055
|
+
key,
|
|
6056
|
+
restored,
|
|
6057
|
+
text: restored ? textOf(remote, options.scope(model)) : Option.none()
|
|
6058
|
+
};
|
|
6059
|
+
},
|
|
6060
|
+
dependenciesToStream: ({ key, restored, text }) => restored ? Stream.fromEffect(Effect.gen(function* () {
|
|
6061
|
+
yield* Effect.sleep(debounce);
|
|
6062
|
+
const kv = yield* KeyValueStore.KeyValueStore;
|
|
6063
|
+
yield* Option.match(text, {
|
|
6064
|
+
onNone: () => kv.remove(key),
|
|
6065
|
+
onSome: (written) => kv.set(key, written)
|
|
6066
|
+
});
|
|
6067
|
+
}).pipe(Effect.ignore)).pipe(Stream.drain) : Stream.empty
|
|
6068
|
+
};
|
|
6069
|
+
return {
|
|
6070
|
+
key: `persistence:${bound.contract.name}`,
|
|
6071
|
+
handles: [],
|
|
6072
|
+
subscriptions: Subscription.make()(() => ({
|
|
6073
|
+
"persistence.restore": restore,
|
|
6074
|
+
"persistence.save": save
|
|
6075
|
+
}))
|
|
6076
|
+
};
|
|
6077
|
+
},
|
|
5100
6078
|
wiring: (active, options) => ({
|
|
5101
6079
|
key: `remote:${bound.contract.name}`,
|
|
5102
6080
|
handles: Object.keys(remoteMessageCases),
|
|
@@ -5143,6 +6121,6 @@ const pickWindow = (window) => ({
|
|
|
5143
6121
|
...window.before === void 0 ? {} : { before: window.before }
|
|
5144
6122
|
});
|
|
5145
6123
|
//#endregion
|
|
5146
|
-
export {
|
|
6124
|
+
export { pruneOverlays as $, relationAlias as $n, QueryRpc as $t, writeRead as A, emptyStore as An, coalescedLayer as At, windowKey as B, remove as Bn, LiveChange as Bt, remoteMessageCases as C, failMutation as Cn, isGapped as Ct, updateRemote as D, reconcileMutation as Dn, terminal as Dt, retentionRootsSchema as E, patchOfWrite as En, segment as Et, RemotePersistence as F, isFieldUnavailable as Fn, coalesceQueries as Ft, liveHasNext as G, writeEntity as Gn, Mutate as Gt, applyEntityEvent as H, setUnavailable as Hn, LiveRequirement as Ht, gc as I, isTombstone as In, coalesceReads as It, addLayer as J, RelationEntityAnnotation as Jn, NormalizedEntity as Jt, liveHasPrevious as K, RELATION_ALIAS as Kn, MutationRequest as Kt, reachable as L, markStale as Ln, requirementKey as Lt, remoteDataSchema as M, entry as Mn, liveEventOf as Mt, remoteErrorSchema as N, hasField as Nn, mutateRemote as Nt, windowGrowthKey as O, schemaOf as On, unknown as Ot, REMOTE_CACHE_VERSION as P, isFieldStale as Pn, remoteError as Pt, emptyOptimistic as Q, refsIn as Qn, QueryResult as Qt, deadlineOf as R, missingFields as Rn, ConnectionChangeSchema as Rt, refreshedAt as S, emptyMutationState as Sn, hasPrevious as St, remoteModelSchema as T, optimisticOfWrite as Tn, merge as Tt, classifyLive as U, tombstone as Un, MAX_FIELDS_PER_REQUEST as Ut, applyConnectionEvent as V, setStale as Vn, LiveEdge as Vt, emptyLiveState as W, writeEntities as Wn, MAX_RELATION_DEPTH as Wt, beginOptimistic as X, isRefPage as Xn, QueryEdge as Xt, addOverlay as Y, aliasedField as Yn, PageSize as Yt, connectionIdentity as Z, refParts as Zn, QueryRequest as Zt, isRemoteMessage as _, Query$1 as _n, unavailableOf as _t, RemoteJsonAnswer as a, RelationRequest as an, connectionsOf as ar, visibleStore as at, overlaysOf as b, Refusal as bn, emptyConnection as bt, emptyRefresh as c, RemoteMutationError as cn, belongsEncoded as ct, initialRemoteModel as d, RemoteReadError as dn, placeIn as dt, REMOTE_PROTOCOL_VERSION as en, relationShape as er, removeLayer as et, invalidateConnections as f, RemoteRpc as fn, Entity$1 as ft, isQueryLoading as g, IDENTITY_SEPARATOR as gn, relationOf as gt, isLoadingThrough as h, WireBoundary as hn, pageSchema as ht, liveFetch as i, ReadRequest as in, Requirement as ir, visibleItems as it, RemoteData as j, entityKey as jn, connectionChangeOf as jt, withRefreshRequested as k, clearStale as kn, RemoteClient as kt, failureOf as l, RemoteProtocolError as ln, matching as lt, isLoading as m, WindowSchema as mn, assemble as mt, RemoteUnsatisfied as n, ReadBatch as nn, RemoteConnections as nr, settleFailure as nt, RemoteJsonRequest as o, RemoteForbidden as on, requirementsOf as or, inspectEntity as ot, isFieldFailed as p, SettledFields as pn, Selection as pt, ConnectionChange as q, RelationAnnotation as qn, MutationResult as qt, httpWithLive as r, ReadBatchResult as rn, RemoteRequirements as rr, settleSuccess as rt, RemotePolicy as s, RemoteLiveError as sn, inspectRemote as st, Remote as t, Read as tn, targetsOf as tr, removeOverlay as tt, forgetRemote as u, RemoteQueryError as un, RowPlace as ut, noFailures as v, stableStringify as vn, cursor as vt, remoteMessageSchema as w, mutationStatus as wn, items as wt, refreshIsInFlight as x, beginMutation as xn, hasNext as xt, onlyOverlaid as y, Mutation as yn, edge as yt, plan as z, readField as zn, Live as zt };
|
|
5147
6125
|
|
|
5148
|
-
//# sourceMappingURL=src-
|
|
6126
|
+
//# sourceMappingURL=src-CcPPtNuH.mjs.map
|