Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions lib/docs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,38 @@ export function consumerDomainRejected(domain: string): Response {
);
}

/** 403 quota_exceeded — count or storage cap hit. */
/**
* A count cap is 403 quota_exceeded: the account is not allowed another document
* or grant. Storage is 413 storage_quota_exceeded: nothing is forbidden, the
* request is too large for the space left. The storage case carries the numbers
* (in use, cap, headroom) and names the one thing that reliably reclaims space,
* because a caller reading "403" goes looking for a permissions problem and
* retries a write that cannot succeed.
*/
export function quotaExceeded(
kind: "doc_count" | "storage" | "grants",
limit: number,
current: number
): Response {
if (kind === "storage") {
const headroom = Math.max(0, limit - current);
return apiError(
413,
"storage_quota_exceeded",
`This write would take the account past its storage limit. ${current} bytes in use against a ${limit}-byte cap, so ${headroom} bytes of headroom. Retained version history is almost always what fills this quota, and there is no version-delete API: publishing the current HTML as a new document resets that document's history to one snapshot.`,
{
limit: kind,
limit_value: limit,
current,
headroom,
suggestions: ["new_document", "delete_documents", "reduce_html"],
}
);
}
const msg =
kind === "doc_count"
? "You have reached the maximum number of documents."
: kind === "grants"
? "This document has reached the maximum number of grants. Revoke an existing grant before adding another."
: "You have reached your total storage limit.";
: "This document has reached the maximum number of grants. Revoke an existing grant before adding another.";
return apiError(403, "quota_exceeded", msg, { limit: kind, limit_value: limit, current });
}

Expand Down
44 changes: 44 additions & 0 deletions lib/docs/quota.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { quotaExceeded } from "./api";
import { MAX_STORAGE_BYTES_PER_USER } from "./config";

async function body(res: Response) {
return (await res.json()) as Record<string, unknown>;
}

