@agent-native/core-corpus 0.209.2 → 0.209.3-nightly-20261016183441
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/corpus/README.md +1 -1
- package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/analytics/actions/update-dashboard.ts +9 -0
- package/corpus/templates/analytics/app/components/auth/SqlQuerySnapshotSessionBridge.tsx +25 -0
- package/corpus/templates/analytics/app/components/dashboard/PanelFreshnessClock.tsx +140 -0
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +104 -48
- package/corpus/templates/analytics/app/components/dashboard/panel-freshness.ts +171 -0
- package/corpus/templates/analytics/app/global.css +59 -0
- package/corpus/templates/analytics/app/i18n/en-US.ts +8 -0
- package/corpus/templates/analytics/app/lib/cost-tracker.ts +122 -16
- package/corpus/templates/analytics/app/lib/query-metrics.ts +50 -13
- package/corpus/templates/analytics/app/lib/sql-query-cache.ts +697 -0
- package/corpus/templates/analytics/app/lib/sql-query-freshness.ts +85 -0
- package/corpus/templates/analytics/app/lib/sql-query-snapshot-session.ts +145 -0
- package/corpus/templates/analytics/app/lib/sql-query.ts +366 -36
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +73 -18
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +45 -12
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +5 -0
- package/corpus/templates/analytics/app/root.tsx +2 -0
- package/corpus/templates/analytics/changelog/2026-10-14-dashboard-charts-now-reappear-instantly-after-a-reload-from-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-14-dashboard-panels-show-fetch-time-in-a-clock-and-label-only-st.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-15-dashboard-query-costs-and-refresh-confirmations-reflect-actu.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-15-dashboard-results-disappear-when-access-is-withdrawn-and-exp.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-dashboard-freshness-stays-in-the-title-row-with-compact-upda.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-dashboard-update-status-remains-visible-for-sources-without-.md +6 -0
- package/corpus/templates/analytics/server/lib/bigquery.ts +35 -4
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +2 -3
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +9 -6
- package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +2 -2
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +4 -2
- package/corpus/templates/analytics/shared/sql-query-contract.ts +49 -0
- package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +24 -0
- package/corpus/templates/design/app/components/design/types.ts +1 -0
- package/corpus/templates/design/app/lib/design-import.ts +35 -0
- package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +11 -0
- package/corpus/templates/design/app/pages/design-editor/command-types.ts +2 -0
- package/corpus/templates/design/app/pages/design-editor/commands/copy-selection.ts +3 -0
- package/corpus/templates/design/app/pages/design-editor/commands/get-selected-layer-snapshots.ts +19 -0
- package/corpus/templates/design/app/pages/design-editor/commands/paste-selection.ts +118 -20
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-layer-actions.ts +15 -3
- package/corpus/templates/design/app/pages/design-editor/relative-position-offset.ts +109 -0
- package/corpus/templates/design/changelog/2026-10-15-dragged-layers-use-the-final-release-position.md +6 -0
- package/corpus/templates/design/changelog/2026-10-15-repeated-pastes-preserve-relative-layer-offsets.md +6 -0
- package/corpus/templates/design/changelog/2026-10-15-text-wrapping-hug-widths.md +6 -0
- package/corpus/templates/design/shared/code-layer.ts +2 -1
- package/corpus/templates/design/shared/relative-position-containing-block.ts +40 -0
- package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +10 -0
- package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +137 -118
- package/corpus/templates/slides/changelog/2026-10-16-search-and-preview-reference-decks-before-choosing-one.md +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded sessionStorage copies of successful SQL query results, so a page
|
|
3
|
+
* reload can show the last rows immediately while ONE normal query refreshes
|
|
4
|
+
* them. sessionStorage is a cache, never a source of truth: every read is
|
|
5
|
+
* validated, every failure is a typed status, and the caller falls back to a
|
|
6
|
+
* normal fetch on anything but `hit`.
|
|
7
|
+
*
|
|
8
|
+
* The caller passes the fully serialized query key (it already carries the
|
|
9
|
+
* source, resolved SQL, dashboard and auth scope) plus the validated account
|
|
10
|
+
* scope. Entries are found only by that exact pair; nothing is read across
|
|
11
|
+
* scopes and nothing is stored for a loading or anonymous session.
|
|
12
|
+
*
|
|
13
|
+
* Storage keys are `an-sql-snapshot/v1/<storedAt>/<scope>/<key>` where scope
|
|
14
|
+
* and key are 106-bit fingerprints, so listing and eviction never parse a
|
|
15
|
+
* value and the SQL text is never written to storage. The account scope is
|
|
16
|
+
* also stored verbatim and compared exactly on read; the fingerprints only
|
|
17
|
+
* pick candidates.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const SQL_QUERY_SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
export const SQL_QUERY_SNAPSHOT_MAX_ENTRIES = 48;
|
|
22
|
+
/** Serialized length in string code units, not UTF-8 bytes. */
|
|
23
|
+
export const SQL_QUERY_SNAPSHOT_MAX_ENTRY_LENGTH = 128 * 1024;
|
|
24
|
+
export const SQL_QUERY_SNAPSHOT_MAX_TOTAL_LENGTH = 2 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
const KEY_PREFIX = "an-sql-snapshot/";
|
|
27
|
+
const KEY_PATTERN =
|
|
28
|
+
/^an-sql-snapshot\/v1\/([0-9a-z]{1,12})\/([0-9a-f]{28})\/([0-9a-f]{28})$/;
|
|
29
|
+
const ENVELOPE_VERSION = 1;
|
|
30
|
+
const FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
31
|
+
const MAX_QUERY_KEY_LENGTH = 100_000;
|
|
32
|
+
// Every JSON value costs at least two serialized characters, so a result with
|
|
33
|
+
// more nodes than this cannot fit the per-entry bound; stop walking early.
|
|
34
|
+
const MAX_RESULT_NODES = SQL_QUERY_SNAPSHOT_MAX_ENTRY_LENGTH / 2;
|
|
35
|
+
const MAX_RESULT_DEPTH = 32;
|
|
36
|
+
const ACCOUNT_SCOPE_PATTERN = /^[^:\s]{1,128}:[^:\s]{1,128}$/;
|
|
37
|
+
const RESERVED_SCOPES = new Set(["session-loading"]);
|
|
38
|
+
|
|
39
|
+
export interface SqlQuerySnapshotResult {
|
|
40
|
+
rows: Record<string, unknown>[];
|
|
41
|
+
schema?: { name: string; type: string }[];
|
|
42
|
+
cached?: boolean;
|
|
43
|
+
providerCacheHit?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* `true` marks rows cut short by a server cap; such a result is never
|
|
46
|
+
* stored, so a restored snapshot is always the complete result.
|
|
47
|
+
*/
|
|
48
|
+
truncated?: boolean;
|
|
49
|
+
/** ISO timestamp of the original fetch, independent of when it was stored. */
|
|
50
|
+
fetchedAt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `SqlQueryResult` fits here; only the fields above are ever persisted. */
|
|
54
|
+
export type SqlQuerySnapshotInput = SqlQuerySnapshotResult & { error?: string };
|
|
55
|
+
|
|
56
|
+
export interface SqlQuerySnapshot {
|
|
57
|
+
result: SqlQuerySnapshotResult;
|
|
58
|
+
/** Epoch ms when this copy was written; separate from `result.fetchedAt`. */
|
|
59
|
+
storedAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SqlQuerySnapshotStorage = Pick<
|
|
63
|
+
Storage,
|
|
64
|
+
"getItem" | "setItem" | "removeItem" | "key" | "length"
|
|
65
|
+
>;
|
|
66
|
+
|
|
67
|
+
export type SqlQuerySnapshotUnavailableReason =
|
|
68
|
+
| "no-storage"
|
|
69
|
+
| "storage-error"
|
|
70
|
+
| "quota-exceeded"
|
|
71
|
+
| "invalid-account-scope"
|
|
72
|
+
| "invalid-query-key";
|
|
73
|
+
|
|
74
|
+
export type SqlQuerySnapshotMissReason =
|
|
75
|
+
| "absent"
|
|
76
|
+
| "expired"
|
|
77
|
+
| "future-dated"
|
|
78
|
+
| "corrupt"
|
|
79
|
+
| "oversized"
|
|
80
|
+
| "truncated"
|
|
81
|
+
| "unsupported-version"
|
|
82
|
+
| "scope-mismatch";
|
|
83
|
+
|
|
84
|
+
export type SqlQuerySnapshotSkipReason =
|
|
85
|
+
| "error-result"
|
|
86
|
+
| "invalid-result"
|
|
87
|
+
| "invalid-fetched-at"
|
|
88
|
+
| "stale"
|
|
89
|
+
| "truncated"
|
|
90
|
+
| "too-large";
|
|
91
|
+
|
|
92
|
+
export type SqlQuerySnapshotRead =
|
|
93
|
+
| { status: "hit"; snapshot: SqlQuerySnapshot }
|
|
94
|
+
| { status: "miss"; reason: SqlQuerySnapshotMissReason }
|
|
95
|
+
| {
|
|
96
|
+
status: "unavailable";
|
|
97
|
+
reason: SqlQuerySnapshotUnavailableReason;
|
|
98
|
+
errorName?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type SqlQuerySnapshotWrite =
|
|
102
|
+
| { status: "written"; storedAt: number; length: number; evicted: number }
|
|
103
|
+
| { status: "skipped"; reason: SqlQuerySnapshotSkipReason }
|
|
104
|
+
| {
|
|
105
|
+
status: "unavailable";
|
|
106
|
+
reason: SqlQuerySnapshotUnavailableReason;
|
|
107
|
+
errorName?: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type SqlQuerySnapshotClear =
|
|
111
|
+
| { status: "cleared"; removed: number }
|
|
112
|
+
| {
|
|
113
|
+
status: "unavailable";
|
|
114
|
+
reason: SqlQuerySnapshotUnavailableReason;
|
|
115
|
+
errorName?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type StorageOptions = {
|
|
119
|
+
/** Test seam; defaults to `window.sessionStorage`. */
|
|
120
|
+
storage?: SqlQuerySnapshotStorage;
|
|
121
|
+
now?: number;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type Identity = { accountScope: string; scopeId: string; keyId: string };
|
|
125
|
+
|
|
126
|
+
type EntryName = {
|
|
127
|
+
name: string;
|
|
128
|
+
storedAt: number;
|
|
129
|
+
scopeId: string;
|
|
130
|
+
keyId: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type Unavailable = {
|
|
134
|
+
status: "unavailable";
|
|
135
|
+
reason: SqlQuerySnapshotUnavailableReason;
|
|
136
|
+
errorName?: string;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function readSqlQuerySnapshot(
|
|
140
|
+
options: StorageOptions & { accountScope: string; queryKey: string },
|
|
141
|
+
): SqlQuerySnapshotRead {
|
|
142
|
+
const identity = resolveIdentity(options.accountScope, options.queryKey);
|
|
143
|
+
if ("status" in identity) return identity;
|
|
144
|
+
const storage = resolveStorage(options.storage);
|
|
145
|
+
if ("status" in storage) return storage;
|
|
146
|
+
const now = options.now ?? Date.now();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const candidates = listEntries(storage).entries.filter(
|
|
150
|
+
(entry) =>
|
|
151
|
+
entry.scopeId === identity.scopeId && entry.keyId === identity.keyId,
|
|
152
|
+
);
|
|
153
|
+
if (candidates.length === 0) return { status: "miss", reason: "absent" };
|
|
154
|
+
const newest = candidates.reduce((a, b) =>
|
|
155
|
+
b.storedAt > a.storedAt ? b : a,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const drop = (reason: SqlQuerySnapshotMissReason): SqlQuerySnapshotRead => {
|
|
159
|
+
storage.removeItem(newest.name);
|
|
160
|
+
return { status: "miss", reason };
|
|
161
|
+
};
|
|
162
|
+
const nameAge = now - newest.storedAt;
|
|
163
|
+
if (nameAge < -FUTURE_SKEW_MS) return drop("future-dated");
|
|
164
|
+
if (nameAge > SQL_QUERY_SNAPSHOT_MAX_AGE_MS) return drop("expired");
|
|
165
|
+
|
|
166
|
+
const raw = storage.getItem(newest.name);
|
|
167
|
+
if (raw === null) return { status: "miss", reason: "absent" };
|
|
168
|
+
// Checked before parsing: a tampered entry must not cost a huge parse.
|
|
169
|
+
if (raw.length > SQL_QUERY_SNAPSHOT_MAX_ENTRY_LENGTH) {
|
|
170
|
+
return drop("oversized");
|
|
171
|
+
}
|
|
172
|
+
const parsed = parseEnvelope(raw);
|
|
173
|
+
if (parsed === null) return drop("corrupt");
|
|
174
|
+
if (parsed === "truncated") return drop(parsed);
|
|
175
|
+
if (parsed === "unsupported-version") return drop(parsed);
|
|
176
|
+
if (parsed.accountScope !== identity.accountScope) {
|
|
177
|
+
return { status: "miss", reason: "scope-mismatch" };
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
parsed.storedAt !== newest.storedAt ||
|
|
181
|
+
parsed.keyLength !== options.queryKey.length
|
|
182
|
+
) {
|
|
183
|
+
return drop("corrupt");
|
|
184
|
+
}
|
|
185
|
+
const fetchedAtMs =
|
|
186
|
+
parsed.result.fetchedAt === undefined
|
|
187
|
+
? undefined
|
|
188
|
+
: Date.parse(parsed.result.fetchedAt);
|
|
189
|
+
if (fetchedAtMs !== undefined) {
|
|
190
|
+
if (now - fetchedAtMs < -FUTURE_SKEW_MS) return drop("future-dated");
|
|
191
|
+
if (now - fetchedAtMs > SQL_QUERY_SNAPSHOT_MAX_AGE_MS) {
|
|
192
|
+
return drop("expired");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
status: "hit",
|
|
197
|
+
snapshot: { result: parsed.result, storedAt: parsed.storedAt },
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return storageFailure(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function writeSqlQuerySnapshot(
|
|
205
|
+
options: StorageOptions & {
|
|
206
|
+
accountScope: string;
|
|
207
|
+
queryKey: string;
|
|
208
|
+
result: SqlQuerySnapshotInput;
|
|
209
|
+
},
|
|
210
|
+
): SqlQuerySnapshotWrite {
|
|
211
|
+
const identity = resolveIdentity(options.accountScope, options.queryKey);
|
|
212
|
+
if ("status" in identity) return identity;
|
|
213
|
+
const storage = resolveStorage(options.storage);
|
|
214
|
+
if ("status" in storage) return storage;
|
|
215
|
+
const now = options.now ?? Date.now();
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const { entries, malformed } = listEntries(storage);
|
|
219
|
+
for (const name of malformed) {
|
|
220
|
+
storage.removeItem(name);
|
|
221
|
+
}
|
|
222
|
+
const survivors: EntryName[] = [];
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const sameIdentity =
|
|
225
|
+
entry.scopeId === identity.scopeId && entry.keyId === identity.keyId;
|
|
226
|
+
const age = now - entry.storedAt;
|
|
227
|
+
if (
|
|
228
|
+
sameIdentity ||
|
|
229
|
+
age > SQL_QUERY_SNAPSHOT_MAX_AGE_MS ||
|
|
230
|
+
age < -FUTURE_SKEW_MS
|
|
231
|
+
) {
|
|
232
|
+
storage.removeItem(entry.name);
|
|
233
|
+
} else {
|
|
234
|
+
survivors.push(entry);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Any write attempt supersedes the previous copy, including a skipped
|
|
239
|
+
// one: after a reload it would show rows older than the latest fetch.
|
|
240
|
+
const prepared = prepareResult(options.result, now);
|
|
241
|
+
if ("skipped" in prepared) {
|
|
242
|
+
return { status: "skipped", reason: prepared.skipped };
|
|
243
|
+
}
|
|
244
|
+
const value = JSON.stringify({
|
|
245
|
+
v: ENVELOPE_VERSION,
|
|
246
|
+
scope: identity.accountScope,
|
|
247
|
+
keyLength: options.queryKey.length,
|
|
248
|
+
storedAt: now,
|
|
249
|
+
result: prepared.result,
|
|
250
|
+
});
|
|
251
|
+
if (value.length > SQL_QUERY_SNAPSHOT_MAX_ENTRY_LENGTH) {
|
|
252
|
+
return { status: "skipped", reason: "too-large" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const name = entryName(now, identity);
|
|
256
|
+
const sized = survivors
|
|
257
|
+
.map((entry) => ({
|
|
258
|
+
entry,
|
|
259
|
+
length: entry.name.length + (storage.getItem(entry.name)?.length ?? 0),
|
|
260
|
+
}))
|
|
261
|
+
.sort(
|
|
262
|
+
(a, b) =>
|
|
263
|
+
a.entry.storedAt - b.entry.storedAt ||
|
|
264
|
+
a.entry.name.localeCompare(b.entry.name),
|
|
265
|
+
);
|
|
266
|
+
let total = sized.reduce((sum, item) => sum + item.length, 0);
|
|
267
|
+
const length = name.length + value.length;
|
|
268
|
+
let evicted = 0;
|
|
269
|
+
const evictOldest = (): boolean => {
|
|
270
|
+
const oldest = sized.shift();
|
|
271
|
+
if (!oldest) return false;
|
|
272
|
+
storage.removeItem(oldest.entry.name);
|
|
273
|
+
total -= oldest.length;
|
|
274
|
+
evicted += 1;
|
|
275
|
+
return true;
|
|
276
|
+
};
|
|
277
|
+
while (
|
|
278
|
+
sized.length >= SQL_QUERY_SNAPSHOT_MAX_ENTRIES ||
|
|
279
|
+
total + length > SQL_QUERY_SNAPSHOT_MAX_TOTAL_LENGTH
|
|
280
|
+
) {
|
|
281
|
+
if (!evictOldest()) break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (;;) {
|
|
285
|
+
try {
|
|
286
|
+
storage.setItem(name, value);
|
|
287
|
+
return { status: "written", storedAt: now, length, evicted };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (!isQuotaError(error)) throw error;
|
|
290
|
+
if (!evictOldest()) {
|
|
291
|
+
return {
|
|
292
|
+
status: "unavailable",
|
|
293
|
+
reason: "quota-exceeded",
|
|
294
|
+
errorName: errorName(error),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return storageFailure(error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function removeSqlQuerySnapshot(
|
|
305
|
+
options: Pick<StorageOptions, "storage"> & {
|
|
306
|
+
accountScope: string;
|
|
307
|
+
queryKey: string;
|
|
308
|
+
},
|
|
309
|
+
): SqlQuerySnapshotClear {
|
|
310
|
+
const identity = resolveIdentity(options.accountScope, options.queryKey);
|
|
311
|
+
if ("status" in identity) return identity;
|
|
312
|
+
const storage = resolveStorage(options.storage);
|
|
313
|
+
if ("status" in storage) return storage;
|
|
314
|
+
try {
|
|
315
|
+
const matches = listEntries(storage).entries.filter(
|
|
316
|
+
(entry) =>
|
|
317
|
+
entry.scopeId === identity.scopeId && entry.keyId === identity.keyId,
|
|
318
|
+
);
|
|
319
|
+
for (const entry of matches) storage.removeItem(entry.name);
|
|
320
|
+
return { status: "cleared", removed: matches.length };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return storageFailure(error);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Drops snapshots for logout or an account/org switch. With
|
|
328
|
+
* `exceptAccountScope` it keeps only that scope's entries; without a valid
|
|
329
|
+
* scope to keep it clears everything.
|
|
330
|
+
*/
|
|
331
|
+
export function clearSqlQuerySnapshots(
|
|
332
|
+
options: Pick<StorageOptions, "storage"> & {
|
|
333
|
+
exceptAccountScope?: string;
|
|
334
|
+
} = {},
|
|
335
|
+
): SqlQuerySnapshotClear {
|
|
336
|
+
const storage = resolveStorage(options.storage);
|
|
337
|
+
if ("status" in storage) return storage;
|
|
338
|
+
const keepScopeId =
|
|
339
|
+
options.exceptAccountScope !== undefined &&
|
|
340
|
+
isValidAccountScope(options.exceptAccountScope)
|
|
341
|
+
? fingerprint(options.exceptAccountScope)
|
|
342
|
+
: null;
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const { entries, malformed } = listEntries(storage);
|
|
346
|
+
let removed = 0;
|
|
347
|
+
for (const name of malformed) {
|
|
348
|
+
storage.removeItem(name);
|
|
349
|
+
removed += 1;
|
|
350
|
+
}
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
if (entry.scopeId === keepScopeId) continue;
|
|
353
|
+
storage.removeItem(entry.name);
|
|
354
|
+
removed += 1;
|
|
355
|
+
}
|
|
356
|
+
return { status: "cleared", removed };
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return storageFailure(error);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function resolveIdentity(
|
|
363
|
+
accountScope: string,
|
|
364
|
+
queryKey: string,
|
|
365
|
+
): Identity | Unavailable {
|
|
366
|
+
if (!isValidAccountScope(accountScope)) {
|
|
367
|
+
return { status: "unavailable", reason: "invalid-account-scope" };
|
|
368
|
+
}
|
|
369
|
+
if (
|
|
370
|
+
typeof queryKey !== "string" ||
|
|
371
|
+
queryKey.length === 0 ||
|
|
372
|
+
queryKey.length > MAX_QUERY_KEY_LENGTH
|
|
373
|
+
) {
|
|
374
|
+
return { status: "unavailable", reason: "invalid-query-key" };
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
accountScope,
|
|
378
|
+
scopeId: fingerprint(accountScope),
|
|
379
|
+
keyId: fingerprint(queryKey),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Accepts only the `user:org` shape `dashboardCacheScope` produces for an
|
|
385
|
+
* authenticated session. The loading placeholder and the anonymous principal
|
|
386
|
+
* are rejected so they can never name a snapshot.
|
|
387
|
+
*/
|
|
388
|
+
function isValidAccountScope(accountScope: unknown): accountScope is string {
|
|
389
|
+
if (typeof accountScope !== "string") return false;
|
|
390
|
+
if (RESERVED_SCOPES.has(accountScope)) return false;
|
|
391
|
+
if (!ACCOUNT_SCOPE_PATTERN.test(accountScope)) return false;
|
|
392
|
+
return !accountScope.startsWith("anonymous:");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolveStorage(
|
|
396
|
+
storage: SqlQuerySnapshotStorage | undefined,
|
|
397
|
+
): SqlQuerySnapshotStorage | Unavailable {
|
|
398
|
+
if (storage) return storage;
|
|
399
|
+
try {
|
|
400
|
+
if (typeof window === "undefined") {
|
|
401
|
+
return { status: "unavailable", reason: "no-storage" };
|
|
402
|
+
}
|
|
403
|
+
// Reading `sessionStorage` itself throws when storage is blocked.
|
|
404
|
+
const sessionStorage = window.sessionStorage;
|
|
405
|
+
return sessionStorage
|
|
406
|
+
? sessionStorage
|
|
407
|
+
: { status: "unavailable", reason: "no-storage" };
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return storageFailure(error);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function storageFailure(error: unknown): Unavailable {
|
|
414
|
+
return {
|
|
415
|
+
status: "unavailable",
|
|
416
|
+
reason: "storage-error",
|
|
417
|
+
errorName: errorName(error),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function errorName(error: unknown): string | undefined {
|
|
422
|
+
return error instanceof Error ? error.name : undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isQuotaError(error: unknown): boolean {
|
|
426
|
+
if (!error || typeof error !== "object") return false;
|
|
427
|
+
const { name, code } = error as { name?: unknown; code?: unknown };
|
|
428
|
+
return (
|
|
429
|
+
name === "QuotaExceededError" ||
|
|
430
|
+
name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
|
431
|
+
code === 22 ||
|
|
432
|
+
code === 1014
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function listEntries(storage: SqlQuerySnapshotStorage): {
|
|
437
|
+
entries: EntryName[];
|
|
438
|
+
malformed: string[];
|
|
439
|
+
} {
|
|
440
|
+
const names: string[] = [];
|
|
441
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
442
|
+
const name = storage.key(index);
|
|
443
|
+
if (name !== null && name.startsWith(KEY_PREFIX)) names.push(name);
|
|
444
|
+
}
|
|
445
|
+
const entries: EntryName[] = [];
|
|
446
|
+
const malformed: string[] = [];
|
|
447
|
+
for (const name of names) {
|
|
448
|
+
const match = KEY_PATTERN.exec(name);
|
|
449
|
+
const storedAt = match ? Number.parseInt(match[1], 36) : Number.NaN;
|
|
450
|
+
if (!match || !Number.isSafeInteger(storedAt)) {
|
|
451
|
+
malformed.push(name);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
entries.push({ name, storedAt, scopeId: match[2], keyId: match[3] });
|
|
455
|
+
}
|
|
456
|
+
return { entries, malformed };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function entryName(storedAt: number, identity: Identity): string {
|
|
460
|
+
return `${KEY_PREFIX}v1/${storedAt.toString(36)}/${identity.scopeId}/${identity.keyId}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Two independent 53-bit hashes; collisions are not an attack surface here. */
|
|
464
|
+
function fingerprint(input: string): string {
|
|
465
|
+
return `${cyrb53(input, 0)}${cyrb53(input, 0x9e3779b9)}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function cyrb53(input: string, seed: number): string {
|
|
469
|
+
let h1 = 0xdeadbeef ^ seed;
|
|
470
|
+
let h2 = 0x41c6ce57 ^ seed;
|
|
471
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
472
|
+
const code = input.charCodeAt(index);
|
|
473
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
474
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
475
|
+
}
|
|
476
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
|
477
|
+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
478
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
|
479
|
+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
480
|
+
const value = 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
481
|
+
return value.toString(16).padStart(14, "0");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function prepareResult(
|
|
485
|
+
input: SqlQuerySnapshotInput,
|
|
486
|
+
now: number,
|
|
487
|
+
):
|
|
488
|
+
| { result: SqlQuerySnapshotResult }
|
|
489
|
+
| { skipped: SqlQuerySnapshotSkipReason } {
|
|
490
|
+
if (!isPlainObject(input)) return { skipped: "invalid-result" };
|
|
491
|
+
// Each field is read once so the checked value is the stored value.
|
|
492
|
+
const {
|
|
493
|
+
error,
|
|
494
|
+
rows,
|
|
495
|
+
schema,
|
|
496
|
+
cached,
|
|
497
|
+
providerCacheHit,
|
|
498
|
+
truncated,
|
|
499
|
+
fetchedAt,
|
|
500
|
+
} = input;
|
|
501
|
+
if (error !== undefined && error !== "") return { skipped: "error-result" };
|
|
502
|
+
if (truncated !== undefined && typeof truncated !== "boolean") {
|
|
503
|
+
return { skipped: "invalid-result" };
|
|
504
|
+
}
|
|
505
|
+
if (truncated === true) return { skipped: "truncated" };
|
|
506
|
+
const budget = { nodes: 0 };
|
|
507
|
+
const rowsVerdict = checkJson(rows, budget, 0);
|
|
508
|
+
if (rowsVerdict === "too-large") return { skipped: "too-large" };
|
|
509
|
+
if (rowsVerdict === "invalid" || !isRowArray(rows)) {
|
|
510
|
+
return { skipped: "invalid-result" };
|
|
511
|
+
}
|
|
512
|
+
if (schema !== undefined) {
|
|
513
|
+
const schemaVerdict = checkJson(schema, budget, 0);
|
|
514
|
+
if (schemaVerdict === "too-large") return { skipped: "too-large" };
|
|
515
|
+
if (schemaVerdict === "invalid" || !isSchema(schema)) {
|
|
516
|
+
return { skipped: "invalid-result" };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (
|
|
520
|
+
(cached !== undefined && typeof cached !== "boolean") ||
|
|
521
|
+
(providerCacheHit !== undefined && typeof providerCacheHit !== "boolean")
|
|
522
|
+
) {
|
|
523
|
+
return { skipped: "invalid-result" };
|
|
524
|
+
}
|
|
525
|
+
if (fetchedAt !== undefined) {
|
|
526
|
+
const fetchedAtMs =
|
|
527
|
+
typeof fetchedAt === "string" ? Date.parse(fetchedAt) : NaN;
|
|
528
|
+
if (!Number.isFinite(fetchedAtMs) || fetchedAtMs - now > FUTURE_SKEW_MS) {
|
|
529
|
+
return { skipped: "invalid-fetched-at" };
|
|
530
|
+
}
|
|
531
|
+
if (now - fetchedAtMs > SQL_QUERY_SNAPSHOT_MAX_AGE_MS) {
|
|
532
|
+
return { skipped: "stale" };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const result: SqlQuerySnapshotResult = { rows };
|
|
536
|
+
if (schema !== undefined) result.schema = schema;
|
|
537
|
+
if (cached !== undefined) result.cached = cached;
|
|
538
|
+
if (providerCacheHit !== undefined)
|
|
539
|
+
result.providerCacheHit = providerCacheHit;
|
|
540
|
+
if (truncated !== undefined) result.truncated = truncated;
|
|
541
|
+
if (fetchedAt !== undefined) result.fetchedAt = fetchedAt;
|
|
542
|
+
return { result };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Walks a value that must survive `JSON.stringify` unchanged. `undefined`,
|
|
547
|
+
* non-finite numbers, bigint, dates, class instances, sparse holes and
|
|
548
|
+
* accessors would be rewritten, dropped or re-evaluated by serialization,
|
|
549
|
+
* which would restore different rows than were shown.
|
|
550
|
+
*
|
|
551
|
+
* The walk is in place and charges the node budget before each member is
|
|
552
|
+
* touched, so a wide or hostile value fails after at most `MAX_RESULT_NODES`
|
|
553
|
+
* reads and never has a getter invoked.
|
|
554
|
+
*/
|
|
555
|
+
function checkJson(
|
|
556
|
+
value: unknown,
|
|
557
|
+
budget: { nodes: number },
|
|
558
|
+
depth: number,
|
|
559
|
+
): "ok" | "invalid" | "too-large" {
|
|
560
|
+
budget.nodes += 1;
|
|
561
|
+
if (budget.nodes > MAX_RESULT_NODES) return "too-large";
|
|
562
|
+
if (depth > MAX_RESULT_DEPTH) return "invalid";
|
|
563
|
+
if (value === null) return "ok";
|
|
564
|
+
switch (typeof value) {
|
|
565
|
+
case "string":
|
|
566
|
+
case "boolean":
|
|
567
|
+
return "ok";
|
|
568
|
+
case "number":
|
|
569
|
+
return Number.isFinite(value) ? "ok" : "invalid";
|
|
570
|
+
case "object":
|
|
571
|
+
break;
|
|
572
|
+
default:
|
|
573
|
+
return "invalid";
|
|
574
|
+
}
|
|
575
|
+
if (Array.isArray(value)) {
|
|
576
|
+
if (value.length > MAX_RESULT_NODES - budget.nodes) return "too-large";
|
|
577
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
578
|
+
const verdict = checkMember(value, String(index), true, budget, depth);
|
|
579
|
+
if (verdict !== "ok") return verdict;
|
|
580
|
+
}
|
|
581
|
+
return "ok";
|
|
582
|
+
}
|
|
583
|
+
if (!isPlainObject(value)) return "invalid";
|
|
584
|
+
for (const key in value) {
|
|
585
|
+
if (budget.nodes >= MAX_RESULT_NODES) return "too-large";
|
|
586
|
+
const verdict = checkMember(value, key, false, budget, depth);
|
|
587
|
+
if (verdict !== "ok") return verdict;
|
|
588
|
+
}
|
|
589
|
+
return "ok";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function checkMember(
|
|
593
|
+
owner: object,
|
|
594
|
+
key: string,
|
|
595
|
+
required: boolean,
|
|
596
|
+
budget: { nodes: number },
|
|
597
|
+
depth: number,
|
|
598
|
+
): "ok" | "invalid" | "too-large" {
|
|
599
|
+
const descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
|
600
|
+
// An inherited enumerable key is not serialized; a hole or accessor is
|
|
601
|
+
// invalid because serialization would not reproduce it.
|
|
602
|
+
if (!descriptor) return required ? "invalid" : "ok";
|
|
603
|
+
if (!("value" in descriptor)) return "invalid";
|
|
604
|
+
return checkJson(descriptor.value, budget, depth + 1);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
608
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
const prototype = Object.getPrototypeOf(value);
|
|
612
|
+
return prototype === Object.prototype || prototype === null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function isRowArray(value: unknown): value is Record<string, unknown>[] {
|
|
616
|
+
return Array.isArray(value) && value.every(isPlainObject);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isSchema(value: unknown): value is { name: string; type: string }[] {
|
|
620
|
+
return (
|
|
621
|
+
Array.isArray(value) &&
|
|
622
|
+
value.every(
|
|
623
|
+
(column) =>
|
|
624
|
+
isPlainObject(column) &&
|
|
625
|
+
typeof column.name === "string" &&
|
|
626
|
+
typeof column.type === "string",
|
|
627
|
+
)
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function parseEnvelope(raw: string):
|
|
632
|
+
| {
|
|
633
|
+
accountScope: string;
|
|
634
|
+
keyLength: number;
|
|
635
|
+
storedAt: number;
|
|
636
|
+
result: SqlQuerySnapshotResult;
|
|
637
|
+
}
|
|
638
|
+
| "unsupported-version"
|
|
639
|
+
| "truncated"
|
|
640
|
+
| null {
|
|
641
|
+
let parsed: unknown;
|
|
642
|
+
try {
|
|
643
|
+
parsed = JSON.parse(raw);
|
|
644
|
+
} catch {
|
|
645
|
+
// coercion-ok: null is the typed "unreadable" value; the caller drops the entry as corrupt, never serves it
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
if (!isPlainObject(parsed)) return null;
|
|
649
|
+
const { v, scope, keyLength, storedAt, result } = parsed;
|
|
650
|
+
if (typeof v !== "number") return null;
|
|
651
|
+
// Other versions may change every other field, so read nothing else.
|
|
652
|
+
if (v !== ENVELOPE_VERSION) return "unsupported-version";
|
|
653
|
+
if (
|
|
654
|
+
typeof scope !== "string" ||
|
|
655
|
+
typeof keyLength !== "number" ||
|
|
656
|
+
typeof storedAt !== "number" ||
|
|
657
|
+
!Number.isSafeInteger(storedAt) ||
|
|
658
|
+
!isPlainObject(result) ||
|
|
659
|
+
!isRowArray(result.rows)
|
|
660
|
+
) {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
const clean: SqlQuerySnapshotResult = { rows: result.rows };
|
|
664
|
+
if (result.schema !== undefined) {
|
|
665
|
+
if (!isSchema(result.schema)) return null;
|
|
666
|
+
clean.schema = result.schema;
|
|
667
|
+
}
|
|
668
|
+
// Truncated rows are never written; one found here was altered or came
|
|
669
|
+
// from code that did not know about the flag, and is not a complete result.
|
|
670
|
+
if (result.truncated !== undefined) {
|
|
671
|
+
if (typeof result.truncated !== "boolean") return null;
|
|
672
|
+
if (result.truncated) return "truncated";
|
|
673
|
+
clean.truncated = false;
|
|
674
|
+
}
|
|
675
|
+
for (const flag of ["cached", "providerCacheHit"] as const) {
|
|
676
|
+
if (result[flag] !== undefined) {
|
|
677
|
+
if (typeof result[flag] !== "boolean") return null;
|
|
678
|
+
clean[flag] = result[flag];
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (result.fetchedAt !== undefined) {
|
|
682
|
+
if (
|
|
683
|
+
typeof result.fetchedAt !== "string" ||
|
|
684
|
+
!Number.isFinite(Date.parse(result.fetchedAt))
|
|
685
|
+
) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
clean.fetchedAt = result.fetchedAt;
|
|
689
|
+
}
|
|
690
|
+
if (result.error !== undefined) return null;
|
|
691
|
+
return {
|
|
692
|
+
accountScope: scope,
|
|
693
|
+
keyLength,
|
|
694
|
+
storedAt,
|
|
695
|
+
result: clean,
|
|
696
|
+
};
|
|
697
|
+
}
|