@banou/ponyfill 0.0.3 → 0.0.4

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 CHANGED
@@ -58,10 +58,10 @@ That is not hypothetical. It is why four of ripple's storage eviction tests sat
58
58
  they filled the origin to provoke that condition, and 3.5 GB of padding left the free figure
59
59
  identical to the byte.
60
60
 
61
- This package does **not** invent a normalised quota, because there is no honest number to invent: on
62
- Chromium you really can write 10 more GiB, so the native answer is true. It names the two shapes
63
- (`QUOTA_CEILING`) and can measure which one an origin has (`measureQuotaCeiling`), by writing a
64
- sparse probe and watching, because at rest the two are indistinguishable.
61
+ This package does **not** invent a normalised quota, and does not add API for the difference either:
62
+ on Chromium you really can write 10 more GiB, so the native answer is true, and there is no platform
63
+ name for "which shape is this". It is written down here and in the source because the two are
64
+ indistinguishable at rest, which is how the difference went unnoticed for months.
65
65
 
66
66
  ## Adding to it
67
67
 
package/build/index.cjs CHANGED
@@ -1,8 +1,3 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_storage = require("./storage.cjs");
3
- exports.QUOTA_CEILING = require_storage.QUOTA_CEILING;
4
- exports.correctedUsage = require_storage.correctedUsage;
5
- exports.isUsageUnderReported = require_storage.isUsageUnderReported;
6
- exports.measureDirectoryBytes = require_storage.measureDirectoryBytes;
7
- exports.measureQuotaCeiling = require_storage.measureQuotaCeiling;
8
3
  exports.storage = require_storage.storage;
package/build/index.d.ts CHANGED
@@ -7,10 +7,15 @@
7
7
  * import { storage } from '@banou/ponyfill'
8
8
  * const { usage, quota } = await storage.estimate()
9
9
  *
10
- * That constraint is the whole design. A polyfill that patched `navigator.storage` would change what
11
- * every other script on the page sees, including code that was correct against the real behaviour,
12
- * and it would make the difference invisible at the call site. An import is greppable, and a package
13
- * boundary is somewhere the measurement that justifies each workaround can live next to the code.
10
+ * That constraint is the whole design, and it cuts both ways. A polyfill that patched
11
+ * `navigator.storage` would change what every other script on the page sees, including code that was
12
+ * correct against the real behaviour. And an export that is not a platform name is not a ponyfill of
13
+ * anything: it is a utility library wearing the word. So the surface here is exactly the platform's
14
+ * surface, and a helper only becomes public if the platform has one by that name.
15
+ *
16
+ * Everything else lives behind it. The walk that corrects `estimate().usage`, the bounds on that
17
+ * walk, and the reconciliation between the measured and reported figures are all internal, because
18
+ * `navigator.storage` has no such members and neither should this.
14
19
  *
15
20
  * ## What belongs in here
16
21
  *
@@ -22,5 +27,5 @@
22
27
  * Every module here states what was measured, on what, and when. A workaround with no measurement
23
28
  * behind it is a guess that outlives the bug it was written for.
24
29
  */
25
- export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage, } from './storage';
26
- export type { QuotaCeiling, StorageEstimate } from './storage';
30
+ export { storage } from './storage';
31
+ export type { StorageEstimate } from './storage';
package/build/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage } from "./storage.js";
2
- export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage };
1
+ import { storage } from "./storage.js";
2
+ export { storage };
package/build/storage.cjs CHANGED
@@ -1,58 +1,39 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/storage.ts
3
3
  /**
4
- * What each engine was measured doing, so a caller can reason without re-running the experiment.
4
+ * Never walk forever: a cycle is impossible in the origin private file system, but a pathological
5
+ * tree is not, and neither is a directory with a hundred thousand entries in it.
5
6
  *
6
- * Deliberately NOT keyed by user agent string and never consulted automatically. It is a record of
7
- * measurements, for a human reading this file or writing a comment, and
8
- * {@link measureQuotaCeiling} is what answers the question for the origin actually in front of you.
9
- */
10
- var QUOTA_CEILING = {
11
- chromium: {
12
- ceiling: "elastic",
13
- measured: "2026-09-03, Chrome 152.0.7977.64, 2.7 TiB free",
14
- note: "quota rose 10.737 GB to 12.353 GB across 1.615 GB written, leaving quota - usage at 10,737,418,240 bytes after every write, unmoved to the byte"
15
- },
16
- firefox: {
17
- ceiling: "fixed",
18
- measured: "2026-09-03, Playwright firefox 1532, same machine and origin",
19
- note: "quota held at 10,737,418,240 while the headroom fell by the 1,613,063,025 bytes written, byte for byte"
20
- }
21
- };
22
- var isDirectory = (handle) => typeof handle.values === "function";
23
- /**
24
- * Never walk forever: a cycle is impossible in OPFS, but a pathological tree is not worth the tick.
25
- *
26
- * These are bounds, not correctness: hitting either returns what was counted so far rather than
27
- * failing, which keeps the answer a floor in the same direction as everything else here.
7
+ * Bounds rather than correctness. Hitting either returns what was counted so far instead of failing,
8
+ * which keeps the answer a floor in the same direction as everything else here.
28
9
  */
