mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
feat: paginated fetching for all (#3431)
* feat: paginated fetching for all * fix: metering top up gui reporting
This commit is contained in:
@@ -170,11 +170,11 @@ describe('MeteringService', () => {
|
||||
});
|
||||
|
||||
it('updateAddonCredit increments purchasedCredits', async () => {
|
||||
await target.updateAddonCredit(actor.user.uuid, 1000);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 1000);
|
||||
const addons = await target.getActorAddons(actor);
|
||||
expect(addons.purchasedCredits).toBe(1000);
|
||||
|
||||
await target.updateAddonCredit(actor.user.uuid, 500);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 500);
|
||||
const updated = await target.getActorAddons(actor);
|
||||
expect(updated.purchasedCredits).toBe(1500);
|
||||
});
|
||||
@@ -302,7 +302,7 @@ describe('MeteringService', () => {
|
||||
it('consumes purchased credits once monthly allowance is exceeded', async () => {
|
||||
const overActor: Actor = { user: makeUser() };
|
||||
const sub = await target.getActorSubscription(overActor);
|
||||
await target.updateAddonCredit(overActor.user.uuid, 5_000_000);
|
||||
await target.updateAddonCredit(overActor.user.uuid!, 5_000_000);
|
||||
|
||||
// Spend the entire monthly allowance — no overage yet.
|
||||
await target.incrementUsage(
|
||||
@@ -405,7 +405,7 @@ describe('MeteringService', () => {
|
||||
const creditActor: Actor = { user: makeUser() };
|
||||
const sub = await target.getActorSubscription(creditActor);
|
||||
await target.updateAddonCredit(
|
||||
creditActor.user.uuid,
|
||||
creditActor.user.uuid!,
|
||||
5_000_000_000,
|
||||
);
|
||||
|
||||
@@ -428,6 +428,100 @@ describe('MeteringService', () => {
|
||||
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
|
||||
alarmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not alarm while the actor is spending down purchased credit', async () => {
|
||||
const creditActor: Actor = { user: makeUser() };
|
||||
const sub = await target.getActorSubscription(creditActor);
|
||||
// Three allowances' worth of purchased credit on top of the monthly
|
||||
// allowance — a total budget of 4x the allowance.
|
||||
await target.updateAddonCredit(
|
||||
creditActor.user.uuid!,
|
||||
sub.monthUsageAllowance * 3,
|
||||
);
|
||||
|
||||
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
|
||||
// Burn through the entire budget (allowance + all purchased credit).
|
||||
// A user actively spending paid-for credit must never page, and even
|
||||
// landing exactly at the budget shouldn't yet.
|
||||
await target.incrementUsage(
|
||||
creditActor,
|
||||
'ai:chat',
|
||||
1,
|
||||
sub.monthUsageAllowance * 3,
|
||||
);
|
||||
await target.incrementUsage(
|
||||
creditActor,
|
||||
'ai:chat',
|
||||
1,
|
||||
sub.monthUsageAllowance,
|
||||
);
|
||||
|
||||
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
|
||||
alarmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not page the moment purchased credit runs dry between allowance marks', async () => {
|
||||
// Regression: the alarm used to count allowance multiples from zero
|
||||
// and only gate on the credit being gone, so the first expense after
|
||||
// a user's purchased credit ran out would page even though they had
|
||||
// just been spending credit they paid for. The purchased credit must
|
||||
// shift the baseline the multiples are measured from.
|
||||
//
|
||||
// The registered-user free allowance is 25e6 micro-cents. Purchased
|
||||
// credit of 37.5e6 (1.5x) makes the full budget run dry at 62.5e6 —
|
||||
// between the 2x (50e6) and 3x (75e6) allowance marks — so a small
|
||||
// expense just past it crosses a from-zero multiple (old: pages)
|
||||
// without crossing a net-of-credit multiple (new: quiet).
|
||||
const creditActor: Actor = { user: makeUser() };
|
||||
const sub = await target.getActorSubscription(creditActor);
|
||||
expect(sub.monthUsageAllowance).toBe(25_000_000);
|
||||
await target.updateAddonCredit(creditActor.user.uuid!, 37_500_000);
|
||||
|
||||
// Burn the allowance + all credit and a bit beyond, one legit jump.
|
||||
await target.incrementUsage(creditActor, 'ai:chat', 1, 70_000_000);
|
||||
|
||||
// A small further expense crosses the 3x-from-zero mark but is still
|
||||
// well within (credit + 2x allowance) — it must stay quiet.
|
||||
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
|
||||
await target.incrementUsage(creditActor, 'ai:chat', 1, 7_500_000);
|
||||
|
||||
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
|
||||
alarmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('alarms once usage reaches purchased credit + 2x the monthly allowance', async () => {
|
||||
const creditActor: Actor = { user: makeUser() };
|
||||
const sub = await target.getActorSubscription(creditActor);
|
||||
const credit = sub.monthUsageAllowance * 3;
|
||||
await target.updateAddonCredit(creditActor.user.uuid!, credit);
|
||||
|
||||
// Consume the allowance + all purchased credit and land one band
|
||||
// past the budget in a single jump — legitimate, so no alarm yet.
|
||||
await target.incrementUsage(
|
||||
creditActor,
|
||||
'ai:chat',
|
||||
1,
|
||||
sub.monthUsageAllowance * 4,
|
||||
);
|
||||
|
||||
// The next allowance-sized expense crosses into 2x-past-the-credit
|
||||
// and is what should finally page.
|
||||
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
|
||||
await target.incrementUsage(
|
||||
creditActor,
|
||||
'ai:chat',
|
||||
1,
|
||||
sub.monthUsageAllowance,
|
||||
);
|
||||
|
||||
expect(alarmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('usage exceeded'),
|
||||
expect.stringContaining('exceeded their usage allowance'),
|
||||
expect.objectContaining({ purchasedCredits: credit }),
|
||||
'warning',
|
||||
);
|
||||
alarmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── batchIncrementUsages ─────────────────────────────────────────
|
||||
@@ -682,8 +776,7 @@ describe('MeteringService', () => {
|
||||
expect(result.total).toBe(500);
|
||||
const adj = (result as Record<string, unknown>)
|
||||
.manual_adjustment as
|
||||
| { cost: number; units: number; count: number }
|
||||
| undefined;
|
||||
{ cost: number; units: number; count: number } | undefined;
|
||||
expect(adj).toMatchObject({ cost: 500, units: 500, count: 1 });
|
||||
});
|
||||
|
||||
@@ -769,7 +862,7 @@ describe('MeteringService', () => {
|
||||
});
|
||||
|
||||
it('adds purchased credits to remaining', async () => {
|
||||
await target.updateAddonCredit(actor.user.uuid, 5_000);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000);
|
||||
const allowed = await target.getAllowedUsage(actor);
|
||||
expect(allowed.remaining).toBe(allowed.monthUsageAllowance + 5_000);
|
||||
});
|
||||
@@ -800,7 +893,7 @@ describe('MeteringService', () => {
|
||||
|
||||
it('does not double-charge same-month overage against remaining (usage total + consumed credits)', async () => {
|
||||
const sub = await target.getActorSubscription(actor);
|
||||
await target.updateAddonCredit(actor.user.uuid, 5_000_000);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
|
||||
|
||||
// Exhaust the allowance, then overspend by 1_000_000 — the overage
|
||||
// is consumed from purchased credits.
|
||||
@@ -825,7 +918,7 @@ describe('MeteringService', () => {
|
||||
it('counts consumed credits from prior months against the credit pool only', async () => {
|
||||
// Simulate a prior-month overage: consumed credits exist but the
|
||||
// current month has no usage (monthly usage keys roll over).
|
||||
await target.updateAddonCredit(actor.user.uuid, 5_000_000);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
|
||||
await server.stores.kv.incr({
|
||||
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`,
|
||||
pathAndAmountMap: { consumedPurchaseCredits: 2_000_000 },
|
||||
@@ -838,7 +931,7 @@ describe('MeteringService', () => {
|
||||
});
|
||||
|
||||
it('hasEnoughCredits compares remaining against the requested amount', async () => {
|
||||
await target.updateAddonCredit(actor.user.uuid, 1_000);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 1_000);
|
||||
expect(await target.hasEnoughCredits(actor, 100)).toBe(true);
|
||||
expect(
|
||||
await target.hasEnoughCredits(actor, Number.MAX_SAFE_INTEGER),
|
||||
@@ -880,7 +973,7 @@ describe('MeteringService', () => {
|
||||
});
|
||||
|
||||
it('persists addons under the policy prefix', async () => {
|
||||
await target.updateAddonCredit(actor.user.uuid, 250);
|
||||
await target.updateAddonCredit(actor.user.uuid!, 250);
|
||||
const key = `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`;
|
||||
const { res } = await server.stores.kv.get({ key });
|
||||
expect(res).toMatchObject({ purchasedCredits: 250 });
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import murmurhash from 'murmurhash';
|
||||
@@ -53,12 +54,12 @@ interface UsageInput {
|
||||
// -- MeteringService --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tracks per-actor and global usage, and exposes subscription/addon lookup.
|
||||
* All metering data is persisted under the system namespace via
|
||||
* `stores.kv` (SystemKVStore)
|
||||
* Tracks per-actor and global usage, and exposes subscription/addon lookup. All
|
||||
* metering data is persisted under the system namespace via `stores.kv`
|
||||
* (SystemKVStore)
|
||||
*
|
||||
* Callers (typically drivers or controllers) pass the user-scoped actor in; we fan that
|
||||
* out into several aggregated KV records.
|
||||
* Callers (typically drivers or controllers) pass the user-scoped actor in; we
|
||||
* fan that out into several aggregated KV records.
|
||||
*/
|
||||
export class MeteringService extends PuterService {
|
||||
static GLOBAL_SHARD_COUNT = 10000;
|
||||
@@ -107,7 +108,7 @@ export class MeteringService extends PuterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a resolver that maps an actor to a *default* subscription id,
|
||||
* Register a resolver that maps an actor to a _default_ subscription id,
|
||||
* used when no explicit subscription is set. First non-empty wins.
|
||||
*/
|
||||
registerDefaultSubscriptionResolver(fn: SubscriptionResolver): void {
|
||||
@@ -787,7 +788,8 @@ export class MeteringService extends PuterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Randomized shard key to spread writes across the global consumption bucket.
|
||||
* Randomized shard key to spread writes across the global consumption
|
||||
* bucket.
|
||||
*/
|
||||
private globalUsageKey(
|
||||
userId: string,
|
||||
@@ -888,26 +890,36 @@ export class MeteringService extends PuterService {
|
||||
// No metered allowance to exceed (e.g. unlimited policies) — nothing to flag.
|
||||
if (!(allowance > 0)) return;
|
||||
|
||||
const previousUsage = actorUsages.total - incrementCost;
|
||||
const allowedMultiple = Math.floor(actorUsages.total / allowance);
|
||||
const previousMultiple = Math.floor(previousUsage / allowance);
|
||||
// Purchased credit extends the budget: the actor is only genuinely
|
||||
// "over" once they've burned through the monthly allowance AND every
|
||||
// purchased credit. Measure usage net of the purchased credit so the
|
||||
// allowance multiples below are counted from the point that whole budget
|
||||
// is exhausted rather than from zero — otherwise a user actively
|
||||
// spending down a large credit balance trips the alarm on every
|
||||
// allowance-sized expense the moment the credit runs dry. (Purchased
|
||||
// credit is a lifetime balance, so in the month it finally runs out this
|
||||
// also grants a small grace window before paging.)
|
||||
const purchasedCredits = actorAddons.purchasedCredits || 0;
|
||||
const consumedPurchaseCredits =
|
||||
actorAddons.consumedPurchaseCredits || 0;
|
||||
const netUsage = actorUsages.total - purchasedCredits;
|
||||
const previousNetUsage = netUsage - incrementCost;
|
||||
|
||||
// Only alarm if the actor was ALREADY at or past their allowance before
|
||||
// this expense arrived. A single large request that jumps past the limit
|
||||
// in one shot (previous usage still under the allowance) is legitimate
|
||||
// and shouldn't page.
|
||||
const wasAlreadyOverLimit = previousUsage >= allowance;
|
||||
const currentMultiple = Math.floor(netUsage / allowance);
|
||||
const previousMultiple = Math.floor(previousNetUsage / allowance);
|
||||
|
||||
// Only alarm if the actor was ALREADY past their full budget (allowance
|
||||
// + purchased credit) before this expense arrived. A single large
|
||||
// request that jumps past the limit in one shot (net usage still under
|
||||
// the allowance beforehand) is legitimate and shouldn't page.
|
||||
const wasAlreadyOverLimit = previousNetUsage >= allowance;
|
||||
// And only when this expense crosses into a new whole multiple of the
|
||||
// allowance (2x, 3x, …) rather than on every expense once over — that
|
||||
// first-over multiple is 2x, since being already over means the previous
|
||||
// multiple was at least 1.
|
||||
const crossedMultiple = previousMultiple < allowedMultiple;
|
||||
const hasNoAddonCredit =
|
||||
(actorAddons.purchasedCredits || 0) <=
|
||||
(actorAddons.consumedPurchaseCredits || 0);
|
||||
// allowance beyond that budget. Being already over means the previous
|
||||
// multiple was at least 1, so the first multiple that fires is 2x — i.e.
|
||||
// usage has reached (purchased credit + 2 x the monthly allowance).
|
||||
const crossedMultiple = previousMultiple < currentMultiple;
|
||||
|
||||
if (!(wasAlreadyOverLimit && crossedMultiple && hasNoAddonCredit))
|
||||
return;
|
||||
if (!(wasAlreadyOverLimit && crossedMultiple)) return;
|
||||
|
||||
this.clients.alarm.create(
|
||||
`metering usage exceeded by user: ${actor.user?.username}`,
|
||||
@@ -923,6 +935,8 @@ export class MeteringService extends PuterService {
|
||||
batchUsages: ctx.batchUsages,
|
||||
totalUsage: actorUsages.total,
|
||||
monthUsageAllowance: actorSubscription.monthUsageAllowance,
|
||||
purchasedCredits,
|
||||
consumedPurchaseCredits,
|
||||
},
|
||||
// Expected-but-worth-tracking signal — record/de-dupe it but don't page on-call.
|
||||
'warning',
|
||||
|
||||
@@ -31,6 +31,8 @@ An object containing the following properties:
|
||||
|
||||
- `includeTotal` (optional): If `true`, the paginated result includes a `total` count of the user's apps.
|
||||
|
||||
- `stream` (optional): If `true`, the method returns an async iterator of page objects instead of a promise, for use with `for await ... of`. Combine with `limit` to control the page size, or `cursor` to resume from a previous page. Cannot be combined with `offset`. With `includeTotal`, only the first page carries `total`.
|
||||
|
||||
## Return value
|
||||
|
||||
A `Promise` that will resolve to an array of all [`App`](/Objects/app/) objects belonging to the user that this app has access to.
|
||||
@@ -41,7 +43,17 @@ When the request includes `cursor` (even `null`), `offset`, or `includeTotal`, t
|
||||
- `cursor` (String) (optional): Present while more pages exist; pass it to the next call.
|
||||
- `total` (Number) (optional): Total app count, present when `includeTotal` was set.
|
||||
|
||||
Requests without pagination params keep returning the full list as a plain array, so existing code is unaffected.
|
||||
Requests without pagination params keep returning the full list as a plain array, so existing code is unaffected — under the hood the SDK now fetches it page by page.
|
||||
|
||||
With `stream: true`, the method returns an async iterator of page objects instead:
|
||||
|
||||
```js
|
||||
for await (const page of puter.apps.list({ stream: true })) {
|
||||
for (const app of page.items) {
|
||||
console.log(app.name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ An object with the following properties:
|
||||
- `sortOrder` (String) (optional) - `asc` or `desc`. Default is `asc`.
|
||||
- `cursor` (String | null) (optional) - Opts into paginated results. Pass `null` for the first page, then the `cursor` from each page to fetch the next one. The cursor pins the sort, so later pages must not request a different `sortBy`/`sortOrder`.
|
||||
- `includeTotal` (Boolean) (optional) - If `true`, the paginated result includes a `total` count of all entries in the directory.
|
||||
- `stream` (Boolean) (optional) - If `true`, the method returns an async iterator of page objects instead of a promise, for use with `for await ... of`. Combine with `limit` to control the page size, or `cursor` to resume from a previous page. Cannot be combined with `offset`. With `includeTotal`, only the first page carries `total`.
|
||||
|
||||
## Return value
|
||||
|
||||
@@ -44,7 +45,17 @@ When the request includes `cursor` (even `null`) or `includeTotal`, the promise
|
||||
- `cursor` (String) (optional): Present while more pages exist; pass it to the next call.
|
||||
- `total` (Number) (optional): Total entry count, present when `includeTotal` was set.
|
||||
|
||||
Requests without pagination params keep returning the full listing as a plain array, so existing code is unaffected.
|
||||
Requests without pagination params keep returning the full listing as a plain array, so existing code is unaffected — under the hood the SDK now fetches it page by page.
|
||||
|
||||
With `stream: true`, the method returns an async iterator of page objects instead:
|
||||
|
||||
```js
|
||||
for await (const page of puter.fs.readdir({ path: './large-dir', stream: true })) {
|
||||
for (const item of page.items) {
|
||||
console.log(item.name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ An object with the following optional properties:
|
||||
- `offset` (Number): Skips the given number of subdomains. Prefer `cursor` for paging through large lists.
|
||||
- `cursor` (String | null): Opts into paginated results. Pass `null` for the first page, then the `cursor` from each page to fetch the next one.
|
||||
- `includeTotal` (Boolean): If `true`, the paginated result includes a `total` count.
|
||||
- `stream` (Boolean): If `true`, the method returns an async iterator of page objects instead of a promise, for use with `for await ... of`. Combine with `limit` to control the page size, or `cursor` to resume from a previous page. Cannot be combined with `offset`. With `includeTotal`, only the first page carries `total`.
|
||||
|
||||
## Return value
|
||||
A `Promise` that will resolve to an array of all [`Subdomain`](/Objects/subdomain/) objects belonging to the user that this app has access to.
|
||||
@@ -32,7 +33,17 @@ When the request includes `cursor` (even `null`) or `includeTotal`, the promise
|
||||
- `cursor` (String) (optional): Present while more pages exist; pass it to the next call.
|
||||
- `total` (Number) (optional): Present when `includeTotal` was set.
|
||||
|
||||
Requests without pagination params keep returning the full list as a plain array, so existing code is unaffected.
|
||||
Requests without pagination params keep returning the full list as a plain array, so existing code is unaffected — under the hood the SDK now fetches it page by page.
|
||||
|
||||
With `stream: true`, the method returns an async iterator of page objects instead:
|
||||
|
||||
```js
|
||||
for await (const page of puter.hosting.list({ stream: true })) {
|
||||
for (const site of page.items) {
|
||||
console.log(site.subdomain);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Worker-backed subdomains are never included in the results — pages and `total` only count sites. Use [`puter.workers.list()`](/Workers/list/) to list workers.
|
||||
|
||||
|
||||
+14
-1
@@ -37,8 +37,9 @@ An object with the following optional properties:
|
||||
- `limit` (Number): Maximum number of items to return in a single call.
|
||||
- `cursor` (String): A pagination cursor from a previous call. Pass the `cursor` value returned by the previous page to fetch the next one.
|
||||
- `offset` (Number): Skips the given number of items before the page starts. Not recommended — requests get slower and more expensive the larger the offset; prefer `cursor`. Maximum `5000`, and cannot be combined with `cursor`.
|
||||
- `includeTotal` (Boolean): If `true`, the result includes a `total` count of every item matching the query (across all pages). Computing the total costs more the more items you have, so request it on the first page only rather than on every page.
|
||||
- `includeTotal` (Boolean): If `true`, the result includes a `total` count of every item matching the query (across all pages). The count is metered and its cost grows with the size of your store — request it once (on the first page) and avoid it in hot paths. If you only need to know whether more pages exist, check for `cursor` instead of counting.
|
||||
- `fetchUntilFull` (Boolean): A page can come back with fewer than `limit` items even when more exist (for example when expired keys are excluded). If `true`, the page is filled up to `limit` items when possible. Requires `limit`.
|
||||
- `stream` (Boolean): If `true`, the method returns an async iterator of [`KVListPage`](/Objects/kvlistpage) objects instead of a promise, for use with `for await ... of`. Combine with `limit` to control the page size, or `cursor` to resume from a previous page. Cannot be combined with `offset`. With `includeTotal`, only the first page carries `total`.
|
||||
|
||||
## Return value
|
||||
|
||||
@@ -52,6 +53,18 @@ If the user has no keys, the array will be empty.
|
||||
|
||||
When paginating, iterate until the result has no `cursor` — a page may hold fewer than `limit` items while more pages still exist.
|
||||
|
||||
Full (non-paginated) listings keep resolving to a plain array, so existing code is unaffected — under the hood the SDK now fetches them page by page. They still read the entire store, though: every page is metered, so on large stores a bare `list()` gets slow and costly (the SDK logs a one-time console warning when a full listing spans multiple pages). Prefer `stream: true` or explicit `limit`/`cursor` pages, and narrow the scan with a `pattern`.
|
||||
|
||||
With `stream: true`, the method returns an async iterator of [`KVListPage`](/Objects/kvlistpage) objects instead:
|
||||
|
||||
```js
|
||||
for await (const page of puter.kv.list({ pattern: 'log:*', stream: true })) {
|
||||
for (const key of page.items) {
|
||||
console.log(key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
<strong class="example-title">Retrieve all keys in the user's key-value store for the current app</strong>
|
||||
|
||||
@@ -22,4 +22,4 @@ A page may hold fewer than `limit` items while `cursor` is still present — alw
|
||||
|
||||
#### `total` (Number) (optional)
|
||||
|
||||
The total number of items matching the query across all pages. Present only when the request set `includeTotal: true`.
|
||||
The total number of items matching the query across all pages. Present only when the request set `includeTotal: true`. Computing it is metered and its cost grows with the store — request it once (on the first page) and avoid it in hot paths. If you only need to know whether more pages exist, check for `cursor` instead.
|
||||
|
||||
@@ -23,6 +23,7 @@ An object with the following optional properties:
|
||||
- `offset` (Number): Skips the given number of workers. Prefer `cursor` for paging through large lists.
|
||||
- `cursor` (String | null): Opts into paginated results. Pass `null` for the first page, then the `cursor` from each page to fetch the next one.
|
||||
- `includeTotal` (Boolean): If `true`, the paginated result includes a `total` count.
|
||||
- `stream` (Boolean): If `true`, the method returns an async iterator of page objects instead of a promise, for use with `for await ... of`. Combine with `limit` to control the page size, or `cursor` to resume from a previous page. Cannot be combined with `offset`. With `includeTotal`, only the first page carries `total`.
|
||||
|
||||
## Return Value
|
||||
|
||||
@@ -34,7 +35,17 @@ When the request includes any pagination option, the promise instead resolves to
|
||||
- `cursor` (String) (optional): Present while more pages exist; pass it to the next call.
|
||||
- `total` (Number) (optional): Present when `includeTotal` was set.
|
||||
|
||||
Requests without pagination params keep returning the full list as a plain array, so existing code is unaffected.
|
||||
Requests without pagination params keep returning the full list as a plain array, so existing code is unaffected — under the hood the SDK now fetches it page by page.
|
||||
|
||||
With `stream: true`, the method returns an async iterator of page objects instead:
|
||||
|
||||
```js
|
||||
for await (const page of puter.workers.list({ stream: true })) {
|
||||
for (const worker of page.items) {
|
||||
console.log(worker.name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -455,15 +455,31 @@ const TabHome = {
|
||||
// Load monthly usage data
|
||||
try {
|
||||
const res = await puter.auth.getMonthlyUsage();
|
||||
let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance;
|
||||
// Actual month-to-date spend. `allowanceInfo.remaining` folds
|
||||
// purchased credits into the remaining pool, so `allowance -
|
||||
// remaining` turns negative once a user has credits. Use the
|
||||
// reported usage total instead.
|
||||
let totalUsage = res.usage?.total ?? 0;
|
||||
let totalUsagePercentage = monthlyAllowance
|
||||
? Math.min(100, (totalUsage / monthlyAllowance) * 100).toFixed(0)
|
||||
: '0';
|
||||
const monthlyAllowance = res.allowanceInfo?.monthUsageAllowance || 0;
|
||||
// Actual month-to-date spend.
|
||||
const totalUsage = res.usage?.total ?? 0;
|
||||
// Purchased credits extend the monthly allowance. `remaining` is the
|
||||
// server-netted pool (allowance-left + purchased-left, with any
|
||||
// overage already charged to credits), so subtracting the allowance
|
||||
// portion back out isolates the purchased-credit balance — no
|
||||
// double-counting of the overage.
|
||||
const remaining = res.allowanceInfo?.remaining ?? 0;
|
||||
const remainingPurchased = Math.max(
|
||||
0,
|
||||
remaining - Math.max(0, monthlyAllowance - totalUsage),
|
||||
);
|
||||
// Capacity grows by whatever purchased credit is left; net usage
|
||||
// (spend minus that credit) drives the percentage, so unused credit
|
||||
// reads as a negative "usage" against the monthly allowance.
|
||||
const capacity = monthlyAllowance + remainingPurchased;
|
||||
const netUsage = totalUsage - remainingPurchased;
|
||||
const rawPercentage = monthlyAllowance
|
||||
? (netUsage / monthlyAllowance) * 100
|
||||
: 0;
|
||||
// Text may go negative (surplus credit) but never above 100%; the
|
||||
// bar fill is clamped to [0, 100].
|
||||
const displayPercentage = Math.round(Math.min(100, rawPercentage));
|
||||
const barPercentage = Math.max(0, Math.min(100, rawPercentage));
|
||||
|
||||
$el_window
|
||||
.find('.bento-resources-used')
|
||||
@@ -473,18 +489,17 @@ const TabHome = {
|
||||
$el_window
|
||||
.find('.bento-resources-capacity')
|
||||
.text(
|
||||
window.number_format(monthlyAllowance / 100_000_000, {
|
||||
window.number_format(capacity / 100_000_000, {
|
||||
decimals: 2,
|
||||
prefix: '$',
|
||||
}),
|
||||
);
|
||||
$el_window
|
||||
.find('.bento-resources-percent')
|
||||
.text(`${totalUsagePercentage}%`);
|
||||
.text(`${displayPercentage}%`);
|
||||
$el_window.find('.bento-resources-bar').css({
|
||||
width: `${totalUsagePercentage}%`,
|
||||
'background-color':
|
||||
window.usage_bar_color(totalUsagePercentage),
|
||||
width: `${barPercentage}%`,
|
||||
'background-color': window.usage_bar_color(barPercentage),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load monthly usage data:', e);
|
||||
|
||||
@@ -272,19 +272,35 @@ function renderUsageTable () {
|
||||
|
||||
async function update_usage_details ($el_window) {
|
||||
const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(res => {
|
||||
let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance;
|
||||
// Actual month-to-date spend. `allowanceInfo.remaining` folds purchased
|
||||
// credits into the remaining pool, so `allowance - remaining` turns
|
||||
// negative as soon as a user has credits. Use the reported usage total.
|
||||
let totalUsage = res.usage?.total ?? 0;
|
||||
let totalUsagePercentage = monthlyAllowance ? Math.min(100, totalUsage / monthlyAllowance * 100).toFixed(0) : '0';
|
||||
const monthlyAllowance = res.allowanceInfo?.monthUsageAllowance || 0;
|
||||
// Actual month-to-date spend.
|
||||
const totalUsage = res.usage?.total ?? 0;
|
||||
// Purchased credits extend the monthly allowance. `remaining` is the
|
||||
// server-netted pool (allowance-left + purchased-left, with any overage
|
||||
// already charged to credits), so subtracting the allowance portion back
|
||||
// out isolates the purchased-credit balance — no double-counting.
|
||||
const remaining = res.allowanceInfo?.remaining ?? 0;
|
||||
const remainingPurchased = Math.max(
|
||||
0,
|
||||
remaining - Math.max(0, monthlyAllowance - totalUsage),
|
||||
);
|
||||
// Capacity grows by whatever purchased credit is left; net usage (spend
|
||||
// minus that credit) drives the percentage, so unused credit reads as a
|
||||
// negative "usage" against the monthly allowance.
|
||||
const capacity = monthlyAllowance + remainingPurchased;
|
||||
const netUsage = totalUsage - remainingPurchased;
|
||||
const rawPercentage = monthlyAllowance ? netUsage / monthlyAllowance * 100 : 0;
|
||||
// Text may go negative (surplus credit) but never above 100%; the bar
|
||||
// fill is clamped to [0, 100].
|
||||
const displayPercentage = Math.round(Math.min(100, rawPercentage));
|
||||
const barPercentage = Math.max(0, Math.min(100, rawPercentage));
|
||||
|
||||
$('#total-usage').html(window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' }));
|
||||
$('#total-capacity').html(window.number_format(monthlyAllowance / 100_000_000, { decimals: 2, prefix: '$' }));
|
||||
$('.usage-progbar-percent').html(`${totalUsagePercentage }%`);
|
||||
$('#total-capacity').html(window.number_format(capacity / 100_000_000, { decimals: 2, prefix: '$' }));
|
||||
$('.usage-progbar-percent').html(`${displayPercentage }%`);
|
||||
$('.usage-progbar').css({
|
||||
width: `${totalUsagePercentage }%`,
|
||||
'background-color': window.usage_bar_color(totalUsagePercentage),
|
||||
width: `${barPercentage }%`,
|
||||
'background-color': window.usage_bar_color(barPercentage),
|
||||
});
|
||||
|
||||
// Store raw data for sorting
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Client-side page iteration for list APIs that follow the standard
|
||||
* `{ items, cursor?, total? }` envelope (doc/pagination.md). List modules
|
||||
* build a `fetchPage` closure that issues one request with their base
|
||||
* arguments merged under the per-page pagination params.
|
||||
*
|
||||
* @typedef {{ items: unknown[], cursor?: string, total?: number }} ListPage
|
||||
* @typedef {(pageParams: { cursor: string | null, includeTotal?: boolean }) => Promise<ListPage | unknown[]>} FetchPage
|
||||
*/
|
||||
|
||||
/**
|
||||
* Async generator over page envelopes, following `cursor` until it is
|
||||
* absent. `includeTotal` is sent on the first request only — totals cost
|
||||
* more the more items exist, and the count doesn't change page to page.
|
||||
* A backend that ignores pagination params responds with a bare array;
|
||||
* that becomes the one and only page, so old backends stay compatible.
|
||||
*
|
||||
* @param {FetchPage} fetchPage
|
||||
* @param {{ cursor?: string | null, includeTotal?: boolean }} [opts]
|
||||
* @returns {AsyncGenerator<ListPage, void, undefined>}
|
||||
*/
|
||||
async function* iteratePages (fetchPage, opts = {}) {
|
||||
/** @type {{ cursor: string | null, includeTotal?: boolean }} */
|
||||
let pageParams = {
|
||||
cursor: opts.cursor ?? null,
|
||||
...(opts.includeTotal === true ? { includeTotal: true } : {}),
|
||||
};
|
||||
while ( true ) {
|
||||
const result = await fetchPage(pageParams);
|
||||
const page = Array.isArray(result) ? { items: result } : (result ?? { items: [] });
|
||||
yield page;
|
||||
if ( ! page.cursor ) return;
|
||||
pageParams = { cursor: page.cursor };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every page and returns the concatenated items — the legacy
|
||||
* full-listing shape, produced with bounded per-request work.
|
||||
*
|
||||
* @param {FetchPage} fetchPage
|
||||
* @returns {Promise<unknown[]>}
|
||||
*/
|
||||
async function fetchAllPages (fetchPage) {
|
||||
const items = [];
|
||||
for await ( const page of iteratePages(fetchPage) ) {
|
||||
items.push(...(page.items ?? []));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export { fetchAllPages, iteratePages };
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchAllPages, iteratePages } from './pagination.js';
|
||||
|
||||
describe('iteratePages', () => {
|
||||
it('follows cursors until a page has none', async () => {
|
||||
const calls = [];
|
||||
const fetchPage = async (params) => {
|
||||
calls.push(params);
|
||||
if ( params.cursor === null ) return { items: ['a'], cursor: 'c2' };
|
||||
if ( params.cursor === 'c2' ) return { items: ['b'], cursor: 'c3' };
|
||||
return { items: ['c'] };
|
||||
};
|
||||
const pages = [];
|
||||
for await ( const page of iteratePages(fetchPage) ) pages.push(page);
|
||||
expect(pages).toEqual([
|
||||
{ items: ['a'], cursor: 'c2' },
|
||||
{ items: ['b'], cursor: 'c3' },
|
||||
{ items: ['c'] },
|
||||
]);
|
||||
expect(calls).toEqual([{ cursor: null }, { cursor: 'c2' }, { cursor: 'c3' }]);
|
||||
});
|
||||
|
||||
it('sends includeTotal on the first request only', async () => {
|
||||
const calls = [];
|
||||
const fetchPage = async (params) => {
|
||||
calls.push(params);
|
||||
return params.cursor === null
|
||||
? { items: [1], cursor: 'c2', total: 2 }
|
||||
: { items: [2] };
|
||||
};
|
||||
const pages = [];
|
||||
for await ( const page of iteratePages(fetchPage, { includeTotal: true }) ) pages.push(page);
|
||||
expect(calls).toEqual([
|
||||
{ cursor: null, includeTotal: true },
|
||||
{ cursor: 'c2' },
|
||||
]);
|
||||
expect(pages[0].total).toBe(2);
|
||||
});
|
||||
|
||||
it('starts from a caller-provided cursor', async () => {
|
||||
const calls = [];
|
||||
const fetchPage = async (params) => {
|
||||
calls.push(params);
|
||||
return { items: [] };
|
||||
};
|
||||
for await ( const page of iteratePages(fetchPage, { cursor: 'resume' }) ) void page;
|
||||
expect(calls).toEqual([{ cursor: 'resume' }]);
|
||||
});
|
||||
|
||||
it('treats a bare-array response as the one and only page', async () => {
|
||||
let calls = 0;
|
||||
const fetchPage = async () => {
|
||||
calls++;
|
||||
return ['a', 'b'];
|
||||
};
|
||||
const pages = [];
|
||||
for await ( const page of iteratePages(fetchPage) ) pages.push(page);
|
||||
expect(pages).toEqual([{ items: ['a', 'b'] }]);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates fetch errors to the consumer', async () => {
|
||||
const fetchPage = async () => {
|
||||
throw { message: 'nope', code: 'forbidden' };
|
||||
};
|
||||
const iterate = async () => {
|
||||
for await ( const page of iteratePages(fetchPage) ) void page;
|
||||
};
|
||||
await expect(iterate()).rejects.toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllPages', () => {
|
||||
it('concatenates items across pages', async () => {
|
||||
const fetchPage = async (params) =>
|
||||
params.cursor === null
|
||||
? { items: ['a', 'b'], cursor: 'c2' }
|
||||
: { items: ['c'] };
|
||||
expect(await fetchAllPages(fetchPage)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns a bare-array response as-is', async () => {
|
||||
expect(await fetchAllPages(async () => ['x'])).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('tolerates short and empty pages while a cursor remains', async () => {
|
||||
const fetchPage = async (params) =>
|
||||
params.cursor === null
|
||||
? { items: [], cursor: 'c2' }
|
||||
: { items: ['only'] };
|
||||
expect(await fetchAllPages(fetchPage)).toEqual(['only']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as utils from '../lib/utils.js';
|
||||
import { fetchUrl } from '../lib/networkUtils.js';
|
||||
import { fetchAllPages, iteratePages } from '../lib/pagination.js';
|
||||
|
||||
class Apps {
|
||||
/**
|
||||
@@ -70,31 +71,55 @@ class Apps {
|
||||
this.APIOrigin = APIOrigin;
|
||||
}
|
||||
|
||||
list = async (...args) => {
|
||||
let options = {};
|
||||
|
||||
list = (...args) => {
|
||||
// if args is a single object, assume it is the options object.
|
||||
// Pagination keys are lifted to top-level driver args; the rest
|
||||
// (icon_size, stats_period, ...) stay in `params`.
|
||||
if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
const { limit, offset, cursor, includeTotal, ...params } = args[0];
|
||||
options.params = params;
|
||||
if ( limit !== undefined ) options.limit = limit;
|
||||
if ( offset !== undefined ) options.offset = offset;
|
||||
if ( Object.prototype.hasOwnProperty.call(args[0], 'cursor') ) {
|
||||
options.cursor = cursor ?? null;
|
||||
// Pagination keys (and `stream`) are lifted to top-level driver args;
|
||||
// the rest (icon_size, stats_period, ...) stay in `params`.
|
||||
const isObjectForm = typeof args[0] === 'object' && args[0] !== null;
|
||||
const opts = isObjectForm ? args[0] : {};
|
||||
const { limit, offset, cursor, includeTotal, stream, ...params } = opts;
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
|
||||
const select = utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'select');
|
||||
const base = { predicate: ['user-can-edit'] };
|
||||
if ( isObjectForm ) base.params = params;
|
||||
if ( limit !== undefined ) base.limit = limit;
|
||||
const fetchPage = pageParams => select.call(this, { ...base, ...pageParams });
|
||||
|
||||
if ( stream === true ) {
|
||||
if ( offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
if ( includeTotal !== undefined ) options.includeTotal = includeTotal;
|
||||
const self = this;
|
||||
return (async function* () {
|
||||
for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) {
|
||||
self.#addUserIterationToApps(page.items ?? []);
|
||||
yield page;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
options.predicate = ['user-can-edit'];
|
||||
|
||||
const result = await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'select').call(this, options);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
this.#addUserIterationToApps(result.items);
|
||||
return result;
|
||||
// Any pagination param keeps the single-request behavior: a bare
|
||||
// (possibly limit-capped) array, or the page envelope once the
|
||||
// request opts into pagination via cursor/offset/includeTotal.
|
||||
if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
const options = { ...base };
|
||||
if ( offset !== undefined ) options.offset = offset;
|
||||
if ( hasCursor ) options.cursor = cursor ?? null;
|
||||
if ( includeTotal !== undefined ) options.includeTotal = includeTotal;
|
||||
const result = await select.call(this, options);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
this.#addUserIterationToApps(result.items);
|
||||
return result;
|
||||
}
|
||||
return this.#addUserIterationToApps(result);
|
||||
})();
|
||||
}
|
||||
return this.#addUserIterationToApps(result);
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single
|
||||
// request carries the whole result, then return the legacy array.
|
||||
return fetchAllPages(fetchPage).then(items => this.#addUserIterationToApps(items));
|
||||
};
|
||||
|
||||
create = async (...args) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as utils from '../../../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../../../lib/pagination.js';
|
||||
import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js';
|
||||
|
||||
// Track in-flight requests to avoid duplicate backend calls
|
||||
@@ -9,7 +10,66 @@ const inflightRequests = new Map();
|
||||
// Requests made within this window will share the same backend call
|
||||
const DEDUPLICATION_WINDOW_MS = 2000; // 2 seconds
|
||||
|
||||
const readdir = async function (...args) {
|
||||
// One HTTP /readdir request. `pageParams` holds the pagination params for
|
||||
// this page (cursor/includeTotal), if any. Resolves with the raw response:
|
||||
// a bare array (legacy) or an `{items, cursor?, total?}` envelope.
|
||||
const requestOnce = function (options, pageParams) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// If auth token is not provided and we are in the web environment,
|
||||
// try to authenticate with Puter
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
} catch (e) {
|
||||
// if authentication fails, throw an error
|
||||
reject('Authentication failed.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// create xhr object
|
||||
const xhr = utils.initXhr('/readdir', this.APIOrigin, undefined, 'post', 'text/plain;actually=json');
|
||||
|
||||
// set up event handlers for load and error events
|
||||
utils.setupXhrEventHandlers(xhr, undefined, undefined, (result) => {
|
||||
// set each individual item's cache
|
||||
const entries = Array.isArray(result) ? result : (result?.items ?? []);
|
||||
for ( const item of entries ) {
|
||||
puter._cache.set(`item:${ item.path}`, item);
|
||||
}
|
||||
resolve(result);
|
||||
}, reject);
|
||||
|
||||
// Build request payload - support both path and uid parameters
|
||||
const payload = {
|
||||
no_thumbs: options.no_thumbs,
|
||||
no_assocs: options.no_assocs,
|
||||
no_subdomains: options.no_subdomains,
|
||||
auth_token: this.authToken,
|
||||
};
|
||||
if ( options.limit !== undefined ) payload.limit = options.limit;
|
||||
if ( options.offset !== undefined ) payload.offset = options.offset;
|
||||
if ( options.sortBy !== undefined ) payload.sortBy = options.sortBy;
|
||||
if ( options.sortOrder !== undefined ) payload.sortOrder = options.sortOrder;
|
||||
if ( pageParams ) {
|
||||
payload.cursor = pageParams.cursor ?? null;
|
||||
if ( pageParams.includeTotal !== undefined ) {
|
||||
payload.includeTotal = pageParams.includeTotal;
|
||||
}
|
||||
}
|
||||
|
||||
// Add either uid or path to the payload
|
||||
if ( options.uid ) {
|
||||
payload.uid = options.uid;
|
||||
} else if ( options.path ) {
|
||||
payload.path = getAbsolutePathForApp(options.path);
|
||||
}
|
||||
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
};
|
||||
|
||||
const readdir = function (...args) {
|
||||
let options;
|
||||
|
||||
// If first argument is an object, it's the options
|
||||
@@ -24,6 +84,23 @@ const readdir = async function (...args) {
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming form: an async iterator of `{items, cursor?, total?}` pages.
|
||||
// No listing cache and no dedup — a generator can't be shared between
|
||||
// consumers — and no legacy callbacks.
|
||||
if ( options.stream === true ) {
|
||||
if ( options.offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
if ( !options.path && !options.uid ) {
|
||||
throw { message: 'Either path or uid must be provided.', code: 'NO_PATH_OR_UID' };
|
||||
}
|
||||
const fetchPage = pageParams => requestOnce.call(this, options, pageParams);
|
||||
return iteratePages(fetchPage, {
|
||||
cursor: options.cursor,
|
||||
includeTotal: options.includeTotal === true,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// consistency levels
|
||||
if ( ! options.consistency ) {
|
||||
@@ -41,10 +118,16 @@ const readdir = async function (...args) {
|
||||
Object.prototype.hasOwnProperty.call(options, 'cursor') ||
|
||||
options.includeTotal === true;
|
||||
|
||||
// Generate cache key based on path or uid. Pages are never cached —
|
||||
// the cache stores full listings keyed by path only.
|
||||
// Unbound listings (no pagination params at all) are fetched page by
|
||||
// page under the hood and returned as the legacy full array.
|
||||
const unbound = ! paginated &&
|
||||
options.limit === undefined &&
|
||||
options.offset === undefined;
|
||||
|
||||
// Generate cache key based on path. Only full listings are cached —
|
||||
// pages and limit/offset-truncated results never are.
|
||||
let cacheKey;
|
||||
if ( options.path && !paginated ) {
|
||||
if ( options.path && unbound ) {
|
||||
cacheKey = `readdir:${ options.path}`;
|
||||
}
|
||||
|
||||
@@ -96,72 +179,39 @@ const readdir = async function (...args) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a promise for this request and store it to deduplicate concurrent calls
|
||||
const requestPromise = new Promise(async (resolveRequest, rejectRequest) => {
|
||||
// If auth token is not provided and we are in the web environment,
|
||||
// try to authenticate with Puter
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
} catch (e) {
|
||||
// if authentication fails, throw an error
|
||||
rejectRequest('Authentication failed.');
|
||||
return;
|
||||
}
|
||||
const requestPromise = (async () => {
|
||||
if ( ! unbound ) {
|
||||
// Single request: legacy limit/offset form, or one page of the
|
||||
// envelope when the caller passed cursor/includeTotal.
|
||||
const pageParams = paginated
|
||||
? { cursor: options.cursor, includeTotal: options.includeTotal }
|
||||
: undefined;
|
||||
return await requestOnce.call(this, options, pageParams);
|
||||
}
|
||||
|
||||
// create xhr object
|
||||
const xhr = utils.initXhr('/readdir', this.APIOrigin, undefined, 'post', 'text/plain;actually=json');
|
||||
const fetchPage = pageParams => requestOnce.call(this, options, pageParams);
|
||||
const result = await fetchAllPages(fetchPage);
|
||||
|
||||
// set up event handlers for load and error events
|
||||
utils.setupXhrEventHandlers(xhr, options.success, options.error, async (result) => {
|
||||
// Calculate the size of the result for cache eligibility check
|
||||
const resultSize = JSON.stringify(result).length;
|
||||
// Calculate the size of the result for cache eligibility check
|
||||
const resultSize = JSON.stringify(result).length;
|
||||
|
||||
// Cache the result if it's not bigger than MAX_CACHE_SIZE
|
||||
const MAX_CACHE_SIZE = 100 * 1024 * 1024;
|
||||
// Cache the result if it's not bigger than MAX_CACHE_SIZE
|
||||
const MAX_CACHE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
if ( cacheKey && resultSize <= MAX_CACHE_SIZE ) {
|
||||
// UPSERT the cache
|
||||
puter._cache.set(cacheKey, result);
|
||||
}
|
||||
|
||||
// set each individual item's cache
|
||||
const entries = paginated ? (result?.items ?? []) : result;
|
||||
for ( const item of entries ) {
|
||||
puter._cache.set(`item:${ item.path}`, item);
|
||||
}
|
||||
|
||||
resolveRequest(result);
|
||||
}, rejectRequest);
|
||||
|
||||
// Build request payload - support both path and uid parameters
|
||||
const payload = {
|
||||
no_thumbs: options.no_thumbs,
|
||||
no_assocs: options.no_assocs,
|
||||
no_subdomains: options.no_subdomains,
|
||||
auth_token: this.authToken,
|
||||
};
|
||||
if ( options.limit !== undefined ) payload.limit = options.limit;
|
||||
if ( options.offset !== undefined ) payload.offset = options.offset;
|
||||
if ( options.sortBy !== undefined ) payload.sortBy = options.sortBy;
|
||||
if ( options.sortOrder !== undefined ) payload.sortOrder = options.sortOrder;
|
||||
if ( paginated ) {
|
||||
payload.cursor = options.cursor ?? null;
|
||||
if ( options.includeTotal !== undefined ) {
|
||||
payload.includeTotal = options.includeTotal;
|
||||
}
|
||||
if ( cacheKey && resultSize <= MAX_CACHE_SIZE ) {
|
||||
// UPSERT the cache
|
||||
puter._cache.set(cacheKey, result);
|
||||
}
|
||||
|
||||
// Add either uid or path to the payload
|
||||
if ( options.uid ) {
|
||||
payload.uid = options.uid;
|
||||
} else if ( options.path ) {
|
||||
payload.path = getAbsolutePathForApp(options.path);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
// Legacy callbacks fire once, for the caller that initiated the
|
||||
// request (dedup-reused and cache-served calls never fired them).
|
||||
requestPromise.then(
|
||||
result => { if ( typeof options.success === 'function' ) options.success(result); },
|
||||
err => { if ( typeof options.error === 'function' ) options.error(err); },
|
||||
);
|
||||
|
||||
// Store the promise and timestamp in the in-flight tracker
|
||||
inflightRequests.set(deduplicationKey, {
|
||||
@@ -181,4 +231,4 @@ const readdir = async function (...args) {
|
||||
});
|
||||
};
|
||||
|
||||
export default readdir;
|
||||
export default readdir;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as utils from '../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../lib/pagination.js';
|
||||
import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js';
|
||||
|
||||
class Hosting {
|
||||
@@ -39,15 +40,63 @@ class Hosting {
|
||||
this.APIOrigin = APIOrigin;
|
||||
}
|
||||
|
||||
// Older backends include worker-backed subdomain rows in select;
|
||||
// current ones exclude them server-side.
|
||||
#withoutWorkerRows (items) {
|
||||
return items.filter(e => !e.subdomain.startsWith('workers.puter.'));
|
||||
}
|
||||
|
||||
// todo document the `Subdomain` object.
|
||||
list = async (...args) => {
|
||||
const result = await utils.make_driver_method([], 'puter-subdomains', undefined, 'select')(...args);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
return result;
|
||||
list = (...args) => {
|
||||
const select = utils.make_driver_method([], 'puter-subdomains', undefined, 'select');
|
||||
|
||||
const opts = (typeof args[0] === 'object' && args[0] !== null) ? args[0] : {};
|
||||
const { limit, offset, cursor, includeTotal, stream, success, error, ...rest } = opts;
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
|
||||
const base = { ...rest };
|
||||
if ( limit !== undefined ) base.limit = limit;
|
||||
const fetchPage = pageParams => select({ ...base, ...pageParams });
|
||||
|
||||
if ( stream === true ) {
|
||||
if ( offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
const self = this;
|
||||
return (async function* () {
|
||||
for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) {
|
||||
yield { ...page, items: self.#withoutWorkerRows(page.items ?? []) };
|
||||
}
|
||||
})();
|
||||
}
|
||||
// Older backends include worker-backed subdomain rows in select;
|
||||
// current ones exclude them server-side.
|
||||
return result.filter(e => !e.subdomain.startsWith('workers.puter.'));
|
||||
|
||||
// Any pagination param keeps the single-request behavior (envelope
|
||||
// once the request opts in via cursor/includeTotal).
|
||||
if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
const result = await select(...args);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
return result;
|
||||
}
|
||||
return this.#withoutWorkerRows(result);
|
||||
})();
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single
|
||||
// request carries the whole result, then return the legacy array.
|
||||
const promise = fetchAllPages(fetchPage).then(items => this.#withoutWorkerRows(items));
|
||||
// Legacy callback forms: list(success, error) and list({ success, error }).
|
||||
// Mirror handle_resp: the callback fires once with the full result and
|
||||
// the returned promise still settles the same way.
|
||||
const success_cb = typeof args[0] === 'function' ? args[0] : success;
|
||||
const error_cb = typeof args[0] === 'function' ? args[1] : error;
|
||||
if ( typeof success_cb === 'function' || typeof error_cb === 'function' ) {
|
||||
promise.then(
|
||||
result => { if ( typeof success_cb === 'function' ) success_cb(result); },
|
||||
err => { if ( typeof error_cb === 'function' ) error_cb(err); },
|
||||
);
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
create = async (...args) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js';
|
||||
import * as utils from '../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../lib/pagination.js';
|
||||
|
||||
export class WorkersHandler {
|
||||
|
||||
@@ -8,14 +9,7 @@ export class WorkersHandler {
|
||||
}
|
||||
|
||||
async create (workerName, filePath, appName) {
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
} catch (e) {
|
||||
// if authentication fails, throw an error
|
||||
throw 'Authentication failed.';
|
||||
}
|
||||
}
|
||||
await this.#authenticateIfNeeded();
|
||||
|
||||
let appId;
|
||||
if ( typeof (appName) === 'object' || typeof (appName) === 'undefined' ) {
|
||||
@@ -57,14 +51,7 @@ export class WorkersHandler {
|
||||
}
|
||||
|
||||
async exec (...args) {
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
} catch (e) {
|
||||
// if authentication fails, throw an error
|
||||
throw 'Authentication failed.';
|
||||
}
|
||||
}
|
||||
await this.#authenticateIfNeeded();
|
||||
|
||||
const req = new Request(...args);
|
||||
if ( ! req.headers.get('puter-auth') && !req.headers.get('x-puter-no-auth')) {
|
||||
@@ -77,7 +64,7 @@ export class WorkersHandler {
|
||||
return fetch(req);
|
||||
}
|
||||
|
||||
async list (options) {
|
||||
async #authenticateIfNeeded () {
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
@@ -86,30 +73,51 @@ export class WorkersHandler {
|
||||
throw 'Authentication failed.';
|
||||
}
|
||||
}
|
||||
const args = {};
|
||||
if ( options && typeof options === 'object' ) {
|
||||
if ( options.limit !== undefined ) args.limit = options.limit;
|
||||
if ( options.offset !== undefined ) args.offset = options.offset;
|
||||
if ( Object.prototype.hasOwnProperty.call(options, 'cursor') ) {
|
||||
args.cursor = options.cursor ?? null;
|
||||
}
|
||||
if ( options.includeTotal !== undefined ) {
|
||||
args.includeTotal = options.includeTotal;
|
||||
}
|
||||
|
||||
list (options) {
|
||||
const opts = (options && typeof options === 'object') ? options : {};
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
const getFilePaths = utils.make_driver_method([], 'workers', 'worker-service', 'getFilePaths');
|
||||
|
||||
const base = {};
|
||||
if ( opts.limit !== undefined ) base.limit = opts.limit;
|
||||
const fetchPage = pageParams => getFilePaths({ ...base, ...pageParams });
|
||||
|
||||
if ( opts.stream === true ) {
|
||||
if ( opts.offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
const self = this;
|
||||
return (async function* () {
|
||||
await self.#authenticateIfNeeded();
|
||||
yield* iteratePages(fetchPage, { cursor: opts.cursor, includeTotal: opts.includeTotal === true });
|
||||
})();
|
||||
}
|
||||
const driverCall = await utils.make_driver_method([], 'workers', 'worker-service', 'getFilePaths')(args);
|
||||
return driverCall;
|
||||
|
||||
// Any pagination param keeps the single-request behavior: the page
|
||||
// envelope from the backend, exactly as requested.
|
||||
if ( opts.limit !== undefined || opts.offset !== undefined || hasCursor || opts.includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
await this.#authenticateIfNeeded();
|
||||
const args = { ...base };
|
||||
if ( opts.offset !== undefined ) args.offset = opts.offset;
|
||||
if ( hasCursor ) args.cursor = opts.cursor ?? null;
|
||||
if ( opts.includeTotal !== undefined ) args.includeTotal = opts.includeTotal;
|
||||
return await getFilePaths(args);
|
||||
})();
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single
|
||||
// request carries the whole result, then return the legacy array.
|
||||
return (async () => {
|
||||
await this.#authenticateIfNeeded();
|
||||
return await fetchAllPages(fetchPage);
|
||||
})();
|
||||
}
|
||||
|
||||
async get (workerName) {
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
} catch (e) {
|
||||
// if authentication fails, throw an error
|
||||
throw 'Authentication failed.';
|
||||
}
|
||||
}
|
||||
await this.#authenticateIfNeeded();
|
||||
|
||||
workerName = workerName.toLocaleLowerCase(); // just incase
|
||||
const driverCall = await utils.make_driver_method(['workerName'], 'workers', 'worker-service', 'getFilePaths')(workerName);
|
||||
@@ -117,14 +125,7 @@ export class WorkersHandler {
|
||||
}
|
||||
|
||||
async delete (workerName) {
|
||||
if ( !puter.authToken && puter.env === 'web' ) {
|
||||
try {
|
||||
await puter.ui.authenticateWithPuter();
|
||||
} catch (e) {
|
||||
// if authentication fails, throw an error
|
||||
throw 'Authentication failed.';
|
||||
}
|
||||
}
|
||||
await this.#authenticateIfNeeded();
|
||||
|
||||
workerName = workerName.toLocaleLowerCase(); // just incase
|
||||
// const driverCall = await puter.drivers.call("workers", "worker-service", "destroy", { authorization: puter.authToken, workerName });
|
||||
|
||||
@@ -185,21 +185,10 @@ const CASES = [
|
||||
{ name: "expireAt('k', ts)", run: (kv) => kv.expireAt('k', 1234567890) },
|
||||
{ name: "expireAt('k', ts, optConfig)", run: (kv) => kv.expireAt('k', 1, { appUuid: 'u' }) },
|
||||
|
||||
// -- list (equivalent forms) --
|
||||
{ name: 'list()', run: (kv) => kv.list() },
|
||||
{ name: 'list(true)', run: (kv) => kv.list(true) },
|
||||
{ name: 'list(false)', run: (kv) => kv.list(false) },
|
||||
{ name: "list('abc*')", run: (kv) => kv.list('abc*') },
|
||||
{ name: "list('abc')", run: (kv) => kv.list('abc') },
|
||||
{ name: "list('*')", run: (kv) => kv.list('*') },
|
||||
{ name: "list('k**')", run: (kv) => kv.list('k**') },
|
||||
{ name: "list(' ')", run: (kv) => kv.list(' ') },
|
||||
{ name: "list('abc*', true)", run: (kv) => kv.list('abc*', true) },
|
||||
{ name: "list('abc*', optConfig)", run: (kv) => kv.list('abc*', { appUuid: 'u' }) },
|
||||
{ name: "list('abc*', true, optConfig)", run: (kv) => kv.list('abc*', true, { appUuid: 'u' }) },
|
||||
// -- list (equivalent paginated forms; unbound forms diverge and are
|
||||
// asserted in the divergences block below) --
|
||||
{ name: 'list(full options object)', run: (kv) => kv.list({ pattern: 'p*', returnValues: true, limit: 5, cursor: 'c1', offset: 2, includeTotal: true, fetchUntilFull: true, optConfig: { appUuid: 'u' } }) },
|
||||
{ name: 'list({ limit })', run: (kv) => kv.list({ limit: 5 }) },
|
||||
{ name: 'list(optConfig shorthand object)', run: (kv) => kv.list({ appUuid: 'u' }) },
|
||||
|
||||
// -- flush / clear --
|
||||
{ name: 'flush()', run: (kv) => kv.flush() },
|
||||
@@ -238,18 +227,56 @@ describe('old vs new KV module equivalence', () => {
|
||||
});
|
||||
|
||||
describe('deliberate divergences (runtime now matches kv.d.ts/docs)', () => {
|
||||
// Unbound (non-paginated) list forms now fetch pages under the hood: the
|
||||
// wire request carries the SDK's paging params, but the resolved value —
|
||||
// the full listing as a plain array — is unchanged.
|
||||
const UNBOUND_LIST_FORMS = [
|
||||
{ name: 'list()', run: (kv) => kv.list() },
|
||||
{ name: 'list(true)', run: (kv) => kv.list(true) },
|
||||
{ name: 'list(false)', run: (kv) => kv.list(false) },
|
||||
{ name: "list('abc*')", run: (kv) => kv.list('abc*') },
|
||||
{ name: "list('abc')", run: (kv) => kv.list('abc') },
|
||||
{ name: "list('*')", run: (kv) => kv.list('*') },
|
||||
{ name: "list('k**')", run: (kv) => kv.list('k**') },
|
||||
{ name: "list(' ')", run: (kv) => kv.list(' ') },
|
||||
{ name: "list('abc*', true)", run: (kv) => kv.list('abc*', true) },
|
||||
{ name: "list('abc*', optConfig)", run: (kv) => kv.list('abc*', { appUuid: 'u' }) },
|
||||
{ name: "list('abc*', true, optConfig)", run: (kv) => kv.list('abc*', true, { appUuid: 'u' }) },
|
||||
{ name: 'list(optConfig shorthand object)', run: (kv) => kv.list({ appUuid: 'u' }) },
|
||||
];
|
||||
for ( const form of UNBOUND_LIST_FORMS ) {
|
||||
it(`${form.name}: new pages under the hood, same resolved value`, async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: ['k1', 'k2'] });
|
||||
const oldRun = await capture(OldKV, form.run);
|
||||
const newRun = await capture(NewKV, form.run);
|
||||
|
||||
// A bare-array response ends the paging loop after one request.
|
||||
expect(newRun.bodies).toHaveLength(oldRun.bodies.length);
|
||||
expect(newRun.bodies[0].args).toEqual({
|
||||
...oldRun.bodies[0].args,
|
||||
limit: 1000,
|
||||
fetchUntilFull: true,
|
||||
cursor: null,
|
||||
});
|
||||
expect(newRun.result).toEqual(oldRun.result);
|
||||
expect(newRun.rejectionCode).toEqual(oldRun.rejectionCode);
|
||||
});
|
||||
}
|
||||
|
||||
it("list(pattern, false): old dropped the pattern, new keeps it", async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: [] });
|
||||
const oldRun = await capture(OldKV, (kv) => kv.list('abc*', false));
|
||||
const newRun = await capture(NewKV, (kv) => kv.list('abc*', false));
|
||||
expect(oldRun.bodies[0].args).toEqual({ as: 'keys' });
|
||||
expect(newRun.bodies[0].args).toEqual({ as: 'keys', pattern: 'abc' });
|
||||
expect(newRun.bodies[0].args).toEqual({ as: 'keys', pattern: 'abc', limit: 1000, fetchUntilFull: true, cursor: null });
|
||||
});
|
||||
|
||||
it('list(true, optConfig): old returned keys, new returns pairs', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: [] });
|
||||
const oldRun = await capture(OldKV, (kv) => kv.list(true, { appUuid: 'u' }));
|
||||
const newRun = await capture(NewKV, (kv) => kv.list(true, { appUuid: 'u' }));
|
||||
expect(oldRun.bodies[0].args).toEqual({ as: 'keys', optConfig: { appUuid: 'u' } });
|
||||
expect(newRun.bodies[0].args).toEqual({ optConfig: { appUuid: 'u' } });
|
||||
expect(newRun.bodies[0].args).toEqual({ optConfig: { appUuid: 'u' }, limit: 1000, fetchUntilFull: true, cursor: null });
|
||||
});
|
||||
|
||||
it('destructured get: old threw (unbound method), new works', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KV } from './index.js';
|
||||
|
||||
/**
|
||||
@@ -63,6 +63,7 @@ const origPuter = globalThis.puter;
|
||||
|
||||
let kv;
|
||||
let fakePuter;
|
||||
let warnSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXHR.requests = [];
|
||||
@@ -71,11 +72,13 @@ beforeEach(() => {
|
||||
fakePuter = makeFakePuter();
|
||||
globalThis.puter = fakePuter;
|
||||
kv = new KV(fakePuter);
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.XMLHttpRequest = origXHR;
|
||||
globalThis.puter = origPuter;
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('kv.set driver payloads', () => {
|
||||
@@ -470,62 +473,160 @@ describe('kv.del driver payloads', () => {
|
||||
});
|
||||
|
||||
describe('kv.list driver payloads', () => {
|
||||
it('list() asks for keys only', async () => {
|
||||
// Unbound (non-paginated) forms fetch pages on the caller's behalf, so
|
||||
// their wire args carry the SDK's paging params on top of the base args.
|
||||
const SDK_PAGING_ARGS = { limit: 1000, fetchUntilFull: true, cursor: null };
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: [] });
|
||||
});
|
||||
|
||||
it('list() asks for keys only', async () => {
|
||||
await kv.list();
|
||||
const body = lastBody();
|
||||
expect(body.method).toBe('list');
|
||||
expect(body.args).toEqual({ as: 'keys' });
|
||||
expect(body.args).toEqual({ as: 'keys', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(true) asks for key-value pairs', async () => {
|
||||
await kv.list(true);
|
||||
expect(lastBody().args).toEqual({});
|
||||
expect(lastBody().args).toEqual({ ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern) strips a trailing wildcard', async () => {
|
||||
await kv.list('abc*');
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern) keeps a bare prefix as-is', async () => {
|
||||
await kv.list('abc');
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list("*") matches everything, so no pattern is sent', async () => {
|
||||
await kv.list('*');
|
||||
expect(lastBody().args).toEqual({ as: 'keys' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern) keeps an inner literal * in the prefix', async () => {
|
||||
await kv.list('k**');
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'k*' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'k*', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern, true) asks for pairs matching the pattern', async () => {
|
||||
await kv.list('abc*', true);
|
||||
expect(lastBody().args).toEqual({ pattern: 'abc' });
|
||||
expect(lastBody().args).toEqual({ pattern: 'abc', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern, false) keeps the pattern', async () => {
|
||||
await kv.list('abc*', false);
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc', ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(true, optConfig) asks for pairs with optConfig', async () => {
|
||||
await kv.list(true, { appUuid: 'u' });
|
||||
expect(lastBody().args).toEqual({ optConfig: { appUuid: 'u' } });
|
||||
expect(lastBody().args).toEqual({ optConfig: { appUuid: 'u' }, ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern, optConfig) includes optConfig', async () => {
|
||||
await kv.list('abc*', { appUuid: 'u' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc', optConfig: { appUuid: 'u' } });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', pattern: 'abc', optConfig: { appUuid: 'u' }, ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list(pattern, true, optConfig) sends all three', async () => {
|
||||
await kv.list('abc*', true, { appUuid: 'u' });
|
||||
expect(lastBody().args).toEqual({ pattern: 'abc', optConfig: { appUuid: 'u' } });
|
||||
expect(lastBody().args).toEqual({ pattern: 'abc', optConfig: { appUuid: 'u' }, ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list() follows cursors and concatenates the full listing', async () => {
|
||||
FakeXHR.respondWith = (body) =>
|
||||
body.args.cursor === null
|
||||
? { success: true, result: { items: ['a', 'b'], cursor: 'c2' } }
|
||||
: { success: true, result: { items: ['c'] } };
|
||||
await expect(kv.list()).resolves.toEqual(['a', 'b', 'c']);
|
||||
expect(FakeXHR.requests).toHaveLength(2);
|
||||
expect(JSON.parse(FakeXHR.requests[0].requestBody).args.cursor).toBe(null);
|
||||
expect(JSON.parse(FakeXHR.requests[1].requestBody).args.cursor).toBe('c2');
|
||||
});
|
||||
|
||||
it('list() treats a bare-array response as the complete listing', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: ['a', 'b'] });
|
||||
await expect(kv.list()).resolves.toEqual(['a', 'b']);
|
||||
expect(FakeXHR.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('warns once when a full listing spans multiple pages', async () => {
|
||||
FakeXHR.respondWith = (body) =>
|
||||
body.args.cursor === null
|
||||
? { success: true, result: { items: ['a'], cursor: 'c2' } }
|
||||
: { success: true, result: { items: ['b'] } };
|
||||
await kv.list();
|
||||
await kv.list();
|
||||
const scanWarnings = warnSpy.mock.calls
|
||||
.filter(([msg]) => String(msg).includes('spanned multiple pages'));
|
||||
expect(scanWarnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not warn when the full listing fits in one page', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: { items: ['a'] } });
|
||||
await kv.list();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns once when includeTotal is requested', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: { items: [], total: 0 } });
|
||||
await kv.list({ limit: 1, includeTotal: true });
|
||||
await kv.list({ limit: 1, includeTotal: true });
|
||||
const totalWarnings = warnSpy.mock.calls
|
||||
.filter(([msg]) => String(msg).includes('includeTotal'));
|
||||
expect(totalWarnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('list({ stream: true }) yields envelope pages and follows cursors', async () => {
|
||||
FakeXHR.respondWith = (body) =>
|
||||
body.args.cursor === null
|
||||
? { success: true, result: { items: ['a'], cursor: 'c2', total: 2 } }
|
||||
: { success: true, result: { items: ['b'] } };
|
||||
const pages = [];
|
||||
for await ( const page of kv.list({ stream: true, includeTotal: true }) ) {
|
||||
pages.push(page);
|
||||
}
|
||||
expect(pages).toEqual([
|
||||
{ items: ['a'], cursor: 'c2', total: 2 },
|
||||
{ items: ['b'] },
|
||||
]);
|
||||
// `includeTotal` rides the first request only.
|
||||
expect(JSON.parse(FakeXHR.requests[0].requestBody).args)
|
||||
.toEqual({ as: 'keys', limit: 1000, fetchUntilFull: true, cursor: null, includeTotal: true });
|
||||
expect(JSON.parse(FakeXHR.requests[1].requestBody).args)
|
||||
.toEqual({ as: 'keys', limit: 1000, fetchUntilFull: true, cursor: 'c2' });
|
||||
});
|
||||
|
||||
it('list({ stream: true, limit }) keeps the caller\'s page size', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: { items: ['a'] } });
|
||||
for await ( const page of kv.list({ stream: true, limit: 2, returnValues: true }) ) {
|
||||
expect(page).toEqual({ items: ['a'] });
|
||||
}
|
||||
expect(lastBody().args).toEqual({ limit: 2, cursor: null });
|
||||
});
|
||||
|
||||
it('list({ stream: true, cursor }) resumes from the cursor', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: { items: [] } });
|
||||
for await ( const page of kv.list({ stream: true, cursor: 'c9' }) ) {
|
||||
expect(page).toEqual({ items: [] });
|
||||
}
|
||||
expect(lastBody().args).toEqual({ as: 'keys', limit: 1000, fetchUntilFull: true, cursor: 'c9' });
|
||||
});
|
||||
|
||||
it('list({ stream: true, offset }) rejects client-side', () => {
|
||||
let err;
|
||||
try {
|
||||
kv.list({ stream: true, offset: 1 });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toMatchObject({ code: 'invalid_request' });
|
||||
expect(FakeXHR.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('list(options) copies every pagination option', async () => {
|
||||
@@ -557,7 +658,15 @@ describe('kv.list driver payloads', () => {
|
||||
|
||||
it('list(optConfig) treats an appUuid object as optConfig shorthand', async () => {
|
||||
await kv.list({ appUuid: 'u' });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', optConfig: { appUuid: 'u' } });
|
||||
expect(lastBody().args).toEqual({ as: 'keys', optConfig: { appUuid: 'u' }, ...SDK_PAGING_ARGS });
|
||||
});
|
||||
|
||||
it('list({ appUuid, stream }) strips stream from the optConfig shorthand', async () => {
|
||||
FakeXHR.respondWith = () => ({ success: true, result: { items: [] } });
|
||||
for await ( const page of kv.list({ appUuid: 'u', stream: true }) ) {
|
||||
expect(page).toEqual({ items: [] });
|
||||
}
|
||||
expect(lastBody().args).toEqual({ as: 'keys', optConfig: { appUuid: 'u' }, ...SDK_PAGING_ARGS });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../../lib/pagination.js';
|
||||
import { isObject, isOptConfigShorthand } from './lib/args.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/kv').KVListOptions} KVListOptions */
|
||||
@@ -13,6 +14,29 @@ import { isObject, isOptConfigShorthand } from './lib/args.js';
|
||||
* @typedef {import('../../../types/modules/kv').KVPair<T>} KVPair
|
||||
*/
|
||||
|
||||
// Page size the SDK uses when it pages on the caller's behalf (full listings
|
||||
// and `stream` without an explicit `limit`). A cursor-only request would make
|
||||
// the backend produce the entire listing in one response, so the SDK always
|
||||
// sends a limit when it drives the paging; `fetchUntilFull` keeps pages full
|
||||
// when expired keys are filtered out.
|
||||
const SDK_PAGE_LIMIT = 1000;
|
||||
|
||||
// One-time-per-instance developer nudges: totals and unbounded scans are
|
||||
// metered and their cost grows with the store, so expensive query shapes get
|
||||
// flagged without spamming the console. Keyed on the module instance so each
|
||||
// SDK instance warns independently.
|
||||
const nudgesShown = new WeakMap();
|
||||
const nudgeOnce = (kv, key, message) => {
|
||||
const seen = nudgesShown.get(kv) ?? nudgesShown.set(kv, new Set()).get(kv);
|
||||
if ( seen.has(key) ) return;
|
||||
seen.add(key);
|
||||
try {
|
||||
console.warn(`puter.kv.list: ${message}`);
|
||||
} catch (e) {
|
||||
// console may be unavailable in exotic embeddings
|
||||
}
|
||||
};
|
||||
|
||||
// The wire pattern is a bare prefix: a trailing `*` wildcard is stripped, and
|
||||
// a pattern matching everything (`*`, empty, whitespace) is omitted entirely.
|
||||
const normalizeListPattern = (pattern) => {
|
||||
@@ -57,6 +81,17 @@ const normalizeListPattern = (pattern) => {
|
||||
* @param {KVOptConfig} optConfig
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {KVListOptions & { stream: true, returnValues?: false }} options
|
||||
* @returns {AsyncIterableIterator<KVListPage<string>>}
|
||||
*/
|
||||
/**
|
||||
* @template [T = unknown]
|
||||
* @overload
|
||||
* @param {KVListOptions & { stream: true, returnValues: true }} options
|
||||
* @returns {AsyncIterableIterator<KVListPage<KVPair<T>>>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {KVListOptions & KVListPaginationOptions & { returnValues?: false }} options
|
||||
@@ -83,7 +118,12 @@ const normalizeListPattern = (pattern) => {
|
||||
* Lists keys in the store for the current app, sorted lexicographically.
|
||||
* Returns just the keys, `KVPair` objects when `returnValues` is `true`, or
|
||||
* a `KVListPage` when any pagination option (`limit`, `cursor`, `offset`,
|
||||
* `includeTotal`, `fetchUntilFull`) is used.
|
||||
* `includeTotal`, `fetchUntilFull`) is used. With `stream: true` it instead
|
||||
* returns an async iterator of `KVListPage`s for `for await ... of`.
|
||||
* Full (non-paginated) listings are fetched page by page under the hood,
|
||||
* but still read the entire store — every page is metered, so prefer
|
||||
* `stream`/`limit` with a narrow `pattern` on large stores. `includeTotal`
|
||||
* is likewise a metered count over every matching key.
|
||||
*
|
||||
* Documented forms:
|
||||
* list()
|
||||
@@ -96,16 +136,20 @@ const normalizeListPattern = (pattern) => {
|
||||
*
|
||||
* @this {import('./index.js').KVModule}
|
||||
* @param {string | boolean
|
||||
* | (KVListOptions & Partial<KVListPaginationOptions> & { returnValues?: boolean })
|
||||
* | (KVListOptions & Partial<KVListPaginationOptions> & { returnValues?: boolean, stream?: boolean })
|
||||
* | (KVListOptions & { [key: string]: unknown })} [patternOrOptions]
|
||||
* @param {boolean | KVOptConfig} [returnValuesOrOptConfig]
|
||||
* @param {KVOptConfig} [maybeOptConfig]
|
||||
* @returns {Promise<string[] | KVPair[] | KVListPage>}
|
||||
* @returns {Promise<string[] | KVPair[] | KVListPage> | AsyncIterableIterator<KVListPage>}
|
||||
*/
|
||||
export async function list (patternOrOptions, returnValuesOrOptConfig, maybeOptConfig) {
|
||||
export function list (patternOrOptions, returnValuesOrOptConfig, maybeOptConfig) {
|
||||
const options = {};
|
||||
let pattern;
|
||||
let returnValues = false;
|
||||
let stream = false;
|
||||
let cursor;
|
||||
let includeTotal = false;
|
||||
let paginated = false;
|
||||
|
||||
const isOptionsObject =
|
||||
isObject(patternOrOptions) &&
|
||||
@@ -118,16 +162,26 @@ export async function list (patternOrOptions, returnValuesOrOptConfig, maybeOptC
|
||||
pattern = input.pattern;
|
||||
}
|
||||
returnValues = !!input.returnValues;
|
||||
stream = input.stream === true;
|
||||
if ( isObject(input.optConfig) ) {
|
||||
options.optConfig = input.optConfig;
|
||||
} else if ( isOptConfigShorthand(input) ) {
|
||||
options.optConfig = input;
|
||||
if ( stream ) {
|
||||
const optConfig = { ...input };
|
||||
delete optConfig.stream;
|
||||
options.optConfig = optConfig;
|
||||
} else {
|
||||
options.optConfig = input;
|
||||
}
|
||||
}
|
||||
for ( const name of ['limit', 'cursor', 'offset', 'includeTotal', 'fetchUntilFull'] ) {
|
||||
if ( input[name] !== undefined ) {
|
||||
options[name] = input[name];
|
||||
paginated = true;
|
||||
}
|
||||
}
|
||||
cursor = input.cursor;
|
||||
includeTotal = input.includeTotal === true;
|
||||
} else {
|
||||
if ( typeof patternOrOptions === 'string' ) {
|
||||
pattern = patternOrOptions;
|
||||
@@ -155,5 +209,45 @@ export async function list (patternOrOptions, returnValuesOrOptConfig, maybeOptC
|
||||
options.pattern = normalizedPattern;
|
||||
}
|
||||
|
||||
return await utils.make_driver_method([], 'puter-kvstore', undefined, 'list', { puter: this.puter })(options);
|
||||
if ( includeTotal ) {
|
||||
nudgeOnce(this, 'includeTotal', '`includeTotal` runs a metered count over every key matching the query, so its cost grows with the store. Request the total once — on the first page — and avoid it in hot paths; to know whether more pages exist, check for `cursor` instead.');
|
||||
}
|
||||
|
||||
const callList = utils.make_driver_method([], 'puter-kvstore', undefined, 'list', { puter: this.puter });
|
||||
|
||||
if ( stream ) {
|
||||
if ( options.offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
const base = { ...options };
|
||||
delete base.cursor;
|
||||
delete base.includeTotal;
|
||||
if ( base.limit === undefined ) {
|
||||
base.limit = SDK_PAGE_LIMIT;
|
||||
base.fetchUntilFull = true;
|
||||
}
|
||||
const fetchPage = pageParams => callList({ ...base, ...pageParams });
|
||||
return iteratePages(fetchPage, { cursor, includeTotal });
|
||||
}
|
||||
|
||||
// Any pagination option keeps the single-request behavior: one
|
||||
// `KVListPage` exactly as the backend returns it.
|
||||
if ( paginated ) {
|
||||
return callList(options);
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single
|
||||
// request carries the whole result, then return the legacy array.
|
||||
const fetchPage = pageParams => {
|
||||
if ( pageParams.cursor !== null ) {
|
||||
nudgeOnce(this, 'unbound-scan', 'a full listing spanned multiple pages; unbounded scans are metered and get slower as the store grows. Prefer `stream: true`, `limit`/`cursor` pages, or a narrower `pattern`.');
|
||||
}
|
||||
return callList({
|
||||
...options,
|
||||
limit: SDK_PAGE_LIMIT,
|
||||
fetchUntilFull: true,
|
||||
...pageParams,
|
||||
});
|
||||
};
|
||||
return fetchAllPages(fetchPage);
|
||||
}
|
||||
|
||||
@@ -86,16 +86,14 @@ const setBatch = (puter, args) =>
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
/**
|
||||
* @template [T = KVScalar]
|
||||
* @overload
|
||||
* @param {KVSetItem<T>[]} items
|
||||
* @param {KVSetItem[]} items
|
||||
* @param {KVOptConfig} [optConfig]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
/**
|
||||
* @template [T = KVScalar]
|
||||
* @overload
|
||||
* @param {KVSetBatch<T>} batch
|
||||
* @param {KVSetBatch} batch
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
/**
|
||||
|
||||
@@ -1003,6 +1003,202 @@ window.kvTests = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListIncludeTotalWarnsOnce",
|
||||
description: "Test that includeTotal returns a numeric total and logs its cost warning exactly once per page load (reload before re-running)",
|
||||
test: async function() {
|
||||
try {
|
||||
const prefix = 'listTotalWarn-' + puter.randName() + '-';
|
||||
await puter.kv.set(prefix + 'a', 1);
|
||||
await puter.kv.set(prefix + 'b', 1);
|
||||
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
warnings.push(args.join(' '));
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
let firstPage;
|
||||
try {
|
||||
firstPage = await puter.kv.list({ pattern: prefix + '*', limit: 1, includeTotal: true });
|
||||
await puter.kv.list({ pattern: prefix + '*', limit: 1, includeTotal: true });
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert(typeof firstPage.total === 'number' && firstPage.total >= 2,
|
||||
"Expected a numeric total >= 2, got: " + firstPage.total);
|
||||
const totalWarnings = warnings.filter(w => w.includes('includeTotal'));
|
||||
assert(totalWarnings.length === 1,
|
||||
"Expected exactly one includeTotal warning, got: " + totalWarnings.length +
|
||||
" (the nudge fires once per page load — reload before re-running)");
|
||||
pass("testListIncludeTotalWarnsOnce passed");
|
||||
} catch (error) {
|
||||
fail("testListIncludeTotalWarnsOnce failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListStreamPages",
|
||||
description: "Test list({ stream: true, limit }) yields pages directly in a for await ... of loop",
|
||||
test: async function() {
|
||||
try {
|
||||
const prefix = 'listStream-' + puter.randName() + '-';
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await puter.kv.set(prefix + i, 'v' + i);
|
||||
}
|
||||
const seen = [];
|
||||
let pages = 0;
|
||||
for await (const page of puter.kv.list({ pattern: prefix + '*', limit: 2, stream: true })) {
|
||||
pages++;
|
||||
assert(Array.isArray(page.items), "Stream page is missing an items array");
|
||||
assert(page.items.length <= 2, "Stream page exceeded the limit: " + page.items.length);
|
||||
for (const item of page.items) seen.push(item);
|
||||
}
|
||||
assert(pages >= 2, "Expected multiple stream pages, got: " + pages);
|
||||
assert(seen.length === 5, "Expected 5 keys across stream pages, got: " + seen.length);
|
||||
pass("testListStreamPages passed");
|
||||
} catch (error) {
|
||||
fail("testListStreamPages failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListStreamAwaitedForm",
|
||||
description: "Test that awaiting list({ stream: true }) first (puter.ai.chat style) also yields an iterable of pages",
|
||||
test: async function() {
|
||||
try {
|
||||
const prefix = 'listStreamAwait-' + puter.randName() + '-';
|
||||
await puter.kv.set(prefix + 'a', 1);
|
||||
await puter.kv.set(prefix + 'b', 2);
|
||||
const iterator = await puter.kv.list({ pattern: prefix + '*', limit: 1, stream: true, returnValues: true });
|
||||
const seen = [];
|
||||
for await (const page of iterator) {
|
||||
for (const item of page.items) seen.push(item.key);
|
||||
}
|
||||
assert(seen.length === 2, "Expected 2 pairs across pages, got: " + seen.length);
|
||||
pass("testListStreamAwaitedForm passed");
|
||||
} catch (error) {
|
||||
fail("testListStreamAwaitedForm failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListStreamIncludeTotalFirstPageOnly",
|
||||
description: "Test that a stream with includeTotal carries total on the first page only",
|
||||
test: async function() {
|
||||
try {
|
||||
const prefix = 'listStreamTotal-' + puter.randName() + '-';
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await puter.kv.set(prefix + i, 'v' + i);
|
||||
}
|
||||
let firstTotal;
|
||||
const laterTotals = [];
|
||||
let pages = 0;
|
||||
for await (const page of puter.kv.list({ pattern: prefix + '*', limit: 1, stream: true, includeTotal: true })) {
|
||||
if (pages === 0) firstTotal = page.total;
|
||||
else laterTotals.push(page.total);
|
||||
pages++;
|
||||
}
|
||||
assert(pages >= 2, "Expected multiple pages, got: " + pages);
|
||||
assert(typeof firstTotal === 'number' && firstTotal >= 3,
|
||||
"Expected a numeric total >= 3 on the first page, got: " + firstTotal);
|
||||
assert(laterTotals.every(t => t === undefined),
|
||||
"Later pages should not carry a total, got: " + JSON.stringify(laterTotals));
|
||||
pass("testListStreamIncludeTotalFirstPageOnly passed");
|
||||
} catch (error) {
|
||||
fail("testListStreamIncludeTotalFirstPageOnly failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListStreamResumesFromCursor",
|
||||
description: "Test that a stream started from a previous page's cursor covers exactly the remaining keys",
|
||||
test: async function() {
|
||||
try {
|
||||
const prefix = 'listStreamResume-' + puter.randName() + '-';
|
||||
const created = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
created.push(prefix + i);
|
||||
await puter.kv.set(prefix + i, 'v' + i);
|
||||
}
|
||||
const first = await puter.kv.list({ pattern: prefix + '*', limit: 2 });
|
||||
assert(first.cursor, "Expected a cursor on the first page");
|
||||
const seen = first.items.slice();
|
||||
for await (const page of puter.kv.list({ pattern: prefix + '*', limit: 2, stream: true, cursor: first.cursor })) {
|
||||
for (const item of page.items) seen.push(item);
|
||||
}
|
||||
assert(seen.length === 4, "Expected 4 keys in total, got: " + seen.length);
|
||||
assert(new Set(seen).size === 4, "Resumed stream repeated keys: " + JSON.stringify(seen));
|
||||
assert(created.every(k => seen.includes(k)), "Missing keys after resume: " + JSON.stringify(seen));
|
||||
pass("testListStreamResumesFromCursor passed");
|
||||
} catch (error) {
|
||||
fail("testListStreamResumesFromCursor failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListStreamRejectsOffset",
|
||||
description: "Test that list({ stream: true, offset }) throws invalid_request synchronously without a request",
|
||||
test: async function() {
|
||||
try {
|
||||
let threw = null;
|
||||
try {
|
||||
puter.kv.list({ stream: true, offset: 1 });
|
||||
} catch (error) {
|
||||
threw = error;
|
||||
}
|
||||
assert(threw, "Expected a synchronous throw for stream + offset");
|
||||
assert(threw.code === 'invalid_request',
|
||||
"Expected code 'invalid_request', got: " + (threw && threw.code));
|
||||
pass("testListStreamRejectsOffset passed");
|
||||
} catch (error) {
|
||||
fail("testListStreamRejectsOffset failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListUnboundScanWarnsOnMultiplePages",
|
||||
description: "SLOW: seeds ~1050 keys, checks a full listing pages under the hood and logs its scan warning once per page load, then flushes the store",
|
||||
test: async function() {
|
||||
try {
|
||||
const prefix = 'listBig-' + puter.randName() + '-';
|
||||
const total = 1050;
|
||||
for (let start = 0; start < total; start += 100) {
|
||||
const items = [];
|
||||
for (let i = start; i < Math.min(start + 100, total); i++) {
|
||||
items.push({ key: prefix + String(i).padStart(4, '0'), value: 1 });
|
||||
}
|
||||
await puter.kv.set(items);
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
warnings.push(args.join(' '));
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
let keys;
|
||||
try {
|
||||
keys = await puter.kv.list(prefix + '*');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert(Array.isArray(keys), "Unbound list() should still resolve to a plain array");
|
||||
assert(keys.length === total, "Expected " + total + " keys, got: " + keys.length);
|
||||
const scanWarnings = warnings.filter(w => w.includes('spanned multiple pages'));
|
||||
assert(scanWarnings.length === 1,
|
||||
"Expected exactly one unbounded-scan warning, got: " + scanWarnings.length +
|
||||
" (the nudge fires once per page load — reload before re-running)");
|
||||
|
||||
await puter.kv.flush();
|
||||
pass("testListUnboundScanWarnsOnMultiplePages passed");
|
||||
} catch (error) {
|
||||
fail("testListUnboundScanWarnsOnMultiplePages failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testClearAlias",
|
||||
description: "Test that clear() is the same function as flush() and empties the store",
|
||||
|
||||
@@ -97,6 +97,28 @@ export default suite('apps', {
|
||||
}
|
||||
},
|
||||
|
||||
'list with stream iterates pages via for await': async (t) => {
|
||||
const names = ['apps-suite-st-a', 'apps-suite-st-b', 'apps-suite-st-c'];
|
||||
for (const name of names) {
|
||||
await t.puter.apps.create(name, `https://example.com/${name}`);
|
||||
}
|
||||
|
||||
const seen: string[] = [];
|
||||
let pages = 0;
|
||||
for await (const page of t.puter.apps.list({ stream: true, limit: 2 }) as AsyncIterable<{
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
}>) {
|
||||
pages++;
|
||||
t.assert.ok(page.items.length <= 2, 'stream pages respect limit');
|
||||
seen.push(...page.items.map((a) => a.name));
|
||||
}
|
||||
t.assert.ok(pages >= 2, 'stream should yield multiple pages');
|
||||
for (const name of names) {
|
||||
t.assert.ok(seen.includes(name), `${name} should appear while streaming`);
|
||||
}
|
||||
},
|
||||
|
||||
'update changes the index URL': async (t) => {
|
||||
await t.puter.apps.create(
|
||||
'apps-suite-update',
|
||||
|
||||
@@ -199,6 +199,28 @@ export default suite('fs', {
|
||||
t.assert.equal(page.total, 3);
|
||||
},
|
||||
|
||||
'readdir with stream iterates pages via for await': async (t) => {
|
||||
const dir = `${home(t)}/fs-suite-page-stream`;
|
||||
await t.puter.fs.mkdir(dir);
|
||||
const names = ['a.txt', 'b.txt', 'c.txt'];
|
||||
for (const n of names) {
|
||||
await t.puter.fs.write(`${dir}/${n}`, 'x');
|
||||
}
|
||||
const seen: string[] = [];
|
||||
let pages = 0;
|
||||
for await (const page of t.puter.fs.readdir({
|
||||
path: dir,
|
||||
limit: 2,
|
||||
stream: true,
|
||||
}) as AsyncIterable<{ items: Array<{ name: string }>; cursor?: string }>) {
|
||||
pages++;
|
||||
t.assert.ok(page.items.length <= 2, 'stream pages respect limit');
|
||||
seen.push(...page.items.map((e) => e.name));
|
||||
}
|
||||
t.assert.ok(pages >= 2, 'stream should yield multiple pages');
|
||||
t.assert.deepEqual(seen, names);
|
||||
},
|
||||
|
||||
'readdir cursor respects descending name sort': async (t) => {
|
||||
const dir = `${home(t)}/fs-suite-page-desc`;
|
||||
await t.puter.fs.mkdir(dir);
|
||||
|
||||
@@ -101,6 +101,29 @@ export default suite('hosting', {
|
||||
}
|
||||
},
|
||||
|
||||
'list with stream iterates pages via for await': async (t) => {
|
||||
const names = ['hosting-suite-st-a', 'hosting-suite-st-b', 'hosting-suite-st-c'];
|
||||
for (const name of names) {
|
||||
const dir = await makeSiteDir(t, `st-${name.slice(-1)}`);
|
||||
await t.puter.hosting.create(name, dir);
|
||||
}
|
||||
|
||||
const seen: string[] = [];
|
||||
let pages = 0;
|
||||
for await (const page of t.puter.hosting.list({ stream: true, limit: 2 }) as AsyncIterable<{
|
||||
items: Array<{ subdomain: string }>;
|
||||
cursor?: string;
|
||||
}>) {
|
||||
pages++;
|
||||
t.assert.ok(page.items.length <= 2, 'stream pages respect limit');
|
||||
seen.push(...page.items.map((s) => s.subdomain));
|
||||
}
|
||||
t.assert.ok(pages >= 2, 'stream should yield multiple pages');
|
||||
for (const name of names) {
|
||||
t.assert.ok(seen.includes(name), `${name} should appear while streaming`);
|
||||
}
|
||||
},
|
||||
|
||||
'a subdomain serves its root directory': async (t) => {
|
||||
const dir = await makeSiteDir(
|
||||
t,
|
||||
|
||||
@@ -260,6 +260,39 @@ export default suite('kv', {
|
||||
]);
|
||||
},
|
||||
|
||||
'list with stream iterates pages via for await': async (t) => {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await t.puter.kv.set(`kv-suite-stream-${i}`, `v${i}`);
|
||||
}
|
||||
const seen: string[] = [];
|
||||
let pages = 0;
|
||||
for await (const page of t.puter.kv.list({
|
||||
pattern: 'kv-suite-stream-*',
|
||||
limit: 2,
|
||||
stream: true,
|
||||
}) as AsyncIterable<{ items: string[]; cursor?: string }>) {
|
||||
pages++;
|
||||
t.assert.ok(page.items.length <= 2, 'stream pages respect limit');
|
||||
seen.push(...page.items);
|
||||
}
|
||||
t.assert.ok(pages >= 2, 'stream should yield multiple pages');
|
||||
t.assert.deepEqual(seen.sort(), [
|
||||
'kv-suite-stream-1',
|
||||
'kv-suite-stream-2',
|
||||
'kv-suite-stream-3',
|
||||
]);
|
||||
},
|
||||
|
||||
'list with stream rejects offset client-side': async (t) => {
|
||||
let err: { code?: string } | undefined;
|
||||
try {
|
||||
t.puter.kv.list({ stream: true, offset: 1 } as never);
|
||||
} catch (e) {
|
||||
err = e as { code?: string };
|
||||
}
|
||||
t.assert.equal(err?.code, 'invalid_request');
|
||||
},
|
||||
|
||||
'clear is an alias of flush and empties the store': async (t) => {
|
||||
await t.puter.kv.set('kv-suite-clear-a', 1);
|
||||
await t.puter.kv.clear();
|
||||
|
||||
@@ -125,6 +125,28 @@ export default suite('workers', {
|
||||
t.assert.ok((total ?? 0) >= 2, 'total should count deployed workers');
|
||||
},
|
||||
|
||||
'list with stream iterates pages via for await': async (t) => {
|
||||
await deployWorker(t, 'workers-suite-st-a');
|
||||
await deployWorker(t, 'workers-suite-st-b');
|
||||
|
||||
const seen: string[] = [];
|
||||
let pages = 0;
|
||||
for await (const page of t.puter.workers.list({ stream: true, limit: 1 }) as AsyncIterable<{
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
}>) {
|
||||
pages++;
|
||||
t.assert.ok(page.items.length <= 1, 'stream pages respect limit');
|
||||
seen.push(...page.items.map((w) => w.name));
|
||||
}
|
||||
t.assert.ok(pages >= 2, 'stream should yield multiple pages');
|
||||
t.assert.ok(
|
||||
seen.includes('workers-suite-st-a') &&
|
||||
seen.includes('workers-suite-st-b'),
|
||||
'both deployed workers should appear while streaming',
|
||||
);
|
||||
},
|
||||
|
||||
'delete removes the worker': async (t) => {
|
||||
await deployWorker(t, 'workers-suite-delete');
|
||||
const deleted = await t.puter.workers.delete('workers-suite-delete');
|
||||
|
||||
Vendored
+9
-4
@@ -1,4 +1,4 @@
|
||||
import type { RequestCallbacks } from '../shared.d.ts';
|
||||
import type { ListPage, ListPaginationOptions, ListStreamOptions, RequestCallbacks } from '../shared.d.ts';
|
||||
|
||||
/** A user of an app, as returned by `App.users()` and `App.getUsers()`. */
|
||||
export interface AppUser {
|
||||
@@ -179,10 +179,15 @@ export interface CheckAppNameResult {
|
||||
/** Create, manage, and interact with applications in the Puter ecosystem. */
|
||||
export class Apps {
|
||||
/**
|
||||
* Returns all apps belonging to the user that this app has access to.
|
||||
* Resolves to an empty array if the user has no apps.
|
||||
* Returns all apps belonging to the user that this app has access to,
|
||||
* fetching page by page under the hood. Resolves to an empty array if
|
||||
* the user has no apps. With `stream: true` it instead returns an async
|
||||
* iterator of pages for `for await ... of`; with `cursor` (even `null`),
|
||||
* `offset`, or `includeTotal` it resolves to a single page envelope.
|
||||
*/
|
||||
list (options?: AppListOptions): Promise<App[]>;
|
||||
list (options: AppListOptions & ListStreamOptions): AsyncIterableIterator<ListPage<App>>;
|
||||
list (options: AppListOptions & ListPaginationOptions & ({ cursor: string | null } | { offset: number } | { includeTotal: true })): Promise<ListPage<App>>;
|
||||
list (options?: AppListOptions & { limit?: number }): Promise<App[]>;
|
||||
/**
|
||||
* Creates a Puter app with the given name. The app name must be unique to
|
||||
* the user's apps; if one already exists the promise is rejected. `indexURL`
|
||||
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import type { RequestCallbacks } from '../shared.d.ts';
|
||||
import type { ListPage, ListStreamOptions, RequestCallbacks } from '../shared.d.ts';
|
||||
import type { FSItem } from './fs-item.d.ts';
|
||||
|
||||
/**
|
||||
@@ -98,6 +98,14 @@ export interface ReaddirOptions extends RequestCallbacks<FSItem[]> {
|
||||
no_thumbs?: boolean;
|
||||
no_assocs?: boolean;
|
||||
consistency?: 'strong' | 'eventual';
|
||||
/** Maximum number of entries to return. */
|
||||
limit?: number;
|
||||
/** Skips the given number of entries. Prefer `cursor` for paging through large directories. */
|
||||
offset?: number;
|
||||
/** Sort field. Default is `name`. */
|
||||
sortBy?: 'name' | 'modified' | 'type' | 'size';
|
||||
/** Sort direction. Default is `asc`. */
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,6 +246,8 @@ export class FS {
|
||||
* (files and directories) within the specified directory.
|
||||
* If `path` is not absolute, it is resolved relative to the app's root directory.
|
||||
*/
|
||||
readdir (options: ReaddirOptions & ListStreamOptions): AsyncIterableIterator<ListPage<FSItem>>;
|
||||
readdir (options: ReaddirOptions & { includeTotal?: boolean } & ({ cursor: string | null } | { includeTotal: true })): Promise<ListPage<FSItem>>;
|
||||
readdir (options: ReaddirOptions): Promise<FSItem[]>;
|
||||
readdir (path: string, success?: (value: FSItem[]) => void, error?: (reason: unknown) => void): Promise<FSItem[]>;
|
||||
|
||||
|
||||
+9
-3
@@ -1,4 +1,5 @@
|
||||
import type { FSItem } from './fs-item.d.ts';
|
||||
import type { ListPage, ListPaginationOptions, ListStreamOptions } from '../shared.d.ts';
|
||||
|
||||
/** A subdomain hosted on Puter, containing its details. */
|
||||
export interface Subdomain {
|
||||
@@ -13,10 +14,15 @@ export interface Subdomain {
|
||||
/** Deploy and manage websites on Puter by hosting directories under subdomains. */
|
||||
export class Hosting {
|
||||
/**
|
||||
* Lists all subdomains belonging to the user that this app has access to.
|
||||
* Resolves to an empty array if the user has no subdomains.
|
||||
* Lists all subdomains belonging to the user that this app has access
|
||||
* to, fetching page by page under the hood. Resolves to an empty array
|
||||
* if the user has no subdomains. With `stream: true` it instead returns
|
||||
* an async iterator of pages for `for await ... of`; with `cursor` (even
|
||||
* `null`) or `includeTotal` it resolves to a single page envelope.
|
||||
*/
|
||||
list (): Promise<Subdomain[]>;
|
||||
list (options: ListStreamOptions): AsyncIterableIterator<ListPage<Subdomain>>;
|
||||
list (options: ListPaginationOptions & ({ cursor: string | null } | { includeTotal: true })): Promise<ListPage<Subdomain>>;
|
||||
list (options?: { limit?: number; offset?: number }): Promise<Subdomain[]>;
|
||||
|
||||
/**
|
||||
* Creates a new subdomain served by the hosting service from the given directory.
|
||||
|
||||
Vendored
+23
-4
@@ -97,8 +97,10 @@ export interface KVListOptions {
|
||||
offset?: number;
|
||||
/**
|
||||
* When `true`, the result includes a `total` count of every item matching
|
||||
* the query across all pages. Computing the total costs more the more
|
||||
* items there are, so request it on the first page only.
|
||||
* the query across all pages. The count is metered and its cost grows
|
||||
* with the store — request it once (on the first page) and avoid it in
|
||||
* hot paths; to know whether more pages exist, check for `cursor`
|
||||
* instead.
|
||||
*/
|
||||
includeTotal?: boolean;
|
||||
/**
|
||||
@@ -114,6 +116,16 @@ export type KVListPaginationOptions =
|
||||
| { limit: number; cursor?: string }
|
||||
| { cursor: string; limit?: number };
|
||||
|
||||
/**
|
||||
* The `stream: true` form of `list()`: returns an async iterator of
|
||||
* `KVListPage`s for `for await ... of` instead of a promise. Cannot be
|
||||
* combined with `offset`; pass `cursor` to resume from a position.
|
||||
*/
|
||||
export interface KVListStreamOptions {
|
||||
/** Stream page envelopes as they are fetched. */
|
||||
stream: true;
|
||||
}
|
||||
|
||||
/** A page of paginated results from `list()` when `limit` or `cursor` is used. */
|
||||
export interface KVListPage<T = unknown> {
|
||||
/** The keys (or `KVPair` objects when `returnValues` is `true`) for this page. */
|
||||
@@ -155,8 +167,8 @@ export class KV {
|
||||
/** @param expireAt - Timestamp, in seconds, at which the key should expire. */
|
||||
set<T = KVScalar>(key: string, value: T, expireAt?: number, optConfig?: KVOptConfig): Promise<boolean>;
|
||||
set<T = KVScalar>(item: KVSetObject<T>): Promise<boolean>;
|
||||
set<T = KVScalar>(items: KVSetItem<T>[], optConfig?: KVOptConfig): Promise<boolean>;
|
||||
set<T = KVScalar>(batch: KVSetBatch<T>): Promise<boolean>;
|
||||
set(items: KVSetItem[], optConfig?: KVOptConfig): Promise<boolean>;
|
||||
set(batch: KVSetBatch): Promise<boolean>;
|
||||
/** Returns the key's value, or `undefined` if the key does not exist. */
|
||||
get<T = unknown>(key: string, optConfig?: KVOptConfig): Promise<T | undefined>;
|
||||
/**
|
||||
@@ -220,6 +232,11 @@ export class KV {
|
||||
* Lists keys in the store for the current app, sorted lexicographically by
|
||||
* key. Returns just the keys, an array of `KVPair` objects when
|
||||
* `returnValues` is `true`, or a `KVListPage` when `limit`/`cursor` is used.
|
||||
* With `stream: true` it instead returns an async iterator of
|
||||
* `KVListPage`s for `for await ... of`. Full (non-paginated) listings are
|
||||
* fetched page by page under the hood, but still read the entire store —
|
||||
* every page is metered, so prefer `stream`/`limit` with a narrow
|
||||
* `pattern` on large stores.
|
||||
* @param pattern - Prefix-based key filter with an optional trailing `*`
|
||||
* wildcard. Defaults to `*`, matching all keys.
|
||||
*/
|
||||
@@ -229,6 +246,8 @@ export class KV {
|
||||
list (pattern: string, returnValues: boolean, optConfig: KVOptConfig): Promise<string[] | KVPair<unknown>[]>;
|
||||
list (pattern: string, optConfig: KVOptConfig): Promise<string[]>;
|
||||
list<T = unknown>(returnValues: true, optConfig: KVOptConfig): Promise<KVPair<T>[]>;
|
||||
list (options: KVListOptions & KVListStreamOptions & { returnValues?: false }): AsyncIterableIterator<KVListPage<string>>;
|
||||
list<T = unknown>(options: KVListOptions & KVListStreamOptions & { returnValues: true }): AsyncIterableIterator<KVListPage<KVPair<T>>>;
|
||||
list (options: KVListOptions & KVListPaginationOptions & { returnValues?: false }): Promise<KVListPage<string>>;
|
||||
list<T = unknown>(options: KVListOptions & KVListPaginationOptions & { returnValues: true }): Promise<KVListPage<KVPair<T>>>;
|
||||
list (options: KVListOptions & { returnValues?: false }): Promise<string[]>;
|
||||
|
||||
+10
-1
@@ -1,3 +1,5 @@
|
||||
import type { ListPage, ListPaginationOptions, ListStreamOptions } from '../shared.d.ts';
|
||||
|
||||
/** Information about a deployed worker, as returned by `get()` and `list()`. */
|
||||
export interface WorkerInfo {
|
||||
/** The name of the worker. */
|
||||
@@ -50,7 +52,14 @@ export class WorkersHandler {
|
||||
exec (request: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
/** Gets information for a specific worker, or `undefined` if it does not exist. */
|
||||
get (workerName: string): Promise<WorkerInfo | undefined>;
|
||||
/** Lists all workers in your account with their details. */
|
||||
/**
|
||||
* Lists all workers in your account with their details, fetching page by
|
||||
* page under the hood. With `stream: true` it instead returns an async
|
||||
* iterator of pages for `for await ... of`; with any pagination option
|
||||
* it resolves to a single page envelope.
|
||||
*/
|
||||
list (options: ListStreamOptions): AsyncIterableIterator<ListPage<WorkerInfo>>;
|
||||
list (options: ListPaginationOptions & ({ limit: number } | { offset: number } | { cursor: string | null } | { includeTotal: true })): Promise<ListPage<WorkerInfo>>;
|
||||
list (): Promise<WorkerInfo[]>;
|
||||
getLoggingHandle (workerName: string): Promise<EventTarget & {
|
||||
close: () => void;
|
||||
|
||||
Vendored
+51
@@ -22,6 +22,57 @@ export interface PaginationOptions {
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard pagination request params shared by list APIs
|
||||
* (`puter.apps.list()`, `puter.hosting.list()`, `puter.workers.list()`,
|
||||
* `puter.fs.readdir()`).
|
||||
*/
|
||||
export interface ListPaginationOptions {
|
||||
/** Maximum items per page. Each endpoint documents its cap and default. */
|
||||
limit?: number;
|
||||
/**
|
||||
* Skips the given number of items. Cannot be combined with `cursor` or
|
||||
* `stream`; prefer `cursor` — requests get slower and more expensive the
|
||||
* larger the offset.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* Opaque continuation cursor. Pass `null` for the first page, then each
|
||||
* page's `cursor` to fetch the next one.
|
||||
*/
|
||||
cursor?: string | null;
|
||||
/**
|
||||
* When `true`, the result includes a `total` count of every item across
|
||||
* all pages.
|
||||
*/
|
||||
includeTotal?: boolean;
|
||||
}
|
||||
|
||||
/** One page of a paginated listing. */
|
||||
export interface ListPage<T> {
|
||||
/** The items on this page. A page may hold fewer than `limit` items while more pages exist. */
|
||||
items: T[];
|
||||
/** Present only while more pages exist; pass it to the next call to resume. */
|
||||
cursor?: string;
|
||||
/** Total item count across all pages; present when requested via `includeTotal`. */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `stream: true` form of list methods: returns an async iterator of
|
||||
* pages for `for await ... of` instead of a promise.
|
||||
*/
|
||||
export interface ListStreamOptions {
|
||||
/** Stream page envelopes as they are fetched. */
|
||||
stream: true;
|
||||
/** Maximum items per page. Defaults to the endpoint's page size. */
|
||||
limit?: number;
|
||||
/** Start streaming from a previous page's `cursor` instead of the beginning. */
|
||||
cursor?: string | null;
|
||||
/** Include a `total` count on the first streamed page. */
|
||||
includeTotal?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
page?: number;
|
||||
|
||||
Reference in New Issue
Block a user