foldkit-remote 0.12.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 +265 -12
- package/dist/{index-njA5CxbS.d.mts → index-CXAGo7Dz.d.mts} +484 -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-9NIJGDTJ.mjs → src-CcPPtNuH.mjs} +1121 -131
- package/dist/src-CcPPtNuH.mjs.map +1 -0
- package/package.json +4 -4
- package/dist/index-njA5CxbS.d.mts.map +0 -1
- package/dist/src-9NIJGDTJ.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();
|
|
@@ -2403,6 +2726,18 @@ const RemoteData = {
|
|
|
2403
2726
|
* be embedded in a hand-written Model schema, not only through `Remote.select`.
|
|
2404
2727
|
*/
|
|
2405
2728
|
schema: (value) => remoteDataSchema(value),
|
|
2729
|
+
/**
|
|
2730
|
+
* Whether the read has an answer: `Ready`, `Refreshing`, `Failed` or
|
|
2731
|
+
* `NotFound`. `Initial` and `Loading` are still waiting for one.
|
|
2732
|
+
*/
|
|
2733
|
+
answered: (data) => RemoteData.match(data, {
|
|
2734
|
+
Initial: () => false,
|
|
2735
|
+
Loading: () => false,
|
|
2736
|
+
Ready: () => true,
|
|
2737
|
+
Refreshing: () => true,
|
|
2738
|
+
Failed: () => true,
|
|
2739
|
+
NotFound: () => true
|
|
2740
|
+
}),
|
|
2406
2741
|
/** Exhaustive: omitting a state is a compile error. */
|
|
2407
2742
|
match: (data, cases) => {
|
|
2408
2743
|
switch (data._tag) {
|
|
@@ -2524,9 +2859,13 @@ const initialRemoteModel = {
|
|
|
2524
2859
|
live: {},
|
|
2525
2860
|
mutations: emptyMutationState,
|
|
2526
2861
|
gaps: /* @__PURE__ */ new Set(),
|
|
2862
|
+
streams: {},
|
|
2527
2863
|
loading: /* @__PURE__ */ new Set(),
|
|
2528
2864
|
refresh: emptyRefresh,
|
|
2529
|
-
failures: noFailures
|
|
2865
|
+
failures: noFailures,
|
|
2866
|
+
restoredFrom: Option.none(),
|
|
2867
|
+
placed: {},
|
|
2868
|
+
placementMismatches: []
|
|
2530
2869
|
};
|
|
2531
2870
|
/** The entity store and the mutation ledger are runtime values, not wire shapes. */
|
|
2532
2871
|
const runtimeSchema = Schema.Unknown;
|
|
@@ -2538,12 +2877,16 @@ const remoteModelSchema = () => Schema.Struct({
|
|
|
2538
2877
|
live: Schema.Record(Schema.String, runtimeSchema),
|
|
2539
2878
|
mutations: runtimeSchema,
|
|
2540
2879
|
gaps: runtimeSchema,
|
|
2880
|
+
streams: Schema.Record(Schema.String, runtimeSchema),
|
|
2541
2881
|
loading: runtimeSchema,
|
|
2542
2882
|
refresh: runtimeSchema,
|
|
2543
2883
|
failures: Schema.Struct({
|
|
2544
2884
|
connections: Schema.Record(Schema.String, remoteErrorSchema),
|
|
2545
2885
|
fields: Schema.Record(Schema.String, remoteErrorSchema)
|
|
2546
|
-
})
|
|
2886
|
+
}),
|
|
2887
|
+
restoredFrom: runtimeSchema,
|
|
2888
|
+
placed: runtimeSchema,
|
|
2889
|
+
placementMismatches: runtimeSchema
|
|
2547
2890
|
});
|
|
2548
2891
|
const retentionRootsSchema = Schema.Struct({
|
|
2549
2892
|
requirements: Schema.Array(ReadRequest),
|
|
@@ -2576,6 +2919,7 @@ const remoteMessageCases = {
|
|
|
2576
2919
|
RetentionChanged: { roots: retentionRootsSchema },
|
|
2577
2920
|
Hydrated: {
|
|
2578
2921
|
connections: Schema.optional(Schema.Unknown),
|
|
2922
|
+
from: Schema.optional(Schema.String),
|
|
2579
2923
|
entities: runtimeSchema,
|
|
2580
2924
|
merge: Schema.Union([Schema.Literal("replace"), Schema.Literal("preserve-existing")])
|
|
2581
2925
|
},
|
|
@@ -2639,7 +2983,10 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
|
|
|
2639
2983
|
...fields
|
|
2640
2984
|
})));
|
|
2641
2985
|
/** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
|
|
2642
|
-
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)] : []))];
|
|
2643
2990
|
/** The key a grown window is kept under: its connection and the window first asked for. */
|
|
2644
2991
|
const windowGrowthKey = (identity, window) => `${identity}\u0000${window}`;
|
|
2645
2992
|
/**
|
|
@@ -2760,6 +3107,22 @@ const withRefreshRequested = (refresh, requests, connections = []) => {
|
|
|
2760
3107
|
};
|
|
2761
3108
|
};
|
|
2762
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
|
+
/**
|
|
2763
3126
|
* Records that a read of `requests` began under `generation`. A read that
|
|
2764
3127
|
* carries no generation began under none, and leaves the marks as they are.
|
|
2765
3128
|
*/
|
|
@@ -2810,9 +3173,49 @@ const withLiveState = (live, stream, state) => live[stream] === state ? live : {
|
|
|
2810
3173
|
...live,
|
|
2811
3174
|
[stream]: state
|
|
2812
3175
|
};
|
|
2813
|
-
const
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
+
};
|
|
2816
3219
|
};
|
|
2817
3220
|
const clearGap = (model, stream) => model.gaps.has(stream) ? {
|
|
2818
3221
|
...model,
|
|
@@ -2820,8 +3223,8 @@ const clearGap = (model, stream) => model.gaps.has(stream) ? {
|
|
|
2820
3223
|
} : model;
|
|
2821
3224
|
/**
|
|
2822
3225
|
* The pure reducer all four producers share. A live event that arrives ahead of
|
|
2823
|
-
* its cursor is a gap: it is not applied, and the stream is recorded so
|
|
2824
|
-
*
|
|
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
|
|
2825
3228
|
* touches keeps its identity, `model` itself is returned, so the application's
|
|
2826
3229
|
* root keeps its identity and Foldkit does not render. The common no-ops do (a
|
|
2827
3230
|
* duplicate live event, a repeated `ReadStarted`); a repeated failure, or a
|
|
@@ -2837,6 +3240,32 @@ const sameFields = (model, next) => {
|
|
|
2837
3240
|
const keys = Object.keys(next);
|
|
2838
3241
|
return keys.length === Object.keys(model).length && keys.every((key) => Object.is(model[key], next[key]));
|
|
2839
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
|
+
};
|
|
2840
3269
|
const reduceRemote = (model, message) => {
|
|
2841
3270
|
switch (message._tag) {
|
|
2842
3271
|
case "ReadReceived": return {
|
|
@@ -2846,7 +3275,7 @@ const reduceRemote = (model, message) => {
|
|
|
2846
3275
|
failures: withoutFieldFailures(model.failures, [...fieldMarks(message.requests), ...patchedMarks(message.result.entities)])
|
|
2847
3276
|
};
|
|
2848
3277
|
case "ReadFailed":
|
|
2849
|
-
if (message.stream !== void 0) return
|
|
3278
|
+
if (message.stream !== void 0) return broke(model, message.stream, Option.some(message.error));
|
|
2850
3279
|
return {
|
|
2851
3280
|
...model,
|
|
2852
3281
|
entities: setStale(model.entities, marksOf(message.requests), false),
|
|
@@ -2886,7 +3315,8 @@ const reduceRemote = (model, message) => {
|
|
|
2886
3315
|
} : {
|
|
2887
3316
|
...Object.fromEntries(restored),
|
|
2888
3317
|
...model.connections
|
|
2889
|
-
}
|
|
3318
|
+
},
|
|
3319
|
+
restoredFrom: message.from === void 0 ? model.restoredFrom : Option.some(message.from)
|
|
2890
3320
|
};
|
|
2891
3321
|
}
|
|
2892
3322
|
case "RefreshStarted": {
|
|
@@ -2956,29 +3386,28 @@ const reduceRemote = (model, message) => {
|
|
|
2956
3386
|
if (message.event._tag === "EntityPatched" || message.event._tag === "EntityDeleted") {
|
|
2957
3387
|
const event = message.event;
|
|
2958
3388
|
const applied = applyEntityEvent(state, model.entities, event, message.now);
|
|
2959
|
-
if (applied.outcome === "gap") return
|
|
3389
|
+
if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
|
|
2960
3390
|
const settled = applied.outcome !== "applied" ? [] : event._tag === "EntityPatched" ? [...patchedMarks([{
|
|
2961
3391
|
...event.ref,
|
|
2962
3392
|
values: event.values
|
|
2963
3393
|
}])] : entityFailureMarks(model.failures, entityKey(event.ref.entity, event.ref.id));
|
|
2964
|
-
|
|
3394
|
+
const next = {
|
|
2965
3395
|
...model,
|
|
2966
3396
|
entities: applied.store,
|
|
2967
3397
|
live: withLiveState(model.live, message.stream, applied.state),
|
|
2968
3398
|
failures: withoutFieldFailures(model.failures, settled)
|
|
2969
|
-
}
|
|
3399
|
+
};
|
|
3400
|
+
return applied.outcome === "applied" ? healthy(next, message.stream) : next;
|
|
2970
3401
|
}
|
|
2971
3402
|
const applied = applyConnectionEvent(state, model.optimistic, message.event, message.policy);
|
|
2972
|
-
if (applied.outcome === "gap") return
|
|
2973
|
-
const
|
|
3403
|
+
if (applied.outcome === "gap") return broke(model, message.stream, Option.none());
|
|
3404
|
+
const written = {
|
|
2974
3405
|
...model,
|
|
2975
3406
|
optimistic: applied.optimistic,
|
|
2976
3407
|
live: withLiveState(model.live, message.stream, applied.state)
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
connection: applied.invalidated
|
|
2981
|
-
});
|
|
3408
|
+
};
|
|
3409
|
+
const next = applied.outcome === "applied" ? healthy(written, message.stream) : written;
|
|
3410
|
+
return applied.invalidated === void 0 ? next : invalidateConnections(next, [applied.invalidated]);
|
|
2982
3411
|
}
|
|
2983
3412
|
case "GapCleared": return clearGap(model, message.stream);
|
|
2984
3413
|
case "ConnectionMerged": {
|
|
@@ -2999,6 +3428,7 @@ const reduceRemote = (model, message) => {
|
|
|
2999
3428
|
const failures = withoutFieldFailures(withoutConnectionFailure(model.failures, message.connection), [...fieldMarks(requests), ...patchedMarks(payload)]);
|
|
3000
3429
|
return {
|
|
3001
3430
|
...model,
|
|
3431
|
+
...checkedPlacement(model, message.connection, message.page, message.refreshes === true),
|
|
3002
3432
|
entities,
|
|
3003
3433
|
connections: {
|
|
3004
3434
|
...model.connections,
|
|
@@ -3529,29 +3959,46 @@ const RemoteJsonRequest = Schema.Struct({
|
|
|
3529
3959
|
payload: Schema.Unknown
|
|
3530
3960
|
});
|
|
3531
3961
|
/** An answer: the call's result, or why there is none. */
|
|
3532
|
-
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
|
+
})]);
|
|
3533
3968
|
const decodeAnswer = Schema.decodeUnknownEffect(RemoteJsonAnswer);
|
|
3534
3969
|
const call = (send, operation, payload, fail) => Effect.tryPromise({
|
|
3535
3970
|
try: () => send(JSON.stringify({
|
|
3536
3971
|
operation,
|
|
3537
3972
|
payload
|
|
3538
3973
|
})),
|
|
3539
|
-
catch: (error) => fail(error instanceof Error ? error.message : String(error))
|
|
3540
|
-
}).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)));
|
|
3541
3976
|
/** Remote's client over `send`, for `Remote.clientLayer`. */
|
|
3542
3977
|
const json = (send) => ({
|
|
3543
|
-
FoldkitRemoteRead: (payload) => call(send, "read", payload, (
|
|
3544
|
-
FoldkitRemoteQuery: (payload) => call(send, "query", payload, (
|
|
3545
|
-
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
|
+
})),
|
|
3546
3987
|
FoldkitRemoteLive: () => Stream.empty
|
|
3547
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");
|
|
3548
3991
|
/**
|
|
3549
3992
|
* Remote's client over HTTP: each call a `POST` of the request to `url`, the
|
|
3550
|
-
* answer its JSON body whatever its status.
|
|
3551
|
-
*
|
|
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.
|
|
3552
3999
|
*/
|
|
3553
4000
|
const http = (url, options = {}) => json(async (request) => {
|
|
3554
|
-
const response = await fetch(url, {
|
|
4001
|
+
const response = await (options.fetch ?? fetch)(url, {
|
|
3555
4002
|
method: "POST",
|
|
3556
4003
|
headers: {
|
|
3557
4004
|
"content-type": "application/json",
|
|
@@ -3559,9 +4006,120 @@ const http = (url, options = {}) => json(async (request) => {
|
|
|
3559
4006
|
},
|
|
3560
4007
|
body: request
|
|
3561
4008
|
});
|
|
3562
|
-
|
|
3563
|
-
|
|
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
|
|
3564
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)
|
|
3565
4123
|
});
|
|
3566
4124
|
//#endregion
|
|
3567
4125
|
//#region src/index.ts
|
|
@@ -3600,6 +4158,30 @@ const readContract = (source, shape, window) => {
|
|
|
3600
4158
|
}
|
|
3601
4159
|
};
|
|
3602
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
|
+
};
|
|
3603
4185
|
const metaOf = (remote, asked) => {
|
|
3604
4186
|
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
3605
4187
|
let updatedAt;
|
|
@@ -3781,7 +4363,8 @@ const defineRemote = (config) => {
|
|
|
3781
4363
|
registry: {
|
|
3782
4364
|
entities: new Map(entities.map((entity) => [entity.name, entity])),
|
|
3783
4365
|
queries: new Map((config.queries ?? []).map((query) => [query.name, query])),
|
|
3784
|
-
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)]] : []))
|
|
3785
4368
|
}
|
|
3786
4369
|
};
|
|
3787
4370
|
};
|
|
@@ -3803,19 +4386,10 @@ const bindRemote = (definition, store) => ({
|
|
|
3803
4386
|
const assertRegistered = (bound, kind, registered, name) => {
|
|
3804
4387
|
if (!registered.has(name)) throw new Error(`Remote: ${kind} "${name}" is not registered with domain "${bound.contract.name}"`);
|
|
3805
4388
|
};
|
|
3806
|
-
const nothingAsked = {
|
|
3807
|
-
requirements: [],
|
|
3808
|
-
connections: []
|
|
3809
|
-
};
|
|
3810
4389
|
const askedOf = (projection) => ({
|
|
3811
4390
|
requirements: requirementsOf(projection),
|
|
3812
4391
|
connections: connectionsOf(projection)
|
|
3813
4392
|
});
|
|
3814
|
-
/** What an active Surface asks for: nothing while it is inactive. */
|
|
3815
|
-
const askedWhile = (projection) => Option.match(projection, {
|
|
3816
|
-
onNone: () => nothingAsked,
|
|
3817
|
-
onSome: askedOf
|
|
3818
|
-
});
|
|
3819
4393
|
/**
|
|
3820
4394
|
* Whether `outer` asks for everything `inner` does of one entity: each field,
|
|
3821
4395
|
* each relation's own slice of its target, and each page window alike.
|
|
@@ -4162,58 +4736,121 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
|
|
|
4162
4736
|
};
|
|
4163
4737
|
};
|
|
4164
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
|
+
};
|
|
4165
4746
|
/** The live entry: subscribes to `requirementsOf(model)` from the Model's resume cursor. */
|
|
4166
4747
|
const liveEntry = (bound, requirementsOf, toMessage, options) => ({
|
|
4167
4748
|
dependenciesSchema: Schema.Struct({
|
|
4168
4749
|
requirements: Schema.Array(ReadRequest),
|
|
4169
4750
|
cursor: resumeCursor,
|
|
4170
|
-
floor: Schema.Number
|
|
4751
|
+
floor: Schema.Number,
|
|
4752
|
+
restarts: Schema.Number,
|
|
4753
|
+
failures: resumeCursor
|
|
4171
4754
|
}),
|
|
4172
4755
|
modelToDependencies: (model) => {
|
|
4173
4756
|
const requirements = requirementsOf(model);
|
|
4174
4757
|
const stream = liveStreamKey(requirements);
|
|
4175
4758
|
const remote = bound.store.get(model);
|
|
4759
|
+
const health = remote.streams[stream];
|
|
4176
4760
|
return {
|
|
4177
4761
|
requirements,
|
|
4178
4762
|
cursor: remote.live[stream]?.cursor ?? 0,
|
|
4179
|
-
floor: remote.refresh.floor
|
|
4763
|
+
floor: remote.refresh.floor,
|
|
4764
|
+
restarts: health?.restarts ?? 0,
|
|
4765
|
+
failures: health?.failures ?? 0
|
|
4180
4766
|
};
|
|
4181
4767
|
},
|
|
4182
|
-
dependenciesToStream: ({ requirements, cursor }) =>
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
event
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
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)
|
|
4198
4837
|
});
|
|
4199
|
-
/** The projection an active Surface has for this Model; none while it is inactive. */
|
|
4200
|
-
const projectionOf = (entry, model) => "projectionOf" in entry ? entry.projectionOf(model) : Option.some(entry.projection());
|
|
4201
4838
|
/**
|
|
4202
|
-
* `
|
|
4203
|
-
* 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
|
|
4204
4841
|
* change, and a Surface's `model` callback (which lifts and builds schemas)
|
|
4205
4842
|
* need not run three times for it. Models are objects, so the memo is weak.
|
|
4206
4843
|
*/
|
|
4207
|
-
const
|
|
4844
|
+
const memoizedResolved = (entry) => {
|
|
4208
4845
|
const cache = /* @__PURE__ */ new WeakMap();
|
|
4209
4846
|
return (model) => {
|
|
4210
4847
|
const key = model;
|
|
4211
|
-
if (typeof key !== "object" || key === null) return
|
|
4848
|
+
if (typeof key !== "object" || key === null) return resolvedOf(entry, model);
|
|
4212
4849
|
const known = cache.get(key);
|
|
4213
4850
|
if (known !== void 0) return known;
|
|
4214
|
-
const
|
|
4215
|
-
cache.set(key,
|
|
4216
|
-
return
|
|
4851
|
+
const resolved = resolvedOf(entry, model);
|
|
4852
|
+
cache.set(key, resolved);
|
|
4853
|
+
return resolved;
|
|
4217
4854
|
};
|
|
4218
4855
|
};
|
|
4219
4856
|
const Remote = {
|
|
@@ -4294,7 +4931,7 @@ const Remote = {
|
|
|
4294
4931
|
* in one step; the result carries the application-facing operations. The
|
|
4295
4932
|
* descriptor alone is `Remote.define`, the binding alone `Remote.at`.
|
|
4296
4933
|
*/
|
|
4297
|
-
make: (config) => bindDomain(defineRemote(config), config.model),
|
|
4934
|
+
make: (config) => bindDomain(defineRemote(config), config.model, { verifyPlacement: config.verifyPlacement }),
|
|
4298
4935
|
/**
|
|
4299
4936
|
* Declares a Remote domain without binding it: its entities, queries, and
|
|
4300
4937
|
* mutations, plus the submodel. For a domain reused across applications or
|
|
@@ -4516,6 +5153,13 @@ const Remote = {
|
|
|
4516
5153
|
* request to `url`. `headers` is read per request (a session token, say).
|
|
4517
5154
|
*/
|
|
4518
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,
|
|
4519
5163
|
clientLayer: (client, options = {}) => coalescedLayer(Layer.effect(RemoteClient, Effect.gen(function* () {
|
|
4520
5164
|
const context = yield* Effect.context();
|
|
4521
5165
|
return {
|
|
@@ -4571,15 +5215,6 @@ const Remote = {
|
|
|
4571
5215
|
}),
|
|
4572
5216
|
/** The `RemoteMessage` that merges a query page into its connection. */
|
|
4573
5217
|
queryMessage: (ref, result, select, now) => pageMessage(ref.identity, result, false, ref.window, select, now),
|
|
4574
|
-
/**
|
|
4575
|
-
* The edges a connection shows: its server-known region with pending and
|
|
4576
|
-
* confirmed overlays placed around it, minus removed edges and edges whose
|
|
4577
|
-
* target is a tombstone.
|
|
4578
|
-
*/
|
|
4579
|
-
visibleItems: (model, connection) => {
|
|
4580
|
-
const identity = connectionIdentity(connection);
|
|
4581
|
-
return visibleItems(model.connections[identity] ?? emptyConnection, identity, model.optimistic.overlays, model.entities);
|
|
4582
|
-
},
|
|
4583
5218
|
/** A pure, serializable view of the whole cache. */
|
|
4584
5219
|
inspect: inspectRemote,
|
|
4585
5220
|
/** A pure, serializable view of one entity, or `undefined` if unknown. */
|
|
@@ -4631,7 +5266,8 @@ const Remote = {
|
|
|
4631
5266
|
* requirements, emitting a `LiveReceived` per event and a `ReadFailed`
|
|
4632
5267
|
* carrying its `stream` when the stream breaks (including
|
|
4633
5268
|
* `ResumeUnavailable`). That records a gap on the stream rather than a failed
|
|
4634
|
-
* 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
|
|
4635
5271
|
* from `RemoteModel.live`, so the application tracks no cursor of its own.
|
|
4636
5272
|
*/
|
|
4637
5273
|
live: (bound, surface, params, toMessage = identityMessage, options = {}) => liveEntry(bound, () => requirementsOf(surface.projection(params)), toMessage, options)
|
|
@@ -4647,7 +5283,7 @@ const failedReadWords = (error) => {
|
|
|
4647
5283
|
}
|
|
4648
5284
|
};
|
|
4649
5285
|
/** The bound domain: the descriptor, the binding, and the operations over them. */
|
|
4650
|
-
const bindDomain = (definition, store) => {
|
|
5286
|
+
const bindDomain = (definition, store, options = {}) => {
|
|
4651
5287
|
const bound = bindRemote(definition, store);
|
|
4652
5288
|
const readers = /* @__PURE__ */ new Map();
|
|
4653
5289
|
const keptConnections = /* @__PURE__ */ new Set();
|
|
@@ -4692,11 +5328,248 @@ const bindDomain = (definition, store) => {
|
|
|
4692
5328
|
policy: livePolicyFor(model, live.event)
|
|
4693
5329
|
};
|
|
4694
5330
|
const remote = store.get(model);
|
|
4695
|
-
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);
|
|
4696
5334
|
return next === remote ? model : store.set(model, next);
|
|
4697
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
|
+
};
|
|
4698
5569
|
const assertOwned = (entry) => {
|
|
4699
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}"`);
|
|
4700
5573
|
};
|
|
4701
5574
|
const domain = {
|
|
4702
5575
|
...definition,
|
|
@@ -4726,16 +5599,16 @@ const bindDomain = (definition, store) => {
|
|
|
4726
5599
|
const entries = {};
|
|
4727
5600
|
for (const entry of Object.values(active)) assertOwned(entry);
|
|
4728
5601
|
for (const [key, entry] of Object.entries(active)) {
|
|
4729
|
-
const
|
|
4730
|
-
readers.set(entry,
|
|
4731
|
-
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));
|
|
4732
5605
|
entries[`${key}.read`] = observeEntry(bound, asked, identityMessage, options);
|
|
4733
5606
|
entries[`${key}.live`] = liveEntry(bound, (model) => asked(model).requirements.filter((requirement) => requirement.live === true), identityMessage, options);
|
|
4734
5607
|
}
|
|
4735
5608
|
for (const identity of options.connections ?? []) keptConnections.add(connectionIdentity(identity));
|
|
4736
5609
|
entries.retain = {
|
|
4737
5610
|
dependenciesSchema: retentionRootsSchema,
|
|
4738
|
-
modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((
|
|
5611
|
+
modelToDependencies: (model) => rootsOf([...readers.values()].flatMap((resolvedAt) => resolvedAt(model).map(({ projection }) => projection)), { connections: [...keptConnections] }),
|
|
4739
5612
|
dependenciesToStream: (current) => Stream.fromEffect(Effect.succeed({
|
|
4740
5613
|
_tag: "RetentionChanged",
|
|
4741
5614
|
roots: current
|
|
@@ -4776,22 +5649,19 @@ const bindDomain = (definition, store) => {
|
|
|
4776
5649
|
const entries = Object.values(active);
|
|
4777
5650
|
entries.forEach(assertOwned);
|
|
4778
5651
|
const planOptions = RemotePolicy.toPlan(RemotePolicy.cacheFirst, now());
|
|
4779
|
-
const plans = (current,
|
|
4780
|
-
const planned = planAsked(store.get(current),
|
|
5652
|
+
const plans = (current, projections) => {
|
|
5653
|
+
const planned = planAsked(store.get(current), askedUnion(projections), planOptions);
|
|
4781
5654
|
return planned.queries.length > 0 || planned.requirements.length > 0;
|
|
4782
5655
|
};
|
|
4783
5656
|
let current = model;
|
|
4784
5657
|
for (let pass = 0; pass < passes; pass++) {
|
|
4785
|
-
if (!entries.some((entry) => plans(current, entry))) return current;
|
|
4786
|
-
for (const entry of entries) {
|
|
4787
|
-
const projection = projectionOf(entry, current);
|
|
4788
|
-
if (Option.isSome(projection)) current = yield* domain.prefetch(current, projection.value, { now });
|
|
4789
|
-
}
|
|
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 });
|
|
4790
5660
|
}
|
|
4791
|
-
const reading = entries.
|
|
5661
|
+
const reading = entries.flatMap((entry) => resolvedOf(entry, current).filter(({ projection }) => plans(current, [projection])));
|
|
4792
5662
|
if (reading.length === 0) return current;
|
|
4793
5663
|
return yield* new RemoteUnsatisfied({
|
|
4794
|
-
surfaces: reading.map((
|
|
5664
|
+
surfaces: reading.map(({ name }) => name),
|
|
4795
5665
|
passes
|
|
4796
5666
|
});
|
|
4797
5667
|
}),
|
|
@@ -4803,10 +5673,10 @@ const bindDomain = (definition, store) => {
|
|
|
4803
5673
|
const listed = query.Result.entity;
|
|
4804
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`);
|
|
4805
5675
|
if (select.entity !== listed) throw new Error(`Remote: the selection is of "${select.entity}", but query "${query.name}" lists "${listed}"`);
|
|
4806
|
-
const { ref, relation, requirement } = readContract(query.ref(input), select, window);
|
|
5676
|
+
const { ref, relation, requirement } = placeableReadContract(readContract(query.ref(input), select, window), query.body);
|
|
4807
5677
|
const relationKey = stableStringify(relation);
|
|
4808
5678
|
const sizeOf = (remote) => windowSize(remote, ref);
|
|
4809
|
-
const shown = (remote, connection) => cutToWindow(
|
|
5679
|
+
const shown = (remote, connection) => cutToWindow(shownEdges(remote, ref.identity, connection), ref.window, sizeOf(remote));
|
|
4810
5680
|
const failedItem = (remote, connection) => {
|
|
4811
5681
|
if (Object.keys(remote.failures.fields).length === 0) return void 0;
|
|
4812
5682
|
for (const edge of shown(remote, connection).edges) {
|
|
@@ -4924,8 +5794,13 @@ const bindDomain = (definition, store) => {
|
|
|
4924
5794
|
},
|
|
4925
5795
|
explain: (model, projection, options) => {
|
|
4926
5796
|
const { ref } = projection;
|
|
4927
|
-
const reading = Object.values(options?.surfaces ?? {}).
|
|
4928
|
-
|
|
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
|
+
}];
|
|
4929
5804
|
});
|
|
4930
5805
|
const body = definition.registry.queries.get(ref.query)?.body;
|
|
4931
5806
|
const [connection] = RemoteConnections.get(projection.metadata);
|
|
@@ -4942,20 +5817,21 @@ const bindDomain = (definition, store) => {
|
|
|
4942
5817
|
},
|
|
4943
5818
|
state: projection.read(model)._tag,
|
|
4944
5819
|
...options?.surfaces === void 0 ? {} : {
|
|
4945
|
-
surfaces: reading.
|
|
4946
|
-
activation: reading.flatMap((
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
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
|
+
})
|
|
4950
5828
|
}
|
|
4951
5829
|
};
|
|
4952
5830
|
},
|
|
4953
5831
|
why: (model, projection, options) => {
|
|
4954
5832
|
const state = projection.read(model);
|
|
4955
5833
|
const asked = askedOf(projection);
|
|
4956
|
-
const reading = options?.surfaces === void 0 ? void 0 : Object.values(options.surfaces).filter((
|
|
4957
|
-
return Option.exists(active.projectionOf(model), (shown) => covers(askedOf(shown), asked));
|
|
4958
|
-
}).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));
|
|
4959
5835
|
const withSurfaces = reading === void 0 ? {} : { surfaces: reading };
|
|
4960
5836
|
switch (state._tag) {
|
|
4961
5837
|
case "Ready": return {
|
|
@@ -5009,8 +5885,17 @@ const bindDomain = (definition, store) => {
|
|
|
5009
5885
|
const remote = store.get(model);
|
|
5010
5886
|
const connection = remote.connections[over.ref.identity];
|
|
5011
5887
|
const visible = visibleStoreOf(remote.entities, remote.optimistic);
|
|
5012
|
-
const among = (connection === void 0 ? [] :
|
|
5013
|
-
|
|
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
|
+
}
|
|
5014
5899
|
const relation = relationOf(over.selection);
|
|
5015
5900
|
const items = [];
|
|
5016
5901
|
let assembledAll = true;
|
|
@@ -5043,6 +5928,11 @@ const bindDomain = (definition, store) => {
|
|
|
5043
5928
|
_tag: "OverlayLifted",
|
|
5044
5929
|
id
|
|
5045
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
|
+
},
|
|
5046
5936
|
mutate: (model, mutation, input, options = {}) => {
|
|
5047
5937
|
assertRegistered(bound, "Mutation", definition.registry.mutations, mutation.name);
|
|
5048
5938
|
const remote = store.get(model);
|
|
@@ -5051,7 +5941,7 @@ const bindDomain = (definition, store) => {
|
|
|
5051
5941
|
const optimistic = typeof options.optimistic === "function" ? options.optimistic({
|
|
5052
5942
|
requestId,
|
|
5053
5943
|
tempId
|
|
5054
|
-
}) : options.optimistic;
|
|
5944
|
+
}) : options.optimistic ?? (mutation.write === void 0 ? void 0 : optimisticOfWrite(mutation.write, input, options.keys));
|
|
5055
5945
|
const started = updateRemote(remote, {
|
|
5056
5946
|
_tag: "MutationStarted",
|
|
5057
5947
|
requestId,
|
|
@@ -5064,7 +5954,7 @@ const bindDomain = (definition, store) => {
|
|
|
5064
5954
|
command: {
|
|
5065
5955
|
name: `Remote.mutate(${mutation.name})`,
|
|
5066
5956
|
args: { requestId },
|
|
5067
|
-
effect: mutateRemote(mutation, input, requestId).pipe(Effect.match({
|
|
5957
|
+
effect: mutateRemote(mutation, input, requestId, options.keys).pipe(Effect.match({
|
|
5068
5958
|
onFailure: (error) => ({
|
|
5069
5959
|
_tag: "MutationFailed",
|
|
5070
5960
|
requestId,
|
|
@@ -5083,8 +5973,108 @@ const bindDomain = (definition, store) => {
|
|
|
5083
5973
|
};
|
|
5084
5974
|
},
|
|
5085
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
|
+
},
|
|
5086
5996
|
reduce,
|
|
5087
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
|
+
},
|
|
5088
6078
|
wiring: (active, options) => ({
|
|
5089
6079
|
key: `remote:${bound.contract.name}`,
|
|
5090
6080
|
handles: Object.keys(remoteMessageCases),
|
|
@@ -5131,6 +6121,6 @@ const pickWindow = (window) => ({
|
|
|
5131
6121
|
...window.before === void 0 ? {} : { before: window.before }
|
|
5132
6122
|
});
|
|
5133
6123
|
//#endregion
|
|
5134
|
-
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 };
|
|
5135
6125
|
|
|
5136
|
-
//# sourceMappingURL=src-
|
|
6126
|
+
//# sourceMappingURL=src-CcPPtNuH.mjs.map
|