29
10
  var MAX_DEPTH = 8;
30
11
  var MAX_ENTRIES = 2e4;
12
+ var isDirectory = (handle) => typeof handle.values === "function";
31
13
  /**
32
- * Every byte under a directory, measured rather than asked for, or `null` when the walk could not
33
- * be done at all.
14
+ * Every byte under a directory, or `null` when the walk could not be done at all.
34
15
  *
35
- * The null is a distinct third answer and callers depend on it: "the origin holds nothing" and "the
36
- * file system was unreachable" lead to opposite decisions, and collapsing them to 0 would have
37
- * {@link correctedUsage} report an empty origin for one that simply could not be read.
16
+ * The null is a distinct third answer and the correction below depends on it: "the origin holds
17
+ * nothing" and "the file system was unreachable" lead to opposite decisions, and collapsing them to
18
+ * 0 would report an empty origin for one that simply could not be read.
38
19
  *
39
- * Counts a file's REPORTED SIZE, which for OPFS is its extent rather than how much of it has been
40
- * written. That is the same accounting the quota system uses, which is the point: a one byte write a
41
- * gigabyte into a file is charged a gigabyte, and a measurement disagreeing with the charge would be
42
- * no more useful than the figure it replaces.
20
+ * Counts a file's REPORTED SIZE, which for this file system is its extent rather than how much of it
21
+ * has been written. That is the same accounting the quota system uses, which is the point: a one byte
22
+ * write a gigabyte into a file is charged a gigabyte, and a measurement that disagreed with the
23
+ * charge would be no more useful than the figure it replaces.
43
24
  *
44
- * An unreadable ENTRY is skipped rather than fatal. A file the engine currently holds an exclusive
45
- * sync access handle for cannot be opened by anyone else, and `getFile()` on it throws rather than
25
+ * An unreadable ENTRY is skipped rather than fatal. A file something else currently holds an
26
+ * exclusive sync access handle for cannot be opened, and `getFile()` on it throws rather than
46
27
  * waiting, so a walk that gave up on the first of those would return nothing for exactly the origins
47
28
  * holding the most. The answer is therefore a floor: short by the files open right now, never long.
48
29
  */
