tledger 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -155
- package/bin/token-ledger-cache-data.mjs +492 -0
- package/bin/token-ledger-cache-image.mjs +7 -1147
- package/bin/token-ledger-cache-sections.mjs +848 -0
- package/bin/token-ledger-cost-terminal.mjs +234 -0
- package/bin/token-ledger-image-layout.mjs +20 -0
- package/bin/token-ledger-image-primitives.mjs +192 -0
- package/bin/token-ledger-report-data.mjs +1159 -0
- package/bin/token-ledger-source-status.mjs +31 -0
- package/bin/token-ledger-terminal.mjs +245 -69
- package/bin/token-ledger-trend-image.mjs +2039 -1599
- package/bin/token-ledger-trend-terminal.mjs +273 -150
- package/bin/token-ledger-trend.mjs +262 -210
- package/bin/token-ledger-tui.mjs +180 -51
- package/bin/token-ledger.mjs +747 -194
- package/docs/durable-ledger-operations.md +198 -0
- package/docs/release-notes-0.4.0.md +41 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-calendar.mjs +225 -0
- package/lib/token-ledger-collection.mjs +100 -0
- package/lib/token-ledger-importer.mjs +3334 -480
- package/lib/token-ledger-labels.mjs +66 -0
- package/lib/token-ledger-ledger.mjs +6056 -0
- package/lib/token-ledger-quota-contract.mjs +38 -0
- package/lib/token-ledger-range-analysis.mjs +120 -0
- package/lib/token-ledger-rates.mjs +330 -0
- package/lib/token-ledger-snapshot.mjs +336 -35
- package/lib/token-ledger-terminal-text.mjs +11 -0
- package/lib/token-ledger-usage.mjs +339 -33
- package/package.json +13 -10
- package/bin/token-ledger-rates.mjs +0 -65
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Durable ledger operations
|
|
2
|
+
|
|
3
|
+
Token Ledger keeps two local artifacts with different responsibilities:
|
|
4
|
+
|
|
5
|
+
- `token-ledger-ledger.sqlite` is the durable, deduplicated source of truth.
|
|
6
|
+
- `token-ledger-snapshot-v3.json.gz` is a bounded report cache that can be
|
|
7
|
+
regenerated from the ledger and currently available Codex sources.
|
|
8
|
+
|
|
9
|
+
The durable database, writer guard, WAL, journal, and shared-memory files live
|
|
10
|
+
only in the app-owned private `~/.token-ledger` directory. Snapshot exports may
|
|
11
|
+
be written elsewhere, but output selection never relocates SQLite state.
|
|
12
|
+
|
|
13
|
+
Deleting or replacing the report cache must not be treated as deleting ledger
|
|
14
|
+
history. Conversely, a successfully parsed report cache is not proof that its
|
|
15
|
+
revision matches the current ledger.
|
|
16
|
+
|
|
17
|
+
## Health signals
|
|
18
|
+
|
|
19
|
+
Generated snapshots expose the durable schema version, revision, quota-identity
|
|
20
|
+
contract, hashed Codex home identity, retention horizons,
|
|
21
|
+
`legacySnapshotStatus`, and separate legacy-quota diagnostics under
|
|
22
|
+
`metadata.durableLedger`. Coverage also includes source-state counts,
|
|
23
|
+
`sourceIncomplete`, compacted and migrated bucket counts, and the same legacy
|
|
24
|
+
statuses.
|
|
25
|
+
|
|
26
|
+
The main legacy statuses are:
|
|
27
|
+
|
|
28
|
+
- `migrated`: the legacy v3 snapshot had a provable collection scope and a
|
|
29
|
+
matching hashed Codex-home identity, so its history was imported as
|
|
30
|
+
estimated compacted rows.
|
|
31
|
+
- `collection-scope-unverified`: the snapshot did not prove whether it included
|
|
32
|
+
archived sources or a time cutoff.
|
|
33
|
+
- `codex-home-unverified`: the snapshot did not carry a verifiable hashed Codex
|
|
34
|
+
home identity.
|
|
35
|
+
- `codex-home-mismatch`: the snapshot belonged to a different Codex home.
|
|
36
|
+
|
|
37
|
+
The last three statuses mean legacy history was deliberately excluded. Exact
|
|
38
|
+
rollout collection still proceeds, and PNG reports show `LEGACY HISTORY
|
|
39
|
+
SKIPPED` so the omission is not silent.
|
|
40
|
+
|
|
41
|
+
Legacy quota rows have a narrower gate than usage history. They migrate only
|
|
42
|
+
when the source snapshot explicitly declares the current quota-identity
|
|
43
|
+
contract. `legacyQuotaStatus` reports `migrated`, `not-present`,
|
|
44
|
+
`skipped-contract-unverified`, `skipped-contract-mismatch`, or
|
|
45
|
+
`skipped-invalid`. A whole-snapshot provenance-gate failure instead reports
|
|
46
|
+
`skipped-<legacySnapshotStatus>`, such as
|
|
47
|
+
`skipped-collection-scope-unverified`, `skipped-codex-home-unverified`, or
|
|
48
|
+
`skipped-codex-home-mismatch`. `legacyQuotaRowsSkipped` records the excluded
|
|
49
|
+
array length; zero with `skipped-invalid` means the malformed non-array shape
|
|
50
|
+
had no reliable row count. Markerless, older, or semantically invalid quota
|
|
51
|
+
rows are never guessed or re-keyed; safe usage and thread history still
|
|
52
|
+
migrate.
|
|
53
|
+
|
|
54
|
+
A missing legacy snapshot is a completed absence check. By contrast, an
|
|
55
|
+
existing malformed usage/thread history, unreadable, oversized, non-regular,
|
|
56
|
+
or non-v3 snapshot
|
|
57
|
+
stops with `ERR_DURABLE_LEDGER_LEGACY_SNAPSHOT`. That failure does not advance
|
|
58
|
+
the ledger revision, mark the one-shot migration as checked, or publish a new
|
|
59
|
+
cache. Preserve the unreadable artifact in a private backup, then repair or
|
|
60
|
+
replace it with the matching legacy v3 snapshot and retry. If migration is
|
|
61
|
+
intentionally declined, move the preserved artifact out of the configured
|
|
62
|
+
snapshot path; the next successful refresh records that absence as checked.
|
|
63
|
+
|
|
64
|
+
`sourceIncomplete` means a previously observed source is missing, tombstoned,
|
|
65
|
+
truncated, or replaced. It is a provenance warning, not proof that SQLite is
|
|
66
|
+
corrupt. A true append keeps stable provenance; a larger same-inode rewrite is
|
|
67
|
+
classified as a replacement rather than an append.
|
|
68
|
+
|
|
69
|
+
A source scan with malformed JSON or an invalid token record is evidence-only:
|
|
70
|
+
its coverage counters and source watermark are retained, but none of its token,
|
|
71
|
+
quota, tool, position, ownership, origin, or thread operations can change the
|
|
72
|
+
last complete durable interpretation. The affected file's usage is excluded
|
|
73
|
+
from totals until it scans cleanly. To recover, fix or remove the malformed
|
|
74
|
+
line, then refresh. If that scan also detected a replacement, the source state
|
|
75
|
+
records a pending reconciliation. The bit survives lifecycle moves, missing or
|
|
76
|
+
tombstoned state, and truncation, then clears atomically only after a clean
|
|
77
|
+
complete scan reconciles every source-owned membership and position.
|
|
78
|
+
|
|
79
|
+
Quota labels are stored separately as explicit, timestamped source evidence.
|
|
80
|
+
Malformed quota scans cannot update that evidence. Missing or tombstoned
|
|
81
|
+
sources are ineligible at read time, and archived-source labels are ineligible
|
|
82
|
+
under `--no-archived`; observation rows are never destructively relabeled.
|
|
83
|
+
|
|
84
|
+
## Schema and privacy upgrades
|
|
85
|
+
|
|
86
|
+
Schema-v1 preview ledgers with reconstructable migration scope upgrade to v2
|
|
87
|
+
inside one SQLite transaction. The upgrade removes stored working directories,
|
|
88
|
+
Git remotes, and raw source values, then compacts the database so those bytes do
|
|
89
|
+
not remain in free pages. New v2 writes store no raw versions of those values.
|
|
90
|
+
|
|
91
|
+
Schema v3 keeps the durable source-position index bounded: `event_key` in
|
|
92
|
+
`source_event_positions` is now a 64-character SHA-256 digest, while the full
|
|
93
|
+
event key remains in the observation or compaction-membership row that owns it.
|
|
94
|
+
Existing v2 ledgers rewrite those position values once on first write. New
|
|
95
|
+
ledgers use SQLite incremental auto-vacuum, and successful writes reclaim a
|
|
96
|
+
large freelist when it crosses both an absolute and percentage threshold;
|
|
97
|
+
small or busy databases defer maintenance to a later write.
|
|
98
|
+
|
|
99
|
+
During importer commits, durable usage and tool rows are materialized through a
|
|
100
|
+
synchronous SQLite cursor into the disk-backed usage spool. The public
|
|
101
|
+
`readDurableLedger()` result remains array-shaped for callers, but the refresh
|
|
102
|
+
hot path retains only the current row and one compacted row's membership
|
|
103
|
+
iterator at a time. Legacy migrated buckets are the bounded exception because
|
|
104
|
+
their residuals must remain available while current observations are compared.
|
|
105
|
+
Quota and thread metadata continue to use their existing collection paths; a
|
|
106
|
+
future change can stream those independently if their retention grows enough
|
|
107
|
+
to justify it.
|
|
108
|
+
|
|
109
|
+
The quota-identity contract upgrades separately. Opening a markerless ledger or
|
|
110
|
+
the prior `codex-limit-id-v1` contract for a write transaction discards all
|
|
111
|
+
pre-contract exact and migrated quota rows with their memberships, then records
|
|
112
|
+
the current contract atomically. Only a subsequent clean source scan can
|
|
113
|
+
repopulate canonical quota history. Rollback restores both the prior rows and
|
|
114
|
+
prior marker. An unknown or future contract stops before mutation with
|
|
115
|
+
`ERR_DURABLE_LEDGER_QUOTA_CONTRACT`; do not downgrade or rewrite that ledger.
|
|
116
|
+
|
|
117
|
+
Quota identity has one irreducible local boundary. Codex rollout
|
|
118
|
+
`RateLimitSnapshot` records carry a provider limit id, but no ChatGPT user or
|
|
119
|
+
account id. Token Ledger therefore separates quota pools by the canonical
|
|
120
|
+
provider limit id only within one account identity per `CODEX_HOME`; an omitted
|
|
121
|
+
or blank id is the default `codex` pool. Limit names and plan labels are display
|
|
122
|
+
metadata, never identity. Reusing one `CODEX_HOME` and durable ledger across
|
|
123
|
+
different ChatGPT users can stitch equal `codex` ids into one quota timeline;
|
|
124
|
+
use separate Codex homes and ledger locations when account isolation matters.
|
|
125
|
+
|
|
126
|
+
An early schema-v1 ledger that contains migrated history without a
|
|
127
|
+
reconstructable scope stops with `ERR_DURABLE_LEDGER_MIGRATION_SCOPE`. That
|
|
128
|
+
database is left unchanged because guessing its scope could either double-count
|
|
129
|
+
or erase usage.
|
|
130
|
+
|
|
131
|
+
## Crash safety and non-destructive repair
|
|
132
|
+
|
|
133
|
+
1. Stop concurrent Token Ledger refreshes and copy the ledger plus any SQLite
|
|
134
|
+
`-wal` and `-shm` sidecars to a private backup location.
|
|
135
|
+
2. For `ERR_DURABLE_LEDGER_CODEX_HOME`, select the Codex home that originally
|
|
136
|
+
created the ledger. Do not rebind the ledger to a different home; use a
|
|
137
|
+
separate OS user profile when account-level isolation is required.
|
|
138
|
+
3. For `ERR_DURABLE_LEDGER_MIGRATION_SCOPE`, keep the preview ledger as a
|
|
139
|
+
backup. Recreate the private app-owned ledger and rebuild exact history from
|
|
140
|
+
the matching Codex sources. Import a legacy snapshot only when its collection
|
|
141
|
+
scope and Codex-home fingerprint are both known.
|
|
142
|
+
4. For a stale or oversized report cache, preserve the ledger and retry with a
|
|
143
|
+
compressed output, `--since`, or `--no-archived`. Removing only the cache is
|
|
144
|
+
safe when a rebuild is desired.
|
|
145
|
+
5. Treat missing or rewritten rollout history as incomplete provenance. Do not
|
|
146
|
+
delete the ledger to make that warning disappear.
|
|
147
|
+
|
|
148
|
+
Snapshot encoding is staged in a private temporary file. Source watermarks are
|
|
149
|
+
validated twice before the SQLite commit and once immediately after it. The
|
|
150
|
+
SQLite commit atomically writes a complete candidate revision. That candidate
|
|
151
|
+
remains pending until the post-commit source check succeeds and its recovery
|
|
152
|
+
marker is cleared. Coordinated readers use the same writer guard, so they
|
|
153
|
+
never observe an in-progress transaction.
|
|
154
|
+
|
|
155
|
+
The staged report cache is published only after post-commit source validation
|
|
156
|
+
succeeds and the pending marker is cleared. If sources changed after the final
|
|
157
|
+
pre-commit check, the candidate is reverted through its undo log, the staged
|
|
158
|
+
cache is discarded, and collection retries from the new source inventory.
|
|
159
|
+
An interrupted validation or failed marker-clear transaction also leaves a
|
|
160
|
+
pending candidate that the next writer reverts before collecting again.
|
|
161
|
+
Observations from such an incomplete refresh are not accepted history; their
|
|
162
|
+
recovery requires the source files to remain available.
|
|
163
|
+
|
|
164
|
+
A crash before commit is rolled back by SQLite. A crash after the candidate
|
|
165
|
+
commit but before finalization can leave that candidate visible to a direct
|
|
166
|
+
ledger reader, but it remains provisional and will be unwound on refresh.
|
|
167
|
+
After finalization, a cache-publication failure leaves the accepted ledger
|
|
168
|
+
ahead of the prior cache. The cache's revision mismatch forces an automatic
|
|
169
|
+
refresh instead of labeling it verified-current. Successfully finalized
|
|
170
|
+
history remains available when its source files disappear.
|
|
171
|
+
|
|
172
|
+
Before staging a replacement cache, Token Ledger also removes a
|
|
173
|
+
same-destination temporary file only when it is an ordinary, single-link file
|
|
174
|
+
owned by the current user and its recorded process is demonstrably gone. No
|
|
175
|
+
ledger-sized baseline or restore copy is created during refresh.
|
|
176
|
+
|
|
177
|
+
## Repeatable refresh benchmark
|
|
178
|
+
|
|
179
|
+
The benchmark can generate actual old token-count events, prove that the
|
|
180
|
+
durable ledger compacted them, verify the durable token total and revision, and
|
|
181
|
+
measure cold plus warm refreshes:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
for events in 100 1000 5000 10000; do
|
|
185
|
+
npm run benchmark:refresh -- \
|
|
186
|
+
--files 24 \
|
|
187
|
+
--token-events "$events" \
|
|
188
|
+
--warm-runs 3 \
|
|
189
|
+
--event-age-days 4000
|
|
190
|
+
done
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Record the operating system, CPU, memory, Node and npm versions with the result.
|
|
194
|
+
Use `warmMedianWallTimeMs` for the steady-state refresh comparison and retain
|
|
195
|
+
`coldWallTimeMs` separately. A valid compaction run has nonzero
|
|
196
|
+
`durableCompactedBuckets`, a `durableRevision` equal to `warmRuns`, the expected
|
|
197
|
+
`durableTotalTokens`, and zero parse errors. Running several benchmarks in
|
|
198
|
+
parallel invalidates timing comparisons.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Token Ledger 0.4.0
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- One-day PNG reports now show hourly token usage and cache efficiency, with
|
|
6
|
+
a partial current hour and support for daylight-saving transitions.
|
|
7
|
+
- Add `--private` to a report to replace project names with ranked anonymous
|
|
8
|
+
labels. Usage figures stay visible, the source snapshot stays unchanged,
|
|
9
|
+
and the default output filename ends in `-private.png`.
|
|
10
|
+
- Recognize Astra in usage reports. Models without bundled prices remain
|
|
11
|
+
explicitly unrated in purchased-credit and API-equivalent estimates.
|
|
12
|
+
- Keep model names and token amounts readable in narrow bars, with floating
|
|
13
|
+
labels for small segments and captions for small model-mix shares.
|
|
14
|
+
- Show unrecorded fast-mode status separately from confirmed fast usage.
|
|
15
|
+
|
|
16
|
+
## Correctness and reliability
|
|
17
|
+
|
|
18
|
+
- Allocate compacted usage across local calendar and hourly boundaries, retain
|
|
19
|
+
persisted call counts, and compare equivalent partial reporting periods.
|
|
20
|
+
- Keep report cutoffs fixed during collection and disclose allocated estimates.
|
|
21
|
+
- Repair token timestamps that precede their own recorded turn, preserve token
|
|
22
|
+
totals, and mark the replacement time as estimated.
|
|
23
|
+
- Reject impossible quota-window timestamps so valid meter history remains
|
|
24
|
+
visible, and keep meter attribution separate from daily token precision.
|
|
25
|
+
- Correct detailed-call coverage for total-only records while retaining valid
|
|
26
|
+
partial coverage in compacted history.
|
|
27
|
+
- Preserve competing snapshot replacements during publication checks.
|
|
28
|
+
- Redact local project paths during legacy migration and report materialization,
|
|
29
|
+
and redact local paths in CLI and collector diagnostics.
|
|
30
|
+
- Reject malformed quota readings, handle fragmented terminal key sequences,
|
|
31
|
+
and tolerate XML-invalid label characters during PNG encoding.
|
|
32
|
+
- Improve cross-month labels, narrow chart spacing, peak-hour labels, and
|
|
33
|
+
fallback to token bars when observed drain is unavailable.
|
|
34
|
+
- Preserve persisted thread-title updates and isolate benchmark state from the
|
|
35
|
+
live ledger.
|
|
36
|
+
|
|
37
|
+
## Release checks and requirements
|
|
38
|
+
|
|
39
|
+
Requires Node.js 22.13 or newer. CI and prepublish checks include regular and
|
|
40
|
+
stress tests, lint, and offline installation of the packed artifact with CLI
|
|
41
|
+
and PNG smoke tests. Token Ledger remains a local-only CLI.
|
|
Binary file
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
2
|
+
const BOUNDARY_SEARCH_RADIUS_MS = 3 * DAY_MS;
|
|
3
|
+
const BOUNDARY_SAMPLE_STEP_MS = 6 * 60 * 60 * 1_000;
|
|
4
|
+
const BOUNDARY_PROBE_STEP_MS = 60 * 60 * 1_000;
|
|
5
|
+
const MAX_BOUNDARY_PROBES = 72;
|
|
6
|
+
const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
7
|
+
|
|
8
|
+
function dateStringFromParts(year, month, day) {
|
|
9
|
+
return [year, month, day]
|
|
10
|
+
.map((value, index) => String(value).padStart(index === 0 ? 4 : 2, "0"))
|
|
11
|
+
.join("-");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseCalendarDate(dateString) {
|
|
15
|
+
if (!CALENDAR_DATE_PATTERN.test(dateString)) {
|
|
16
|
+
throw new Error(`Invalid calendar date: ${dateString}`);
|
|
17
|
+
}
|
|
18
|
+
const [year, month, day] = dateString.split("-").map(Number);
|
|
19
|
+
const check = new Date(0);
|
|
20
|
+
check.setUTCFullYear(year, month - 1, day);
|
|
21
|
+
check.setUTCHours(0, 0, 0, 0);
|
|
22
|
+
if (
|
|
23
|
+
check.getUTCFullYear() !== year ||
|
|
24
|
+
check.getUTCMonth() + 1 !== month ||
|
|
25
|
+
check.getUTCDate() !== day
|
|
26
|
+
) {
|
|
27
|
+
throw new Error(`Invalid calendar date: ${dateString}`);
|
|
28
|
+
}
|
|
29
|
+
return { year, month, day };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function calendarDate(dateString) {
|
|
33
|
+
const { year, month, day } = parseCalendarDate(dateString);
|
|
34
|
+
const date = new Date(0);
|
|
35
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
36
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
37
|
+
return date;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function utcMilliseconds(year, month, day, hour, minute, second) {
|
|
41
|
+
const date = new Date(0);
|
|
42
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
43
|
+
date.setUTCHours(hour, minute, second, 0);
|
|
44
|
+
return date.getTime();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function numericDateTimeParts(timestampMs, formatter) {
|
|
48
|
+
const parts = formatter.formatToParts(new Date(timestampMs));
|
|
49
|
+
return Object.fromEntries(
|
|
50
|
+
parts
|
|
51
|
+
.filter((part) => part.type !== "literal" && part.type !== "timeZoneName")
|
|
52
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function timeZoneOffsetMs(timestampMs, formatter) {
|
|
57
|
+
const values = numericDateTimeParts(timestampMs, formatter);
|
|
58
|
+
const instantSecondMs = Math.floor(timestampMs / 1_000) * 1_000;
|
|
59
|
+
return (
|
|
60
|
+
utcMilliseconds(
|
|
61
|
+
values.year,
|
|
62
|
+
values.month,
|
|
63
|
+
values.day,
|
|
64
|
+
values.hour,
|
|
65
|
+
values.minute,
|
|
66
|
+
values.second,
|
|
67
|
+
) - instantSecondMs
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function localDateStringWithFormatter(timestampMs, formatter) {
|
|
72
|
+
const values = numericDateTimeParts(timestampMs, formatter);
|
|
73
|
+
return dateStringFromParts(values.year, values.month, values.day);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findEarliestLocalDateInstant(candidateMs, dateString, formatter) {
|
|
77
|
+
let high = candidateMs;
|
|
78
|
+
let low = high - BOUNDARY_PROBE_STEP_MS;
|
|
79
|
+
let probes = 0;
|
|
80
|
+
while (
|
|
81
|
+
localDateStringWithFormatter(low, formatter) === dateString &&
|
|
82
|
+
probes < MAX_BOUNDARY_PROBES
|
|
83
|
+
) {
|
|
84
|
+
high = low;
|
|
85
|
+
low -= BOUNDARY_PROBE_STEP_MS;
|
|
86
|
+
probes += 1;
|
|
87
|
+
}
|
|
88
|
+
if (localDateStringWithFormatter(low, formatter) === dateString) {
|
|
89
|
+
throw new Error(`Could not find the start of local date ${dateString}.`);
|
|
90
|
+
}
|
|
91
|
+
while (high - low > 1) {
|
|
92
|
+
const middle = Math.floor((low + high) / 2);
|
|
93
|
+
if (localDateStringWithFormatter(middle, formatter) === dateString) {
|
|
94
|
+
high = middle;
|
|
95
|
+
} else {
|
|
96
|
+
low = middle;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return high;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function candidateOffsets(utcGuess, formatter) {
|
|
103
|
+
// Keep both sides of a nearby offset transition available. A fixed-point
|
|
104
|
+
// midnight guess alone can land in the preceding local date when midnight
|
|
105
|
+
// was skipped.
|
|
106
|
+
const offsets = new Set();
|
|
107
|
+
for (
|
|
108
|
+
let delta = -BOUNDARY_SEARCH_RADIUS_MS;
|
|
109
|
+
delta <= BOUNDARY_SEARCH_RADIUS_MS;
|
|
110
|
+
delta += BOUNDARY_SAMPLE_STEP_MS
|
|
111
|
+
) {
|
|
112
|
+
offsets.add(timeZoneOffsetMs(utcGuess + delta, formatter));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let probe = utcGuess;
|
|
116
|
+
for (let index = 0; index < 8; index += 1) {
|
|
117
|
+
const offset = timeZoneOffsetMs(probe, formatter);
|
|
118
|
+
offsets.add(offset);
|
|
119
|
+
const next = utcGuess - offset;
|
|
120
|
+
if (next === probe) break;
|
|
121
|
+
probe = next;
|
|
122
|
+
}
|
|
123
|
+
return offsets;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createTimeZoneFormatter(timeZone) {
|
|
127
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
128
|
+
timeZone,
|
|
129
|
+
timeZoneName: "longOffset",
|
|
130
|
+
year: "numeric",
|
|
131
|
+
month: "2-digit",
|
|
132
|
+
day: "2-digit",
|
|
133
|
+
hour: "2-digit",
|
|
134
|
+
minute: "2-digit",
|
|
135
|
+
second: "2-digit",
|
|
136
|
+
hourCycle: "h23",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function validateTimeZone(timeZone) {
|
|
141
|
+
try {
|
|
142
|
+
createTimeZoneFormatter(timeZone);
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error(`Unknown IANA timezone: ${timeZone}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function shiftCalendarDate(dateString, amount) {
|
|
149
|
+
const { year, month, day } = parseCalendarDate(dateString);
|
|
150
|
+
const delta = Number(amount);
|
|
151
|
+
if (!Number.isSafeInteger(delta)) {
|
|
152
|
+
throw new Error(`Calendar date shift must be an integer: ${amount}`);
|
|
153
|
+
}
|
|
154
|
+
const date = new Date(0);
|
|
155
|
+
date.setUTCFullYear(year, month - 1, day + delta);
|
|
156
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
157
|
+
return dateStringFromParts(
|
|
158
|
+
date.getUTCFullYear(),
|
|
159
|
+
date.getUTCMonth() + 1,
|
|
160
|
+
date.getUTCDate(),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function localDateString(timestampMs, timeZone, formatter) {
|
|
165
|
+
const dateFormatter = formatter ?? createTimeZoneFormatter(timeZone);
|
|
166
|
+
return localDateStringWithFormatter(timestampMs, dateFormatter);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function todayInTimeZone(timeZone, formatter) {
|
|
170
|
+
return localDateString(Date.now(), timeZone, formatter);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The boundary is the first valid instant belonging to the requested local
|
|
174
|
+
// date. If a time-zone transition skips the entire date, the boundary is
|
|
175
|
+
// collapsed onto the next representable date, so the skipped date is empty.
|
|
176
|
+
// When midnight is repeated, the earliest occurrence is selected.
|
|
177
|
+
export function localDateBoundary(dateString, timeZone, formatter) {
|
|
178
|
+
parseCalendarDate(dateString);
|
|
179
|
+
const dateFormatter = formatter ?? createTimeZoneFormatter(timeZone);
|
|
180
|
+
let boundaryDateString = dateString;
|
|
181
|
+
|
|
182
|
+
for (let skippedDays = 0; skippedDays <= 370; skippedDays += 1) {
|
|
183
|
+
const utcGuess = calendarDate(boundaryDateString).getTime();
|
|
184
|
+
const starts = [];
|
|
185
|
+
for (const offset of candidateOffsets(utcGuess, dateFormatter)) {
|
|
186
|
+
const candidateMs = utcGuess - offset;
|
|
187
|
+
if (
|
|
188
|
+
localDateStringWithFormatter(candidateMs, dateFormatter) !==
|
|
189
|
+
boundaryDateString
|
|
190
|
+
) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
starts.push(
|
|
194
|
+
findEarliestLocalDateInstant(
|
|
195
|
+
candidateMs,
|
|
196
|
+
boundaryDateString,
|
|
197
|
+
dateFormatter,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (starts.length > 0) return new Date(Math.min(...starts));
|
|
202
|
+
boundaryDateString = shiftCalendarDate(boundaryDateString, 1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new Error(`Could not resolve a local calendar boundary near ${dateString}.`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatCalendarDate(dateString, options) {
|
|
209
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
210
|
+
...options,
|
|
211
|
+
timeZone: "UTC",
|
|
212
|
+
}).format(calendarDate(dateString));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function calendarDateParts(dateString, options) {
|
|
216
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
217
|
+
...options,
|
|
218
|
+
timeZone: "UTC",
|
|
219
|
+
}).formatToParts(calendarDate(dateString));
|
|
220
|
+
return Object.fromEntries(
|
|
221
|
+
parts
|
|
222
|
+
.filter((part) => part.type !== "literal")
|
|
223
|
+
.map((part) => [part.type, part.value]),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const ISO_TIMESTAMP_PATTERN =
|
|
2
|
+
/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d+))?)?(?<timeZone>Z|[+-]\d{2}:\d{2})$/;
|
|
3
|
+
|
|
4
|
+
function invalidCollectionSince() {
|
|
5
|
+
return new Error("--since requires a valid ISO timestamp.");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeCollectionSince(value) {
|
|
9
|
+
if (value === null || value === undefined) return null;
|
|
10
|
+
if (value instanceof Date) {
|
|
11
|
+
if (!Number.isFinite(value.getTime())) throw invalidCollectionSince();
|
|
12
|
+
return value.toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const match = ISO_TIMESTAMP_PATTERN.exec(value);
|
|
16
|
+
if (!match) throw invalidCollectionSince();
|
|
17
|
+
const date = new Date(value);
|
|
18
|
+
if (!Number.isFinite(date.getTime())) throw invalidCollectionSince();
|
|
19
|
+
|
|
20
|
+
const components = match.groups;
|
|
21
|
+
const year = Number(components.year);
|
|
22
|
+
const month = Number(components.month);
|
|
23
|
+
const day = Number(components.day);
|
|
24
|
+
const hour = Number(components.hour);
|
|
25
|
+
const minute = Number(components.minute);
|
|
26
|
+
const second = components.second ? Number(components.second) : 0;
|
|
27
|
+
const milliseconds = components.fraction
|
|
28
|
+
? Number(components.fraction.slice(0, 3).padEnd(3, "0"))
|
|
29
|
+
: 0;
|
|
30
|
+
const timeZone = components.timeZone;
|
|
31
|
+
const offsetMinutes =
|
|
32
|
+
timeZone === "Z"
|
|
33
|
+
? 0
|
|
34
|
+
: (timeZone[0] === "-" ? -1 : 1) *
|
|
35
|
+
(Number(timeZone.slice(1, 3)) * 60 + Number(timeZone.slice(4, 6)));
|
|
36
|
+
const localDate = new Date(date.getTime() + offsetMinutes * 60 * 1_000);
|
|
37
|
+
if (
|
|
38
|
+
localDate.getUTCFullYear() !== year ||
|
|
39
|
+
localDate.getUTCMonth() + 1 !== month ||
|
|
40
|
+
localDate.getUTCDate() !== day ||
|
|
41
|
+
localDate.getUTCHours() !== hour ||
|
|
42
|
+
localDate.getUTCMinutes() !== minute ||
|
|
43
|
+
localDate.getUTCSeconds() !== second ||
|
|
44
|
+
localDate.getUTCMilliseconds() !== milliseconds
|
|
45
|
+
) {
|
|
46
|
+
throw invalidCollectionSince();
|
|
47
|
+
}
|
|
48
|
+
return date.toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectionScope(options = {}) {
|
|
52
|
+
return {
|
|
53
|
+
since: normalizeCollectionSince(options.since),
|
|
54
|
+
includeArchived: options.includeArchived !== false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function snapshotCollectionScope(snapshot = {}) {
|
|
59
|
+
const collection = snapshot?.provenance?.collection;
|
|
60
|
+
if (!collection) return null;
|
|
61
|
+
if (
|
|
62
|
+
!Object.prototype.hasOwnProperty.call(collection, "since") ||
|
|
63
|
+
(collection.includeArchived !== true && collection.includeArchived !== false)
|
|
64
|
+
) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
let since;
|
|
68
|
+
try {
|
|
69
|
+
since = normalizeCollectionSince(collection.since);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (since !== collection.since) return null;
|
|
74
|
+
return { since, includeArchived: collection.includeArchived };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function snapshotMatchesCollectionScope(snapshot, scope) {
|
|
78
|
+
const actual = snapshotCollectionScope(snapshot);
|
|
79
|
+
return Boolean(
|
|
80
|
+
actual &&
|
|
81
|
+
actual.since === scope.since &&
|
|
82
|
+
actual.includeArchived === scope.includeArchived,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function historyScopeLabel(snapshot = {}) {
|
|
87
|
+
const scope = snapshotCollectionScope(snapshot);
|
|
88
|
+
if (!scope || (scope.since === null && scope.includeArchived)) return null;
|
|
89
|
+
|
|
90
|
+
const details = [];
|
|
91
|
+
if (scope.since !== null) details.push(`before ${scope.since}`);
|
|
92
|
+
if (!scope.includeArchived) details.push("archived sessions excluded");
|
|
93
|
+
return `TRUNCATED HISTORY${details.length ? ` · ${details.join(" · ")}` : ""}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function snapshotCollectionCutoffMs(snapshot = {}) {
|
|
97
|
+
const scope = snapshotCollectionScope(snapshot);
|
|
98
|
+
if (!scope?.since) return null;
|
|
99
|
+
return Date.parse(scope.since);
|
|
100
|
+
}
|