@usegraft/registry 0.1.1 → 0.2.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 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/feat/core/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 GraftError({
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 GraftError({
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 GraftError({
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 GraftError({
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 GraftError({
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 GraftError2 } from "@usegraft/contracts";
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 GraftError2({
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
- if (file.identical) {
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.1.1",
3
+ "version": "0.2.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.1.1",
43
+ "@usegraft/contracts": "0.2.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({ description: "Slug of the page the comment belongs to." }),
26
- author: field.string({ description: "Commenter's display name." }),
27
- body: field.text({ description: "The comment text." }),
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({ optional: true, description: "Max rows scanned (default 100)." }),
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
- const records = await listRecords(ctx, comments, { limit: ctx.input.limit ?? 100 });
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
- .filter((r) => r.data.approved && r.data.pageSlug === ctx.input.pageSlug)
67
- .map((r) => ({
68
- id: r.id,
69
- author: r.data.author,
70
- body: r.data.body,
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({ description: "Price in the smallest currency unit (e.g. cents)." }),
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 (>= 1)." }),
74
- unitPriceCents: field.number({ description: "Unit price at order time." }),
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
- for (const slug of unique) {
100
- const row = await ctx.db.query.contentIndex.findFirst({
101
- where: (t, ops) =>
102
- ops.and(
103
- ops.eq(t.branchId, ctx.branch),
104
- ops.eq(t.collection, "products"),
105
- ops.eq(t.slug, slug),
106
- ops.eq(t.deleted, false),
107
- ),
108
- });
109
- if (!row) continue;
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({ description: "Product slug from content/products/." }),
142
- qty: field.number({ description: "Quantity (>= 1)." }),
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({ optional: true, description: "Max rows (default 50)." }),
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) => {