49
- var measureDirectoryBytes = async (directory, { maxDepth = 8, maxEntries = MAX_ENTRIES } = {}) => {
30
+ var walkBytes = async (directory) => {
50
31
  let total = 0;
51
32
  let seen = 0;
52
33
  const visit = async (handle, depth) => {
53
- if (depth > maxDepth) return;
34
+ if (depth > MAX_DEPTH) return;
54
35
  for await (const child of handle.values()) {
55
- if (++seen > maxEntries) return;
36
+ if (++seen > MAX_ENTRIES) return;
56
37
  if (isDirectory(child)) {
57
38
  await visit(child, depth + 1);
58
39
  continue;
@@ -69,61 +50,38 @@ var measureDirectoryBytes = async (directory, { maxDepth = 8, maxEntries = MAX_E
69
50
  }
70
51
  };
71
52
  /**
72
- * The usage figure to believe, given what the browser said and what a walk found.
73
- *
74
- * The larger of the two, and the reason is asymmetric: a browser that OVER-reports has never been
75
- * observed, while one that under-reports by six orders of magnitude has. So the walk can only ever
76
- * raise the answer.
77
- *
78
- * `usageDetails.fileSystem` is subtracted out before the walk is added when the browser volunteers
79
- * it, because everything else the browser counts (IndexedDB, caches, service worker registrations)
80
- * it counts correctly and the walk cannot see any of it. Without that split, an origin holding real
81
- * IndexedDB data would have it silently dropped from the total.
53
+ * The usage to report, given what the platform said and what the walk found.
82
54
  *
83
- * Pure, so the arithmetic is testable without a browser: this is the part that can be wrong in a way
84
- * no integration test would notice.
55
+ * The walk can only ever RAISE the answer: a browser over-reporting has never been observed, while
56
+ * one under-reporting by six orders of magnitude has.
85
57
  */
86
- var correctedUsage = (estimate, walkedBytes) => {
58
+ var reconcile = (estimate, walked) => {
87
59
  const reported = estimate.usage;
88
- if (walkedBytes === null || !Number.isFinite(walkedBytes) || walkedBytes < 0) return reported ?? null;
89
- if (reported === void 0) return walkedBytes;
60
+ if (walked === null || !Number.isFinite(walked) || walked < 0) return reported;
61
+ if (reported === void 0) return walked;
90
62
  const fileSystem = estimate.usageDetails?.fileSystem;
91
63
  if (typeof fileSystem === "number") {
92
64
  const other = Math.max(0, reported - fileSystem);
93
- return Math.max(reported, walkedBytes + other);
65
+ return Math.max(reported, walked + other);
94
66
  }
95
- return Math.max(reported, walkedBytes);
67
+ return Math.max(reported, walked);
96
68
  };
97
- /** True when the browser's own figure is too far below the measured one to be believed. */
98
- var isUsageUnderReported = (estimate, walkedBytes) => walkedBytes !== null && walkedBytes > 0 && (estimate.usage ?? 0) < walkedBytes / 2;
99
- /**
100
- * `navigator.storage`, with the same method names.
101
- *
102
- * Only `estimate` behaves differently from the platform's, and only in the way documented at the top
103
- * of this file. The rest are passed straight through so that a caller can import this once and never
104
- * reach for the global, which is what keeps the difference in one place instead of at every call
105
- * site that happens to remember.
106
- */
107
69
  var storage = {
108
70
  /**
109
71
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
110
72
  *
111
- * Falls back to the browser's own figure whenever the walk cannot be done: no origin private file
112
- * system, a directory that will not enumerate, or a platform with no `estimate` at all. Falling
113
- * back is not a silent downgrade, because the browser's figure is a floor rather than a guess: it
114
- * is never observed to be too HIGH.
73
+ * Falls back to the platform's own figure whenever the walk cannot be done: no origin private file
74
+ * system, a directory that will not enumerate, or no `estimate` at all. That is not a silent
75
+ * downgrade, because the platform's figure is a floor rather than a guess.
115
76
  */
116
77
  estimate: async () => {
117
78
  const native = globalThis.navigator?.storage;
118
- if (!native?.estimate) return {
119
- usage: 0,
120
- quota: 0
121
- };
79
+ if (!native?.estimate) return {};
122
80
  const estimate = await native.estimate();
123
- const walked = native.getDirectory ? await native.getDirectory().then((directory) => measureDirectoryBytes(directory)).catch(() => null) : null;
81
+ const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
124
82
  return {
125
83
  ...estimate,
126
- usage: correctedUsage(estimate, walked) ?? estimate.usage ?? 0
84
+ usage: reconcile(estimate, walked)
127
85
  };
128
86
  },
129
87
  persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
@@ -134,54 +92,5 @@ var storage = {
134
92
  return native.getDirectory();
135
93
  }
136
94
  };
137
- /**
138
- * Which ceiling this origin has, by WRITING and watching, because nothing else can tell.
139
- *
140
- * The two shapes are indistinguishable at rest: at low usage a flat quota and a flat headroom are
141
- * the same pair of numbers, which is exactly how the difference went unnoticed. Only writing
142
- * separates them, so this writes, and it is therefore not something to call casually.
143
- *
144
- * Sparse, so it costs no real disk: the quota system charges a file's EXTENT, and a single byte
145
- * written `probeBytes - 1` into a file is charged the whole extent instantly. The probe file is
146
- * removed again, in a `finally`, whether or not the measurement succeeded.
147
- *
148
- * Answers `unknown` rather than guessing when the probe cannot be written or the origin refuses it,
149
- * because "I could not tell" and "the ceiling is fixed" lead to opposite decisions.
150
- */
151
- var measureQuotaCeiling = async ({ probeBytes = 536870912, name = ".ponyfill-quota-probe" } = {}) => {
152
- const native = globalThis.navigator?.storage;
153
- if (!native?.estimate || !native.getDirectory) return "unknown";
154
- let root;
155
- try {
156
- root = await native.getDirectory();
157
- } catch {
158
- return "unknown";
159
- }
160
- const before = await native.estimate();
161
- try {
162
- const writable = await (await root.getFileHandle(name, { create: true })).createWritable();
163
- await writable.write({
164
- type: "write",
165
- position: probeBytes - 1,
166
- data: /* @__PURE__ */ new Uint8Array(1)
167
- });
168
- await writable.close();
169
- const after = await native.estimate();
170
- const written = (after.usage ?? 0) - (before.usage ?? 0);
171
- if (written < probeBytes / 2) return "unknown";
172
- return (before.quota ?? 0) - (before.usage ?? 0) - ((after.quota ?? 0) - (after.usage ?? 0)) < written / 4 ? "elastic" : "fixed";
173
- } catch {
174
- return "unknown";
175
- } finally {
176
- await root.removeEntry(name).catch(() => {});
177
- }
178
- };
179
95
  //#endregion
180
- exports.MAX_DEPTH = MAX_DEPTH;
181
- exports.MAX_ENTRIES = MAX_ENTRIES;
182
- exports.QUOTA_CEILING = QUOTA_CEILING;
183
- exports.correctedUsage = correctedUsage;
184
- exports.isUsageUnderReported = isUsageUnderReported;
185
- exports.measureDirectoryBytes = measureDirectoryBytes;
186
- exports.measureQuotaCeiling = measureQuotaCeiling;
187
96
  exports.storage = storage;
@@ -1,180 +1,87 @@
1
1
  /**
2
- * `navigator.storage`, with the two places browsers disagree about it handled in one file.
2
+ * `navigator.storage`, with the same method names and no extra ones.
3
3
  *
4
- * A PONYFILL, so nothing here touches a global. Import `storage` and call the same method names the
5
- * platform uses, and the difference is that these answers can be relied on across engines:
4
+ * A PONYFILL, so nothing here touches a global and nothing here invents API. You import `storage`
5
+ * and call it exactly as you would the platform's:
6
6
  *
7
7
  * import { storage } from '@banou/ponyfill'
8
8
  * const { usage, quota } = await storage.estimate()
9
9
  *
10
- * ## What is actually wrong with the native one
10
+ * Only `estimate` behaves differently from the platform's, and only by being CORRECT. The rest are
11
+ * passed straight through so this can be a drop-in for `navigator.storage`, which is the whole point:
12
+ * a caller that had to mix `storage.estimate()` with `navigator.storage.getDirectory()` would be
13
+ * back to remembering which half is safe.
11
14
  *
12
- * ### 1. `usage` can be six orders of magnitude short, and nothing says so
15
+ * ## Why `estimate` cannot be believed as the platform gives it
16
+ *
17
+ * ### `usage` can be six orders of magnitude short
13
18
  *
14
19
  * MEASURED on Chrome 151: an origin holding a VERIFIED 1,783,407,077 bytes of torrent data reported
15
- * `usage: 1,813,502`, with `usageDetails.fileSystem: 752`. Not a rounding difference, not a stale
16
- * cache: 752 bytes against 1.78 GB. Anything deciding whether a write will fit from that figure
17
- * decides that everything fits, and then the write fails with `QuotaExceededError` at whatever
18
- * moment the real limit is reached.
19
20
  *
20
- * So `estimate()` here WALKS the origin's file system and reports the larger of the two. The walk
21
- * costs a directory traversal, which is why the native figure is kept as a floor rather than
22
- * discarded: everything the browser counts that is not the file system is counted correctly, and
23
- * only the file system part is worth re-measuring.
21
+ * usage: 1813502
22
+ * usageDetails: { fileSystem: 752, indexedDB: 1809581, serviceWorkerRegistrations: 3169 }
23
+ *
24
+ * 752 bytes against 1.78 GB. Not rounding and not lag, and not universal either: another machine
25
+ * reported the same data correctly, so the figure can neither be trusted nor discarded.
26
+ *
27
+ * So `estimate()` WALKS the origin's file system and reports whichever answer is larger. Larger is
28
+ * the safe direction on purpose. Over-reporting makes a caller reclaim cache slightly early, which
29
+ * is what cache is for; under-reporting makes it decide there is room forever, and what happens then
30
+ * is not a full disk, it is a write failing with `QuotaExceededError` at some unrelated moment.
31
+ *
32
+ * Where `usageDetails` is available the correction is surgical: the file system component is the
33
+ * broken one, so it is replaced with the measurement and the components the browser counts correctly
34
+ * are kept. Without it, the larger of the two is the best available answer.
24
35
  *
25
- * ### 2. `quota` means a different thing on each engine, and the shapes are indistinguishable at rest
36
+ * ### `quota` means a different thing on each engine
26
37
  *
27
38
  * MEASURED 2026-09-03, one machine with 2.7 TiB free, one origin, three 512 MiB sparse writes per
28
39
  * engine, same page and same code:
29
40
  *
30
- * | engine | quota at rest | quota after 1.615 GB written | `quota - usage` |
41
+ * | engine | quota at rest | after 1.615 GB written | `quota - usage` |
31
42
  * | --- | --- | --- | --- |
32
- * | Chromium 152 | 10,737,491,968 | 12,353,… , up by exactly what was written | 10,737,418,240 every time, moved 0 bytes |
33
- * | Firefox | 10,737,418,240 | 10,737,418,240, unmoved | fell 536,870,912 per write |
43
+ * | Chromium 152 | 10,737,491,968 | rose by exactly what was written | 10,737,418,240 every time, moved 0 bytes |
44
+ * | Firefox | 10,737,418,240 | unmoved | fell 536,870,912 per write |
34
45
  *
35
46
  * Both cap at 10 GiB. They cap DIFFERENT QUANTITIES. Chromium's quota is a FLOATING ceiling,
36
47
  * `usage + headroom`, so the headroom is a constant and can never shrink however much is written.
37
48
  * Firefox's is a FIXED ceiling, so writing consumes it.
38
49
  *
39
- * Neither is a bug. The Storage Standard calls quota "a conservative estimate" and never says how to
50
+ * Neither is a bug: the Storage Standard calls quota "a conservative estimate" and never says how to
40
51
  * compute it. But it means one very common line is dead on one engine and live on the other:
41
52
  *
42
53
  * if (quota - usage < someFloor) { ... } // can never be true on Chromium
43
54
  *
44
- * That is not a hypothetical. It is why four of ripple's eviction tests sat failing for months: they
45
- * filled the origin to provoke exactly that condition, and on Chromium the target recedes as fast as
46
- * it is approached, so 3.5 GB of padding left the free figure identical to the byte.
47
- *
48
- * This module does NOT paper over that by inventing a normalised quota, because there is no honest
49
- * number to invent: on Chromium you really can write 10 more GiB, so the native answer is true. What
50
- * it does is name the two shapes, say which one an origin has, and refuse to let the difference be
51
- * discovered again by somebody debugging a dead branch. See {@link QUOTA_CEILING} and
52
- * {@link measureQuotaCeiling}.
55
+ * Nothing here papers over that, because there is no honest number to substitute: on Chromium you
56
+ * really can write 10 more GiB, so the platform's answer is true. It is written down here because
57
+ * the two shapes are indistinguishable at rest, which is how the difference went unnoticed long
58
+ * enough to leave four of ripple's storage tests failing for months against a condition that could
59
+ * never occur.
53
60
  */
54
- /** The same shape `navigator.storage.estimate()` resolves to, plus what the browser volunteered. */
55
- export type StorageEstimate = {
56
- usage: number;
57
- quota: number;
58
- usageDetails?: Record<string, number>;
59
- };
60
61
  /**
61
- * How an engine's `quota` behaves as bytes are written.
62
+ * The platform's own `StorageEstimate`, named the same.
62
63
  *
63
- * `fixed` is what most code assumes: a ceiling that stays put, so `quota - usage` falls as you
64
- * write. `elastic` is Chromium's: the ceiling rises by whatever was written, so `quota - usage` is a
65
- * constant and never signals pressure. `unknown` is the honest answer before anything has measured
66
- * it, and it is the default, because the alternative is sniffing the user agent.
64
+ * Every field optional exactly as the specification has them, so this is a drop-in for what
65
+ * `navigator.storage.estimate()` resolves to rather than a stricter thing a caller has to adapt to.
67
66
  */
68
- export type QuotaCeiling = 'fixed' | 'elastic' | 'unknown';
69
- /**
70
- * What each engine was measured doing, so a caller can reason without re-running the experiment.
71
- *
72
- * Deliberately NOT keyed by user agent string and never consulted automatically. It is a record of
73
- * measurements, for a human reading this file or writing a comment, and
74
- * {@link measureQuotaCeiling} is what answers the question for the origin actually in front of you.
75
- */
76
- export declare const QUOTA_CEILING: Record<string, {
77
- ceiling: QuotaCeiling;
78
- measured: string;
79
- note: string;
80
- }>;
81
- /** A directory handle, narrowed to the two members a size walk actually touches. */
82
- type WalkableDirectory = {
83
- values: () => AsyncIterable<WalkableDirectory | WalkableFile>;
84
- kind?: string;
85
- };
86
- type WalkableFile = {
87
- kind?: string;
88
- getFile: () => Promise<{
89
- size: number;
90
- }>;
67
+ export type StorageEstimate = {
68
+ usage?: number;
69
+ quota?: number;
70
+ /** Chrome only, and the whole reason the correction can be surgical rather than merely larger. */
71
+ usageDetails?: {
72
+ fileSystem?: number;
73
+ };
91
74
  };
92
- /**
93
- * Never walk forever: a cycle is impossible in OPFS, but a pathological tree is not worth the tick.
94
- *
95
- * These are bounds, not correctness: hitting either returns what was counted so far rather than
96
- * failing, which keeps the answer a floor in the same direction as everything else here.
97
- */
98
- export declare const MAX_DEPTH = 8;
99
- export declare const MAX_ENTRIES = 20000;
100
- /**
101
- * Every byte under a directory, measured rather than asked for, or `null` when the walk could not
102
- * be done at all.
103
- *
104
- * The null is a distinct third answer and callers depend on it: "the origin holds nothing" and "the
105
- * file system was unreachable" lead to opposite decisions, and collapsing them to 0 would have
106
- * {@link correctedUsage} report an empty origin for one that simply could not be read.
107
- *
108
- * Counts a file's REPORTED SIZE, which for OPFS is its extent rather than how much of it has been
109
- * written. That is the same accounting the quota system uses, which is the point: a one byte write a
110
- * gigabyte into a file is charged a gigabyte, and a measurement disagreeing with the charge would be
111
- * no more useful than the figure it replaces.
112
- *
113
- * An unreadable ENTRY is skipped rather than fatal. A file the engine currently holds an exclusive
114
- * sync access handle for cannot be opened by anyone else, and `getFile()` on it throws rather than
115
- * waiting, so a walk that gave up on the first of those would return nothing for exactly the origins
116
- * holding the most. The answer is therefore a floor: short by the files open right now, never long.
117
- */
118
- export declare const measureDirectoryBytes: (directory: WalkableDirectory, { maxDepth, maxEntries }?: {
119
- maxDepth?: number;
120
- maxEntries?: number;
121
- }) => Promise<number | null>;
122
- /**
123
- * The usage figure to believe, given what the browser said and what a walk found.
124
- *
125
- * The larger of the two, and the reason is asymmetric: a browser that OVER-reports has never been
126
- * observed, while one that under-reports by six orders of magnitude has. So the walk can only ever
127
- * raise the answer.
128
- *
129
- * `usageDetails.fileSystem` is subtracted out before the walk is added when the browser volunteers
130
- * it, because everything else the browser counts (IndexedDB, caches, service worker registrations)
131
- * it counts correctly and the walk cannot see any of it. Without that split, an origin holding real
132
- * IndexedDB data would have it silently dropped from the total.
133
- *
134
- * Pure, so the arithmetic is testable without a browser: this is the part that can be wrong in a way
135
- * no integration test would notice.
136
- */
137
- export declare const correctedUsage: (estimate: Partial<StorageEstimate>, walkedBytes: number | null) => number | null;
138
- /** True when the browser's own figure is too far below the measured one to be believed. */
139
- export declare const isUsageUnderReported: (estimate: Partial<StorageEstimate>, walkedBytes: number | null) => boolean;
140
- /**
141
- * `navigator.storage`, with the same method names.
142
- *
143
- * Only `estimate` behaves differently from the platform's, and only in the way documented at the top
144
- * of this file. The rest are passed straight through so that a caller can import this once and never
145
- * reach for the global, which is what keeps the difference in one place instead of at every call
146
- * site that happens to remember.
147
- */
148
75
  export declare const storage: {
149
76
  /**
150
77
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
151
78
  *
152
- * Falls back to the browser's own figure whenever the walk cannot be done: no origin private file
153
- * system, a directory that will not enumerate, or a platform with no `estimate` at all. Falling
154
- * back is not a silent downgrade, because the browser's figure is a floor rather than a guess: it
155
- * is never observed to be too HIGH.
79
+ * Falls back to the platform's own figure whenever the walk cannot be done: no origin private file
80
+ * system, a directory that will not enumerate, or no `estimate` at all. That is not a silent
81
+ * downgrade, because the platform's figure is a floor rather than a guess.
156
82
  */
157
83
  estimate: () => Promise<StorageEstimate>;
158
84
  persist: () => Promise<boolean>;
159
85
  persisted: () => Promise<boolean>;
160
86
  getDirectory: () => Promise<FileSystemDirectoryHandle>;
161
87
  };
162
- /**
163
- * Which ceiling this origin has, by WRITING and watching, because nothing else can tell.
164
- *
165
- * The two shapes are indistinguishable at rest: at low usage a flat quota and a flat headroom are
166
- * the same pair of numbers, which is exactly how the difference went unnoticed. Only writing
167
- * separates them, so this writes, and it is therefore not something to call casually.
168
- *
169
- * Sparse, so it costs no real disk: the quota system charges a file's EXTENT, and a single byte
170
- * written `probeBytes - 1` into a file is charged the whole extent instantly. The probe file is
171
- * removed again, in a `finally`, whether or not the measurement succeeded.
172
- *
173
- * Answers `unknown` rather than guessing when the probe cannot be written or the origin refuses it,
174
- * because "I could not tell" and "the ceiling is fixed" lead to opposite decisions.
175
- */
176
- export declare const measureQuotaCeiling: ({ probeBytes, name }?: {
177
- probeBytes?: number;
178
- name?: string;
179
- }) => Promise<QuotaCeiling>;
180
- export {};
package/build/storage.js CHANGED
@@ -1,57 +1,38 @@
1
1
  //#region src/storage.ts
2
2
  /**
3
- * What each engine was measured doing, so a caller can reason without re-running the experiment.
3
+ * Never walk forever: a cycle is impossible in the origin private file system, but a pathological
4
+ * tree is not, and neither is a directory with a hundred thousand entries in it.
4
5
  *
5
- * Deliberately NOT keyed by user agent string and never consulted automatically. It is a record of
6
- * measurements, for a human reading this file or writing a comment, and
7
- * {@link measureQuotaCeiling} is what answers the question for the origin actually in front of you.
8
- */
9
- var QUOTA_CEILING = {
10
- chromium: {
11
- ceiling: "elastic",
12
- measured: "2026-09-03, Chrome 152.0.7977.64, 2.7 TiB free",
13
- note: "quota rose 10.737 GB to 12.353 GB across 1.615 GB written, leaving quota - usage at 10,737,418,240 bytes after every write, unmoved to the byte"
14
- },
15
- firefox: {
16
- ceiling: "fixed",
17
- measured: "2026-09-03, Playwright firefox 1532, same machine and origin",
18
- note: "quota held at 10,737,418,240 while the headroom fell by the 1,613,063,025 bytes written, byte for byte"
19
- }
20
- };
21
- var isDirectory = (handle) => typeof handle.values === "function";
22
- /**
23
- * Never walk forever: a cycle is impossible in OPFS, but a pathological tree is not worth the tick.
24
- *
25
- * These are bounds, not correctness: hitting either returns what was counted so far rather than
26
- * failing, which keeps the answer a floor in the same direction as everything else here.
6
+ * Bounds rather than correctness. Hitting either returns what was counted so far instead of failing,
7
+ * which keeps the answer a floor in the same direction as everything else here.
27
8
  */
28
9
  var MAX_DEPTH = 8;
29
10
  var MAX_ENTRIES = 2e4;
11
+ var isDirectory = (handle) => typeof handle.values === "function";
30
12
  /**
31
- * Every byte under a directory, measured rather than asked for, or `null` when the walk could not
32
- * be done at all.
13
+ * Every byte under a directory, or `null` when the walk could not be done at all.
33
14
  *
34
- * The null is a distinct third answer and callers depend on it: "the origin holds nothing" and "the
35
- * file system was unreachable" lead to opposite decisions, and collapsing them to 0 would have
36
- * {@link correctedUsage} report an empty origin for one that simply could not be read.
15
+ * The null is a distinct third answer and the correction below depends on it: "the origin holds
16
+ * nothing" and "the file system was unreachable" lead to opposite decisions, and collapsing them to
17
+ * 0 would report an empty origin for one that simply could not be read.
37
18
  *
38
- * Counts a file's REPORTED SIZE, which for OPFS is its extent rather than how much of it has been
39
- * written. That is the same accounting the quota system uses, which is the point: a one byte write a
40
- * gigabyte into a file is charged a gigabyte, and a measurement disagreeing with the charge would be
41
- * no more useful than the figure it replaces.
19
+ * Counts a file's REPORTED SIZE, which for this file system is its extent rather than how much of it
20
+ * has been written. That is the same accounting the quota system uses, which is the point: a one byte
21
+ * write a gigabyte into a file is charged a gigabyte, and a measurement that disagreed with the
22
+ * charge would be no more useful than the figure it replaces.
42
23
  *
43
- * An unreadable ENTRY is skipped rather than fatal. A file the engine currently holds an exclusive
44
- * sync access handle for cannot be opened by anyone else, and `getFile()` on it throws rather than
24
+ * An unreadable ENTRY is skipped rather than fatal. A file something else currently holds an
25
+ * exclusive sync access handle for cannot be opened, and `getFile()` on it throws rather than
45
26
  * waiting, so a walk that gave up on the first of those would return nothing for exactly the origins
46
27
  * holding the most. The answer is therefore a floor: short by the files open right now, never long.
47
28
  */
48
- var measureDirectoryBytes = async (directory, { maxDepth = 8, maxEntries = MAX_ENTRIES } = {}) => {
29
+ var walkBytes = async (directory) => {
49
30
  let total = 0;
50
31
  let seen = 0;
51
32
  const visit = async (handle, depth) => {
52
- if (depth > maxDepth) return;
33
+ if (depth > MAX_DEPTH) return;
53
34
  for await (const child of handle.values()) {
54
- if (++seen > maxEntries) return;
35
+ if (++seen > MAX_ENTRIES) return;
55
36
  if (isDirectory(child)) {
56
37
  await visit(child, depth + 1);
57
38
  continue;
@@ -68,61 +49,38 @@ var measureDirectoryBytes = async (directory, { maxDepth = 8, maxEntries = MAX_E
68
49
  }
69
50
  };
70
51
  /**
71
- * The usage figure to believe, given what the browser said and what a walk found.
72
- *
73
- * The larger of the two, and the reason is asymmetric: a browser that OVER-reports has never been
74
- * observed, while one that under-reports by six orders of magnitude has. So the walk can only ever
75
- * raise the answer.
76
- *
77
- * `usageDetails.fileSystem` is subtracted out before the walk is added when the browser volunteers
78
- * it, because everything else the browser counts (IndexedDB, caches, service worker registrations)
79
- * it counts correctly and the walk cannot see any of it. Without that split, an origin holding real
80
- * IndexedDB data would have it silently dropped from the total.
52
+ * The usage to report, given what the platform said and what the walk found.
81
53
  *
82
- * Pure, so the arithmetic is testable without a browser: this is the part that can be wrong in a way
83
- * no integration test would notice.
54
+ * The walk can only ever RAISE the answer: a browser over-reporting has never been observed, while
55
+ * one under-reporting by six orders of magnitude has.
84
56
  */
85
- var correctedUsage = (estimate, walkedBytes) => {
57
+ var reconcile = (estimate, walked) => {
86
58
  const reported = estimate.usage;
87
- if (walkedBytes === null || !Number.isFinite(walkedBytes) || walkedBytes < 0) return reported ?? null;
88
- if (reported === void 0) return walkedBytes;
59
+ if (walked === null || !Number.isFinite(walked) || walked < 0) return reported;
60
+ if (reported === void 0) return walked;
89
61
  const fileSystem = estimate.usageDetails?.fileSystem;
90
62
  if (typeof fileSystem === "number") {
91
63
  const other = Math.max(0, reported - fileSystem);
92
- return Math.max(reported, walkedBytes + other);
64
+ return Math.max(reported, walked + other);
93
65
  }
94
- return Math.max(reported, walkedBytes);
66
+ return Math.max(reported, walked);
95
67
  };
96
- /** True when the browser's own figure is too far below the measured one to be believed. */
97
- var isUsageUnderReported = (estimate, walkedBytes) => walkedBytes !== null && walkedBytes > 0 && (estimate.usage ?? 0) < walkedBytes / 2;
98
- /**
99
- * `navigator.storage`, with the same method names.
100
- *
101
- * Only `estimate` behaves differently from the platform's, and only in the way documented at the top
102
- * of this file. The rest are passed straight through so that a caller can import this once and never
103
- * reach for the global, which is what keeps the difference in one place instead of at every call
104
- * site that happens to remember.
105
- */
106
68
  var storage = {
107
69
  /**
108
70
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
109
71
  *
110
- * Falls back to the browser's own figure whenever the walk cannot be done: no origin private file
111
- * system, a directory that will not enumerate, or a platform with no `estimate` at all. Falling
112
- * back is not a silent downgrade, because the browser's figure is a floor rather than a guess: it
113
- * is never observed to be too HIGH.
72
+ * Falls back to the platform's own figure whenever the walk cannot be done: no origin private file
73
+ * system, a directory that will not enumerate, or no `estimate` at all. That is not a silent
74
+ * downgrade, because the platform's figure is a floor rather than a guess.
114
75
  */
115
76
  estimate: async () => {
116
77
  const native = globalThis.navigator?.storage;
117
- if (!native?.estimate) return {
118
- usage: 0,
119
- quota: 0
120
- };
78
+ if (!native?.estimate) return {};
121
79
  const estimate = await native.estimate();
122
- const walked = native.getDirectory ? await native.getDirectory().then((directory) => measureDirectoryBytes(directory)).catch(() => null) : null;
80
+ const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
123
81
  return {
124
82
  ...estimate,
125
- usage: correctedUsage(estimate, walked) ?? estimate.usage ?? 0
83
+ usage: reconcile(estimate, walked)
126
84
  };
127
85
  },
128
86
  persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
@@ -133,47 +91,5 @@ var storage = {
133
91
  return native.getDirectory();
134
92
  }
135
93
  };
136
- /**
137
- * Which ceiling this origin has, by WRITING and watching, because nothing else can tell.
138
- *
139
- * The two shapes are indistinguishable at rest: at low usage a flat quota and a flat headroom are
140
- * the same pair of numbers, which is exactly how the difference went unnoticed. Only writing
141
- * separates them, so this writes, and it is therefore not something to call casually.
142
- *
143
- * Sparse, so it costs no real disk: the quota system charges a file's EXTENT, and a single byte
144
- * written `probeBytes - 1` into a file is charged the whole extent instantly. The probe file is
145
- * removed again, in a `finally`, whether or not the measurement succeeded.
146
- *
147
- * Answers `unknown` rather than guessing when the probe cannot be written or the origin refuses it,
148
- * because "I could not tell" and "the ceiling is fixed" lead to opposite decisions.
149
- */
150
- var measureQuotaCeiling = async ({ probeBytes = 536870912, name = ".ponyfill-quota-probe" } = {}) => {
151
- const native = globalThis.navigator?.storage;
152
- if (!native?.estimate || !native.getDirectory) return "unknown";
153
- let root;
154
- try {
155
- root = await native.getDirectory();
156
- } catch {
157
- return "unknown";
158
- }
159
- const before = await native.estimate();
160
- try {
161
- const writable = await (await root.getFileHandle(name, { create: true })).createWritable();
162
- await writable.write({
163
- type: "write",
164
- position: probeBytes - 1,
165
- data: /* @__PURE__ */ new Uint8Array(1)
166
- });
167
- await writable.close();
168
- const after = await native.estimate();
169
- const written = (after.usage ?? 0) - (before.usage ?? 0);
170
- if (written < probeBytes / 2) return "unknown";
171
- return (before.quota ?? 0) - (before.usage ?? 0) - ((after.quota ?? 0) - (after.usage ?? 0)) < written / 4 ? "elastic" : "fixed";
172
- } catch {
173
- return "unknown";
174
- } finally {
175
- await root.removeEntry(name).catch(() => {});
176
- }
177
- };
178
94
  //#endregion
179
- export { MAX_DEPTH, MAX_ENTRIES, QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage };
95
+ export { storage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/ponyfill",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "main": "build/index.cjs",
6
6
  "module": "build/index.js",