describe("quotaExceeded", () => {
it("answers a storage cap with 413 and the numbers, not 403", async () => {
const current = MAX_STORAGE_BYTES_PER_USER - 1024;
const res = quotaExceeded("storage", MAX_STORAGE_BYTES_PER_USER, current);
expect(res.status).toBe(413);
const b = await body(res);
expect(b.error).toBe("storage_quota_exceeded");
expect(b.limit).toBe("storage");
expect(b.limit_value).toBe(MAX_STORAGE_BYTES_PER_USER);
expect(b.current).toBe(current);
expect(b.headroom).toBe(1024);
expect(b.suggestions).toContain("new_document");
});

it("says how to reclaim the space, because there is no version-delete API", async () => {
const res = quotaExceeded("storage", MAX_STORAGE_BYTES_PER_USER, MAX_STORAGE_BYTES_PER_USER * 2);
const b = await body(res);
expect(String(b.message)).toContain("version-delete");
// over the cap: headroom floors at zero rather than going negative
expect(b.headroom).toBe(0);
});

it("keeps a count cap as 403 quota_exceeded", async () => {
const res = quotaExceeded("doc_count", 500, 500);
expect(res.status).toBe(403);
const b = await body(res);
expect(b.error).toBe("quota_exceeded");
expect(b.limit).toBe("doc_count");
});

it("keeps a grant cap as 403 quota_exceeded", async () => {
const res = quotaExceeded("grants", 50, 50);
expect(res.status).toBe(403);
expect((await body(res)).error).toBe("quota_exceeded");
});
});
19 changes: 16 additions & 3 deletions lib/docs/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function granteeView(doc: DocRow, includeHtml: boolean, role: string) {
* `role`. Two near-identical shapes coexist by design; don't conflate them.
*/
export function listItemView(
doc: DocRow & { comment_count?: number | string },
doc: DocRow & { comment_count?: number | string; storage_bytes?: number | string },
access: "owner" | "editor" | "commenter" | "viewer"
) {
return {
Expand All @@ -109,6 +109,9 @@ export function listItemView(
// comment_count; the /docs dashboard rows surface it too). Defaults to 0 when
// the row didn't carry the aggregate.
comment_count: Number(doc.comment_count ?? 0),
// The bytes this document costs the account, so a listing can be sorted by
// what would actually be reclaimed.
storage_bytes: Number(doc.storage_bytes ?? 0),
created_at: doc.created_at,
updated_at: doc.updated_at,
// Owned docs carry the view_token (the owner's shareable capability); shared
Expand Down Expand Up @@ -577,7 +580,13 @@ export async function findBySlug(slug: string): Promise<DocRow | null> {
* dashboard rows and GET /api/v1/docs items, birthday.md B11). comment_count is
* the number of non-deleted comments + replies on the doc.
*/
export type DocListRow = DocRow & { comment_count: number };
export type DocListRow = DocRow & {
comment_count: number;
// Current html plus every retained snapshot for this doc, the same bytes that
// count against the storage quota. Without it a caller cannot tell which
// documents are holding the space and has to fetch each one to find out.
storage_bytes: number;
};

/** Correlated live-comment count, reused by both listing queries. */
const COMMENT_COUNT_SUBQUERY = `
Expand All @@ -587,7 +596,11 @@ const COMMENT_COUNT_SUBQUERY = `
/** List a user's live docs, newest first (with comment_count). */
export async function listDocs(ownerId: number, limit: number): Promise<DocListRow[]> {
const { rows } = await query<DocListRow>(
`SELECT d.*, ${COMMENT_COUNT_SUBQUERY}
`SELECT d.*, ${COMMENT_COUNT_SUBQUERY},
(octet_length(d.html) + COALESCE((
SELECT sum(octet_length(v.html)) FROM doc_versions v
WHERE v.doc_id = d.id
), 0))::bigint AS storage_bytes
FROM documents d
WHERE d.owner_id = $1 AND d.deleted_at IS NULL
ORDER BY d.created_at DESC LIMIT $2`,
Expand Down
18 changes: 12 additions & 6 deletions lib/skill-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,17 +268,20 @@ Resource quotas (per user):
Max HTML size per doc 2 MB request rejected 413 payload_too_large
Docs per user 500 soft-deleted don't count; 403 quota_exceeded
Versions retained per doc 100 oldest snapshots pruned beyond this
Total storage per user 100 MiB current html + retained snapshots; 403
Total storage per user 100 MiB current html + retained snapshots; 413 storage_quota_exceeded
Grants per doc 50 403 quota_exceeded
Comment body size 10 KB 413 payload_too_large
Comments per doc 1,000 403 quota_exceeded
API keys per user 10

### Recovering from a storage or document-count quota

A 403 with { "error":"quota_exceeded", "limit":"storage",
"limit_value":104857600, "current":... } means the write would exceed the
account's 100 MiB storage quota (current and limit_value are bytes).
A 413 with { "error":"storage_quota_exceeded", "limit":"storage",
"limit_value":104857600, "current":..., "headroom":... } means the write would
exceed the account's 100 MiB storage quota. All three are bytes: current is in
use now, limit_value is the cap, headroom is what is left. A 403
quota_exceeded with limit:"doc_count" is the 500-document cap, which is a
different problem with a different fix.
limit:"doc_count" means the 500-document cap. This is not an authentication or
rate-limit failure: do not
re-register, repeatedly retry the write, or switch hosts without asking the user.
Expand All @@ -297,8 +300,11 @@ Keep the unpublished HTML locally while resolving the quota.
returns current html; GET /docs/:slug/versions returns versions[].bytes.
Add the UTF-8 byte length of current html to the sum of ALL retained snapshot
bytes (including the current version's snapshot). Both count toward quota.
The listing has no storage-size field. Shrinking HTML can add a snapshot,
so it is not a reliable way to reclaim space; there is no version-delete API.
The listing carries storage_bytes per document, so candidates sort by what
they would actually reclaim rather than by guesswork. Shrinking HTML can add
a snapshot, so it is not a reliable way to reclaim space; there is no
version-delete API. Publishing the current HTML as a new document is the only
way to drop a document's history, and it starts that document at version 1.

3. Offer 3-5 candidates (or fewer if fewer are suitable) as suggestions, with
title, URL/slug, last-updated date, estimated reclaimable MiB, and why each
Expand Down
18 changes: 12 additions & 6 deletions skills/just-html/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,17 +260,20 @@ Resource quotas (per user):
Max HTML size per doc 2 MB request rejected 413 payload_too_large
Docs per user 500 soft-deleted don't count; 403 quota_exceeded
Versions retained per doc 100 oldest snapshots pruned beyond this
Total storage per user 100 MiB current html + retained snapshots; 403
Total storage per user 100 MiB current html + retained snapshots; 413 storage_quota_exceeded
Grants per doc 50 403 quota_exceeded
Comment body size 10 KB 413 payload_too_large
Comments per doc 1,000 403 quota_exceeded
API keys per user 10

### Recovering from a storage or document-count quota

A 403 with { "error":"quota_exceeded", "limit":"storage",
"limit_value":104857600, "current":... } means the write would exceed the
account's 100 MiB storage quota (current and limit_value are bytes).
A 413 with { "error":"storage_quota_exceeded", "limit":"storage",
"limit_value":104857600, "current":..., "headroom":... } means the write would
exceed the account's 100 MiB storage quota. All three are bytes: current is in
use now, limit_value is the cap, headroom is what is left. A 403
quota_exceeded with limit:"doc_count" is the 500-document cap, which is a
different problem with a different fix.
limit:"doc_count" means the 500-document cap. This is not an authentication or
rate-limit failure: do not
re-register, repeatedly retry the write, or switch hosts without asking the user.
Expand All @@ -289,8 +292,11 @@ Keep the unpublished HTML locally while resolving the quota.
returns current html; GET /docs/:slug/versions returns versions[].bytes.
Add the UTF-8 byte length of current html to the sum of ALL retained snapshot
bytes (including the current version's snapshot). Both count toward quota.
The listing has no storage-size field. Shrinking HTML can add a snapshot,
so it is not a reliable way to reclaim space; there is no version-delete API.
The listing carries storage_bytes per document, so candidates sort by what
they would actually reclaim rather than by guesswork. Shrinking HTML can add
a snapshot, so it is not a reliable way to reclaim space; there is no
version-delete API. Publishing the current HTML as a new document is the only
way to drop a document's history, and it starts that document at version 1.

3. Offer 3-5 candidates (or fewer if fewer are suitable) as suggestions, with
title, URL/slug, last-updated date, estimated reclaimable MiB, and why each
Expand Down
Loading