foldkit-remote 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context, Deferred, Duration, Effect, Layer, Option, Request, RequestResolver, Result, Schema, SchemaGetter, Stream } from "effect";
2
2
  import * as Domain from "foldkit-entity";
3
- import { Entity as Entity$1, SelectionTypeId } from "foldkit-entity";
3
+ import { Entity as Entity$1, Expr, Query as Query$1, SelectionTypeId } from "foldkit-entity";
4
4
  import * as Subscription from "foldkit/subscription";
5
5
  import { Metadata } from "foldkit-surface";
6
6
  import { Rpc, RpcGroup } from "effect/unstable/rpc";
@@ -108,6 +108,91 @@ const requirementsOf = (projection) => RemoteRequirements.get(projection.metadat
108
108
  /** The query connections a Projection carries. */
109
109
  const connectionsOf = (projection) => RemoteConnections.get(projection.metadata);
110
110
  //#endregion
111
+ //#region src/relation.ts
112
+ /** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
113
+ const RelationAnnotation = "foldkitRemoteRelation";
114
+ /** The target entity's name on the same codec, so a nested selection of another entity is refused. */
115
+ const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
116
+ /**
117
+ * A page of a relation that is a whole list is read under a name of its own, the
118
+ * field's name and the page's size: `comments@first=10`. It is a field like any
119
+ * other to the store, the planner, and the wire, so the whole list and a page of
120
+ * it are held side by side, merged, marked stale, and persisted each on its own.
121
+ * The server reads the name apart, reads the relation with the window, and
122
+ * answers under the alias. A cursor is not part of the name: a page read from a
123
+ * cursor merges onto the page it continues.
124
+ */
125
+ const RELATION_ALIAS = "@";
126
+ /** The alias a relation field is read under, for a window of this size. */
127
+ const relationAlias = (field, window) => `${field}@${window.last !== void 0 ? `last=${window.last}` : `first=${window.first ?? ""}`}`;
128
+ /** The field an alias reads; a name that is no alias is its own field. */
129
+ const aliasedField = (name) => {
130
+ const at = name.indexOf("@");
131
+ return at === -1 ? name : name.slice(0, at);
132
+ };
133
+ /** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
134
+ const refParts = (encoded) => {
135
+ const separator = encoded.indexOf(":");
136
+ return separator === -1 ? {
137
+ entity: encoded,
138
+ id: ""
139
+ } : {
140
+ entity: encoded.slice(0, separator),
141
+ id: encoded.slice(separator + 1)
142
+ };
143
+ };
144
+ /**
145
+ * The refs a relation value carries, in order. Anything that is not a ref key,
146
+ * an array of them, or a page of them contributes nothing.
147
+ */
148
+ const refsIn = (value) => {
149
+ if (typeof value === "string") return [refParts(value)];
150
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
151
+ if (value !== null && typeof value === "object") {
152
+ const refs = value.refs;
153
+ if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
154
+ }
155
+ return [];
156
+ };
157
+ /** The refs in a relation value that name the relation's target entity, and no other. */
158
+ const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
159
+ const isRefPage = (value) => {
160
+ if (value === null || typeof value !== "object") return false;
161
+ const page = value;
162
+ return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
163
+ };
164
+ /**
165
+ * The relation shape an entity field schema declares: a ref (`one`), a nullable
166
+ * ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
167
+ * scalar field, which cannot take a nested selection.
168
+ */
169
+ const relationShape = (schema) => shapeOf(schema.ast, false);
170
+ const shapeOf = (ast, nullable) => {
171
+ const annotated = ast.annotations?.[RelationAnnotation];
172
+ if (annotated === "one" || annotated === "page") return {
173
+ kind: annotated,
174
+ nullable,
175
+ entity: String(ast.annotations?.[RelationEntityAnnotation])
176
+ };
177
+ if (ast._tag === "Arrays") {
178
+ const item = ast.rest?.[0];
179
+ const inner = item === void 0 ? void 0 : shapeOf(item, false);
180
+ return inner?.kind === "one" ? {
181
+ kind: "many",
182
+ nullable,
183
+ entity: inner.entity
184
+ } : void 0;
185
+ }
186
+ if (ast._tag === "Union") {
187
+ const members = ast.types ?? [];
188
+ const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
189
+ for (const member of members) {
190
+ const shape = shapeOf(member, optional);
191
+ if (shape !== void 0) return shape;
192
+ }
193
+ }
194
+ };
195
+ //#endregion
111
196
  //#region src/store.ts
112
197
  /**
113
198
  * The pure entity store: values, per-field presence, staleness, and tombstones.
@@ -142,6 +227,9 @@ const written = (previous, values, now, windows) => {
142
227
  const requested = windows?.[field];
143
228
  if (requested !== void 0 && requested !== "") nextWindows[field] = requested;
144
229
  else delete nextWindows[field];
230
+ if (!field.includes("@")) {
231
+ for (const held of present) if (held.startsWith(`${field}@`) && !(held in values)) stale.add(held);
232
+ }
145
233
  }
146
234
  return {
147
235
  values: {
@@ -361,7 +449,32 @@ const stableStringify = (value) => {
361
449
  return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
362
450
  };
363
451
  const isEntityName = (result) => typeof result === "object" && result !== null && typeof result.name === "string";
452
+ /**
453
+ * The relational half comes from `foldkit-entity`, so one `Query` namespace
454
+ * holds both what a query means (`from`, `where`, `orderBy`) and how this
455
+ * client addresses it (`make`, `connection`, the window steps). They are one
456
+ * vocabulary in use — a definition's body is composed and then named — and two
457
+ * namespaces of the same name in one file would be a trap.
458
+ */
459
+ /**
460
+ * One placeholder per key of the input's schema, each carrying that key's own
461
+ * codec so a comparison against it is checked.
462
+ *
463
+ * A codec that is not a `Schema.Struct` exposes no keys to stand for, while its
464
+ * *type* may well have them — so the body would be handed an empty object,
465
+ * `input.slug` would be `undefined`, and the comparison would silently be
466
+ * against nothing while the types all agreed. That is a wrong query rather than
467
+ * a missing one, so it is refused where the query is declared.
468
+ */
469
+ const inputsOf = (name, schema) => {
470
+ const fields = schema.fields;
471
+ if (fields === void 0) throw new Error(`[foldkit-remote] Query.define: the Input of "${name}" is not a Schema.Struct, so its body has no inputs to read. Declare it as fields (\`{ slug: Schema.String }\`) or a plain \`Schema.Struct\`.`);
472
+ const inputs = {};
473
+ for (const key of Object.keys(fields)) inputs[key] = Expr.input(key, fields[key]);
474
+ return Object.freeze(inputs);
475
+ };
364
476
  const Query = {
477
+ ...Query$1,
365
478
  /** Declares the entity and options a query's result is a connection over. */
366
479
  connection: (entity, options) => ({
367
480
  entity: entity.name,
@@ -389,6 +502,41 @@ const Query = {
389
502
  })
390
503
  };
391
504
  },
505
+ /**
506
+ * Declares a query by what it means. The body is built once, here, and the
507
+ * `input` it is given holds placeholders rather than values — there is
508
+ * nothing yet to branch on, so a condition that depends on what was passed is
509
+ * a comparison over the placeholder:
510
+ *
511
+ * ```ts
512
+ * const PostsBySlug = Query.define('PostsBySlug', { slug: Schema.String }, ({ input }) =>
513
+ * Query.from(Post).pipe(
514
+ * Query.where(Expr.eq(Post.fields.slug, input.slug)),
515
+ * Query.orderBy(Order.asc(Post.fields.id)),
516
+ * ),
517
+ * )
518
+ * ```
519
+ *
520
+ * What comes back is an ordinary `QueryDescriptor` — the same name, `Input`,
521
+ * `ref` and connection identity `Query.make` gives — carrying its `body`
522
+ * besides. A server can compile that body instead of being told the same
523
+ * thing again in its own dialect, and one that would rather answer the query
524
+ * its own way still can.
525
+ *
526
+ * The result is a connection over the Entity the body reads, so it is not
527
+ * named twice.
528
+ */
529
+ define: (name, Input, body, options) => {
530
+ const schema = schemaOf(Input);
531
+ const built = body({ input: inputsOf(name, schema) });
532
+ return {
533
+ ...Query.make(name, {
534
+ Input,
535
+ Result: Query.connection(built.entity, options)
536
+ }),
537
+ body: built
538
+ };
539
+ },
392
540
  first: (count) => (ref) => ({
393
541
  ...ref,
394
542
  window: {
@@ -1016,74 +1164,6 @@ const hasNext = (connection) => {
1016
1164
  /** A connection with more than one segment has an explicit unknown gap. */
1017
1165
  const isGapped = (connection) => connection.segments.length > 1;
1018
1166
  //#endregion
1019
- //#region src/relation.ts
1020
- /** Marks the codec `Entity.ref`/`Entity.refPage` produce, so a Selection can tell the field's shape. */
1021
- const RelationAnnotation = "foldkitRemoteRelation";
1022
- /** The target entity's name on the same codec, so a nested selection of another entity is refused. */
1023
- const RelationEntityAnnotation = "foldkitRemoteRelationEntity";
1024
- /** Splits a ref key (`"Entity:id"`, the store key too) back into its parts. */
1025
- const refParts = (encoded) => {
1026
- const separator = encoded.indexOf(":");
1027
- return separator === -1 ? {
1028
- entity: encoded,
1029
- id: ""
1030
- } : {
1031
- entity: encoded.slice(0, separator),
1032
- id: encoded.slice(separator + 1)
1033
- };
1034
- };
1035
- /**
1036
- * The refs a relation value carries, in order. Anything that is not a ref key,
1037
- * an array of them, or a page of them contributes nothing.
1038
- */
1039
- const refsIn = (value) => {
1040
- if (typeof value === "string") return [refParts(value)];
1041
- if (Array.isArray(value)) return value.filter((item) => typeof item === "string").map(refParts);
1042
- if (value !== null && typeof value === "object") {
1043
- const refs = value.refs;
1044
- if (Array.isArray(refs)) return refs.filter((item) => typeof item === "string").map(refParts);
1045
- }
1046
- return [];
1047
- };
1048
- /** The refs in a relation value that name the relation's target entity, and no other. */
1049
- const targetsOf = (value, relation) => refsIn(value).filter((ref) => ref.entity === relation.entity);
1050
- const isRefPage = (value) => {
1051
- if (value === null || typeof value !== "object") return false;
1052
- const page = value;
1053
- return Array.isArray(page.refs) && page.refs.every((ref) => typeof ref === "string") && typeof page.hasNext === "boolean" && typeof page.hasPrevious === "boolean";
1054
- };
1055
- /**
1056
- * The relation shape an entity field schema declares: a ref (`one`), a nullable
1057
- * ref, an array of refs (`many`), or a page of refs (`page`). `undefined` for a
1058
- * scalar field, which cannot take a nested selection.
1059
- */
1060
- const relationShape = (schema) => shapeOf(schema.ast, false);
1061
- const shapeOf = (ast, nullable) => {
1062
- const annotated = ast.annotations?.[RelationAnnotation];
1063
- if (annotated === "one" || annotated === "page") return {
1064
- kind: annotated,
1065
- nullable,
1066
- entity: String(ast.annotations?.[RelationEntityAnnotation])
1067
- };
1068
- if (ast._tag === "Arrays") {
1069
- const item = ast.rest?.[0];
1070
- const inner = item === void 0 ? void 0 : shapeOf(item, false);
1071
- return inner?.kind === "one" ? {
1072
- kind: "many",
1073
- nullable,
1074
- entity: inner.entity
1075
- } : void 0;
1076
- }
1077
- if (ast._tag === "Union") {
1078
- const members = ast.types ?? [];
1079
- const optional = members.some((member) => member._tag === "Null" || member._tag === "Undefined");
1080
- for (const member of members) {
1081
- const shape = shapeOf(member, optional);
1082
- if (shape !== void 0) return shape;
1083
- }
1084
- }
1085
- };
1086
- //#endregion
1087
1167
  //#region src/selection.ts
1088
1168
  /**
1089
1169
  * Selections: the fields a Surface picks of an entity (nested through
@@ -1109,15 +1189,20 @@ const relationOf = (selection) => ({
1109
1189
  ...selection.connections === void 0 ? {} : { windows: selection.connections },
1110
1190
  ...selection.relations === void 0 ? {} : { relations: selection.relations }
1111
1191
  });
1192
+ const fromEntitySelections = /* @__PURE__ */ new WeakMap();
1112
1193
  const Selection = {
1113
1194
  /**
1114
1195
  * The Remote Selection of a `foldkit-entity` Selection: the same members, read
1115
1196
  * from Remote's store. `true` on a relation reads its refs, as it does here.
1116
1197
  */
1117
1198
  from: (selection) => {
1199
+ const compiled = fromEntitySelections.get(selection);
1200
+ if (compiled !== void 0) return compiled;
1118
1201
  const spec = {};
1119
1202
  for (const [key, selected] of Object.entries(selection.members)) spec[key] = selected === true ? true : Domain.SelectionPageTypeId in selected ? Selection.connection(Entity.from(selected.selection.entity), selected.window, Selection.from(selected.selection)) : Selection.from(selected);
1120
- return Selection.make(Entity.from(selection.entity), spec);
1203
+ const made = Selection.make(Entity.from(selection.entity), spec);
1204
+ fromEntitySelections.set(selection, made);
1205
+ return made;
1121
1206
  },
1122
1207
  /**
1123
1208
  * The fields to read of `entity`. A nested `Selection` on a relation field
@@ -1127,12 +1212,14 @@ const Selection = {
1127
1212
  make: (entity, selection) => {
1128
1213
  if (Object.keys(selection).length === 0) throw new Error(`Selection.make: a selection of "${entity.name}" picks at least one field`);
1129
1214
  const picked = {};
1215
+ const names = [];
1130
1216
  const connections = {};
1131
1217
  const relations = {};
1132
1218
  for (const key of Object.keys(selection)) {
1133
1219
  const choice = selection[key];
1134
1220
  if (choice === true) {
1135
1221
  picked[key] = entity.fields[key];
1222
+ names.push(key);
1136
1223
  continue;
1137
1224
  }
1138
1225
  const nested = choice;
@@ -1141,18 +1228,21 @@ const Selection = {
1141
1228
  if (nested.entity !== shape.entity) throw new Error(`Selection.make: "${key}" on "${entity.name}" refers to "${shape.entity}", not "${nested.entity}"`);
1142
1229
  const nullable = (codec) => shape.nullable ? Schema.NullOr(codec) : codec;
1143
1230
  if (nested.window !== void 0) {
1144
- connections[key] = nested.window;
1231
+ const name = shape.kind === "many" ? relationAlias(key, nested.window) : key;
1232
+ names.push(name);
1233
+ connections[name] = nested.window;
1145
1234
  picked[key] = nullable(nested.schema);
1146
- if (nested.fields.length > 0) relations[key] = relationOf(nested);
1235
+ if (nested.fields.length > 0) relations[name] = relationOf(nested);
1147
1236
  continue;
1148
1237
  }
1238
+ names.push(key);
1149
1239
  const item = nested.schema;
1150
1240
  picked[key] = nullable(shape.kind === "many" ? Schema.Array(item) : shape.kind === "page" ? pageSchema(item) : item);
1151
1241
  relations[key] = relationOf(nested);
1152
1242
  }
1153
1243
  return {
1154
1244
  entity: entity.name,
1155
- fields: Object.keys(selection),
1245
+ fields: names,
1156
1246
  schema: Schema.Struct(picked),
1157
1247
  ...Object.keys(connections).length === 0 ? {} : { connections },
1158
1248
  ...Object.keys(relations).length === 0 ? {} : { relations }
@@ -1189,12 +1279,12 @@ const assemble = (store, key, requirement) => {
1189
1279
  refreshing ||= isFieldStale(store, key, field);
1190
1280
  const relation = requirement.relations?.[field];
1191
1281
  if (relation === void 0) {
1192
- values[field] = value.value;
1282
+ values[aliasedField(field)] = value.value;
1193
1283
  continue;
1194
1284
  }
1195
1285
  const nested = assembleRelation(store, value.value, relation);
1196
1286
  if (nested === void 0) return void 0;
1197
- values[field] = nested.values;
1287
+ values[aliasedField(field)] = nested.values;
1198
1288
  refreshing ||= nested.refreshing;
1199
1289
  }
1200
1290
  return {
@@ -1291,6 +1381,8 @@ const Entity = {
1291
1381
  return descriptor;
1292
1382
  },
1293
1383
  make: (name, schema) => {
1384
+ const reserved = Object.keys(schema.fields).find((field) => field.includes("@"));
1385
+ if (reserved !== void 0) throw new Error(`Entity.make: field "${reserved}" of "${name}" contains "@", which names a page of a relation`);
1294
1386
  const descriptor = {
1295
1387
  name,
1296
1388
  schema,
@@ -1347,6 +1439,7 @@ const inspectRemote = (model) => ({
1347
1439
  connections: Object.keys(model.connections),
1348
1440
  live: Object.keys(model.live),
1349
1441
  gaps: [...model.gaps],
1442
+ loading: [...model.loading],
1350
1443
  mutations: {
1351
1444
  pending: [...model.mutations.pending],
1352
1445
  failed: [...model.mutations.failed],
@@ -1930,6 +2023,8 @@ const RemotePersistence = {
1930
2023
  //#endregion
1931
2024
  //#region src/remoteData.ts
1932
2025
  /** `RemoteData`: the state of a value the store may not hold yet. */
2026
+ const fresh = { _tag: "Fresh" };
2027
+ const refreshing = { _tag: "Refreshing" };
1933
2028
  const remoteErrorSchema = Schema.Struct({
1934
2029
  _tag: Schema.String,
1935
2030
  message: Schema.String
@@ -1977,6 +2072,43 @@ const RemoteData = {
1977
2072
  case "NotFound": return cases.NotFound();
1978
2073
  }
1979
2074
  },
2075
+ /**
2076
+ * The view-oriented fold: the six states as the three things a view actually
2077
+ * draws, under the policy that useful data stays on screen. A value that is
2078
+ * being refetched, or that a later read failed to replace, still reaches
2079
+ * `data` — with `freshness` saying which — instead of being replaced by a
2080
+ * spinner or an error the reader cannot act on.
2081
+ *
2082
+ * ```ts
2083
+ * RemoteData.render(model.user, {
2084
+ * loading: () => UserSkeleton(),
2085
+ * notFound: () => NoSuchUser(),
2086
+ * failed: error => ErrorView(error),
2087
+ * data: (user, freshness) => UserView({ user, dimmed: freshness._tag !== 'Fresh' }),
2088
+ * })
2089
+ * ```
2090
+ *
2091
+ * `notFound` is its own branch and not optional: a row the server answered
2092
+ * for and does not have is neither loading nor a failure, and drawing it as
2093
+ * either is a spinner that never ends or an error nobody can fix.
2094
+ *
2095
+ * This does not replace `match`, which is the exhaustive fold over the states
2096
+ * themselves. Reach for `match` when the six states really do draw
2097
+ * differently, and for this when they draw the usual three ways.
2098
+ */
2099
+ render: (data, cases) => {
2100
+ switch (data._tag) {
2101
+ case "Initial":
2102
+ case "Loading": return cases.loading();
2103
+ case "NotFound": return cases.notFound();
2104
+ case "Ready": return cases.data(data.value, fresh);
2105
+ case "Refreshing": return cases.data(data.value, refreshing);
2106
+ case "Failed": return data.previous === void 0 ? cases.failed(data.error) : cases.data(data.previous, {
2107
+ _tag: "Stale",
2108
+ error: data.error
2109
+ });
2110
+ }
2111
+ },
1980
2112
  map: (data, f) => {
1981
2113
  switch (data._tag) {
1982
2114
  case "Ready": return {
@@ -2006,6 +2138,11 @@ const RemoteData = {
2006
2138
  * optimistic layers an application keeps in its Model, the Messages that
2007
2139
  * change it, and the pure reducer over them.
2008
2140
  */
2141
+ const emptyRefresh = {
2142
+ generation: 0,
2143
+ requested: /* @__PURE__ */ new Map(),
2144
+ started: /* @__PURE__ */ new Map()
2145
+ };
2009
2146
  const initialRemoteModel = {
2010
2147
  entities: emptyStore,
2011
2148
  connections: {},
@@ -2014,10 +2151,7 @@ const initialRemoteModel = {
2014
2151
  mutations: emptyMutationState,
2015
2152
  gaps: /* @__PURE__ */ new Set(),
2016
2153
  loading: /* @__PURE__ */ new Set(),
2017
- refresh: {
2018
- requested: 0,
2019
- started: 0
2020
- }
2154
+ refresh: emptyRefresh
2021
2155
  };
2022
2156
  /** The entity store and the mutation ledger are runtime values, not wire shapes. */
2023
2157
  const runtimeSchema = Schema.Unknown;
@@ -2029,10 +2163,7 @@ const remoteModelSchema = () => Schema.Struct({
2029
2163
  mutations: runtimeSchema,
2030
2164
  gaps: runtimeSchema,
2031
2165
  loading: runtimeSchema,
2032
- refresh: Schema.Struct({
2033
- requested: Schema.Number,
2034
- started: Schema.Number
2035
- })
2166
+ refresh: runtimeSchema
2036
2167
  });
2037
2168
  const retentionRootsSchema = Schema.Struct({
2038
2169
  requirements: Schema.Array(ReadRequest),
@@ -2082,6 +2213,11 @@ const remoteMessageCases = {
2082
2213
  requestId: Schema.String,
2083
2214
  error: remoteErrorSchema
2084
2215
  },
2216
+ OverlayShown: {
2217
+ id: Schema.String,
2218
+ optimistic: Schema.Array(Schema.Unknown)
2219
+ },
2220
+ OverlayLifted: { id: Schema.String },
2085
2221
  LiveReceived: {
2086
2222
  stream: Schema.String,
2087
2223
  event: Schema.Unknown,
@@ -2110,39 +2246,122 @@ const remoteMessageSchema = Schema.Union(Object.entries(remoteMessageCases).map(
2110
2246
  _tag: Schema.Literal(tag),
2111
2247
  ...fields
2112
2248
  })));
2249
+ /** An overlay's layer, named apart from every request's: a request id is the application's to choose too. */
2250
+ const overlayLayer = (id) => `overlay:${id}`;
2113
2251
  const marksOf = (requests) => requests.map((request) => [entityKey(request.entity, request.id), request.fields]);
2114
2252
  /**
2115
- * One mark per requested field. A read is identified by what it asks for, not by
2116
- * a request id: two reads asking for one field share its mark, so the first
2117
- * answer clears it. Over-clearing shows `Initial` rather than a spinner, which
2118
- * is the safer way to be wrong.
2253
+ * One mark per requested field, `entity\0id\0field`. A read is identified by
2254
+ * what it asks for, not by a request id: two reads asking for one field share
2255
+ * its mark, so the first answer clears it. Over-clearing shows `Initial` rather
2256
+ * than a spinner, which is the safer way to be wrong. The refresh generations
2257
+ * below key off the same marks, so "what a read asks for" means one thing.
2119
2258
  */
2120
- const loadingMarks = function* (requests) {
2121
- for (const request of requests) {
2122
- const key = entityKey(request.entity, request.id);
2123
- for (const field of request.fields) yield `${key}\u0000${field}`;
2124
- }
2259
+ const fieldMark = (entity, id, field) => `${entityKey(entity, id)}\u0000${field}`;
2260
+ const fieldMarks = function* (requests) {
2261
+ for (const request of requests) for (const field of request.fields) yield fieldMark(request.entity, request.id, field);
2125
2262
  };
2126
2263
  const withLoading = (loading, requests) => {
2127
2264
  const next = new Set(loading);
2128
- for (const mark of loadingMarks(requests)) next.add(mark);
2265
+ for (const mark of fieldMarks(requests)) next.add(mark);
2129
2266
  return next;
2130
2267
  };
2131
2268
  const withoutLoading = (loading, requests) => {
2132
2269
  if (loading.size === 0) return loading;
2133
2270
  const next = new Set(loading);
2134
- for (const mark of loadingMarks(requests)) next.delete(mark);
2271
+ for (const mark of fieldMarks(requests)) next.delete(mark);
2135
2272
  return next;
2136
2273
  };
2137
2274
  /**
2275
+ * A connection's mark. The leading separator keeps it out of the field marks'
2276
+ * space, whatever a connection identity happens to spell.
2277
+ */
2278
+ const connectionPrefix = "\0connection\0";
2279
+ const connectionMark = (identity) => `${connectionPrefix}${identity}`;
2280
+ /**
2281
+ * Drops the refresh marks of entities and connections that were collected.
2282
+ * Nothing observes a collected entity — the retention roots are the active
2283
+ * Surfaces — so no read entry's generation can fall back when its mark goes.
2284
+ * Without this the generations would be bounded by how many things the
2285
+ * application has ever refreshed rather than by what it currently holds, which
2286
+ * for a list refreshed on a timer is the same as not bounded at all.
2287
+ */
2288
+ const prunedRefresh = (refresh, retained) => {
2289
+ const live = (mark) => mark.startsWith(connectionPrefix) ? retained.connections[mark.slice(12)] !== void 0 : retained.entities[mark.slice(0, mark.indexOf("\0"))] !== void 0;
2290
+ const keep = (marks) => {
2291
+ const next = /* @__PURE__ */ new Map();
2292
+ for (const [mark, generation] of marks) if (live(mark)) next.set(mark, generation);
2293
+ return next.size === marks.size ? marks : next;
2294
+ };
2295
+ const requested = keep(refresh.requested);
2296
+ const started = keep(refresh.started);
2297
+ return requested === refresh.requested && started === refresh.started ? refresh : {
2298
+ ...refresh,
2299
+ requested,
2300
+ started
2301
+ };
2302
+ };
2303
+ /**
2304
+ * The generation a read is planned under: the highest any of the fields it
2305
+ * reads or connections it runs was refreshed at. A read entry carries this as a
2306
+ * dependency, so it restarts when what it observes is refreshed and not when
2307
+ * anything else is.
2308
+ */
2309
+ const refreshedAt = (refresh, requests, connections = []) => {
2310
+ let at = 0;
2311
+ for (const mark of fieldMarks(requests)) at = Math.max(at, refresh.requested.get(mark) ?? 0);
2312
+ for (const identity of connections) at = Math.max(at, refresh.requested.get(connectionMark(identity)) ?? 0);
2313
+ return at;
2314
+ };
2315
+ /**
2316
+ * Marks every field of `requests` and every connection in `connections`
2317
+ * refreshed at the next generation.
2318
+ */
2319
+ const withRefreshRequested = (refresh, requests, connections = []) => {
2320
+ const generation = refresh.generation + 1;
2321
+ const requested = new Map(refresh.requested);
2322
+ for (const mark of fieldMarks(requests)) requested.set(mark, generation);
2323
+ for (const identity of connections) requested.set(connectionMark(identity), generation);
2324
+ return {
2325
+ ...refresh,
2326
+ generation,
2327
+ requested
2328
+ };
2329
+ };
2330
+ /**
2331
+ * Records that a read of `requests` began under `generation`. A read that
2332
+ * carries no generation began under none, and leaves the marks as they are.
2333
+ */
2334
+ const withRefreshStarted = (refresh, requests, generation) => {
2335
+ if (generation === void 0 || generation === 0) return refresh;
2336
+ let started;
2337
+ for (const mark of fieldMarks(requests)) {
2338
+ if ((refresh.started.get(mark) ?? 0) >= generation) continue;
2339
+ started ??= new Map(refresh.started);
2340
+ started.set(mark, generation);
2341
+ }
2342
+ return started === void 0 ? refresh : {
2343
+ ...refresh,
2344
+ started
2345
+ };
2346
+ };
2347
+ /**
2348
+ * Whether one field's read already began under the generation that field was
2349
+ * last refreshed at. Such a read is in flight and would otherwise outlive the
2350
+ * refresh, so the refresh takes a new generation to restart it even though the
2351
+ * field is already stale.
2352
+ */
2353
+ const refreshIsInFlight = (refresh, request, field) => {
2354
+ const mark = fieldMark(request.entity, request.id, field);
2355
+ return (refresh.started.get(mark) ?? 0) === (refresh.requested.get(mark) ?? 0);
2356
+ };
2357
+ /**
2138
2358
  * Whether a read is fetching any of `fields` for this entity. Consulted only
2139
2359
  * when the store lacks the value, which is what separates `Loading` from
2140
2360
  * `Initial`.
2141
2361
  */
2142
2362
  const isLoading = (model, entity, id, fields) => {
2143
2363
  if (model.loading.size === 0) return false;
2144
- const key = entityKey(entity, id);
2145
- return fields.some((field) => model.loading.has(`${key}\u0000${field}`));
2364
+ return fields.some((field) => model.loading.has(fieldMark(entity, id, field)));
2146
2365
  };
2147
2366
  const setConnectionStale = (connections, connection, stale) => {
2148
2367
  const current = connections[connection] ?? emptyConnection;
@@ -2179,32 +2398,31 @@ const updateRemote = (model, message) => {
2179
2398
  entities: setStale(model.entities, marksOf(message.requests), false),
2180
2399
  loading: withoutLoading(model.loading, message.requests)
2181
2400
  };
2182
- case "RetentionChanged": return {
2183
- ...model,
2184
- ...gc(model, message.roots)
2185
- };
2401
+ case "RetentionChanged": {
2402
+ const retained = gc(model, message.roots);
2403
+ return {
2404
+ ...model,
2405
+ ...retained,
2406
+ refresh: prunedRefresh(model.refresh, retained)
2407
+ };
2408
+ }
2186
2409
  case "Hydrated": return {
2187
2410
  ...model,
2188
2411
  entities: RemotePersistence.mergeStores(model.entities, message.entities, message.merge)
2189
2412
  };
2190
2413
  case "RefreshStarted": {
2191
- const entities = setStale(model.entities, marksOf(message.requests), true);
2414
+ const asked = message.requests.reduce((store, request) => isTombstone(store, entityKey(request.entity, request.id)) ? remove(store, entityKey(request.entity, request.id)) : store, model.entities);
2415
+ const entities = setStale(asked, marksOf(message.requests), true);
2192
2416
  return entities === model.entities ? model : {
2193
2417
  ...model,
2194
2418
  entities
2195
2419
  };
2196
2420
  }
2197
- case "ReadStarted": {
2198
- const started = Math.max(model.refresh.started, message.refresh ?? 0);
2199
- return {
2200
- ...model,
2201
- loading: withLoading(model.loading, message.requests),
2202
- refresh: started === model.refresh.started ? model.refresh : {
2203
- ...model.refresh,
2204
- started
2205
- }
2206
- };
2207
- }
2421
+ case "ReadStarted": return {
2422
+ ...model,
2423
+ loading: withLoading(model.loading, message.requests),
2424
+ refresh: withRefreshStarted(model.refresh, message.requests, message.refresh)
2425
+ };
2208
2426
  case "MutationStarted": return {
2209
2427
  ...model,
2210
2428
  mutations: beginMutation(model.mutations, message.requestId),
@@ -2219,6 +2437,17 @@ const updateRemote = (model, message) => {
2219
2437
  optimistic: settled.optimistic
2220
2438
  };
2221
2439
  }
2440
+ case "OverlayShown": return {
2441
+ ...model,
2442
+ optimistic: beginOptimistic(settleFailure(model.optimistic, overlayLayer(message.id)), overlayLayer(message.id), message.optimistic)
2443
+ };
2444
+ case "OverlayLifted": {
2445
+ const optimistic = settleFailure(model.optimistic, overlayLayer(message.id));
2446
+ return optimistic.layers.length === model.optimistic.layers.length && optimistic.overlays.length === model.optimistic.overlays.length ? model : {
2447
+ ...model,
2448
+ optimistic
2449
+ };
2450
+ }
2222
2451
  case "MutationFailed": return {
2223
2452
  ...model,
2224
2453
  mutations: failMutation(model.mutations, message.requestId, message.error),
@@ -2414,6 +2643,22 @@ const storeOf = (bound, model) => {
2414
2643
  const remote = bound.store.get(model);
2415
2644
  return visibleStoreOf(remote.entities, remote.optimistic);
2416
2645
  };
2646
+ /**
2647
+ * The Model as it would be with nothing optimistic pending. Every read reaches
2648
+ * the layers through the Model it is given, so dropping them here is what makes
2649
+ * a read confirmed — no read has to know it is being read confirmed.
2650
+ */
2651
+ const withoutOptimistic = (bound, model) => {
2652
+ const remote = bound.store.get(model);
2653
+ return remote.optimistic.layers.length === 0 && remote.optimistic.overlays.length === 0 ? model : bound.store.set(model, {
2654
+ ...remote,
2655
+ optimistic: emptyOptimistic
2656
+ });
2657
+ };
2658
+ const confirmed = (bound, projection) => ({
2659
+ ...projection,
2660
+ read: (root) => projection.read(withoutOptimistic(bound, root))
2661
+ });
2417
2662
  const visibleStores = /* @__PURE__ */ new WeakMap();
2418
2663
  const visibleStoreOf = (entities, optimistic) => {
2419
2664
  let byStore = visibleStores.get(optimistic);
@@ -2639,7 +2884,7 @@ const observeEntry = (bound, askedOf, toMessage, options) => {
2639
2884
  const remote = bound.store.get(model);
2640
2885
  const planned = planAsked(remote, askedOf(model), RemotePolicy.toPlan(policy, now()));
2641
2886
  return {
2642
- refresh: remote.refresh.requested,
2887
+ refresh: refreshedAt(remote.refresh, planned.requirements, planned.queries.map((query) => query.identity)),
2643
2888
  requirements: planned.requirements,
2644
2889
  queries: planned.queries.map(({ identity, window, select }) => ({
2645
2890
  identity,
@@ -2801,6 +3046,12 @@ const Remote = {
2801
3046
  */
2802
3047
  storeOf: (bound, model) => storeOf(bound, model),
2803
3048
  /**
3049
+ * The projection read against the server-derived store alone: the same
3050
+ * requirements, planned the same way, with the pending optimistic layers and
3051
+ * connection overlays left off. See the bound `Remote.confirmed`.
3052
+ */
3053
+ confirmed: (bound, projection) => confirmed(bound, projection),
3054
+ /**
2804
3055
  * Executes the plan against the `RemoteClient` and returns a new store. Used
2805
3056
  * for SSR route prefetch, hover prefetch, and tests. Never called during render.
2806
3057
  * `policy` decides what a present field means (default cache-first); `now`
@@ -2847,19 +3098,17 @@ const Remote = {
2847
3098
  return known === void 0 ? [] : itemsOf(visibleItems(known, connection.identity, remote.optimistic.overlays, remote.entities).map((edge) => edge.ref), connection.select);
2848
3099
  });
2849
3100
  const requirements = plan(visibleStoreOf(remote.entities, remote.optimistic), [...asked.requirements, ...items], { force: true });
3101
+ const invalidated = connections.filter((connection) => remote.connections[connection.identity] !== void 0).map((connection) => connection.identity);
2850
3102
  const marked = [...requirements.length === 0 ? [] : [{
2851
3103
  _tag: "RefreshStarted",
2852
3104
  requests: requirements
2853
- }], ...connections.filter((connection) => remote.connections[connection.identity] !== void 0).map((connection) => ({
3105
+ }], ...invalidated.map((connection) => ({
2854
3106
  _tag: "ConnectionInvalidated",
2855
- connection: connection.identity
3107
+ connection
2856
3108
  }))].reduce(updateRemote, remote);
2857
- const inFlight = remote.refresh.started === remote.refresh.requested && requirements.some((requirement) => requirement.fields.some((field) => remote.entities[entityKey(requirement.entity, requirement.id)]?.stale.has(field) === true));
3109
+ const inFlight = requirements.some((requirement) => requirement.fields.some((field) => remote.entities[entityKey(requirement.entity, requirement.id)]?.stale.has(field) === true && refreshIsInFlight(remote.refresh, requirement, field)));
2858
3110
  if (marked === remote && !inFlight) return model;
2859
- const refresh = {
2860
- ...marked.refresh,
2861
- requested: marked.refresh.requested + 1
2862
- };
3111
+ const refresh = withRefreshRequested(marked.refresh, requirements, invalidated);
2863
3112
  return bound.store.set(model, {
2864
3113
  ...marked,
2865
3114
  refresh
@@ -3034,6 +3283,7 @@ const bindDomain = (definition, store) => {
3034
3283
  },
3035
3284
  plan: (model, projection, options) => Remote.plan(bound, model, projection, options),
3036
3285
  storeOf: (model) => storeOf(bound, model),
3286
+ confirmed: (projection) => confirmed(bound, projection),
3037
3287
  prefetch: (model, projection, options = {}) => Effect.gen(function* () {
3038
3288
  const { policy = RemotePolicy.cacheFirst, now = Date.now } = options;
3039
3289
  const client = yield* RemoteClient;
@@ -3143,6 +3393,19 @@ const bindDomain = (definition, store) => {
3143
3393
  } : void 0;
3144
3394
  },
3145
3395
  refresh: (model, target) => Remote.refresh(bound, model, target),
3396
+ overlay: (model, id, optimistic) => bound.store.set(model, updateRemote(bound.store.get(model), {
3397
+ _tag: "OverlayShown",
3398
+ id,
3399
+ optimistic
3400
+ })),
3401
+ lift: (model, id) => {
3402
+ const remote = bound.store.get(model);
3403
+ const lifted = updateRemote(remote, {
3404
+ _tag: "OverlayLifted",
3405
+ id
3406
+ });
3407
+ return lifted === remote ? model : bound.store.set(model, lifted);
3408
+ },
3146
3409
  fetch: (ref) => ({
3147
3410
  name: `Remote.query(${ref.query})`,
3148
3411
  args: {
@@ -3222,6 +3485,6 @@ const pageSize = (window, key) => {
3222
3485
  return size === void 0 ? {} : { [key]: size };
3223
3486
  };
3224
3487
  //#endregion
3225
- export { ConnectionChange, ConnectionChangeSchema, Entity, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, Requirement, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isLoading, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, mutationStatus, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, refsIn, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, writeEntities, writeEntity, writeRead };
3488
+ export { ConnectionChange, ConnectionChangeSchema, Entity, Live, LiveChange, LiveEdge, LiveRequirement, MAX_FIELDS_PER_REQUEST, MAX_RELATION_DEPTH, Mutate, Mutation, MutationRequest, MutationResult, NormalizedEntity, PageSize, Query, QueryEdge, QueryRequest, QueryResult, QueryRpc, RELATION_ALIAS, REMOTE_CACHE_VERSION, REMOTE_PROTOCOL_VERSION, Read, ReadBatch, ReadBatchResult, ReadRequest, RelationAnnotation, RelationEntityAnnotation, RelationRequest, Remote, RemoteClient, RemoteConnections, RemoteData, RemoteLiveError, RemoteMutationError, RemotePersistence, RemotePolicy, RemoteProtocolError, RemoteQueryError, RemoteReadError, RemoteRequirements, RemoteRpc, Requirement, Selection, WindowSchema, WireBoundary, addLayer, addOverlay, aliasedField, applyConnectionEvent, applyEntityEvent, assemble, beginMutation, beginOptimistic, classifyLive, clearStale, coalesceQueries, coalesceReads, coalescedLayer, connectionChangeOf, connectionIdentity, connectionsOf, cursor, edge, emptyConnection, emptyLiveState, emptyMutationState, emptyOptimistic, emptyRefresh, emptyStore, entityKey, entry, failMutation, gc, hasField, hasNext, hasPrevious, initialRemoteModel, inspectEntity, inspectRemote, invalidateConnection, isFieldStale, isGapped, isLoading, isRefPage, isRemoteMessage, isStale, isTombstone, items, liveEventOf, liveHasNext, liveHasPrevious, markStale, merge, missingFields, mutateRemote, mutationStatus, pageSchema, plan, pruneOverlays, reachable, readField, reconcileMutation, refParts, refreshConnection, refreshIsInFlight, refreshedAt, refsIn, relationAlias, relationOf, relationShape, remoteDataSchema, remoteError, remoteErrorSchema, remoteMessageCases, remoteMessageSchema, remoteModelSchema, remove, removeLayer, removeOverlay, requirementKey, requirementsOf, retentionRootsSchema, schemaOf, segment, setStale, settleFailure, settleSuccess, shouldWake, stableStringify, targetsOf, terminal, tombstone, unknown, updateRemote, visibleItems, visibleStore, windowKey, withRefreshRequested, writeEntities, writeEntity, writeRead };
3226
3489
 
3227
3490
  //# sourceMappingURL=index.mjs.map