@nkzw/fate 1.4.0 → 1.5.1
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 +324 -0
- package/docs/api/classes/FateClient.md +51 -199
- package/docs/api/functions/clientRoot.md +1 -1
- package/docs/api/functions/createClient.md +1 -1
- package/docs/api/functions/createGraphQLTransport.md +1 -1
- package/docs/api/functions/createHTTPTransport.md +1 -1
- package/docs/api/functions/createTRPCTransport.md +5 -1
- package/docs/api/functions/defer.md +1 -1
- package/docs/api/functions/getDeferredMetadata.md +1 -1
- package/docs/api/functions/getListEntries.md +1 -1
- package/docs/api/functions/getSelectionPlan.md +1 -1
- package/docs/api/functions/graphqlMutation.md +1 -1
- package/docs/api/functions/isDeferred.md +1 -1
- package/docs/api/functions/isRecord.md +1 -1
- package/docs/api/functions/isViewTag.md +1 -1
- package/docs/api/functions/liveConnectionTopic.md +1 -1
- package/docs/api/functions/liveEntityTopic.md +1 -1
- package/docs/api/functions/liveGlobalConnectionTopic.md +1 -1
- package/docs/api/functions/mutation.md +1 -1
- package/docs/api/functions/toEntityId.md +1 -1
- package/docs/api/functions/view.md +1 -1
- package/docs/api/index.md +6 -1
- package/docs/api/interfaces/FateThenable.md +3 -3
- package/docs/api/interfaces/Persistence.md +5 -0
- package/docs/api/interfaces/PersistenceSession.md +109 -0
- package/docs/api/interfaces/Transport.md +46 -7
- package/docs/api/server/classes/FateRequestError.md +5 -5
- package/docs/api/server/drizzle/functions/createDrizzleFate.md +1 -1
- package/docs/api/server/drizzle/functions/createDrizzleSourceAdapter.md +1 -1
- package/docs/api/server/drizzle/functions/createDrizzleSourceRegistry.md +1 -1
- package/docs/api/server/drizzle/type-aliases/DrizzleManyToManyConfig.md +1 -1
- package/docs/api/server/drizzle/type-aliases/DrizzleManyToManyInput.md +1 -1
- package/docs/api/server/drizzle/type-aliases/DrizzleQueryExtra.md +3 -3
- package/docs/api/server/drizzle/type-aliases/DrizzleSourceAdapter.md +9 -9
- package/docs/api/server/drizzle/type-aliases/DrizzleViewConfig.md +1 -1
- package/docs/api/server/functions/bindSourceProcedures.md +1 -1
- package/docs/api/server/functions/computed.md +1 -1
- package/docs/api/server/functions/count.md +1 -1
- package/docs/api/server/functions/createFateFetchHandler.md +1 -1
- package/docs/api/server/functions/createFateServer.md +1 -1
- package/docs/api/server/functions/createHonoFateHandler.md +1 -1
- package/docs/api/server/functions/createLiveEventBus.md +1 -1
- package/docs/api/server/functions/createNestedSourcePlan.md +1 -1
- package/docs/api/server/functions/createResolver.md +1 -1
- package/docs/api/server/functions/createSourcePlan.md +1 -1
- package/docs/api/server/functions/createSourceProcedures.md +1 -1
- package/docs/api/server/functions/dataView.md +1 -1
- package/docs/api/server/functions/field.md +1 -1
- package/docs/api/server/functions/getNestedSelection.md +1 -1
- package/docs/api/server/functions/getScopedArgs.md +1 -1
- package/docs/api/server/functions/hasNestedSelection.md +1 -1
- package/docs/api/server/functions/list.md +1 -1
- package/docs/api/server/functions/refetchSourceById.md +1 -1
- package/docs/api/server/functions/resolveSourceById.md +1 -1
- package/docs/api/server/functions/resolveSourceByIds.md +1 -1
- package/docs/api/server/functions/resolveSourceConnection.md +1 -1
- package/docs/api/server/functions/resolver.md +1 -1
- package/docs/api/server/functions/toPrismaSelect.md +1 -1
- package/docs/api/server/functions/withConnection.md +1 -1
- package/docs/api/server/prisma/functions/createPrismaFate.md +1 -1
- package/docs/api/server/prisma/functions/createPrismaSourceAdapter.md +1 -1
- package/docs/api/server/prisma/functions/createPrismaSourceRegistry.md +1 -1
- package/docs/api/server/prisma/functions/prismaConnectionArgs.md +1 -1
- package/docs/api/server/prisma/type-aliases/PrismaDelegate.md +4 -4
- package/docs/api/server/prisma/type-aliases/PrismaQueryExtra.md +1 -1
- package/docs/api/server/prisma/type-aliases/PrismaSourceAdapter.md +9 -9
- package/docs/api/server/prisma/type-aliases/PrismaViewConfig.md +1 -1
- package/docs/api/server/type-aliases/ComputedField.md +5 -5
- package/docs/api/server/type-aliases/ComputedSelection.md +1 -1
- package/docs/api/server/type-aliases/ConnectionItem.md +3 -3
- package/docs/api/server/type-aliases/ConnectionPagination.md +5 -5
- package/docs/api/server/type-aliases/ConnectionResult.md +3 -3
- package/docs/api/server/type-aliases/CountSelection.md +4 -4
- package/docs/api/server/type-aliases/CountWhere.md +1 -1
- package/docs/api/server/type-aliases/DataViewListOptions.md +2 -2
- package/docs/api/server/type-aliases/DataViewOrderBy.md +1 -1
- package/docs/api/server/type-aliases/DataViewOrderDirection.md +1 -1
- package/docs/api/server/type-aliases/DataViewResult.md +1 -1
- package/docs/api/server/type-aliases/Entity.md +1 -1
- package/docs/api/server/type-aliases/FateServer.md +5 -5
- package/docs/api/server/type-aliases/FateServerManifest.md +1 -1
- package/docs/api/server/type-aliases/FieldSelection.md +3 -3
- package/docs/api/server/type-aliases/LiveConnectionEventType.md +1 -1
- package/docs/api/server/type-aliases/LiveConnectionSourceEvent.md +1 -1
- package/docs/api/server/type-aliases/LiveEventBus.md +1 -1
- package/docs/api/server/type-aliases/LiveEventType.md +1 -1
- package/docs/api/server/type-aliases/LiveSourceEvent.md +1 -1
- package/docs/api/server/type-aliases/NativeFateAPI.md +4 -4
- package/docs/api/server/type-aliases/OrderDirection.md +1 -1
- package/docs/api/server/type-aliases/SourceConfig.md +6 -6
- package/docs/api/server/type-aliases/SourceDefinition.md +6 -6
- package/docs/api/server/type-aliases/SourceOrder.md +1 -1
- package/docs/api/server/type-aliases/SourceOrderField.md +3 -3
- package/docs/api/server/type-aliases/SourcePlan.md +1 -1
- package/docs/api/server/type-aliases/SourcePlanNode.md +1 -1
- package/docs/api/server/type-aliases/SourceRegistry.md +1 -1
- package/docs/api/server/type-aliases/SourceRelation.md +1 -1
- package/docs/api/server/type-aliases/SourceRelationConfig.md +1 -1
- package/docs/api/server/variables/byIdInput.md +1 -1
- package/docs/api/server/variables/connectionArgs.md +1 -1
- package/docs/api/type-aliases/ConnectionMetadata.md +1 -1
- package/docs/api/type-aliases/ConnectionRef.md +1 -1
- package/docs/api/type-aliases/Deferred.md +1 -1
- package/docs/api/type-aliases/DeferredMetadata.md +1 -1
- package/docs/api/type-aliases/DeferredSelection.md +1 -1
- package/docs/api/type-aliases/DeferredSnapshot.md +1 -1
- package/docs/api/type-aliases/Entity.md +2 -2
- package/docs/api/type-aliases/EntityId.md +1 -1
- package/docs/api/type-aliases/FateDehydratedState.md +1 -1
- package/docs/api/type-aliases/FateLiveConnectionEvent.md +1 -1
- package/docs/api/type-aliases/FateLiveEvent.md +1 -1
- package/docs/api/type-aliases/FateMutations.md +1 -1
- package/docs/api/type-aliases/FateOperation.md +2 -2
- package/docs/api/type-aliases/FateProtocolRequest.md +1 -1
- package/docs/api/type-aliases/FateProtocolResponse.md +1 -1
- package/docs/api/type-aliases/FateRecord.md +1 -1
- package/docs/api/type-aliases/FateRoots.md +1 -1
- package/docs/api/type-aliases/GraphQLMutationDefinition.md +1 -1
- package/docs/api/type-aliases/GraphQLMutationInput.md +1 -1
- package/docs/api/type-aliases/GraphQLMutationMap.md +1 -1
- package/docs/api/type-aliases/GraphQLMutationOutput.md +1 -1
- package/docs/api/type-aliases/GraphQLTransportOptions.md +19 -11
- package/docs/api/type-aliases/HydrateOptions.md +1 -1
- package/docs/api/type-aliases/HydrationLimits.md +1 -1
- package/docs/api/type-aliases/InferFateAPI.md +1 -1
- package/docs/api/type-aliases/List.md +1 -1
- package/docs/api/type-aliases/ListEntry.md +1 -1
- package/docs/api/type-aliases/ListItem.md +1 -1
- package/docs/api/type-aliases/Mask.md +1 -1
- package/docs/api/type-aliases/MutationDefinition.md +1 -1
- package/docs/api/type-aliases/MutationEntity.md +1 -1
- package/docs/api/type-aliases/MutationIdentifier.md +1 -1
- package/docs/api/type-aliases/MutationIdentity.md +7 -0
- package/docs/api/type-aliases/MutationInput.md +1 -1
- package/docs/api/type-aliases/MutationResult.md +1 -1
- package/docs/api/type-aliases/NodesItem.md +1 -1
- package/docs/api/type-aliases/Pagination.md +5 -5
- package/docs/api/type-aliases/PersistedMutationStatus.md +5 -0
- package/docs/api/type-aliases/PersistenceSnapshot.md +5 -0
- package/docs/api/type-aliases/Request.md +1 -1
- package/docs/api/type-aliases/RequestMode.md +1 -1
- package/docs/api/type-aliases/RequestOptions.md +2 -2
- package/docs/api/type-aliases/RequestPersistenceOptions.md +5 -0
- package/docs/api/type-aliases/RequestResult.md +1 -1
- package/docs/api/type-aliases/Selection.md +1 -1
- package/docs/api/type-aliases/TypeConfig.md +4 -4
- package/docs/api/type-aliases/View.md +1 -1
- package/docs/api/type-aliases/ViewData.md +1 -1
- package/docs/api/type-aliases/ViewEntity.md +1 -1
- package/docs/api/type-aliases/ViewEntityName.md +1 -1
- package/docs/api/type-aliases/ViewRef.md +1 -1
- package/docs/api/type-aliases/ViewSelection.md +1 -1
- package/docs/api/type-aliases/ViewSnapshot.md +1 -1
- package/docs/api/type-aliases/ViewTag.md +1 -1
- package/docs/api/variables/ConnectionTag.md +1 -1
- package/docs/api/variables/DeferTag.md +1 -1
- package/docs/api/variables/DeferredTag.md +1 -1
- package/docs/guide/getting-started.md +2 -0
- package/docs/guide/persistence.md +321 -0
- package/lib/{liveTopics-uVP0LG-Q.mjs → args-CRLIRl_v.mjs} +1 -59
- package/lib/{executor-BRudKWDY.d.mts → executor-oPTYqgQV.d.mts} +1 -1
- package/lib/{graphqlTransport-BDR1pnUQ.d.mts → graphqlTransport-CGYE4NdD.d.mts} +3 -2
- package/lib/{graphqlTransport-DSBVS2Fq.mjs → graphqlTransport-DmheG3AO.mjs} +3 -2
- package/lib/graphqlTransport.d.mts +1 -1
- package/lib/graphqlTransport.mjs +1 -1
- package/lib/hydration-DPZRJgnu.mjs +273 -0
- package/lib/idempotency-types-DuqJ7KSd.d.mts +16 -0
- package/lib/idempotency.d.mts +26 -0
- package/lib/idempotency.mjs +34 -0
- package/lib/index.d.mts +5 -5
- package/lib/index.mjs +275 -1567
- package/lib/{list-Bb1SeABn.d.mts → list-5hrTVoiB.d.mts} +1 -1
- package/lib/list.d.mts +1 -1
- package/lib/liveTopics-DuFkDZQm.mjs +17 -0
- package/lib/persistence.d.mts +40 -0
- package/lib/persistence.mjs +1730 -0
- package/lib/protocol-DS5sf6sd.mjs +44 -0
- package/lib/{record-BTUXkHQU.d.mts → record-Dxjvpsep.d.mts} +8 -4
- package/lib/server/drizzle.d.mts +2 -2
- package/lib/server/drizzle.mjs +1 -1
- package/lib/server/prisma.d.mts +2 -2
- package/lib/server/prisma.mjs +1 -6
- package/lib/server.d.mts +3 -3
- package/lib/server.mjs +19 -7
- package/lib/sortObjectKeys-yhuitEQZ.mjs +9 -0
- package/lib/{sourceRouter-DSaADXSf.mjs → sourceRouter-51POBlEE.mjs} +9 -6
- package/lib/store-CakBBzWG.mjs +1386 -0
- package/lib/{transport-Bo1Jgo9R.d.mts → transport-DR0fwleU.d.mts} +607 -412
- package/lib/vite.mjs +18 -10
- package/package.json +12 -2
- package/docs/api/type-aliases/Snapshot.md +0 -7
|
@@ -0,0 +1,1730 @@
|
|
|
1
|
+
import { t as isRecord } from "./record-B07VwXd-.mjs";
|
|
2
|
+
import { i as filterConnectionArgs } from "./args-CRLIRl_v.mjs";
|
|
3
|
+
import { a as createNodeRef, n as decodeHydrationValue, o as getNodeRefId, r as encodeHydrationValue, s as isNodeRef, t as decodeClientHydrationState } from "./hydration-DPZRJgnu.mjs";
|
|
4
|
+
import { i as getErrorStatusCode, m as toEntityId, n as getListKey, o as prepareMutation } from "./store-CakBBzWG.mjs";
|
|
5
|
+
import { t as FateRequestError } from "./protocol-DS5sf6sd.mjs";
|
|
6
|
+
//#region src/persistence-codec.ts
|
|
7
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
8
|
+
const isLivePolicy = (value) => isRecord(value) && (value.append === void 0 || value.append === "edge" || value.append === "visible") && (value.prepend === void 0 || value.prepend === "edge" || value.prepend === "visible");
|
|
9
|
+
const isSerializedPlan = (value) => isRecord(value) && Array.isArray(value.args) && value.args.every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && isRecord(entry[1]) && typeof entry[1].hash === "string" && isRecord(entry[1].value) && (entry[1].ignoreKeys === void 0 || isStringArray(entry[1].ignoreKeys))) && Array.isArray(value.live) && value.live.every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && isLivePolicy(entry[1])) && isStringArray(value.paths);
|
|
10
|
+
const isSerializedRequest = (value) => isRecord(value) && typeof value.key === "string" && Array.isArray(value.items) && value.items.every((item) => isRecord(item) && isSerializedPlan(item.plan) && (item.kind === "list" ? isStringArray(item.nodeRefViewNames) : (item.kind === "node" || item.kind === "nodes" || item.kind === "query") && isStringArray(item.refViewNames)));
|
|
11
|
+
const serializePlan = (plan) => ({
|
|
12
|
+
args: [...plan.args].map(([key, value]) => [key, {
|
|
13
|
+
...value,
|
|
14
|
+
ignoreKeys: value.ignoreKeys ? [...value.ignoreKeys] : void 0
|
|
15
|
+
}]),
|
|
16
|
+
live: [...plan.live],
|
|
17
|
+
paths: [...plan.paths]
|
|
18
|
+
});
|
|
19
|
+
const deserializePlan = (value) => ({
|
|
20
|
+
args: new Map(value.args.map(([key, entry]) => [key, {
|
|
21
|
+
...entry,
|
|
22
|
+
ignoreKeys: entry.ignoreKeys ? new Set(entry.ignoreKeys) : void 0
|
|
23
|
+
}])),
|
|
24
|
+
live: new Map(value.live),
|
|
25
|
+
paths: new Set(value.paths)
|
|
26
|
+
});
|
|
27
|
+
const encodeRequests = (requests) => encodeHydrationValue(requests.map((request) => ({
|
|
28
|
+
...request,
|
|
29
|
+
items: request.items.map((item) => ({
|
|
30
|
+
...item,
|
|
31
|
+
plan: serializePlan(item.plan),
|
|
32
|
+
..."refViewNames" in item ? { refViewNames: [...item.refViewNames] } : { nodeRefViewNames: [...item.nodeRefViewNames] }
|
|
33
|
+
}))
|
|
34
|
+
})));
|
|
35
|
+
const decodeItem = (item) => item.kind === "list" ? {
|
|
36
|
+
...item,
|
|
37
|
+
nodeRefViewNames: new Set(item.nodeRefViewNames),
|
|
38
|
+
plan: deserializePlan(item.plan)
|
|
39
|
+
} : {
|
|
40
|
+
...item,
|
|
41
|
+
plan: deserializePlan(item.plan),
|
|
42
|
+
refViewNames: new Set(item.refViewNames)
|
|
43
|
+
};
|
|
44
|
+
const decodeRequests = (value) => {
|
|
45
|
+
const requests = decodeHydrationValue(value);
|
|
46
|
+
if (!Array.isArray(requests) || !requests.every(isSerializedRequest)) throw new Error("fate: Invalid persisted request descriptors.");
|
|
47
|
+
return requests.map((request) => ({
|
|
48
|
+
...request,
|
|
49
|
+
items: request.items.map(decodeItem)
|
|
50
|
+
}));
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/persistence-utils.ts
|
|
54
|
+
const encoder = new TextEncoder();
|
|
55
|
+
const persistenceEntrySize = (key, value) => encoder.encode(JSON.stringify([key, value])).byteLength;
|
|
56
|
+
const yieldPersistenceTask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/persistence-cache.ts
|
|
59
|
+
const createNodeChanges = () => ({
|
|
60
|
+
listBases: /* @__PURE__ */ new Map(),
|
|
61
|
+
recordFields: /* @__PURE__ */ new Map(),
|
|
62
|
+
referenceChanges: /* @__PURE__ */ new Set(),
|
|
63
|
+
values: /* @__PURE__ */ new Map()
|
|
64
|
+
});
|
|
65
|
+
const decodeStoredValue = (value) => {
|
|
66
|
+
if (value === void 0) return;
|
|
67
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || !Object.hasOwn(value, "value")) throw new Error("fate: Unsupported or corrupt persisted cache data.");
|
|
68
|
+
return value;
|
|
69
|
+
};
|
|
70
|
+
const mergeCacheUpdate = (key, before, after) => ({
|
|
71
|
+
fields: after.fields && (!before || before.fields) ? [.../* @__PURE__ */ new Set([...before?.fields ?? [], ...after.fields])] : void 0,
|
|
72
|
+
previousList: before ? before.previousList : after.previousList,
|
|
73
|
+
referencesChanged: before?.referencesChanged || after.referencesChanged,
|
|
74
|
+
value: key.startsWith("r:") && before?.value && after.value ? applyRecordUpdate(before.value, after.value, after.fields ? new Set(after.fields) : void 0) : after.value
|
|
75
|
+
});
|
|
76
|
+
const withStoredSize = (key, value) => {
|
|
77
|
+
const item = {
|
|
78
|
+
bytes: 0,
|
|
79
|
+
value
|
|
80
|
+
};
|
|
81
|
+
for (;;) {
|
|
82
|
+
const bytes = persistenceEntrySize(key, item);
|
|
83
|
+
if (bytes === item.bytes) return item;
|
|
84
|
+
item.bytes = bytes;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const stamp = (time) => String(time).padStart(16, "0");
|
|
88
|
+
const recordKey = (id) => `r:${id}`;
|
|
89
|
+
const listKey = (id) => `l:${id}`;
|
|
90
|
+
const queryKey = (id) => `q:${id}`;
|
|
91
|
+
const pathsOverlap = (path, selected) => path === selected || path.startsWith(`${selected}.`) || selected.startsWith(`${path}.`);
|
|
92
|
+
const projectRecord = (value, paths) => {
|
|
93
|
+
const fields = /* @__PURE__ */ new Set([
|
|
94
|
+
"id",
|
|
95
|
+
"__typename",
|
|
96
|
+
...paths.map((path) => path.split(".")[0])
|
|
97
|
+
]);
|
|
98
|
+
return {
|
|
99
|
+
paths: value.paths.filter((path) => paths.some((selected) => pathsOverlap(path, selected))),
|
|
100
|
+
record: Object.fromEntries(Object.entries(value.record).filter(([field]) => fields.has(field)))
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const applyRecordUpdate = (before, after, fields) => ({
|
|
104
|
+
paths: [.../* @__PURE__ */ new Set([...before.paths, ...after.paths.filter((path) => !fields || fields.has(path.split(".")[0]))])],
|
|
105
|
+
record: {
|
|
106
|
+
...before.record,
|
|
107
|
+
...Object.fromEntries(Object.entries(after.record).filter(([key]) => !fields || fields.has(key)))
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const mergeIds = (before, after, latest) => {
|
|
111
|
+
const old = new Set(before);
|
|
112
|
+
const next = new Set(after);
|
|
113
|
+
let retained = latest.filter((id) => !old.has(id) || next.has(id));
|
|
114
|
+
const present = new Set(retained);
|
|
115
|
+
const oldOrder = before.filter((id) => next.has(id));
|
|
116
|
+
const nextOrder = after.filter((id) => old.has(id));
|
|
117
|
+
if (oldOrder.some((id, index) => id !== nextOrder[index])) {
|
|
118
|
+
let index = 0;
|
|
119
|
+
const reordered = nextOrder.filter((id) => present.has(id));
|
|
120
|
+
retained = retained.map((id) => old.has(id) ? reordered[index++] : id);
|
|
121
|
+
}
|
|
122
|
+
const insertions = /* @__PURE__ */ new Map();
|
|
123
|
+
let anchor;
|
|
124
|
+
for (let index = after.length - 1; index >= 0; index--) {
|
|
125
|
+
const id = after[index];
|
|
126
|
+
if (present.has(id)) anchor = id;
|
|
127
|
+
else if (!old.has(id)) {
|
|
128
|
+
const group = insertions.get(anchor) ?? [];
|
|
129
|
+
group.push(id);
|
|
130
|
+
insertions.set(anchor, group);
|
|
131
|
+
present.add(id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...retained.flatMap((id) => [...insertions.get(id)?.reverse() ?? [], id]), ...insertions.get(void 0)?.reverse() ?? []];
|
|
135
|
+
};
|
|
136
|
+
const membershipFields = [
|
|
137
|
+
"ids",
|
|
138
|
+
"liveAfterIds",
|
|
139
|
+
"liveBeforeIds",
|
|
140
|
+
"pendingAfterIds",
|
|
141
|
+
"pendingBeforeIds"
|
|
142
|
+
];
|
|
143
|
+
const applyListUpdate = (latest, before, after) => {
|
|
144
|
+
const previousMembers = new Set(membershipFields.flatMap((field) => before[field] ?? []));
|
|
145
|
+
const latestMembers = new Set(membershipFields.flatMap((field) => latest[field] ?? []));
|
|
146
|
+
const surviving = (ids) => ids.filter((id) => !previousMembers.has(id) || latestMembers.has(id));
|
|
147
|
+
const ids = mergeIds(before.ids, surviving(after.ids), latest.ids);
|
|
148
|
+
const oldCursors = new Map(before.ids.map((id, index) => [id, before.cursors?.[index]]));
|
|
149
|
+
const nextCursors = new Map(after.ids.map((id, index) => [id, after.cursors?.[index]]));
|
|
150
|
+
const cursors = new Map(latest.ids.map((id, index) => [id, latest.cursors?.[index]]));
|
|
151
|
+
for (const [id, cursor] of nextCursors) if (!oldCursors.has(id) || oldCursors.get(id) !== cursor) cursors.set(id, cursor);
|
|
152
|
+
const result = {
|
|
153
|
+
...latest,
|
|
154
|
+
ids
|
|
155
|
+
};
|
|
156
|
+
if (latest.cursors || after.cursors) result.cursors = ids.map((id) => cursors.get(id));
|
|
157
|
+
for (const field of [
|
|
158
|
+
"liveAfterIds",
|
|
159
|
+
"liveBeforeIds",
|
|
160
|
+
"pendingAfterIds",
|
|
161
|
+
"pendingBeforeIds"
|
|
162
|
+
]) if (before[field] || after[field] || latest[field]) result[field] = mergeIds(before[field] ?? [], surviving(after[field] ?? []), latest[field] ?? []);
|
|
163
|
+
for (const field of ["backwardPageLimit", "forwardPageLimit"]) if (before[field] !== after[field]) result[field] = after[field];
|
|
164
|
+
if (after.pagination) result.pagination = {
|
|
165
|
+
...latest.pagination ?? after.pagination,
|
|
166
|
+
...Object.fromEntries(Object.entries(after.pagination).filter(([key, value]) => value !== before.pagination?.[key]))
|
|
167
|
+
};
|
|
168
|
+
return result;
|
|
169
|
+
};
|
|
170
|
+
/** Independently addressable normalized disk data. No in-memory retention roots. */
|
|
171
|
+
var PersistenceCache = class {
|
|
172
|
+
constructor(client, storage, key, maxAge, maxBytes) {
|
|
173
|
+
this.client = client;
|
|
174
|
+
this.storage = storage;
|
|
175
|
+
this.key = key;
|
|
176
|
+
this.maxAge = maxAge;
|
|
177
|
+
this.maxBytes = maxBytes;
|
|
178
|
+
this.pendingNodes = createNodeChanges();
|
|
179
|
+
this.flushingNodes = createNodeChanges();
|
|
180
|
+
this.recoveryUpdates = /* @__PURE__ */ new Map();
|
|
181
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
182
|
+
this.suppressed = false;
|
|
183
|
+
this.disposed = false;
|
|
184
|
+
this.work = 0;
|
|
185
|
+
this.journalBytes = 0;
|
|
186
|
+
this.policies = /* @__PURE__ */ new WeakMap();
|
|
187
|
+
this.prefix = `${JSON.stringify([key, "cache-v2"])}:`;
|
|
188
|
+
}
|
|
189
|
+
async initialize() {
|
|
190
|
+
await this.storage.exclusive(`${this.key}:write`, async () => {
|
|
191
|
+
this.assertActive();
|
|
192
|
+
if (await this.read(`${this.prefix}scope`) !== this.client.getPersistenceScope()) {
|
|
193
|
+
await this.clear();
|
|
194
|
+
if (withStoredSize(`${this.prefix}scope`, this.client.getPersistenceScope()).bytes + this.overhead <= this.maxBytes) await this.put([[`${this.prefix}scope`, this.client.getPersistenceScope()]]);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
assertActive() {
|
|
199
|
+
if (this.disposed) throw new Error("fate: Persistence was disposed.");
|
|
200
|
+
}
|
|
201
|
+
async active() {
|
|
202
|
+
this.assertActive();
|
|
203
|
+
const scope = await this.read(`${this.prefix}scope`);
|
|
204
|
+
this.assertActive();
|
|
205
|
+
return scope === this.client.getPersistenceScope();
|
|
206
|
+
}
|
|
207
|
+
restoreBase(state) {
|
|
208
|
+
this.assertActive();
|
|
209
|
+
this.suppressed = true;
|
|
210
|
+
try {
|
|
211
|
+
this.client.restorePersistenceData(state);
|
|
212
|
+
} finally {
|
|
213
|
+
this.suppressed = false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Save only recovery dependencies, never an unrelated read-cache snapshot. */
|
|
217
|
+
async mutationBase(command, base) {
|
|
218
|
+
const previous = base ? decodeClientHydrationState(base.data).store : void 0;
|
|
219
|
+
const previousRecords = new Map(previous?.records);
|
|
220
|
+
const previousCoverage = new Map(previous?.coverage);
|
|
221
|
+
const previousLists = new Map(previous?.lists);
|
|
222
|
+
const ids = new Set(previousRecords.keys());
|
|
223
|
+
const config = this.client.getTypeConfig(command.entity);
|
|
224
|
+
for (const input of [command.input, command.optimistic]) if (input) try {
|
|
225
|
+
const id = config.getId(input);
|
|
226
|
+
if (id != null) ids.add(toEntityId(command.entity, id));
|
|
227
|
+
} catch {}
|
|
228
|
+
const lists = /* @__PURE__ */ new Set([...previousLists.keys(), ...command.insert !== "none" ? this.client.getPersistenceRootLists(command.entity) : []]);
|
|
229
|
+
for (const id of ids) {
|
|
230
|
+
const owners = this.client.store.getPersistenceOwners(id);
|
|
231
|
+
for (const owner of owners.records) ids.add(owner);
|
|
232
|
+
for (const key of owners.lists) lists.add(key);
|
|
233
|
+
}
|
|
234
|
+
const records = [];
|
|
235
|
+
const coverage = [];
|
|
236
|
+
const visit = (value) => {
|
|
237
|
+
if (isNodeRef(value)) ids.add(getNodeRefId(value));
|
|
238
|
+
else if (Array.isArray(value)) for (const item of value) visit(item);
|
|
239
|
+
else if (value && typeof value === "object") for (const item of Object.values(value)) visit(item);
|
|
240
|
+
};
|
|
241
|
+
for (const id of ids) {
|
|
242
|
+
const key = recordKey(id);
|
|
243
|
+
const saved = previousRecords.get(id);
|
|
244
|
+
const before = saved ? {
|
|
245
|
+
paths: [...previousCoverage.get(id) ?? []],
|
|
246
|
+
record: saved
|
|
247
|
+
} : void 0;
|
|
248
|
+
const memory = this.client.store.readConfirmed(id);
|
|
249
|
+
const disk = memory || before ? void 0 : await this.node(key);
|
|
250
|
+
const recovery = this.recoveryUpdates.get(key);
|
|
251
|
+
const update = this.pendingNodes.values.has(key) ? mergeCacheUpdate(key, recovery, {
|
|
252
|
+
fields: this.pendingNodes.recordFields.get(key) ? [...this.pendingNodes.recordFields.get(key)] : void 0,
|
|
253
|
+
value: this.pendingNodes.values.get(key)
|
|
254
|
+
}) : recovery;
|
|
255
|
+
const incoming = update?.value;
|
|
256
|
+
const value = update ? incoming && (before ? applyRecordUpdate(before, incoming, update.fields ? new Set(update.fields) : void 0) : incoming) : before ?? memory ?? (disk ? decodeHydrationValue(disk.value) : void 0);
|
|
257
|
+
if (value) {
|
|
258
|
+
records.push([id, value.record]);
|
|
259
|
+
coverage.push([id, value.paths]);
|
|
260
|
+
visit(value.record);
|
|
261
|
+
for (const [field, entry] of Object.entries(value.record)) if (Array.isArray(entry)) for (const [key] of this.client.store.getListsForField(id, field)) lists.add(key);
|
|
262
|
+
}
|
|
263
|
+
await this.step();
|
|
264
|
+
}
|
|
265
|
+
const listValues = [];
|
|
266
|
+
for (const key of lists) {
|
|
267
|
+
const value = this.pendingNodes.values.has(listKey(key)) ? this.pendingNodes.values.get(listKey(key)) : this.recoveryUpdates.has(listKey(key)) ? this.recoveryUpdates.get(listKey(key)).value : previousLists.get(key) ?? this.client.store.readConfirmedList(key);
|
|
268
|
+
if (value) listValues.push([key, value]);
|
|
269
|
+
await this.step();
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
data: encodeHydrationValue({
|
|
273
|
+
rootLists: [[command.entity, [...this.client.getPersistenceRootLists(command.entity)]]],
|
|
274
|
+
rootRequests: [],
|
|
275
|
+
store: {
|
|
276
|
+
coverage,
|
|
277
|
+
lists: listValues,
|
|
278
|
+
records
|
|
279
|
+
}
|
|
280
|
+
}),
|
|
281
|
+
scope: this.client.getPersistenceScope(),
|
|
282
|
+
version: 1
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
get overhead() {
|
|
286
|
+
return persistenceEntrySize(`${this.prefix}bytes`, this.maxBytes);
|
|
287
|
+
}
|
|
288
|
+
async step() {
|
|
289
|
+
this.assertActive();
|
|
290
|
+
if (++this.work % 64 === 0) {
|
|
291
|
+
await yieldPersistenceTask();
|
|
292
|
+
this.assertActive();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
nodeKey(key) {
|
|
296
|
+
return `${this.prefix}node:${key}`;
|
|
297
|
+
}
|
|
298
|
+
rootKey(key) {
|
|
299
|
+
return `${this.prefix}root:${key}`;
|
|
300
|
+
}
|
|
301
|
+
async read(key) {
|
|
302
|
+
return (await this.readStored(key))?.value;
|
|
303
|
+
}
|
|
304
|
+
async readStored(key) {
|
|
305
|
+
return decodeStoredValue(await this.storage.read(key));
|
|
306
|
+
}
|
|
307
|
+
async put(entries) {
|
|
308
|
+
let bytes = await this.storage.read(`${this.prefix}bytes`) ?? 0;
|
|
309
|
+
const writes = [];
|
|
310
|
+
const unique = /* @__PURE__ */ new Map();
|
|
311
|
+
for (const [key, value, previousBytes] of entries) unique.set(key, [value, previousBytes]);
|
|
312
|
+
for (const [key, [value, knownPreviousBytes]] of unique) {
|
|
313
|
+
const previousBytes = knownPreviousBytes ?? (await this.readStored(key))?.bytes ?? 0;
|
|
314
|
+
bytes -= previousBytes;
|
|
315
|
+
if (value === void 0) writes.push([key, void 0]);
|
|
316
|
+
else {
|
|
317
|
+
const item = withStoredSize(key, value);
|
|
318
|
+
bytes += item.bytes;
|
|
319
|
+
writes.push([key, item]);
|
|
320
|
+
}
|
|
321
|
+
await this.step();
|
|
322
|
+
}
|
|
323
|
+
writes.push([`${this.prefix}bytes`, Math.max(0, bytes)]);
|
|
324
|
+
this.assertActive();
|
|
325
|
+
await this.storage.writeBatch(writes);
|
|
326
|
+
}
|
|
327
|
+
get hasChanges() {
|
|
328
|
+
return this.pendingNodes.values.size > 0 || this.recoveryUpdates.size > 0;
|
|
329
|
+
}
|
|
330
|
+
changedNode(change) {
|
|
331
|
+
if (!this.suppressed && change) {
|
|
332
|
+
const selected = change.kind === "record" && change.paths ? new Set([...change.paths].map((path) => path.split(".")[0])) : void 0;
|
|
333
|
+
if (change.kind === "record") {
|
|
334
|
+
const id = recordKey(change.key);
|
|
335
|
+
const previous = this.pendingNodes.recordFields.get(id);
|
|
336
|
+
this.pendingNodes.recordFields.set(id, selected?.size && (!this.pendingNodes.recordFields.has(id) || previous) ? /* @__PURE__ */ new Set([...previous ?? [], ...selected]) : void 0);
|
|
337
|
+
if (change.referencesChanged) this.pendingNodes.referenceChanges.add(id);
|
|
338
|
+
}
|
|
339
|
+
const id = change.kind === "record" ? recordKey(change.key) : listKey(change.key);
|
|
340
|
+
const value = change.kind === "record" ? this.client.store.readConfirmed(change.key) : this.client.store.readConfirmedList(change.key);
|
|
341
|
+
if (change.kind === "list" && !this.pendingNodes.values.has(id)) this.pendingNodes.listBases.set(id, change.previousList);
|
|
342
|
+
this.pendingNodes.values.set(id, value);
|
|
343
|
+
if (this.capturing) {
|
|
344
|
+
const previous = this.capturing.get(id);
|
|
345
|
+
this.capturing.set(id, {
|
|
346
|
+
fields: selected?.size && (!previous || previous.fields) ? [.../* @__PURE__ */ new Set([...previous?.fields ?? [], ...selected])] : void 0,
|
|
347
|
+
previousList: previous ? previous.previousList : change.kind === "list" ? change.previousList : void 0,
|
|
348
|
+
referencesChanged: previous?.referencesChanged || change.kind === "record" && change.referencesChanged,
|
|
349
|
+
value
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
/** A recovered receipt has already been checkpointed by another tab. */
|
|
357
|
+
withoutChanges(commit) {
|
|
358
|
+
this.suppressed = true;
|
|
359
|
+
try {
|
|
360
|
+
commit();
|
|
361
|
+
} finally {
|
|
362
|
+
this.suppressed = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** Capture only the confirmed writes made by this mutation for crash recovery. */
|
|
366
|
+
captureChanges(commit) {
|
|
367
|
+
const changes = /* @__PURE__ */ new Map();
|
|
368
|
+
this.capturing = changes;
|
|
369
|
+
try {
|
|
370
|
+
commit();
|
|
371
|
+
} finally {
|
|
372
|
+
this.capturing = void 0;
|
|
373
|
+
}
|
|
374
|
+
for (const [key, update] of changes) {
|
|
375
|
+
changes.set(key, {
|
|
376
|
+
fields: this.pendingNodes.recordFields.get(key) ? [...this.pendingNodes.recordFields.get(key)] : void 0,
|
|
377
|
+
previousList: this.pendingNodes.listBases.has(key) ? this.pendingNodes.listBases.get(key) : update.previousList,
|
|
378
|
+
referencesChanged: this.pendingNodes.referenceChanges.has(key),
|
|
379
|
+
value: this.pendingNodes.values.get(key)
|
|
380
|
+
});
|
|
381
|
+
this.pendingNodes.values.delete(key);
|
|
382
|
+
this.pendingNodes.recordFields.delete(key);
|
|
383
|
+
this.pendingNodes.referenceChanges.delete(key);
|
|
384
|
+
this.pendingNodes.listBases.delete(key);
|
|
385
|
+
}
|
|
386
|
+
return encodeHydrationValue([...changes]);
|
|
387
|
+
}
|
|
388
|
+
replayChanges(updates) {
|
|
389
|
+
this.recoveryUpdates.clear();
|
|
390
|
+
for (const encoded of updates) for (const [key, update] of decodeHydrationValue(encoded)) {
|
|
391
|
+
const compatible = key.startsWith("r:") && update.referencesChanged === void 0 ? {
|
|
392
|
+
...update,
|
|
393
|
+
referencesChanged: true
|
|
394
|
+
} : update;
|
|
395
|
+
this.recoveryUpdates.set(key, mergeCacheUpdate(key, this.recoveryUpdates.get(key), compatible));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
fetched(request, maxAge = this.maxAge, fromNetwork = true) {
|
|
399
|
+
if (!Number.isSafeInteger(maxAge) || maxAge < 0 || maxAge > Number.MAX_SAFE_INTEGER - Date.now()) throw new Error("fate: Persistence maxAge must be a non-negative finite duration.");
|
|
400
|
+
if (!fromNetwork && this.policies.get(request) === maxAge) return;
|
|
401
|
+
this.policies.set(request, maxAge);
|
|
402
|
+
const previous = this.pendingRequests.get(request.key);
|
|
403
|
+
const retain = this.client.retainPersistenceRequest(request);
|
|
404
|
+
previous?.release();
|
|
405
|
+
this.pendingRequests.set(request.key, {
|
|
406
|
+
fetchedAt: fromNetwork ? Date.now() : previous?.fetchedAt,
|
|
407
|
+
maxAge,
|
|
408
|
+
release: retain.dispose,
|
|
409
|
+
request
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async node(key) {
|
|
413
|
+
const node = await this.read(this.nodeKey(key));
|
|
414
|
+
if (!node) return;
|
|
415
|
+
node.claims = Object.fromEntries(Object.entries(node.claims).filter(([, claim]) => claim.expiresAt > Date.now()));
|
|
416
|
+
if (!Object.keys(node.claims).length) return;
|
|
417
|
+
if (key.startsWith("r:")) {
|
|
418
|
+
const paths = Object.values(node.claims).flatMap((claim) => claim.paths);
|
|
419
|
+
node.value = encodeHydrationValue(projectRecord(decodeHydrationValue(node.value), paths));
|
|
420
|
+
}
|
|
421
|
+
return node;
|
|
422
|
+
}
|
|
423
|
+
/** Walk only the requested selection, yielding between small pieces of work. */
|
|
424
|
+
async collect(request, source) {
|
|
425
|
+
const readsMemory = source !== "disk";
|
|
426
|
+
const prefersNetworkResult = source === "network";
|
|
427
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
428
|
+
const visited = /* @__PURE__ */ new Map();
|
|
429
|
+
let complete = true;
|
|
430
|
+
let fetchedAt = Infinity;
|
|
431
|
+
const queue = [];
|
|
432
|
+
const readNode = async (key) => {
|
|
433
|
+
await this.step();
|
|
434
|
+
if (readsMemory) {
|
|
435
|
+
if (this.flushingNodes.values.has(key)) {
|
|
436
|
+
const incoming = this.flushingNodes.values.get(key);
|
|
437
|
+
const base = this.flushingNodes.listBases.get(key);
|
|
438
|
+
if (!prefersNetworkResult && incoming && base && key.startsWith("l:")) {
|
|
439
|
+
const stored = await this.node(key);
|
|
440
|
+
if (stored) return applyListUpdate(decodeHydrationValue(stored.value), base, incoming);
|
|
441
|
+
}
|
|
442
|
+
if (incoming && key.startsWith("r:") && this.flushingNodes.recordFields.get(key)) {
|
|
443
|
+
const stored = await this.node(key);
|
|
444
|
+
if (stored) return applyRecordUpdate(decodeHydrationValue(stored.value), incoming, this.flushingNodes.recordFields.get(key));
|
|
445
|
+
}
|
|
446
|
+
return incoming;
|
|
447
|
+
}
|
|
448
|
+
if (prefersNetworkResult) {
|
|
449
|
+
const id = key.slice(2);
|
|
450
|
+
const value = key.startsWith("r:") ? this.client.store.readConfirmed(id) : key.startsWith("l:") ? this.client.store.readConfirmedList(id) : this.client.getPersistenceQuery(id);
|
|
451
|
+
if (value !== void 0) return value;
|
|
452
|
+
if (this.pendingNodes.values.has(key)) return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const stored = await this.node(key);
|
|
456
|
+
if (stored) for (const claim of Object.values(stored.claims)) fetchedAt = Math.min(fetchedAt, claim.fetchedAt);
|
|
457
|
+
if (!stored) return readsMemory && key.startsWith("r:") ? this.client.store.readConfirmed(key.slice(2)) : void 0;
|
|
458
|
+
const value = decodeHydrationValue(stored.value);
|
|
459
|
+
if (!readsMemory) return value;
|
|
460
|
+
const deleted = (id) => this.flushingNodes.values.has(recordKey(id)) && this.flushingNodes.values.get(recordKey(id)) === void 0;
|
|
461
|
+
if (key.startsWith("l:")) {
|
|
462
|
+
const list = value;
|
|
463
|
+
return {
|
|
464
|
+
...list,
|
|
465
|
+
cursors: list.cursors?.filter((_, index) => !deleted(list.ids[index])),
|
|
466
|
+
ids: list.ids.filter((id) => !deleted(id)),
|
|
467
|
+
liveAfterIds: list.liveAfterIds?.filter((id) => !deleted(id)),
|
|
468
|
+
liveBeforeIds: list.liveBeforeIds?.filter((id) => !deleted(id)),
|
|
469
|
+
pendingAfterIds: list.pendingAfterIds?.filter((id) => !deleted(id)),
|
|
470
|
+
pendingBeforeIds: list.pendingBeforeIds?.filter((id) => !deleted(id))
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (key.startsWith("r:")) {
|
|
474
|
+
const record = value;
|
|
475
|
+
return {
|
|
476
|
+
...record,
|
|
477
|
+
record: Object.fromEntries(Object.entries(record.record).map(([field, value]) => [field, isNodeRef(value) && deleted(getNodeRefId(value)) ? null : Array.isArray(value) ? value.filter((item) => !isNodeRef(item) || !deleted(getNodeRefId(item))) : value]))
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return typeof value === "string" && deleted(value) ? null : value;
|
|
481
|
+
};
|
|
482
|
+
const addList = async (key, paths, plan, prefix) => {
|
|
483
|
+
const list = await readNode(listKey(key));
|
|
484
|
+
if (!list) {
|
|
485
|
+
complete = false;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
nodes.set(listKey(key), {
|
|
489
|
+
paths: /* @__PURE__ */ new Set(),
|
|
490
|
+
value: list
|
|
491
|
+
});
|
|
492
|
+
for (const id of [
|
|
493
|
+
...list.ids,
|
|
494
|
+
...list.pendingBeforeIds ?? [],
|
|
495
|
+
...list.pendingAfterIds ?? [],
|
|
496
|
+
...list.liveBeforeIds ?? [],
|
|
497
|
+
...list.liveAfterIds ?? []
|
|
498
|
+
]) queue.push({
|
|
499
|
+
id,
|
|
500
|
+
paths,
|
|
501
|
+
plan,
|
|
502
|
+
prefix
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
for (const item of request.items) {
|
|
506
|
+
const paths = [...item.plan.paths];
|
|
507
|
+
if (item.kind === "node" || item.kind === "nodes") for (const id of item.ids) queue.push({
|
|
508
|
+
id: toEntityId(item.type, id),
|
|
509
|
+
paths,
|
|
510
|
+
plan: item.plan,
|
|
511
|
+
prefix: ""
|
|
512
|
+
});
|
|
513
|
+
else if (item.kind === "query") {
|
|
514
|
+
const id = await readNode(queryKey(item.queryKey));
|
|
515
|
+
if (id === void 0) {
|
|
516
|
+
complete = false;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
nodes.set(queryKey(item.queryKey), {
|
|
520
|
+
paths: /* @__PURE__ */ new Set(),
|
|
521
|
+
value: id
|
|
522
|
+
});
|
|
523
|
+
if (typeof id === "string") queue.push({
|
|
524
|
+
id,
|
|
525
|
+
paths,
|
|
526
|
+
plan: item.plan,
|
|
527
|
+
prefix: ""
|
|
528
|
+
});
|
|
529
|
+
} else if (item.kind === "list") await addList(item.listKey, paths, item.plan, "");
|
|
530
|
+
}
|
|
531
|
+
for (let index = 0; index < queue.length; index++) {
|
|
532
|
+
const { id, paths, plan, prefix } = queue[index];
|
|
533
|
+
const key = recordKey(id);
|
|
534
|
+
const seen = nodes.get(key);
|
|
535
|
+
const contexts = visited.get(plan) ?? /* @__PURE__ */ new Map();
|
|
536
|
+
visited.set(plan, contexts);
|
|
537
|
+
const context = JSON.stringify([id, prefix]);
|
|
538
|
+
const traversed = contexts.get(context);
|
|
539
|
+
const needed = paths.filter((path) => !traversed?.has(path));
|
|
540
|
+
if (traversed && !needed.length) continue;
|
|
541
|
+
contexts.set(context, /* @__PURE__ */ new Set([...traversed ?? [], ...paths]));
|
|
542
|
+
const record = await readNode(key);
|
|
543
|
+
if (!record) {
|
|
544
|
+
complete = false;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (paths.some((path) => !record.paths.some((covered) => path === covered || path.startsWith(`${covered}.`)))) complete = false;
|
|
548
|
+
const all = /* @__PURE__ */ new Set([...seen?.paths ?? [], ...paths]);
|
|
549
|
+
nodes.set(key, {
|
|
550
|
+
paths: all,
|
|
551
|
+
value: projectRecord(record, [...all])
|
|
552
|
+
});
|
|
553
|
+
const groups = /* @__PURE__ */ new Map();
|
|
554
|
+
for (const path of needed) {
|
|
555
|
+
const [field, ...rest] = path.split(".");
|
|
556
|
+
const children = groups.get(field) ?? [];
|
|
557
|
+
if (rest.length) children.push(rest.join("."));
|
|
558
|
+
groups.set(field, children);
|
|
559
|
+
}
|
|
560
|
+
const walk = (value, children, childPrefix) => {
|
|
561
|
+
if (isNodeRef(value)) queue.push({
|
|
562
|
+
id: getNodeRefId(value),
|
|
563
|
+
paths: children,
|
|
564
|
+
plan,
|
|
565
|
+
prefix: childPrefix
|
|
566
|
+
});
|
|
567
|
+
else if (Array.isArray(value)) for (const item of value) walk(item, children, childPrefix);
|
|
568
|
+
else if (value && typeof value === "object" && children.length) for (const child of children) {
|
|
569
|
+
const [field, ...rest] = child.split(".");
|
|
570
|
+
walk(value[field], rest.length ? [rest.join(".")] : [], `${childPrefix}.${field}`);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
for (const [field, children] of groups) {
|
|
574
|
+
const childPrefix = prefix ? `${prefix}.${field}` : field;
|
|
575
|
+
const value = record.record[field];
|
|
576
|
+
if (Array.isArray(value)) {
|
|
577
|
+
const nestedKey = getListKey(id, field, plan.args.get(childPrefix)?.hash);
|
|
578
|
+
const list = await readNode(listKey(nestedKey));
|
|
579
|
+
if (list) {
|
|
580
|
+
const projected = nodes.get(key).value;
|
|
581
|
+
projected.record[field] = list.ids.map(createNodeRef);
|
|
582
|
+
await addList(nestedKey, children, plan, childPrefix);
|
|
583
|
+
} else walk(value, children, childPrefix);
|
|
584
|
+
} else walk(value, children, childPrefix);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
complete,
|
|
589
|
+
fetchedAt,
|
|
590
|
+
nodes
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async restore(request) {
|
|
594
|
+
await this.storage.exclusive(`${this.key}:write`, async () => {
|
|
595
|
+
if (!await this.active()) return;
|
|
596
|
+
const { complete, nodes } = await this.collect(request, "disk");
|
|
597
|
+
if (!complete) return;
|
|
598
|
+
for (const [key, node] of nodes) {
|
|
599
|
+
this.assertActive();
|
|
600
|
+
const id = key.slice(2);
|
|
601
|
+
const state = {
|
|
602
|
+
rootLists: [],
|
|
603
|
+
rootRequests: [],
|
|
604
|
+
store: {
|
|
605
|
+
coverage: [],
|
|
606
|
+
lists: [],
|
|
607
|
+
records: []
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
if (key.startsWith("r:")) {
|
|
611
|
+
const value = node.value;
|
|
612
|
+
state.store.records.push([id, value.record]);
|
|
613
|
+
state.store.coverage.push([id, value.paths]);
|
|
614
|
+
} else if (key.startsWith("l:")) {
|
|
615
|
+
state.store.lists.push([id, node.value]);
|
|
616
|
+
for (const item of request.items) if (item.kind === "list" && item.listKey === id && !filterConnectionArgs(item.argsPayload)) state.rootLists.push([item.type, [id]]);
|
|
617
|
+
} else state.rootRequests.push([id, node.value]);
|
|
618
|
+
this.suppressed = true;
|
|
619
|
+
try {
|
|
620
|
+
this.client.restorePersistenceData(state);
|
|
621
|
+
} finally {
|
|
622
|
+
this.suppressed = false;
|
|
623
|
+
}
|
|
624
|
+
await this.step();
|
|
625
|
+
}
|
|
626
|
+
const root = await this.read(this.rootKey(request.key));
|
|
627
|
+
if (root && root.fetchedAt + root.maxAge > Date.now()) {
|
|
628
|
+
const old = `${this.prefix}lru:${stamp(root.usedAt)}:${request.key}`;
|
|
629
|
+
root.usedAt = Date.now();
|
|
630
|
+
await this.put([
|
|
631
|
+
[old, void 0],
|
|
632
|
+
[this.rootKey(request.key), root],
|
|
633
|
+
[`${this.prefix}lru:${stamp(root.usedAt)}:${request.key}`, request.key]
|
|
634
|
+
]);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
async remove(rootId) {
|
|
639
|
+
const root = await this.read(this.rootKey(rootId));
|
|
640
|
+
if (!root) return;
|
|
641
|
+
let writes = [];
|
|
642
|
+
for (const key of root.nodes) {
|
|
643
|
+
const storageKey = this.nodeKey(key);
|
|
644
|
+
const stored = await this.readStored(storageKey);
|
|
645
|
+
const node = stored?.value;
|
|
646
|
+
if (node) {
|
|
647
|
+
delete node.claims[rootId];
|
|
648
|
+
const live = Object.values(node.claims).filter((claim) => claim.expiresAt > Date.now());
|
|
649
|
+
if (key.startsWith("r:") && live.length) node.value = encodeHydrationValue(projectRecord(decodeHydrationValue(node.value), live.flatMap((claim) => claim.paths)));
|
|
650
|
+
writes.push([
|
|
651
|
+
storageKey,
|
|
652
|
+
live.length ? node : void 0,
|
|
653
|
+
stored.bytes
|
|
654
|
+
]);
|
|
655
|
+
if (writes.length === 64) {
|
|
656
|
+
await this.put(writes);
|
|
657
|
+
writes = [];
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
await this.step();
|
|
661
|
+
}
|
|
662
|
+
if (writes.length) await this.put(writes);
|
|
663
|
+
await this.put([
|
|
664
|
+
[this.rootKey(rootId), void 0],
|
|
665
|
+
[`${this.prefix}lru:${stamp(root.usedAt)}:${rootId}`, void 0],
|
|
666
|
+
[`${this.prefix}expiry:${stamp(root.fetchedAt + root.maxAge)}:${rootId}`, void 0]
|
|
667
|
+
]);
|
|
668
|
+
}
|
|
669
|
+
async save(request, fetchedAt, maxAge, usedAt = Date.now(), fromNetwork = false) {
|
|
670
|
+
if (!maxAge || fetchedAt + maxAge <= Date.now()) {
|
|
671
|
+
await this.remove(request.key);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const { nodes } = await this.collect(request, fromNetwork ? "network" : "memory");
|
|
675
|
+
const root = {
|
|
676
|
+
fetchedAt,
|
|
677
|
+
maxAge,
|
|
678
|
+
nodes: [...nodes.keys()],
|
|
679
|
+
request: encodeRequests([request]),
|
|
680
|
+
usedAt
|
|
681
|
+
};
|
|
682
|
+
let minimum = withStoredSize(this.rootKey(request.key), root).bytes + withStoredSize(`${this.prefix}lru:${stamp(usedAt)}:${request.key}`, request.key).bytes + withStoredSize(`${this.prefix}expiry:${stamp(fetchedAt + maxAge)}:${request.key}`, request.key).bytes + withStoredSize(`${this.prefix}scope`, this.client.getPersistenceScope()).bytes;
|
|
683
|
+
for (const [key, node] of nodes) {
|
|
684
|
+
minimum += withStoredSize(this.nodeKey(key), {
|
|
685
|
+
claims: { [request.key]: {
|
|
686
|
+
expiresAt: fetchedAt + maxAge,
|
|
687
|
+
fetchedAt,
|
|
688
|
+
paths: [...node.paths]
|
|
689
|
+
} },
|
|
690
|
+
value: encodeHydrationValue(node.value)
|
|
691
|
+
}).bytes;
|
|
692
|
+
await this.step();
|
|
693
|
+
}
|
|
694
|
+
if (minimum + this.journalBytes + this.overhead > this.maxBytes) {
|
|
695
|
+
await this.remove(request.key);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const prepare = async () => {
|
|
699
|
+
const old = await this.read(this.rootKey(request.key));
|
|
700
|
+
const writes = /* @__PURE__ */ new Map();
|
|
701
|
+
if (old) {
|
|
702
|
+
writes.set(`${this.prefix}lru:${stamp(old.usedAt)}:${request.key}`, void 0);
|
|
703
|
+
writes.set(`${this.prefix}expiry:${stamp(old.fetchedAt + old.maxAge)}:${request.key}`, void 0);
|
|
704
|
+
}
|
|
705
|
+
writes.set(this.rootKey(request.key), {
|
|
706
|
+
...root,
|
|
707
|
+
nodes: [.../* @__PURE__ */ new Set([...root.nodes, ...old?.nodes ?? []])]
|
|
708
|
+
});
|
|
709
|
+
writes.set(`${this.prefix}lru:${stamp(root.usedAt)}:${request.key}`, request.key);
|
|
710
|
+
writes.set(`${this.prefix}expiry:${stamp(fetchedAt + maxAge)}:${request.key}`, request.key);
|
|
711
|
+
for (const key of /* @__PURE__ */ new Set([...nodes.keys(), ...old?.nodes ?? []])) {
|
|
712
|
+
const previous = await this.node(key);
|
|
713
|
+
const next = nodes.get(key);
|
|
714
|
+
const claims = previous?.claims ?? {};
|
|
715
|
+
if (!next) delete claims[request.key];
|
|
716
|
+
else claims[request.key] = {
|
|
717
|
+
expiresAt: fetchedAt + maxAge,
|
|
718
|
+
fetchedAt,
|
|
719
|
+
paths: [...next.paths]
|
|
720
|
+
};
|
|
721
|
+
let value = next?.value;
|
|
722
|
+
if (previous && key.startsWith("r:")) {
|
|
723
|
+
const before = decodeHydrationValue(previous.value);
|
|
724
|
+
const after = next?.value;
|
|
725
|
+
value = projectRecord({
|
|
726
|
+
paths: [.../* @__PURE__ */ new Set([...before.paths, ...after?.paths ?? []])],
|
|
727
|
+
record: {
|
|
728
|
+
...before.record,
|
|
729
|
+
...after?.record
|
|
730
|
+
}
|
|
731
|
+
}, Object.values(claims).flatMap((claim) => claim.paths));
|
|
732
|
+
}
|
|
733
|
+
writes.set(this.nodeKey(key), Object.keys(claims).length ? {
|
|
734
|
+
claims,
|
|
735
|
+
value: value !== void 0 ? encodeHydrationValue(value) : previous.value
|
|
736
|
+
} : void 0);
|
|
737
|
+
await this.step();
|
|
738
|
+
}
|
|
739
|
+
return writes;
|
|
740
|
+
};
|
|
741
|
+
let writes = await prepare();
|
|
742
|
+
for (;;) {
|
|
743
|
+
let added = 0;
|
|
744
|
+
for (const [key, value] of writes) {
|
|
745
|
+
const before = await this.readStored(key);
|
|
746
|
+
added += Math.max(0, (value === void 0 ? 0 : withStoredSize(key, value).bytes) - (before?.bytes ?? 0));
|
|
747
|
+
await this.step();
|
|
748
|
+
}
|
|
749
|
+
if ((await this.storage.read(`${this.prefix}bytes`) ?? 0) + added + this.journalBytes + this.overhead <= this.maxBytes) break;
|
|
750
|
+
const candidate = (await this.storage.scan(`${this.prefix}lru:`, void 0, 2)).find((entry) => decodeStoredValue(entry.value)?.value !== request.key);
|
|
751
|
+
if (!candidate) {
|
|
752
|
+
await this.remove(request.key);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const id = decodeStoredValue(candidate.value).value;
|
|
756
|
+
if (await this.read(this.rootKey(id))) await this.remove(id);
|
|
757
|
+
else await this.put([[candidate.key, void 0]]);
|
|
758
|
+
writes = await prepare();
|
|
759
|
+
}
|
|
760
|
+
const entries = [...writes];
|
|
761
|
+
for (let index = 0; index < entries.length; index += 64) {
|
|
762
|
+
await this.put(entries.slice(index, index + 64));
|
|
763
|
+
await yieldPersistenceTask();
|
|
764
|
+
}
|
|
765
|
+
await this.put([[this.rootKey(request.key), root]]);
|
|
766
|
+
}
|
|
767
|
+
/** Called under the shared write lock. Expiry/LRU indexes avoid whole-cache scans. */
|
|
768
|
+
async prune(journalBytes) {
|
|
769
|
+
this.journalBytes = journalBytes;
|
|
770
|
+
while (true) {
|
|
771
|
+
const [entry] = await this.storage.scan(`${this.prefix}expiry:`, void 0, 1);
|
|
772
|
+
if (!entry) break;
|
|
773
|
+
const id = decodeStoredValue(entry.value).value;
|
|
774
|
+
const root = await this.read(this.rootKey(id));
|
|
775
|
+
if (root && root.fetchedAt + root.maxAge > Date.now()) break;
|
|
776
|
+
if (root) await this.remove(id);
|
|
777
|
+
else await this.put([[entry.key, void 0]]);
|
|
778
|
+
await this.step();
|
|
779
|
+
}
|
|
780
|
+
while ((await this.storage.read(`${this.prefix}bytes`) ?? 0) + journalBytes + this.overhead > this.maxBytes) {
|
|
781
|
+
const [entry] = await this.storage.scan(`${this.prefix}lru:`, void 0, 1);
|
|
782
|
+
if (!entry) break;
|
|
783
|
+
const id = decodeStoredValue(entry.value).value;
|
|
784
|
+
if (await this.read(this.rootKey(id))) await this.remove(id);
|
|
785
|
+
else await this.put([[entry.key, void 0]]);
|
|
786
|
+
await this.step();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async fits(journalBytes) {
|
|
790
|
+
return (await this.storage.read(`${this.prefix}bytes`) ?? 0) + journalBytes + this.overhead <= this.maxBytes;
|
|
791
|
+
}
|
|
792
|
+
/** Check fixed cache metadata before evicting any saved requests for a new journal entry. */
|
|
793
|
+
async fitsWithoutEviction(journalBytes) {
|
|
794
|
+
this.assertActive();
|
|
795
|
+
const scope = await this.storage.read(`${this.prefix}scope`);
|
|
796
|
+
this.assertActive();
|
|
797
|
+
return (scope?.bytes ?? 0) + journalBytes + this.overhead <= this.maxBytes;
|
|
798
|
+
}
|
|
799
|
+
async flush(journalBytes) {
|
|
800
|
+
if (!await this.active()) {
|
|
801
|
+
this.reset();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
await this.prune(journalBytes);
|
|
805
|
+
const pending = this.pendingRequests;
|
|
806
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
807
|
+
const pendingNodes = this.pendingNodes;
|
|
808
|
+
this.pendingNodes = createNodeChanges();
|
|
809
|
+
const { listBases, recordFields, referenceChanges, values } = pendingNodes;
|
|
810
|
+
const recovery = this.recoveryUpdates;
|
|
811
|
+
this.recoveryUpdates = /* @__PURE__ */ new Map();
|
|
812
|
+
const updates = new Map(recovery);
|
|
813
|
+
for (const [key, value] of values) updates.set(key, mergeCacheUpdate(key, updates.get(key), {
|
|
814
|
+
fields: recordFields.get(key) ? [...recordFields.get(key)] : void 0,
|
|
815
|
+
previousList: listBases.get(key),
|
|
816
|
+
referencesChanged: referenceChanges.has(key),
|
|
817
|
+
value
|
|
818
|
+
}));
|
|
819
|
+
this.flushingNodes = {
|
|
820
|
+
listBases: new Map([...updates].map(([key, update]) => [key, update.previousList])),
|
|
821
|
+
recordFields: new Map([...updates].map(([key, update]) => [key, update.fields ? new Set(update.fields) : void 0])),
|
|
822
|
+
referenceChanges: new Set([...updates].filter(([, update]) => update.referencesChanged).map(([key]) => key)),
|
|
823
|
+
values: new Map([...updates].map(([key, update]) => [key, update.value]))
|
|
824
|
+
};
|
|
825
|
+
try {
|
|
826
|
+
const affected = /* @__PURE__ */ new Map();
|
|
827
|
+
for (const key of this.flushingNodes.values.keys()) {
|
|
828
|
+
const node = await this.node(key);
|
|
829
|
+
const incoming = this.flushingNodes.values.get(key);
|
|
830
|
+
if (node && incoming && key.startsWith("r:") && !this.flushingNodes.referenceChanges.has(key)) {
|
|
831
|
+
const before = decodeHydrationValue(node.value);
|
|
832
|
+
const after = applyRecordUpdate(before, incoming, this.flushingNodes.recordFields.get(key));
|
|
833
|
+
const value = encodeHydrationValue(projectRecord({
|
|
834
|
+
paths: [.../* @__PURE__ */ new Set([...before.paths, ...after.paths])],
|
|
835
|
+
record: {
|
|
836
|
+
...before.record,
|
|
837
|
+
...after.record
|
|
838
|
+
}
|
|
839
|
+
}, Object.values(node.claims).flatMap((claim) => claim.paths)));
|
|
840
|
+
const next = {
|
|
841
|
+
...node,
|
|
842
|
+
value
|
|
843
|
+
};
|
|
844
|
+
const beforeSize = (await this.readStored(this.nodeKey(key))).bytes;
|
|
845
|
+
const growth = Math.max(0, withStoredSize(this.nodeKey(key), next).bytes - beforeSize);
|
|
846
|
+
await this.prune(journalBytes + growth);
|
|
847
|
+
const retained = await this.node(key);
|
|
848
|
+
if (retained) {
|
|
849
|
+
next.claims = retained.claims;
|
|
850
|
+
next.value = encodeHydrationValue(projectRecord(decodeHydrationValue(value), Object.values(retained.claims).flatMap((claim) => claim.paths)));
|
|
851
|
+
await this.put([[this.nodeKey(key), next]]);
|
|
852
|
+
}
|
|
853
|
+
this.journalBytes = journalBytes;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
for (const rootId of Object.keys(node?.claims ?? {})) {
|
|
857
|
+
if (pending.has(rootId) || affected.has(rootId)) continue;
|
|
858
|
+
const root = await this.read(this.rootKey(rootId));
|
|
859
|
+
if (root) affected.set(rootId, root);
|
|
860
|
+
}
|
|
861
|
+
await this.step();
|
|
862
|
+
}
|
|
863
|
+
for (const root of affected.values()) await this.save(decodeRequests(root.request)[0], root.fetchedAt, root.maxAge, root.usedAt);
|
|
864
|
+
for (const { fetchedAt, maxAge, request } of pending.values()) if (fetchedAt !== void 0) await this.save(request, fetchedAt, maxAge, Date.now(), true);
|
|
865
|
+
for (const { fetchedAt, maxAge, request } of pending.values()) {
|
|
866
|
+
if (fetchedAt !== void 0) continue;
|
|
867
|
+
if (!maxAge) {
|
|
868
|
+
await this.remove(request.key);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const cached = await this.collect(request, "disk");
|
|
872
|
+
if (cached.complete && Number.isFinite(cached.fetchedAt)) {
|
|
873
|
+
const root = await this.read(this.rootKey(request.key));
|
|
874
|
+
await this.save(request, root?.fetchedAt ?? cached.fetchedAt, maxAge);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
this.flushingNodes.values.clear();
|
|
878
|
+
await this.prune(journalBytes);
|
|
879
|
+
} catch (error) {
|
|
880
|
+
if (!this.disposed) {
|
|
881
|
+
for (const [key, update] of recovery) this.recoveryUpdates.set(key, this.recoveryUpdates.has(key) ? mergeCacheUpdate(key, update, this.recoveryUpdates.get(key)) : update);
|
|
882
|
+
for (const [key, value] of values) {
|
|
883
|
+
if (referenceChanges.has(key)) this.pendingNodes.referenceChanges.add(key);
|
|
884
|
+
if (key.startsWith("l:")) this.pendingNodes.listBases.set(key, listBases.get(key));
|
|
885
|
+
if (!this.pendingNodes.values.has(key)) {
|
|
886
|
+
this.pendingNodes.values.set(key, value);
|
|
887
|
+
this.pendingNodes.recordFields.set(key, recordFields.get(key));
|
|
888
|
+
} else if (key.startsWith("r:")) {
|
|
889
|
+
const before = recordFields.get(key);
|
|
890
|
+
const after = this.pendingNodes.recordFields.get(key);
|
|
891
|
+
this.pendingNodes.recordFields.set(key, before && after ? /* @__PURE__ */ new Set([...before, ...after]) : void 0);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
for (const [key, entry] of pending) if (!this.pendingRequests.has(key)) {
|
|
895
|
+
this.pendingRequests.set(key, entry);
|
|
896
|
+
pending.delete(key);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
throw error;
|
|
900
|
+
} finally {
|
|
901
|
+
this.flushingNodes = createNodeChanges();
|
|
902
|
+
for (const entry of pending.values()) entry.release();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async clear() {
|
|
906
|
+
this.assertActive();
|
|
907
|
+
this.reset();
|
|
908
|
+
while (true) {
|
|
909
|
+
const entries = await this.storage.scan(this.prefix, void 0, 64);
|
|
910
|
+
this.assertActive();
|
|
911
|
+
if (!entries.length) return;
|
|
912
|
+
await this.storage.writeBatch(entries.map(({ key }) => [key, void 0]));
|
|
913
|
+
await yieldPersistenceTask();
|
|
914
|
+
this.assertActive();
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
dispose() {
|
|
918
|
+
this.disposed = true;
|
|
919
|
+
this.reset();
|
|
920
|
+
}
|
|
921
|
+
reset() {
|
|
922
|
+
this.recoveryUpdates.clear();
|
|
923
|
+
this.policies = /* @__PURE__ */ new WeakMap();
|
|
924
|
+
for (const entry of this.pendingRequests.values()) entry.release();
|
|
925
|
+
this.pendingRequests.clear();
|
|
926
|
+
this.pendingNodes = createNodeChanges();
|
|
927
|
+
this.flushingNodes = createNodeChanges();
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/persistence-journal.ts
|
|
932
|
+
const invalid = () => /* @__PURE__ */ new Error("fate: Unsupported or corrupt persistence data. It has not been overwritten.");
|
|
933
|
+
const valid = (value) => {
|
|
934
|
+
if (!value || typeof value !== "object") return false;
|
|
935
|
+
const entry = value;
|
|
936
|
+
if (typeof entry.id !== "string" || typeof entry.scope !== "string" || !Number.isInteger(entry.attempts) || entry.attempts < 0) return false;
|
|
937
|
+
if (entry.status === "failed") return !!entry.error && typeof entry.error === "object" && typeof entry.error.message === "string";
|
|
938
|
+
if (entry.status === "confirmed") return "cacheUpdates" in entry && "result" in entry;
|
|
939
|
+
return entry.status === "queued" || entry.status === "sending";
|
|
940
|
+
};
|
|
941
|
+
const hasSameFields = (left, right) => {
|
|
942
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)])].every((key) => left[key] === right[key]);
|
|
943
|
+
};
|
|
944
|
+
/** All methods run under the account's write lock, including reads across scan pages. */
|
|
945
|
+
var PersistenceJournal = class {
|
|
946
|
+
constructor(storage, key, validate) {
|
|
947
|
+
this.storage = storage;
|
|
948
|
+
this.key = key;
|
|
949
|
+
this.validate = validate;
|
|
950
|
+
this.disposed = false;
|
|
951
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
952
|
+
this.prefix = `${JSON.stringify([key, "journal"])}:`;
|
|
953
|
+
}
|
|
954
|
+
dispose() {
|
|
955
|
+
this.disposed = true;
|
|
956
|
+
this.entries.clear();
|
|
957
|
+
}
|
|
958
|
+
assertActive() {
|
|
959
|
+
if (this.disposed) throw new Error("fate: Persistence was disposed.");
|
|
960
|
+
}
|
|
961
|
+
async load() {
|
|
962
|
+
this.assertActive();
|
|
963
|
+
const value = await this.storage.read(this.key);
|
|
964
|
+
this.assertActive();
|
|
965
|
+
if (value == null) {
|
|
966
|
+
this.header = void 0;
|
|
967
|
+
this.entries.clear();
|
|
968
|
+
} else if (value.version === 1) {
|
|
969
|
+
if (!Array.isArray(value.mutations) || value.mutations.some((entry) => !valid(entry)) || new Set(value.mutations.map(({ id }) => id)).size !== value.mutations.length) throw invalid();
|
|
970
|
+
let checked = 0;
|
|
971
|
+
for (const entry of value.mutations) {
|
|
972
|
+
this.validate?.(entry);
|
|
973
|
+
if (++checked % 64 === 0) {
|
|
974
|
+
await yieldPersistenceTask();
|
|
975
|
+
this.assertActive();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
this.header = void 0;
|
|
979
|
+
this.entries.clear();
|
|
980
|
+
await this.save({ mutations: value.mutations });
|
|
981
|
+
} else if (value.version !== 2 || typeof value.revision !== "string" || !Number.isSafeInteger(value.nextSequence) || value.nextSequence < 0) throw invalid();
|
|
982
|
+
else if (this.header?.revision !== value.revision) {
|
|
983
|
+
const entries = /* @__PURE__ */ new Map();
|
|
984
|
+
let after;
|
|
985
|
+
while (true) {
|
|
986
|
+
const page = await this.storage.scan(this.prefix, after, 64);
|
|
987
|
+
this.assertActive();
|
|
988
|
+
for (const { key, value } of page) {
|
|
989
|
+
const stored = value;
|
|
990
|
+
if (!stored || !valid(stored.entry) || !Number.isSafeInteger(stored.bytes) || stored.bytes <= 0 || entries.has(stored.entry.id)) throw invalid();
|
|
991
|
+
this.validate?.(stored.entry);
|
|
992
|
+
entries.set(stored.entry.id, {
|
|
993
|
+
...stored,
|
|
994
|
+
key
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
if (page.length < 64) break;
|
|
998
|
+
after = page.at(-1).key;
|
|
999
|
+
await yieldPersistenceTask();
|
|
1000
|
+
}
|
|
1001
|
+
this.assertActive();
|
|
1002
|
+
this.header = value;
|
|
1003
|
+
this.entries = entries;
|
|
1004
|
+
}
|
|
1005
|
+
return { mutations: [...this.entries.values()].map(({ entry }) => ({ ...entry })) };
|
|
1006
|
+
}
|
|
1007
|
+
async prepare(data) {
|
|
1008
|
+
this.assertActive();
|
|
1009
|
+
const header = {
|
|
1010
|
+
nextSequence: this.header?.nextSequence ?? 0,
|
|
1011
|
+
revision: crypto.randomUUID(),
|
|
1012
|
+
version: 2
|
|
1013
|
+
};
|
|
1014
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1015
|
+
const writes = [];
|
|
1016
|
+
let bytes = 0;
|
|
1017
|
+
let count = 0;
|
|
1018
|
+
for (const entry of data.mutations) {
|
|
1019
|
+
const previous = this.entries.get(entry.id);
|
|
1020
|
+
let stored = previous;
|
|
1021
|
+
if (!previous || !hasSameFields(previous.entry, entry)) {
|
|
1022
|
+
const key = previous?.key ?? `${this.prefix}${String(header.nextSequence++).padStart(16, "0")}:${entry.id}`;
|
|
1023
|
+
const value = {
|
|
1024
|
+
bytes: 0,
|
|
1025
|
+
entry
|
|
1026
|
+
};
|
|
1027
|
+
let measured = persistenceEntrySize(key, value);
|
|
1028
|
+
while (measured !== value.bytes) {
|
|
1029
|
+
value.bytes = measured;
|
|
1030
|
+
measured = persistenceEntrySize(key, value);
|
|
1031
|
+
}
|
|
1032
|
+
stored = {
|
|
1033
|
+
...value,
|
|
1034
|
+
entry: { ...entry },
|
|
1035
|
+
key
|
|
1036
|
+
};
|
|
1037
|
+
writes.push([key, value]);
|
|
1038
|
+
}
|
|
1039
|
+
entries.set(entry.id, stored);
|
|
1040
|
+
bytes += stored.bytes;
|
|
1041
|
+
if (++count % 64 === 0) {
|
|
1042
|
+
await yieldPersistenceTask();
|
|
1043
|
+
this.assertActive();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
for (const [id, { key }] of this.entries) if (!entries.has(id)) writes.push([key, void 0]);
|
|
1047
|
+
bytes += persistenceEntrySize(this.key, header);
|
|
1048
|
+
return {
|
|
1049
|
+
bytes,
|
|
1050
|
+
entries,
|
|
1051
|
+
header,
|
|
1052
|
+
writes
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
async commit(plan) {
|
|
1056
|
+
this.assertActive();
|
|
1057
|
+
if (plan.writes.length || !this.header) {
|
|
1058
|
+
await this.storage.writeBatch([...plan.writes, [this.key, plan.header]]);
|
|
1059
|
+
this.assertActive();
|
|
1060
|
+
this.header = plan.header;
|
|
1061
|
+
this.entries = plan.entries;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async save(data) {
|
|
1065
|
+
await this.commit(await this.prepare(data));
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
//#endregion
|
|
1069
|
+
//#region src/persistence.ts
|
|
1070
|
+
const isPendingMutation = (entry) => entry.status === "queued" || entry.status === "sending";
|
|
1071
|
+
const replaceMutation = (data, id, replace) => {
|
|
1072
|
+
const index = data.mutations.findIndex((entry) => entry.id === id);
|
|
1073
|
+
if (index >= 0) data.mutations[index] = replace(data.mutations[index]);
|
|
1074
|
+
};
|
|
1075
|
+
const isTerminalDeliveryError = (status) => status !== void 0 && status >= 400 && status < 500 && ![
|
|
1076
|
+
401,
|
|
1077
|
+
403,
|
|
1078
|
+
408,
|
|
1079
|
+
429
|
|
1080
|
+
].includes(status);
|
|
1081
|
+
const mutationDiscardedError = () => /* @__PURE__ */ new Error("fate: Mutation was discarded.");
|
|
1082
|
+
const encodeCommand = (command) => encodeHydrationValue({
|
|
1083
|
+
...command,
|
|
1084
|
+
plan: command.plan ? serializePlan(command.plan) : void 0
|
|
1085
|
+
});
|
|
1086
|
+
const decodeCommand = (value) => {
|
|
1087
|
+
const command = decodeHydrationValue(value);
|
|
1088
|
+
if (!command || typeof command.key !== "string" || typeof command.entity !== "string" || ![
|
|
1089
|
+
"after",
|
|
1090
|
+
"before",
|
|
1091
|
+
"none"
|
|
1092
|
+
].includes(command.insert)) throw new Error("fate: Invalid persisted mutation.");
|
|
1093
|
+
return {
|
|
1094
|
+
...command,
|
|
1095
|
+
plan: command.plan ? deserializePlan(command.plan) : void 0
|
|
1096
|
+
};
|
|
1097
|
+
};
|
|
1098
|
+
/**
|
|
1099
|
+
* Enables cache persistence and durable delivery through the existing mutation API.
|
|
1100
|
+
* Import this entry point only in applications using persistence. Storage keys must
|
|
1101
|
+
* isolate authenticated accounts/workspaces and match the server's identity scope.
|
|
1102
|
+
*/
|
|
1103
|
+
function createPersistence({ key, maxAge = 864e5, maxBytes = 26214400, online = () => typeof navigator === "undefined" || navigator.onLine, retryDelay = 1e3, storage }) {
|
|
1104
|
+
if (!key || key.length > 1024) throw new Error("fate: Persistence requires an account-scoped key of 1–1024 characters.");
|
|
1105
|
+
if (!Number.isFinite(retryDelay) || retryDelay <= 0) throw new Error("fate: retryDelay must be a positive finite number.");
|
|
1106
|
+
if (!Number.isSafeInteger(maxAge) || maxAge < 0 || maxAge > Number.MAX_SAFE_INTEGER - Date.now()) throw new Error("fate: Persistence maxAge must be a non-negative finite duration.");
|
|
1107
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("fate: Persistence maxBytes must be a positive safe integer.");
|
|
1108
|
+
return { attach: (client) => new Session(client, storage, key, online, retryDelay, maxAge, maxBytes) };
|
|
1109
|
+
}
|
|
1110
|
+
var Session = class {
|
|
1111
|
+
constructor(client, storage, key, online, retryDelay, maxAge, maxBytes) {
|
|
1112
|
+
this.client = client;
|
|
1113
|
+
this.storage = storage;
|
|
1114
|
+
this.key = key;
|
|
1115
|
+
this.online = online;
|
|
1116
|
+
this.retryDelay = retryDelay;
|
|
1117
|
+
this.snapshot = {
|
|
1118
|
+
mutations: [],
|
|
1119
|
+
status: "restoring"
|
|
1120
|
+
};
|
|
1121
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1122
|
+
this.commands = /* @__PURE__ */ new Map();
|
|
1123
|
+
this.operations = /* @__PURE__ */ new Map();
|
|
1124
|
+
this.mutationAdmission = Promise.resolve();
|
|
1125
|
+
this.cacheChangeRevision = 0;
|
|
1126
|
+
this.baseCheckpointRevision = 0;
|
|
1127
|
+
this.checkpointedCacheUpdates = /* @__PURE__ */ new Set();
|
|
1128
|
+
this.needsCacheRecovery = false;
|
|
1129
|
+
this.needsCheckpoint = false;
|
|
1130
|
+
this.waiters = /* @__PURE__ */ new Map();
|
|
1131
|
+
this.disposed = false;
|
|
1132
|
+
this.draining = false;
|
|
1133
|
+
this.flushRequested = false;
|
|
1134
|
+
this.refreshRequested = false;
|
|
1135
|
+
this.onOnline = () => this.retry();
|
|
1136
|
+
this.getSnapshot = () => this.snapshot;
|
|
1137
|
+
this.subscribe = (listener) => {
|
|
1138
|
+
this.listeners.add(listener);
|
|
1139
|
+
return () => {
|
|
1140
|
+
this.listeners.delete(listener);
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
this.loadJournal = () => this.storage.exclusive(`${this.key}:write`, () => this.journal.load());
|
|
1144
|
+
this.updateJournal = (apply, admit = false) => this.storage.exclusive(`${this.key}:write`, async () => {
|
|
1145
|
+
this.assertActive();
|
|
1146
|
+
const data = await this.journal.load();
|
|
1147
|
+
const result = await apply(data);
|
|
1148
|
+
const plan = await this.journal.prepare(data);
|
|
1149
|
+
const { bytes } = plan;
|
|
1150
|
+
if (admit && !await this.cache.fitsWithoutEviction(bytes)) throw new Error("fate: Persistence size limit exceeded; unconfirmed mutations have been preserved.");
|
|
1151
|
+
try {
|
|
1152
|
+
await this.cache.prune(bytes);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
if (admit) throw error;
|
|
1155
|
+
this.report(error);
|
|
1156
|
+
}
|
|
1157
|
+
if (admit && !await this.cache.fits(bytes)) throw new Error("fate: Persistence size limit exceeded; unconfirmed mutations have been preserved.");
|
|
1158
|
+
this.assertActive();
|
|
1159
|
+
await this.journal.commit(plan);
|
|
1160
|
+
return result;
|
|
1161
|
+
});
|
|
1162
|
+
this.journal = new PersistenceJournal(storage, key, (entry) => {
|
|
1163
|
+
decodeCommand(entry.command);
|
|
1164
|
+
});
|
|
1165
|
+
this.cache = new PersistenceCache(client, storage, key, maxAge, maxBytes);
|
|
1166
|
+
try {
|
|
1167
|
+
client.validatePersistence();
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
this.mutationError = error instanceof Error ? error : new Error(String(error));
|
|
1170
|
+
this.snapshot = {
|
|
1171
|
+
...this.snapshot,
|
|
1172
|
+
error: this.mutationError
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
this.ready = this.restore().catch((error) => {
|
|
1176
|
+
if (!this.disposed) this.snapshot = {
|
|
1177
|
+
...this.snapshot,
|
|
1178
|
+
status: "error"
|
|
1179
|
+
};
|
|
1180
|
+
throw error;
|
|
1181
|
+
});
|
|
1182
|
+
this.ready.catch((error) => this.report(error));
|
|
1183
|
+
}
|
|
1184
|
+
command(entry) {
|
|
1185
|
+
let cached = this.commands.get(entry.id);
|
|
1186
|
+
if (cached?.value !== entry.command) {
|
|
1187
|
+
cached = {
|
|
1188
|
+
command: decodeCommand(entry.command),
|
|
1189
|
+
input: decodeHydrationValue(entry.command).input,
|
|
1190
|
+
value: entry.command
|
|
1191
|
+
};
|
|
1192
|
+
this.commands.set(entry.id, cached);
|
|
1193
|
+
}
|
|
1194
|
+
return cached.command;
|
|
1195
|
+
}
|
|
1196
|
+
publish(data) {
|
|
1197
|
+
if (data) this.snapshot = {
|
|
1198
|
+
...this.snapshot,
|
|
1199
|
+
mutations: data.mutations.filter((entry) => entry.status !== "confirmed").map((entry) => ({
|
|
1200
|
+
error: entry.error?.message,
|
|
1201
|
+
id: entry.id,
|
|
1202
|
+
input: this.commands.get(entry.id).input,
|
|
1203
|
+
name: this.command(entry).key,
|
|
1204
|
+
status: entry.status
|
|
1205
|
+
}))
|
|
1206
|
+
};
|
|
1207
|
+
for (const listener of this.listeners) try {
|
|
1208
|
+
listener();
|
|
1209
|
+
} catch {}
|
|
1210
|
+
}
|
|
1211
|
+
report(error) {
|
|
1212
|
+
if (this.disposed) return;
|
|
1213
|
+
this.snapshot = {
|
|
1214
|
+
...this.snapshot,
|
|
1215
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1216
|
+
status: this.snapshot.status === "restoring" ? "error" : this.snapshot.status
|
|
1217
|
+
};
|
|
1218
|
+
this.publish();
|
|
1219
|
+
}
|
|
1220
|
+
clearError() {
|
|
1221
|
+
if (!this.mutationError && this.snapshot.error) {
|
|
1222
|
+
this.snapshot = {
|
|
1223
|
+
...this.snapshot,
|
|
1224
|
+
error: void 0
|
|
1225
|
+
};
|
|
1226
|
+
this.publish();
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
assertActive() {
|
|
1230
|
+
if (this.disposed) throw new Error("fate: Persistence was disposed.");
|
|
1231
|
+
}
|
|
1232
|
+
async restore() {
|
|
1233
|
+
const data = await this.loadJournal();
|
|
1234
|
+
await this.cache.initialize();
|
|
1235
|
+
if (this.disposed) return;
|
|
1236
|
+
for (const entry of data.mutations) if (isPendingMutation(entry)) {
|
|
1237
|
+
if (entry.base && entry.base.scope === this.client.getPersistenceScope()) this.cache.restoreBase(decodeClientHydrationState(entry.base.data));
|
|
1238
|
+
}
|
|
1239
|
+
this.snapshot = {
|
|
1240
|
+
error: this.mutationError,
|
|
1241
|
+
mutations: [],
|
|
1242
|
+
status: "ready"
|
|
1243
|
+
};
|
|
1244
|
+
await this.reconcile(data);
|
|
1245
|
+
if (this.needsCacheRecovery) try {
|
|
1246
|
+
await this.flushCache();
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
this.report(error);
|
|
1249
|
+
}
|
|
1250
|
+
if (this.disposed) return;
|
|
1251
|
+
this.unsubscribe = this.storage.subscribe?.(this.key, () => {
|
|
1252
|
+
this.refresh().catch((error) => this.report(error));
|
|
1253
|
+
});
|
|
1254
|
+
if (typeof globalThis.addEventListener === "function") globalThis.addEventListener("online", this.onOnline);
|
|
1255
|
+
this.retry();
|
|
1256
|
+
}
|
|
1257
|
+
async reconcile(data) {
|
|
1258
|
+
if (this.disposed) return;
|
|
1259
|
+
let processed = 0;
|
|
1260
|
+
for (const entry of data.mutations) {
|
|
1261
|
+
if (++processed % 64 === 0) await yieldPersistenceTask();
|
|
1262
|
+
if (this.disposed) return;
|
|
1263
|
+
this.command(entry);
|
|
1264
|
+
if (entry.status === "confirmed" || entry.status === "failed") {
|
|
1265
|
+
const operation = this.operations.get(entry.id);
|
|
1266
|
+
if (entry.status === "confirmed") {
|
|
1267
|
+
this.needsCheckpoint = true;
|
|
1268
|
+
const result = operation || this.waiters.has(entry.id) ? decodeHydrationValue(entry.result) : void 0;
|
|
1269
|
+
operation?.commit(result);
|
|
1270
|
+
if (entry.cacheUpdates && entry.scope === this.client.getPersistenceScope()) this.needsCacheRecovery = true;
|
|
1271
|
+
this.waiters.get(entry.id)?.resolve(result);
|
|
1272
|
+
} else {
|
|
1273
|
+
operation?.rollback();
|
|
1274
|
+
this.waiters.get(entry.id)?.reject(new FateRequestError("BAD_REQUEST", entry.error?.message ?? "Mutation failed.", { status: entry.error?.status ?? 400 }));
|
|
1275
|
+
}
|
|
1276
|
+
this.operations.delete(entry.id);
|
|
1277
|
+
this.waiters.delete(entry.id);
|
|
1278
|
+
} else if (!this.operations.has(entry.id) && entry.scope === this.client.getPersistenceScope()) {
|
|
1279
|
+
if (entry.base) this.cache.restoreBase(decodeClientHydrationState(entry.base.data));
|
|
1280
|
+
const command = this.command(entry);
|
|
1281
|
+
if (this.client.hasMutation(command.key, command.entity)) this.operations.set(entry.id, this.prepareOperation(command));
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const retained = new Set(data.mutations.map(({ id }) => id));
|
|
1285
|
+
for (const id of this.commands.keys()) if (!retained.has(id)) this.commands.delete(id);
|
|
1286
|
+
this.publish(data);
|
|
1287
|
+
}
|
|
1288
|
+
prepareOperation(command) {
|
|
1289
|
+
const operation = prepareMutation(this.client, command, this.client.getTypeConfig(command.entity), true);
|
|
1290
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
1291
|
+
let cacheUpdates;
|
|
1292
|
+
let commitError;
|
|
1293
|
+
let commitFailed = false;
|
|
1294
|
+
if (operation.entityId) this.client.registerPendingOptimisticMutation(operation.entityId, promise);
|
|
1295
|
+
return {
|
|
1296
|
+
...operation,
|
|
1297
|
+
commit: (result, persist = true) => {
|
|
1298
|
+
if (commitFailed) throw commitError;
|
|
1299
|
+
try {
|
|
1300
|
+
if (persist) cacheUpdates ??= this.cache.captureChanges(() => operation.commit(result));
|
|
1301
|
+
else this.cache.withoutChanges(() => operation.commit(result));
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
commitFailed = true;
|
|
1304
|
+
commitError = error;
|
|
1305
|
+
throw error;
|
|
1306
|
+
}
|
|
1307
|
+
resolve();
|
|
1308
|
+
},
|
|
1309
|
+
getCacheUpdates: () => cacheUpdates,
|
|
1310
|
+
rollback: (persist = true) => {
|
|
1311
|
+
try {
|
|
1312
|
+
if (persist) operation.rollback();
|
|
1313
|
+
else this.cache.withoutChanges(() => operation.rollback());
|
|
1314
|
+
} finally {
|
|
1315
|
+
resolve();
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
refresh() {
|
|
1321
|
+
this.refreshRequested = true;
|
|
1322
|
+
return this.refreshing ??= this.drainRefreshes();
|
|
1323
|
+
}
|
|
1324
|
+
async drainRefreshes() {
|
|
1325
|
+
try {
|
|
1326
|
+
while (this.refreshRequested && !this.disposed) {
|
|
1327
|
+
this.refreshRequested = false;
|
|
1328
|
+
await this.reconcile(await this.loadJournal());
|
|
1329
|
+
}
|
|
1330
|
+
this.retry();
|
|
1331
|
+
} finally {
|
|
1332
|
+
this.refreshing = void 0;
|
|
1333
|
+
if (this.refreshRequested && !this.disposed) this.refresh().catch((error) => this.report(error));
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async mutate(command) {
|
|
1337
|
+
if (this.mutationError) throw this.mutationError;
|
|
1338
|
+
const encoded = encodeCommand(command);
|
|
1339
|
+
await this.ready;
|
|
1340
|
+
this.assertActive();
|
|
1341
|
+
const id = crypto.randomUUID();
|
|
1342
|
+
const admission = this.mutationAdmission.then(async () => {
|
|
1343
|
+
if (this.needsCheckpoint) await this.flushCache().catch((error) => this.report(error));
|
|
1344
|
+
return this.updateJournal(async (data) => {
|
|
1345
|
+
await this.reconcile(data);
|
|
1346
|
+
const base = await this.cache.mutationBase(decodeCommand(encoded));
|
|
1347
|
+
data.mutations.push({
|
|
1348
|
+
attempts: 0,
|
|
1349
|
+
base,
|
|
1350
|
+
command: encoded,
|
|
1351
|
+
id,
|
|
1352
|
+
scope: base.scope,
|
|
1353
|
+
status: "queued"
|
|
1354
|
+
});
|
|
1355
|
+
return base;
|
|
1356
|
+
}, true);
|
|
1357
|
+
});
|
|
1358
|
+
this.mutationAdmission = admission.catch(() => {});
|
|
1359
|
+
const base = await admission;
|
|
1360
|
+
this.assertActive();
|
|
1361
|
+
this.cache.restoreBase(decodeClientHydrationState(base.data));
|
|
1362
|
+
const result = new Promise((resolve, reject) => this.waiters.set(id, {
|
|
1363
|
+
reject,
|
|
1364
|
+
resolve
|
|
1365
|
+
}));
|
|
1366
|
+
try {
|
|
1367
|
+
if (!this.operations.has(id)) this.operations.set(id, this.prepareOperation(decodeCommand(encoded)));
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
await this.fail(id, error);
|
|
1370
|
+
}
|
|
1371
|
+
this.refresh().catch((error) => this.report(error));
|
|
1372
|
+
this.retry();
|
|
1373
|
+
return result;
|
|
1374
|
+
}
|
|
1375
|
+
async restoreRequest(request) {
|
|
1376
|
+
await this.ready;
|
|
1377
|
+
this.assertActive();
|
|
1378
|
+
try {
|
|
1379
|
+
if (this.needsCacheRecovery) await this.flush();
|
|
1380
|
+
await this.cache.restore(request);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
if (this.disposed) throw error;
|
|
1383
|
+
this.report(error);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
fetched(request, options) {
|
|
1387
|
+
if (this.disposed) return;
|
|
1388
|
+
this.cache.fetched(request, options?.maxAge);
|
|
1389
|
+
this.changed();
|
|
1390
|
+
}
|
|
1391
|
+
used(request, options) {
|
|
1392
|
+
if (this.disposed) return;
|
|
1393
|
+
this.cache.fetched(request, options?.maxAge, false);
|
|
1394
|
+
this.changed();
|
|
1395
|
+
}
|
|
1396
|
+
changed(change) {
|
|
1397
|
+
if (this.disposed) return;
|
|
1398
|
+
if (this.cache.changedNode(change)) {
|
|
1399
|
+
this.needsCacheRecovery = true;
|
|
1400
|
+
this.cacheChangeRevision++;
|
|
1401
|
+
}
|
|
1402
|
+
if (this.disposed || this.snapshot.status !== "ready") return;
|
|
1403
|
+
if (this.flushing || this.draining) this.flushRequested = true;
|
|
1404
|
+
else this.scheduleSave();
|
|
1405
|
+
}
|
|
1406
|
+
async flush() {
|
|
1407
|
+
clearTimeout(this.saveTimer);
|
|
1408
|
+
this.saveTimer = void 0;
|
|
1409
|
+
await this.ready;
|
|
1410
|
+
await this.flushCache();
|
|
1411
|
+
this.clearError();
|
|
1412
|
+
}
|
|
1413
|
+
scheduleSave() {
|
|
1414
|
+
if (!this.saveTimer && !this.disposed) this.saveTimer = setTimeout(() => {
|
|
1415
|
+
this.saveTimer = void 0;
|
|
1416
|
+
this.flush().catch((error) => this.report(error));
|
|
1417
|
+
}, 25);
|
|
1418
|
+
}
|
|
1419
|
+
confirmedCacheEntries(data) {
|
|
1420
|
+
const scope = this.client.getPersistenceScope();
|
|
1421
|
+
return data.mutations.flatMap((entry) => entry.status === "confirmed" && entry.scope === scope ? [[entry.id, entry.cacheUpdates]] : []);
|
|
1422
|
+
}
|
|
1423
|
+
confirmedCacheUpdates(data) {
|
|
1424
|
+
return this.confirmedCacheEntries(data).map(([, updates]) => updates);
|
|
1425
|
+
}
|
|
1426
|
+
async checkpointPendingBases(data) {
|
|
1427
|
+
const revision = this.cacheChangeRevision;
|
|
1428
|
+
const cacheUpdateIds = this.confirmedCacheEntries(data).map(([id]) => id);
|
|
1429
|
+
if (revision === this.baseCheckpointRevision && cacheUpdateIds.every((id) => this.checkpointedCacheUpdates.has(id))) return;
|
|
1430
|
+
const scope = this.client.getPersistenceScope();
|
|
1431
|
+
if (!data.mutations.some((entry) => isPendingMutation(entry) && entry.scope === scope)) {
|
|
1432
|
+
this.baseCheckpointRevision = revision;
|
|
1433
|
+
for (const id of cacheUpdateIds) this.checkpointedCacheUpdates.add(id);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
for (const entry of data.mutations) if (isPendingMutation(entry) && entry.scope === scope) entry.base = await this.cache.mutationBase(this.command(entry), entry.base);
|
|
1437
|
+
const plan = await this.journal.prepare(data);
|
|
1438
|
+
await this.cache.prune(plan.bytes);
|
|
1439
|
+
this.assertActive();
|
|
1440
|
+
await this.journal.commit(plan);
|
|
1441
|
+
this.baseCheckpointRevision = revision;
|
|
1442
|
+
for (const id of cacheUpdateIds) this.checkpointedCacheUpdates.add(id);
|
|
1443
|
+
}
|
|
1444
|
+
async removeConfirmedEntries(data) {
|
|
1445
|
+
const confirmedIds = new Set(data.mutations.filter((entry) => entry.status === "confirmed").map(({ id }) => id));
|
|
1446
|
+
if (!confirmedIds.size) return;
|
|
1447
|
+
data.mutations = data.mutations.filter(({ id }) => !confirmedIds.has(id));
|
|
1448
|
+
await this.journal.commit(await this.journal.prepare(data));
|
|
1449
|
+
for (const id of confirmedIds) this.checkpointedCacheUpdates.delete(id);
|
|
1450
|
+
}
|
|
1451
|
+
flushCache() {
|
|
1452
|
+
this.assertActive();
|
|
1453
|
+
this.flushRequested = true;
|
|
1454
|
+
if (!this.flushing) {
|
|
1455
|
+
const flushing = this.drainCacheFlushes();
|
|
1456
|
+
this.flushing = flushing;
|
|
1457
|
+
flushing.then(() => this.finishCacheFlush(flushing), () => this.finishCacheFlush(flushing));
|
|
1458
|
+
}
|
|
1459
|
+
return this.flushing;
|
|
1460
|
+
}
|
|
1461
|
+
finishCacheFlush(flushing) {
|
|
1462
|
+
if (this.flushing !== flushing) return;
|
|
1463
|
+
this.flushing = void 0;
|
|
1464
|
+
if (this.flushRequested && !this.disposed) this.scheduleSave();
|
|
1465
|
+
}
|
|
1466
|
+
async drainCacheFlushes() {
|
|
1467
|
+
while (this.flushRequested && !this.disposed) {
|
|
1468
|
+
this.flushRequested = false;
|
|
1469
|
+
await this.flushCacheOnce();
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
async flushCacheOnce() {
|
|
1473
|
+
await this.storage.exclusive(`${this.key}:write`, async () => {
|
|
1474
|
+
this.assertActive();
|
|
1475
|
+
const data = await this.journal.load();
|
|
1476
|
+
await this.reconcile(data);
|
|
1477
|
+
this.cache.replayChanges(this.confirmedCacheUpdates(data));
|
|
1478
|
+
await this.checkpointPendingBases(data);
|
|
1479
|
+
await this.cache.flush((await this.journal.prepare(data)).bytes);
|
|
1480
|
+
this.assertActive();
|
|
1481
|
+
await this.removeConfirmedEntries(data);
|
|
1482
|
+
this.needsCacheRecovery = this.cache.hasChanges;
|
|
1483
|
+
this.needsCheckpoint = false;
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
retry() {
|
|
1487
|
+
if (this.mutationError || this.disposed || this.snapshot.status !== "ready" || this.draining) return;
|
|
1488
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1489
|
+
this.timer = setTimeout(() => {
|
|
1490
|
+
this.timer = void 0;
|
|
1491
|
+
this.drain().catch((error) => {
|
|
1492
|
+
this.report(error);
|
|
1493
|
+
this.scheduleRetry();
|
|
1494
|
+
});
|
|
1495
|
+
}, 0);
|
|
1496
|
+
}
|
|
1497
|
+
scheduleRetry(delay = this.retryDelay) {
|
|
1498
|
+
if (!this.disposed && !this.timer) this.timer = setTimeout(() => {
|
|
1499
|
+
this.timer = void 0;
|
|
1500
|
+
this.retry();
|
|
1501
|
+
}, delay);
|
|
1502
|
+
}
|
|
1503
|
+
async fail(id, error, status) {
|
|
1504
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
1505
|
+
status ??= getErrorStatusCode(failure);
|
|
1506
|
+
await this.updateJournal((data) => {
|
|
1507
|
+
replaceMutation(data, id, (entry) => ({
|
|
1508
|
+
attempts: entry.attempts,
|
|
1509
|
+
command: entry.command,
|
|
1510
|
+
error: {
|
|
1511
|
+
message: failure.message,
|
|
1512
|
+
status
|
|
1513
|
+
},
|
|
1514
|
+
id: entry.id,
|
|
1515
|
+
scope: entry.scope,
|
|
1516
|
+
status: "failed"
|
|
1517
|
+
}));
|
|
1518
|
+
});
|
|
1519
|
+
await this.reconcile(await this.loadJournal());
|
|
1520
|
+
}
|
|
1521
|
+
forgetOperation(id) {
|
|
1522
|
+
this.operations.delete(id);
|
|
1523
|
+
this.waiters.delete(id);
|
|
1524
|
+
}
|
|
1525
|
+
/** Resolve an operation whose journal entry another client already removed. */
|
|
1526
|
+
async recoverOperation(id, operation) {
|
|
1527
|
+
try {
|
|
1528
|
+
const result = await operation.execute({
|
|
1529
|
+
id,
|
|
1530
|
+
replayOnly: true,
|
|
1531
|
+
scope: this.key
|
|
1532
|
+
});
|
|
1533
|
+
if (this.disposed) return;
|
|
1534
|
+
operation.commit(result, false);
|
|
1535
|
+
this.waiters.get(id)?.resolve(result);
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
if (this.disposed) return;
|
|
1538
|
+
if (!(error instanceof Error) || getErrorStatusCode(error) !== 404) throw error;
|
|
1539
|
+
operation.rollback(false);
|
|
1540
|
+
this.waiters.get(id)?.reject(mutationDiscardedError());
|
|
1541
|
+
}
|
|
1542
|
+
this.forgetOperation(id);
|
|
1543
|
+
}
|
|
1544
|
+
async recoverRemovedOperations(data) {
|
|
1545
|
+
const retained = new Set(data.mutations.map(({ id }) => id));
|
|
1546
|
+
for (const [id, operation] of this.operations) {
|
|
1547
|
+
if (retained.has(id)) continue;
|
|
1548
|
+
try {
|
|
1549
|
+
await this.recoverOperation(id, operation);
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
this.report(error);
|
|
1552
|
+
this.scheduleRetry();
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
if (this.disposed) return false;
|
|
1556
|
+
}
|
|
1557
|
+
return true;
|
|
1558
|
+
}
|
|
1559
|
+
async claim(entry) {
|
|
1560
|
+
const data = await this.updateJournal((data) => {
|
|
1561
|
+
const current = data.mutations.find((item) => item.id === entry.id);
|
|
1562
|
+
if (!current || !isPendingMutation(current)) return;
|
|
1563
|
+
replaceMutation(data, entry.id, () => ({
|
|
1564
|
+
...current,
|
|
1565
|
+
attempts: current.attempts + 1,
|
|
1566
|
+
status: "sending"
|
|
1567
|
+
}));
|
|
1568
|
+
return data;
|
|
1569
|
+
});
|
|
1570
|
+
if (!data) return false;
|
|
1571
|
+
await this.reconcile(data);
|
|
1572
|
+
return true;
|
|
1573
|
+
}
|
|
1574
|
+
async confirm(entry, operation, encodedResult) {
|
|
1575
|
+
const cacheUpdates = operation.getCacheUpdates();
|
|
1576
|
+
if (!cacheUpdates) throw new Error("fate: Mutation confirmation did not capture its cache updates.");
|
|
1577
|
+
await this.updateJournal((data) => {
|
|
1578
|
+
replaceMutation(data, entry.id, (current) => ({
|
|
1579
|
+
attempts: current.attempts,
|
|
1580
|
+
cacheUpdates,
|
|
1581
|
+
command: current.command,
|
|
1582
|
+
id: current.id,
|
|
1583
|
+
result: encodedResult,
|
|
1584
|
+
scope: current.scope,
|
|
1585
|
+
status: "confirmed"
|
|
1586
|
+
}));
|
|
1587
|
+
});
|
|
1588
|
+
await this.reconcile(await this.loadJournal());
|
|
1589
|
+
}
|
|
1590
|
+
/** Deliver one claimed entry. False means the queue must pause for a retry. */
|
|
1591
|
+
async deliver(entry, operation) {
|
|
1592
|
+
let result;
|
|
1593
|
+
try {
|
|
1594
|
+
result = await operation.execute({
|
|
1595
|
+
id: entry.id,
|
|
1596
|
+
scope: this.key
|
|
1597
|
+
});
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
if (this.disposed) return false;
|
|
1600
|
+
const status = error instanceof Error ? getErrorStatusCode(error) : void 0;
|
|
1601
|
+
if (isTerminalDeliveryError(status)) {
|
|
1602
|
+
await this.fail(entry.id, error);
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
await this.queueRetry(entry.id, error, status);
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
if (this.disposed) return false;
|
|
1609
|
+
let encodedResult;
|
|
1610
|
+
try {
|
|
1611
|
+
encodedResult = encodeHydrationValue(result);
|
|
1612
|
+
operation.commit(result);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
await this.fail(entry.id, error, 500);
|
|
1615
|
+
return true;
|
|
1616
|
+
}
|
|
1617
|
+
try {
|
|
1618
|
+
await this.confirm(entry, operation, encodedResult);
|
|
1619
|
+
return true;
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
if (this.disposed) return false;
|
|
1622
|
+
const status = error instanceof Error ? getErrorStatusCode(error) : void 0;
|
|
1623
|
+
await this.queueRetry(entry.id, error, status);
|
|
1624
|
+
return false;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
async drain() {
|
|
1628
|
+
if (this.draining || this.disposed || !this.online()) return;
|
|
1629
|
+
this.draining = true;
|
|
1630
|
+
try {
|
|
1631
|
+
await this.storage.exclusive(`${this.key}:delivery`, async () => {
|
|
1632
|
+
while (!this.disposed && this.online()) {
|
|
1633
|
+
const data = await this.loadJournal();
|
|
1634
|
+
await this.reconcile(data);
|
|
1635
|
+
if (!await this.recoverRemovedOperations(data)) return;
|
|
1636
|
+
const entry = data.mutations.find((item) => item.status !== "confirmed" && item.status !== "failed");
|
|
1637
|
+
if (!entry) return;
|
|
1638
|
+
if (entry.nextAttemptAt && entry.nextAttemptAt > Date.now()) {
|
|
1639
|
+
this.scheduleRetry(entry.nextAttemptAt - Date.now());
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const command = this.command(entry);
|
|
1643
|
+
if (entry.scope !== this.client.getPersistenceScope() || !this.client.hasMutation(command.key, command.entity)) {
|
|
1644
|
+
await this.fail(entry.id, new FateRequestError("BAD_REQUEST", "Persisted mutation is incompatible with this client. Its input has been preserved."));
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
const operation = this.operations.get(entry.id);
|
|
1648
|
+
if (!operation) throw new Error(`fate: Missing restored mutation operation '${entry.id}'.`);
|
|
1649
|
+
const claimed = await this.claim(entry);
|
|
1650
|
+
if (this.disposed) return;
|
|
1651
|
+
if (!claimed) continue;
|
|
1652
|
+
if (!await this.deliver(entry, operation)) return;
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
if ((this.needsCheckpoint || this.flushRequested) && !this.disposed) await this.flush().catch((error) => this.report(error));
|
|
1656
|
+
} finally {
|
|
1657
|
+
this.draining = false;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
async queueRetry(id, error, status) {
|
|
1661
|
+
await this.updateJournal((latest) => {
|
|
1662
|
+
const item = latest.mutations.find((item) => item.id === id);
|
|
1663
|
+
if (item && isPendingMutation(item)) replaceMutation(latest, id, () => ({
|
|
1664
|
+
...item,
|
|
1665
|
+
error: {
|
|
1666
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1667
|
+
status
|
|
1668
|
+
},
|
|
1669
|
+
nextAttemptAt: Date.now() + Math.min(3e4, this.retryDelay * 2 ** Math.min(item.attempts - 1, 10)),
|
|
1670
|
+
status: "queued"
|
|
1671
|
+
}));
|
|
1672
|
+
});
|
|
1673
|
+
await this.reconcile(await this.loadJournal());
|
|
1674
|
+
this.scheduleRetry();
|
|
1675
|
+
}
|
|
1676
|
+
async discard(id) {
|
|
1677
|
+
await this.ready;
|
|
1678
|
+
const outcome = await this.updateJournal((data) => {
|
|
1679
|
+
const entry = data.mutations.find((entry) => entry.id === id);
|
|
1680
|
+
if (entry && entry.attempts > 0 && entry.status !== "failed" && entry.status !== "confirmed") throw new Error("fate: An attempted mutation may have committed remotely and cannot be discarded until its outcome is known.");
|
|
1681
|
+
if (entry?.status === "confirmed") return "confirmed";
|
|
1682
|
+
if (!entry) return "missing";
|
|
1683
|
+
data.mutations = data.mutations.filter((entry) => entry.id !== id);
|
|
1684
|
+
return "discarded";
|
|
1685
|
+
});
|
|
1686
|
+
if (outcome === "missing") {
|
|
1687
|
+
const operation = this.operations.get(id);
|
|
1688
|
+
if (operation) await this.recoverOperation(id, operation);
|
|
1689
|
+
} else if (outcome === "discarded") {
|
|
1690
|
+
this.operations.get(id)?.rollback();
|
|
1691
|
+
this.waiters.get(id)?.reject(mutationDiscardedError());
|
|
1692
|
+
this.forgetOperation(id);
|
|
1693
|
+
}
|
|
1694
|
+
await this.refresh();
|
|
1695
|
+
}
|
|
1696
|
+
async clearCache() {
|
|
1697
|
+
await this.ready;
|
|
1698
|
+
this.assertActive();
|
|
1699
|
+
await this.storage.exclusive(`${this.key}:write`, async () => {
|
|
1700
|
+
this.assertActive();
|
|
1701
|
+
await this.cache.clear();
|
|
1702
|
+
});
|
|
1703
|
+
this.assertActive();
|
|
1704
|
+
await this.cache.initialize();
|
|
1705
|
+
this.clearError();
|
|
1706
|
+
}
|
|
1707
|
+
dispose() {
|
|
1708
|
+
if (this.disposed) return;
|
|
1709
|
+
this.disposed = true;
|
|
1710
|
+
this.client.disposePersistence();
|
|
1711
|
+
this.cache.dispose();
|
|
1712
|
+
this.journal.dispose();
|
|
1713
|
+
clearTimeout(this.timer);
|
|
1714
|
+
clearTimeout(this.saveTimer);
|
|
1715
|
+
this.unsubscribe?.();
|
|
1716
|
+
if (typeof globalThis.removeEventListener === "function") globalThis.removeEventListener("online", this.onOnline);
|
|
1717
|
+
for (const operation of this.operations.values()) operation.rollback();
|
|
1718
|
+
this.operations.clear();
|
|
1719
|
+
this.commands.clear();
|
|
1720
|
+
for (const waiter of this.waiters.values()) waiter.reject(/* @__PURE__ */ new Error("fate: Persistence was disposed; pending mutations remain stored."));
|
|
1721
|
+
this.waiters.clear();
|
|
1722
|
+
this.snapshot = {
|
|
1723
|
+
...this.snapshot,
|
|
1724
|
+
status: "disposed"
|
|
1725
|
+
};
|
|
1726
|
+
this.publish();
|
|
1727
|
+
}
|
|
1728
|
+
};
|
|
1729
|
+
//#endregion
|
|
1730
|
+
export { createPersistence };
|