@usegraft/registry 0.1.1 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +42 -9
- package/package.json +18 -4
- package/registry/comments/graft/comments.ts +31 -17
- package/registry/commerce/graft/commerce.ts +60 -27
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @usegraft/registry
|
|
2
|
+
|
|
3
|
+
> The shadcn-style registry behind `graft add`. Primitives are copied into your repository, not imported from ours.
|
|
4
|
+
|
|
5
|
+
Part of [Graft](https://github.com/AndersonDesign1/graft), a CMS built so an AI agent is the primary operator.
|
|
6
|
+
|
|
7
|
+
## The model
|
|
8
|
+
|
|
9
|
+
`graft add faq` writes real files into `graft/` in your project and regenerates the barrel. You own them from that moment: edit them, delete them, or diverge entirely. There is no version of ours you are pinned to and no upgrade that overwrites your changes.
|
|
10
|
+
|
|
11
|
+
That is the whole extensibility story. A plugin you cannot read is a plugin an agent cannot edit.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i @usegraft/registry
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Most people use `graft add` rather than this package directly.
|
|
20
|
+
|
|
21
|
+
## Browse
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { listItems, describeItem, loadItem } from "@usegraft/registry";
|
|
25
|
+
|
|
26
|
+
const items = listItems();
|
|
27
|
+
const detail = describeItem("faq");
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Agents reach the same surface over MCP as `list_registry` and `describe_item`.
|
|
31
|
+
|
|
32
|
+
## Apply
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { loadItem, applyPlan } from "@usegraft/registry";
|
|
36
|
+
|
|
37
|
+
const item = loadItem("faq");
|
|
38
|
+
await applyPlan(plan);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`applyPlan` resolves transitive dependencies, writes the files, and regenerates the `graft/` barrel and the MDX component map.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
MIT. [Repository](https://github.com/AndersonDesign1/graft) · [Changelog](https://github.com/AndersonDesign1/graft/blob/main/packages/registry/CHANGELOG.md)
|
package/dist/index.d.ts
CHANGED
|
@@ -109,7 +109,6 @@ declare function mdxComponentsSource(basenames: readonly string[]): string;
|
|
|
109
109
|
declare function registryRoot(): string;
|
|
110
110
|
/** Names of every bundled item (a subdirectory holding a manifest), sorted. */
|
|
111
111
|
declare function listItemNames(root?: string): string[];
|
|
112
|
-
/** Load + validate one item by name. Throws REGISTRY_ITEM_NOT_FOUND / _INVALID. */
|
|
113
112
|
declare function loadItem(name: string, root?: string): RegistryItem;
|
|
114
113
|
interface ResolveOptions {
|
|
115
114
|
/** Registry root override (tests point this at a fixture dir). */
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,7 @@ function satisfies(version, range) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// src/barrel.ts
|
|
58
|
+
import { GraftError } from "@usegraft/contracts";
|
|
58
59
|
var HEADER = [
|
|
59
60
|
"// Generated by `graft add` \u2014 do not edit.",
|
|
60
61
|
"// Aggregates the owned primitives under graft/ into merged collections/functions.",
|
|
@@ -69,6 +70,20 @@ function moduleIdentifier(basename2) {
|
|
|
69
70
|
}
|
|
70
71
|
function barrelSource(basenames) {
|
|
71
72
|
const entries = [...new Set(basenames)].sort().map((base) => ({ base, id: moduleIdentifier(base) }));
|
|
73
|
+
const byId = /* @__PURE__ */ new Map();
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
byId.set(entry.id, [...byId.get(entry.id) ?? [], entry.base]);
|
|
76
|
+
}
|
|
77
|
+
const collisions = [...byId.entries()].filter(([, files]) => files.length > 1);
|
|
78
|
+
if (collisions.length > 0) {
|
|
79
|
+
const described = collisions.map(([id, files]) => `${files.map((f) => `${f}.ts`).join(" and ")} both become "${id}"`).join("; ");
|
|
80
|
+
throw new GraftError({
|
|
81
|
+
code: "REGISTRY_ITEM_INVALID",
|
|
82
|
+
message: `Modules under graft/ collide when converted to identifiers: ${described}.`,
|
|
83
|
+
fix: "Rename one of each colliding pair so their camelCase forms differ \u2014 graft/index.ts imports each module under that name, and two imports cannot share one binding.",
|
|
84
|
+
details: { collisions: Object.fromEntries(collisions) }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
72
87
|
const imports = entries.map((e) => `import * as ${e.id} from "./${e.base}";`);
|
|
73
88
|
const list = entries.map((e) => e.id).join(", ");
|
|
74
89
|
return [
|
|
@@ -124,7 +139,7 @@ function mdxComponentsSource(basenames) {
|
|
|
124
139
|
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
125
140
|
import { dirname, join } from "path";
|
|
126
141
|
import { fileURLToPath } from "url";
|
|
127
|
-
import { GraftError } from "@usegraft/contracts";
|
|
142
|
+
import { GraftError as GraftError2 } from "@usegraft/contracts";
|
|
128
143
|
var MANIFEST_FILE = "registry.item.json";
|
|
129
144
|
function registryRoot() {
|
|
130
145
|
return join(dirname(fileURLToPath(import.meta.url)), "..", "registry");
|
|
@@ -133,12 +148,21 @@ function listItemNames(root = registryRoot()) {
|
|
|
133
148
|
if (!existsSync(root)) return [];
|
|
134
149
|
return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, MANIFEST_FILE))).map((entry) => entry.name).sort();
|
|
135
150
|
}
|
|
151
|
+
var ITEM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
136
152
|
function loadItem(name, root = registryRoot()) {
|
|
153
|
+
if (!ITEM_NAME_RE.test(name)) {
|
|
154
|
+
throw new GraftError2({
|
|
155
|
+
code: "REGISTRY_ITEM_INVALID",
|
|
156
|
+
message: `"${name}" is not a registry item name.`,
|
|
157
|
+
fix: 'Item names are kebab-case: lowercase letters, digits and single hyphens, e.g. "comments". Call list_registry to see what is available.',
|
|
158
|
+
details: { name, pattern: ITEM_NAME_RE.source }
|
|
159
|
+
});
|
|
160
|
+
}
|
|
137
161
|
const dir = join(root, name);
|
|
138
162
|
const manifestPath = join(dir, MANIFEST_FILE);
|
|
139
163
|
if (!existsSync(manifestPath)) {
|
|
140
164
|
const available = listItemNames(root);
|
|
141
|
-
throw new
|
|
165
|
+
throw new GraftError2({
|
|
142
166
|
code: "REGISTRY_ITEM_NOT_FOUND",
|
|
143
167
|
message: `No registry item named "${name}".`,
|
|
144
168
|
fix: `Add one of the available items instead: ${available.join(", ") || "(none bundled)"}.`,
|
|
@@ -149,7 +173,7 @@ function loadItem(name, root = registryRoot()) {
|
|
|
149
173
|
try {
|
|
150
174
|
raw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
151
175
|
} catch (error) {
|
|
152
|
-
throw new
|
|
176
|
+
throw new GraftError2({
|
|
153
177
|
code: "REGISTRY_ITEM_INVALID",
|
|
154
178
|
message: `Item "${name}" has an unparseable ${MANIFEST_FILE}: ${error instanceof Error ? error.message : String(error)}`,
|
|
155
179
|
fix: "This is a registry bug \u2014 the manifest must be valid JSON. Fix the item or report it.",
|
|
@@ -162,7 +186,7 @@ function loadItem(name, root = registryRoot()) {
|
|
|
162
186
|
path: i.path.join("."),
|
|
163
187
|
message: i.message
|
|
164
188
|
}));
|
|
165
|
-
throw new
|
|
189
|
+
throw new GraftError2({
|
|
166
190
|
code: "REGISTRY_ITEM_INVALID",
|
|
167
191
|
message: `Item "${name}" has an invalid ${MANIFEST_FILE}.`,
|
|
168
192
|
fix: "This is a registry bug \u2014 the manifest does not match the item schema (see details.issues). Fix the item or report it.",
|
|
@@ -170,7 +194,7 @@ function loadItem(name, root = registryRoot()) {
|
|
|
170
194
|
});
|
|
171
195
|
}
|
|
172
196
|
if (parsed.data.name !== name) {
|
|
173
|
-
throw new
|
|
197
|
+
throw new GraftError2({
|
|
174
198
|
code: "REGISTRY_ITEM_INVALID",
|
|
175
199
|
message: `Item in "${name}/" declares name "${parsed.data.name}" \u2014 it must match its directory.`,
|
|
176
200
|
fix: "Rename the directory or the manifest `name` so they agree.",
|
|
@@ -189,7 +213,7 @@ function resolveItems(names, options = {}) {
|
|
|
189
213
|
visiting.add(name);
|
|
190
214
|
const item = loadItem(name, root);
|
|
191
215
|
if (options.coreVersion && !satisfies(options.coreVersion, item.graftVersion)) {
|
|
192
|
-
throw new
|
|
216
|
+
throw new GraftError2({
|
|
193
217
|
code: "REGISTRY_ITEM_INVALID",
|
|
194
218
|
message: `Item "${name}" needs @usegraft/core ${item.graftVersion}, but ${options.coreVersion} is installed.`,
|
|
195
219
|
fix: `Move @usegraft/core into ${item.graftVersion}, or use a version of "${name}" compatible with ${options.coreVersion}.`,
|
|
@@ -223,7 +247,7 @@ function listItems(root = registryRoot()) {
|
|
|
223
247
|
// src/add.ts
|
|
224
248
|
import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
225
249
|
import { basename, dirname as dirname2, join as join2 } from "path";
|
|
226
|
-
import { GraftError as
|
|
250
|
+
import { GraftError as GraftError3 } from "@usegraft/contracts";
|
|
227
251
|
var GRAFT_DIR = "graft";
|
|
228
252
|
var COMPONENTS_DIR = "components";
|
|
229
253
|
var MDX_MAP_FILE = "mdx-components.ts";
|
|
@@ -293,7 +317,7 @@ function fragmentHeading(fragment) {
|
|
|
293
317
|
}
|
|
294
318
|
function applyPlan(plan, options = {}) {
|
|
295
319
|
if (!options.overwrite && plan.conflicts.length > 0) {
|
|
296
|
-
throw new
|
|
320
|
+
throw new GraftError3({
|
|
297
321
|
code: "REGISTRY_FILE_EXISTS",
|
|
298
322
|
message: `Adding ${plan.items.map((i) => i.name).join(", ")} would overwrite ${plan.conflicts.length} existing file(s) that differ: ${plan.conflicts.join(", ")}.`,
|
|
299
323
|
fix: "Re-run with --overwrite to replace them, or move/rename those files first. `graft add --dry-run <item>` previews every path an item writes.",
|
|
@@ -303,10 +327,19 @@ function applyPlan(plan, options = {}) {
|
|
|
303
327
|
const written = [];
|
|
304
328
|
const skipped = [];
|
|
305
329
|
for (const file of plan.files) {
|
|
306
|
-
|
|
330
|
+
const current = existsSync2(file.targetPath) ? readFileSync2(file.targetPath, "utf8") : void 0;
|
|
331
|
+
if (current === file.content) {
|
|
307
332
|
skipped.push(file.relPath);
|
|
308
333
|
continue;
|
|
309
334
|
}
|
|
335
|
+
if (current !== void 0 && !options.overwrite) {
|
|
336
|
+
throw new GraftError3({
|
|
337
|
+
code: "REGISTRY_FILE_EXISTS",
|
|
338
|
+
message: `"${file.relPath}" changed on disk after the plan was made, and adding would overwrite it.`,
|
|
339
|
+
fix: "Re-run `graft add` to plan against the current tree, or pass --overwrite if replacing that file is the intent.",
|
|
340
|
+
details: { path: file.relPath }
|
|
341
|
+
});
|
|
342
|
+
}
|
|
310
343
|
mkdirSync(dirname2(file.targetPath), { recursive: true });
|
|
311
344
|
writeFileSync(file.targetPath, file.content, "utf8");
|
|
312
345
|
written.push(file.relPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usegraft/registry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
|
+
"description": "The shadcn-style registry behind `graft add`. Primitives are copied into your repository, not imported from ours.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"ai",
|
|
8
|
+
"cms",
|
|
9
|
+
"codegen",
|
|
10
|
+
"graft",
|
|
11
|
+
"headless-cms",
|
|
12
|
+
"mcp",
|
|
13
|
+
"primitives",
|
|
14
|
+
"registry",
|
|
15
|
+
"shadcn",
|
|
16
|
+
"typescript"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/AndersonDesign1/graft#readme",
|
|
4
19
|
"license": "MIT",
|
|
5
20
|
"repository": {
|
|
6
21
|
"type": "git",
|
|
@@ -25,7 +40,7 @@
|
|
|
25
40
|
"access": "public"
|
|
26
41
|
},
|
|
27
42
|
"dependencies": {
|
|
28
|
-
"@usegraft/contracts": "0.
|
|
43
|
+
"@usegraft/contracts": "1.0.0-beta.0",
|
|
29
44
|
"zod": "^4.1.0"
|
|
30
45
|
},
|
|
31
46
|
"engines": {
|
|
@@ -35,7 +50,6 @@
|
|
|
35
50
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
36
51
|
"dev": "tsup src/index.ts --format esm --watch",
|
|
37
52
|
"typecheck": "tsc --noEmit",
|
|
38
|
-
"test": "vitest run --passWithNoTests"
|
|
39
|
-
"lint": "oxlint ."
|
|
53
|
+
"test": "vitest run --passWithNoTests"
|
|
40
54
|
}
|
|
41
55
|
}
|
|
@@ -22,9 +22,12 @@ export const comments = defineCollection({
|
|
|
22
22
|
authority: "db-authoritative",
|
|
23
23
|
description: "Visitor comments, held for moderation until approved.",
|
|
24
24
|
fields: {
|
|
25
|
-
pageSlug: field.string({
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
pageSlug: field.string({
|
|
26
|
+
maxLength: 200,
|
|
27
|
+
description: "Slug of the page the comment belongs to.",
|
|
28
|
+
}),
|
|
29
|
+
author: field.string({ maxLength: 80, description: "Commenter's display name." }),
|
|
30
|
+
body: field.text({ maxLength: 4000, description: "The comment text." }),
|
|
28
31
|
approved: field.boolean({ description: "Only approved comments list publicly." }),
|
|
29
32
|
},
|
|
30
33
|
});
|
|
@@ -39,9 +42,9 @@ export const postComment = defineFunction({
|
|
|
39
42
|
"Post a comment (public; held unapproved until a moderator approves it). 5/min per caller.",
|
|
40
43
|
returns: "{ id: string; receivedAt: string }",
|
|
41
44
|
input: {
|
|
42
|
-
pageSlug: field.string({ description: "Page the comment is on." }),
|
|
43
|
-
author: field.string({ description: "Display name." }),
|
|
44
|
-
body: field.text({ description: "The comment text." }),
|
|
45
|
+
pageSlug: field.string({ maxLength: 200, description: "Page the comment is on." }),
|
|
46
|
+
author: field.string({ maxLength: 80, description: "Display name." }),
|
|
47
|
+
body: field.text({ maxLength: 4000, description: "The comment text." }),
|
|
45
48
|
},
|
|
46
49
|
handler: async (ctx) => {
|
|
47
50
|
const record = await insertRecord(ctx, comments, { ...ctx.input, approved: false });
|
|
@@ -55,21 +58,32 @@ export const listComments = defineFunction({
|
|
|
55
58
|
kind: "query",
|
|
56
59
|
description: "List approved comments for a page, newest first.",
|
|
57
60
|
returns: "{ comments: { id: string; author: string; body: string; receivedAt: string }[] }",
|
|
61
|
+
rateLimit: { limit: 60, windowSeconds: 60 },
|
|
58
62
|
input: {
|
|
59
|
-
pageSlug: field.string({ description: "Page to list comments for." }),
|
|
60
|
-
limit: field.number({
|
|
63
|
+
pageSlug: field.string({ maxLength: 200, description: "Page to list comments for." }),
|
|
64
|
+
limit: field.number({
|
|
65
|
+
optional: true,
|
|
66
|
+
int: true,
|
|
67
|
+
min: 1,
|
|
68
|
+
max: 100,
|
|
69
|
+
description: "Max comments to return (default 100).",
|
|
70
|
+
}),
|
|
61
71
|
},
|
|
62
72
|
handler: async (ctx) => {
|
|
63
|
-
|
|
73
|
+
// Both predicates run in SQL. Filtering after the row cap meant unapproved
|
|
74
|
+
// comments consumed the window, so posting enough of them hid every
|
|
75
|
+
// approved comment on every page — silently, with no error.
|
|
76
|
+
const records = await listRecords(ctx, comments, {
|
|
77
|
+
limit: ctx.input.limit ?? 100,
|
|
78
|
+
match: { approved: true, pageSlug: ctx.input.pageSlug },
|
|
79
|
+
});
|
|
64
80
|
return {
|
|
65
|
-
comments: records
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
receivedAt: r.createdAt.toISOString(),
|
|
72
|
-
})),
|
|
81
|
+
comments: records.map((r) => ({
|
|
82
|
+
id: r.id,
|
|
83
|
+
author: r.data.author,
|
|
84
|
+
body: r.data.body,
|
|
85
|
+
receivedAt: r.createdAt.toISOString(),
|
|
86
|
+
})),
|
|
73
87
|
};
|
|
74
88
|
},
|
|
75
89
|
});
|
|
@@ -40,11 +40,20 @@ export const products = defineCollection({
|
|
|
40
40
|
name: "products",
|
|
41
41
|
description: "Sellable catalog items (file-authoritative). Author under content/products/.",
|
|
42
42
|
fields: {
|
|
43
|
-
title: field.string({ description: "Product name." }),
|
|
44
|
-
description: field.text({ description: "Short product description." }),
|
|
45
|
-
priceCents: field.number({
|
|
43
|
+
title: field.string({ maxLength: 200, description: "Product name." }),
|
|
44
|
+
description: field.text({ maxLength: 2000, description: "Short product description." }),
|
|
45
|
+
priceCents: field.number({
|
|
46
|
+
int: true,
|
|
47
|
+
min: 0,
|
|
48
|
+
max: 100_000_000,
|
|
49
|
+
description: "Price in the smallest currency unit (e.g. cents).",
|
|
50
|
+
}),
|
|
46
51
|
currency: field.string({
|
|
47
52
|
optional: true,
|
|
53
|
+
// Exactly three letters: Intl.NumberFormat throws a RangeError on
|
|
54
|
+
// anything else, and one malformed product used to break the whole
|
|
55
|
+
// catalog page at render time.
|
|
56
|
+
pattern: /^[A-Za-z]{3}$/,
|
|
48
57
|
description: 'ISO currency code (default "USD").',
|
|
49
58
|
}),
|
|
50
59
|
image: field.asset({ optional: true, description: "Product image." }),
|
|
@@ -64,21 +73,25 @@ export const orders = defineCollection({
|
|
|
64
73
|
authority: "db-authoritative",
|
|
65
74
|
description: "Customer orders. Place via placeOrder; admin via list/update/cancel.",
|
|
66
75
|
fields: {
|
|
67
|
-
email: field.string({ description: "Buyer email." }),
|
|
76
|
+
email: field.string({ maxLength: 320, description: "Buyer email." }),
|
|
68
77
|
items: field.array({
|
|
69
78
|
description: "Line items snapshotted at order time (price locked).",
|
|
70
79
|
of: field.object({
|
|
71
80
|
fields: {
|
|
72
|
-
productSlug: field.string({ description: "Catalog product slug." }),
|
|
73
|
-
qty: field.number({ description: "Quantity (
|
|
74
|
-
unitPriceCents: field.number({
|
|
81
|
+
productSlug: field.string({ maxLength: 200, description: "Catalog product slug." }),
|
|
82
|
+
qty: field.number({ int: true, min: 1, max: 10_000, description: "Quantity (1-10000)." }),
|
|
83
|
+
unitPriceCents: field.number({
|
|
84
|
+
int: true,
|
|
85
|
+
min: 0,
|
|
86
|
+
description: "Unit price at order time.",
|
|
87
|
+
}),
|
|
75
88
|
},
|
|
76
89
|
}),
|
|
77
90
|
}),
|
|
78
91
|
status: field.string({
|
|
79
92
|
description: "pending | paid | cancelled | fulfilled",
|
|
80
93
|
}),
|
|
81
|
-
totalCents: field.number({ description: "Sum of qty * unitPriceCents." }),
|
|
94
|
+
totalCents: field.number({ int: true, min: 0, description: "Sum of qty * unitPriceCents." }),
|
|
82
95
|
},
|
|
83
96
|
});
|
|
84
97
|
|
|
@@ -96,25 +109,31 @@ async function loadProducts(
|
|
|
96
109
|
): Promise<Map<string, ProductRow>> {
|
|
97
110
|
const unique = [...new Set(slugs)];
|
|
98
111
|
const out = new Map<string, ProductRow>();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
if (unique.length === 0) return out;
|
|
113
|
+
|
|
114
|
+
// One query, not one per slug. The loop this replaces ran before unknown
|
|
115
|
+
// slugs were rejected, so a request carrying thousands of bogus slugs held a
|
|
116
|
+
// pooled connection for that many serial round-trips and only then failed
|
|
117
|
+
// validation — starving every other function sharing the pool.
|
|
118
|
+
const rows = await ctx.db.query.contentIndex.findMany({
|
|
119
|
+
where: (t, ops) =>
|
|
120
|
+
ops.and(
|
|
121
|
+
ops.eq(t.branchId, ctx.branch),
|
|
122
|
+
ops.eq(t.collection, "products"),
|
|
123
|
+
ops.inArray(t.slug, unique),
|
|
124
|
+
ops.eq(t.deleted, false),
|
|
125
|
+
),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (const row of rows) {
|
|
110
129
|
const data = row.data as {
|
|
111
130
|
title?: string;
|
|
112
131
|
priceCents?: number;
|
|
113
132
|
active?: boolean;
|
|
114
133
|
};
|
|
115
134
|
if (typeof data.priceCents !== "number" || typeof data.title !== "string") continue;
|
|
116
|
-
out.set(slug, {
|
|
117
|
-
slug,
|
|
135
|
+
out.set(row.slug, {
|
|
136
|
+
slug: row.slug,
|
|
118
137
|
title: data.title,
|
|
119
138
|
priceCents: data.priceCents,
|
|
120
139
|
active: data.active !== false,
|
|
@@ -133,13 +152,21 @@ export const placeOrder = defineFunction({
|
|
|
133
152
|
"Place an order for active products. Snapshots unit prices; status starts as pending. 10/min per caller.",
|
|
134
153
|
returns: "{ id: string; totalCents: number; status: string; receivedAt: string }",
|
|
135
154
|
input: {
|
|
136
|
-
email: field.string({ description: "Buyer email." }),
|
|
155
|
+
email: field.string({ maxLength: 320, description: "Buyer email." }),
|
|
137
156
|
items: field.array({
|
|
138
157
|
description: "What to buy.",
|
|
158
|
+
// Capped: each entry drives catalog work, so an uncapped array is a
|
|
159
|
+
// per-request amplifier.
|
|
160
|
+
maxItems: 100,
|
|
139
161
|
of: field.object({
|
|
140
162
|
fields: {
|
|
141
|
-
productSlug: field.string({
|
|
142
|
-
|
|
163
|
+
productSlug: field.string({
|
|
164
|
+
maxLength: 200,
|
|
165
|
+
description: "Product slug from content/products/.",
|
|
166
|
+
}),
|
|
167
|
+
// Bounded so price x qty cannot exceed Number.MAX_SAFE_INTEGER and
|
|
168
|
+
// silently store a wrong total.
|
|
169
|
+
qty: field.number({ int: true, min: 1, max: 10_000, description: "Quantity (1-10000)." }),
|
|
143
170
|
},
|
|
144
171
|
}),
|
|
145
172
|
}),
|
|
@@ -224,7 +251,13 @@ export const listOrders = defineFunction({
|
|
|
224
251
|
description: "List recent orders, newest first. Requires commerce:orders:read.",
|
|
225
252
|
returns: "{ orders: { id, email, status, totalCents, items, receivedAt }[] }",
|
|
226
253
|
input: {
|
|
227
|
-
limit: field.number({
|
|
254
|
+
limit: field.number({
|
|
255
|
+
optional: true,
|
|
256
|
+
int: true,
|
|
257
|
+
min: 1,
|
|
258
|
+
max: 200,
|
|
259
|
+
description: "Max rows (default 50).",
|
|
260
|
+
}),
|
|
228
261
|
},
|
|
229
262
|
access: requireOrdersRead,
|
|
230
263
|
handler: async (ctx) => {
|
|
@@ -250,8 +283,8 @@ export const updateOrderStatus = defineFunction({
|
|
|
250
283
|
"Set an order's status (pending|paid|cancelled|fulfilled). Requires commerce:orders:write.",
|
|
251
284
|
returns: "{ id: string; status: string }",
|
|
252
285
|
input: {
|
|
253
|
-
id: field.string({ description: "Order row id (uuid)." }),
|
|
254
|
-
status: field.string({ description: "pending | paid | cancelled | fulfilled" }),
|
|
286
|
+
id: field.string({ maxLength: 64, description: "Order row id (uuid)." }),
|
|
287
|
+
status: field.string({ maxLength: 32, description: "pending | paid | cancelled | fulfilled" }),
|
|
255
288
|
},
|
|
256
289
|
access: requireOrdersWrite,
|
|
257
290
|
handler: async (ctx) => {
|