@aotter/mantle 0.2.0 → 0.2.1-alpha.2

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.
Files changed (89) hide show
  1. package/README.md +6 -2
  2. package/dist/build/compiler.d.ts +6 -6
  3. package/dist/build/compiler.js +6 -3
  4. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
  5. package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
  6. package/dist/build/spec/domain/model/SqlIr.d.ts +30 -0
  7. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  8. package/dist/domain/describe/chunk-CW7X5Q5T.js +1 -0
  9. package/dist/domain/describe/index.d.ts +1 -0
  10. package/dist/domain/describe/index.js +1 -1
  11. package/dist/domain/describe/match.d.ts +19 -0
  12. package/dist/domain/describe/public.d.ts +3 -0
  13. package/dist/domain/describe/public.js +1 -0
  14. package/dist/infrastructure/adminEntries.js +19 -3
  15. package/dist/infrastructure/bundle.js +43 -8
  16. package/dist/infrastructure/cli.js +1 -1
  17. package/dist/infrastructure/generate.js +17 -3
  18. package/dist/kernel/binds.d.ts +18 -3
  19. package/dist/kernel/binds.js +1 -1
  20. package/dist/kernel/contracts.d.ts +7 -0
  21. package/dist/kernel/contracts.js +9 -0
  22. package/dist/kernel/driver/bun-sqlite.js +3 -3
  23. package/dist/kernel/driver/d1.d.ts +1 -1
  24. package/dist/kernel/driver/d1.js +6 -3
  25. package/dist/kernel/driver/postgres.d.ts +2 -1
  26. package/dist/kernel/driver/postgres.js +11 -6
  27. package/dist/kernel/mcp-apps.d.ts +3 -2
  28. package/dist/kernel/mcp-apps.js +7 -2
  29. package/dist/kernel/mcp-handler.js +22 -9
  30. package/dist/kernel/mcp.d.ts +18 -0
  31. package/dist/kernel/mcp.js +1 -0
  32. package/dist/kernel/rest-router.js +3 -5
  33. package/dist/kernel/rest.d.ts +2 -1
  34. package/dist/kernel/rest.js +1 -0
  35. package/dist/kernel/route-pattern.d.ts +26 -0
  36. package/dist/kernel/route-pattern.js +34 -0
  37. package/dist/kernel/store.d.ts +15 -1
  38. package/dist/kernel/store.js +51 -18
  39. package/dist/usecase/adminBindings.js +2 -2
  40. package/dist/usecase/adminClient.js +4 -1
  41. package/dist/usecase/generate.js +7 -5
  42. package/dist/usecase/handlers.js +1 -1
  43. package/dist/usecase/publishing.js +20 -7
  44. package/dist/usecase/rest.js +5 -5
  45. package/dist/usecase/stores.js +8 -3
  46. package/docs/examples/README.md +1 -1
  47. package/docs/examples/app-shell/README.md +58 -10
  48. package/docs/examples/app-shell/package.json +6 -6
  49. package/docs/examples/club-events/manifests/club-events.yaml +2 -0
  50. package/docs/examples/generated-web/seed.sql +5 -4
  51. package/docs/examples/purchasing/manifests/purchasing.yaml +3 -0
  52. package/docs/handbook/cloudflare/media-r2.md +6 -3
  53. package/docs/handbook/cloudflare/service-entry.md +2 -0
  54. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  55. package/docs/handbook/examples/hub.md +1 -1
  56. package/docs/handbook/guides/admin-ui.md +1 -1
  57. package/docs/handbook/guides/agent-setup.md +13 -8
  58. package/docs/handbook/guides/agent-workflow-acceptance.md +129 -19
  59. package/docs/handbook/guides/local-testing.md +50 -5
  60. package/docs/handbook/guides/mcp-apps.md +40 -0
  61. package/docs/handbook/guides/native-admin.md +218 -11
  62. package/docs/handbook/guides/native-execution.md +5 -4
  63. package/docs/handbook/guides/revisions.md +584 -0
  64. package/docs/handbook/guides/size-and-startup.md +140 -0
  65. package/docs/handbook/guides/sql-patterns.md +47 -1
  66. package/docs/handbook/guides/typed-queries.md +8 -3
  67. package/docs/handbook/navigation.json +4 -0
  68. package/docs/handbook/reference/authorization.md +2 -0
  69. package/docs/handbook/reference/describe.md +56 -4
  70. package/docs/handbook/reference/diagnostics.md +12 -3
  71. package/docs/handbook/reference/procedure.md +4 -4
  72. package/docs/handbook/reference/schema.md +71 -7
  73. package/docs/handbook/reference/sql.md +2 -1
  74. package/docs/handbook/reference/surface.md +13 -2
  75. package/docs/handbook/reference/trigger.md +12 -0
  76. package/docs/handbook/releases/index.md +75 -1
  77. package/docs/handbook/start/agents.md +6 -2
  78. package/docs/handbook/start/project-and-cli.md +4 -0
  79. package/docs/handbook/start/quickstart-worker.md +1 -1
  80. package/docs/skills/develop/SKILL.md +15 -3
  81. package/docs/skills/integrate/SKILL.md +3 -1
  82. package/docs/skills/performance/SKILL.md +169 -0
  83. package/docs/skills/update/SKILL.md +9 -5
  84. package/docs/upgrade-0.1-to-0.2.md +151 -39
  85. package/package.json +10 -11
  86. package/skills/README.md +6 -2
  87. package/skills/mantle/SKILL.md +19 -5
  88. package/dist/domain/describe/chunk-SGMRVDGE.js +0 -1
  89. package/dist/domain/describe/target-hash.js +0 -1
@@ -13,6 +13,7 @@ test("the Admin MCP App works in an MCP host", { timeout: 300_000 }, async () =>
13
13
  await checkMcpApp({ catalogue: mcpAppCatalogues.admin, html });
14
14
  });
15
15
  `;
16
+ const nullable = (p) => p.nullable === true || [p.type].flat().includes("null") || (p.enum ?? []).includes(null);
16
17
  const closed = (typeOf, schema) => !schema ? "{}" : schema.properties || schema.type === "object" && !schema["$ref"] ? typeOf({ ...schema, additionalProperties: false }) : "{ readonly [key: string]: unknown }";
17
18
  export function emitAdminClient(plan, typeOf) {
18
19
  const tools = mcpTools(plan, "admin"), hasTool = (kind, source) => tools.some(t => t.kind === kind && t.source === source);
@@ -20,7 +21,9 @@ export function emitAdminClient(plan, typeOf) {
20
21
  const operations = Object.entries(plan.procedures).filter(([name]) => hasTool("procedure", name)).sort(([a], [b]) => a < b ? -1 : 1);
21
22
  const schemas = Object.values(plan.schemas).sort((a, b) => a.name < b.name ? -1 : 1);
22
23
  const dataOf = (s) => {
23
- const scope = s.scope ? s.names[s.scope] ?? s.scope : undefined, { [scope ?? ""]: _scope, ...properties } = s.schema.properties ?? {};
24
+ const scope = s.scope ? s.names[s.scope] ?? s.scope : undefined, { [scope ?? ""]: _scope, ...declared } = s.schema.properties ?? {};
25
+ const certain = (name) => !s.publishing && s.schema.required?.includes(name);
26
+ const properties = Object.fromEntries(Object.entries(declared).map(([name, p]) => [name, certain(name) || nullable(p) ? p : { ...p, nullable: true }]));
24
27
  const required = s.publishing ? [] : s.schema.required?.filter(f => f !== scope);
25
28
  const schema = { ...s.schema, properties, ...(required ? { required } : {}) };
26
29
  return s.translates ? typeOf(schema) : closed(typeOf, schema);
@@ -77,14 +77,16 @@ export function generate(input) {
77
77
  };
78
78
  const values = new Set();
79
79
  const step = (s, path, fields, row = false) => {
80
- const recipes = recipesOf(s, fields, message => refuse(path, message));
81
- for (const r of recipes) {
80
+ const recipes = recipesOf(s, fields, message => refuse(path, message)), probe = row && s.probe ? recipesOf(s.probe, fields, message => refuse(path, message)) : [];
81
+ for (const r of [...recipes, ...probe]) {
82
82
  if (r.k === "now" || r.k === "cutoff")
83
83
  values.add(r.k);
84
84
  else if (r.k === "input" && r.type)
85
85
  values.add(r.type === "date" ? "date" : "timestamp");
86
86
  }
87
- return `{ sql: ${q(s.sql)}, binds: ${q(recipes)}${row ? ", row: true" : ""} }`;
87
+ const locks = Object.fromEntries((plan.schemas[s.schema ?? ""]?.locks ?? []).filter(l => l.on.includes(s.verb)).map(l => [l.name, l.message]));
88
+ const lock = row && s.probe ? `, lock: ${q({ sql: s.probe.sql, binds: probe, schema: plan.schemas[s.schema].name, verb: s.verb, locks })}` : "";
89
+ return `{ sql: ${q(s.sql)}, binds: ${q(recipes)}${row ? ", row: true" : s.onNoRows ? ', row: "some"' : ""}${lock} }`;
88
90
  };
89
91
  const declarations = [], views = [], procedures = [], refProcedures = [];
90
92
  const entries = {};
@@ -137,8 +139,8 @@ export function generate(input) {
137
139
  entries[`procedures/${encodeURIComponent(name)}.ts`] = entry("procedure", name, `Procedure${index}Input as Input`, `export type { Output } from "${base}contracts";\n`);
138
140
  }
139
141
  emitTtl(plan, isSqlite, declarations, procedures, entries, refuse);
140
- emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse);
141
- const stores = emitStores(plan.schemas, isSqlite, newId, refuse, hasHandlers);
142
+ emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse, input.locks);
143
+ const stores = emitStores(plan.schemas, isSqlite, newId, refuse, hasHandlers, input.locks);
142
144
  const surfaces = { ...emitPaging(plan, lowered, target, refuse, paging), ...emitPaging(plan, input.wireLowered ?? lowered, target, refuse, paging, true), ...emitValidation(plan, refuse), ...emitRest(plan, lowered, target, input.description), ...emitWeb(plan, lowered, target), ...emitMcp(plan, lowered, target, input.description, refuse, !!site), ...emitAdminDescriptors({ plan, admin }, diagnostics), ...emitAdminBindings(input, refuse), ...(admin ? emitAdminClient(plan, typeOf) : {}), ...emitSite(target, site, refuse, Object.keys(plan.schemas)), ...emitTranslations(plan, target) };
143
145
  if (diagnostics.length)
144
146
  return { ok: false, diagnostics };
@@ -6,7 +6,7 @@ export function emitHandlers(plan, interactive, typeOf, declarations, procedures
6
6
  declarations.push("export interface Handlers {}");
7
7
  return [];
8
8
  }
9
- declarations.push('export type HandlerStore = Pick<ReturnType<typeof makeStore>, "collection" | "views" | "procedures">;',
9
+ declarations.push('export type HandlerStore = Pick<ReturnType<typeof makeStore>, "collection" | "batch" | "views" | "procedures">;',
10
10
  `export interface HandlerContext { readonly caller: Caller; readonly db: Database; readonly store: HandlerStore;${interactive ? " readonly transaction: <R>(fn: (ctx: { readonly store: HandlerStore; readonly db: Database }) => Promise<R>) => Promise<R>;" : ""} }`, "export interface Handlers {", ...refs.map(({ name, index }) => ` readonly [${q(name)}]: (ctx: HandlerContext, input: Procedure${index}Input) => Promise<Procedure${index}Output>;`), "}");
11
11
  for (const { name, procedure, index } of refs) {
12
12
  const path = `procedures/${name}`, fn = `procedure${index}`;
@@ -6,7 +6,9 @@ import { publishedParent } from "./translations.js";
6
6
  import { entry, exportName, q } from "../domain/entry.js";
7
7
  import { publishingAnnotations, publishingInput, publishingTransitions } from "../domain/generated-tools.js";
8
8
  export { publishingAnnotations, publishingInput, publishingTransitions };
9
- export function emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse) {
9
+ export const publishRequired = (s) => (s.schema.required ?? []).flatMap(field => Object.keys(s.fields).filter(c => (s.names?.[c] ?? c) === field).slice(0, 1)
10
+ .flatMap(c => s.fields[c] === "geo" ? [[`${c}_lat`, field], [`${c}_lng`, field]] : [[c, field]]));
11
+ export function emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse, locks = {}) {
10
12
  const d = isSqlite ? sqlite : postgres;
11
13
  const translations = Object.values(plan.schemas).some(s => s.translates);
12
14
  if (translations)
@@ -35,15 +37,26 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
35
37
  if (term[1] !== "admin")
36
38
  conditions.push(renderTerm(d, term, term[1] === "parent" ? d.ident(s.name) + "." : "", recipe => (recipes.push(recipe), d.param(recipes.length))));
37
39
  if (verb === "publish")
38
- for (const field of s.schema.required ?? []) {
39
- const column = Object.keys(s.fields).find(c => (s.names?.[c] ?? c) === field);
40
- if (column)
41
- conditions.push(`${d.ident(column)} IS NOT NULL`);
42
- }
40
+ for (const [column] of publishRequired(s))
41
+ conditions.push(`${d.ident(column)} IS NOT NULL`);
43
42
  if (verb === "publish" && s.translates)
44
43
  conditions.push(publishedParent(plan, schema, d, recipes, trusted));
45
44
  const clock = d.param(1);
46
- return { sql: `UPDATE ${d.ident(s.name)} SET status = '${to}', published_at = ${verb === "publish" ? clock : "NULL"}, version = version + 1, updated_at = ${clock} WHERE ${conditions.join(" AND ")} RETURNING id, version, status, ${d.wireTimestamp(d.ident("published_at")).join("")} AS published_at`, binds: recipes, row: true };
45
+ const held = trusted ? [] : (locks[s.name.toLowerCase()] ?? []).filter(l => l.on.includes("update"));
46
+ const lock = held.length ? (() => {
47
+ const probe = [], number = new Map(), renumber = (sql) => sql.replace(isSqlite ? /\?(\d+)/g : /\$(\d+)/g, (_, n) => {
48
+ const i = Number(n);
49
+ if (!number.has(i)) {
50
+ probe.push(recipes[i - 1]);
51
+ number.set(i, d.param(probe.length));
52
+ }
53
+ return number.get(i);
54
+ });
55
+ const where = renumber(conditions.filter(c => c !== `version = ${d.param(3)}`).join(" AND "));
56
+ return { sql: `SELECT CASE ${held.map(l => `WHEN (${l.sql}) IS NOT FALSE THEN '${l.name}'`).join(" ")} END AS ${d.ident("lock")} FROM ${d.ident(s.name)} WHERE ${where}`, binds: probe, schema: s.name, verb: "update", locks: Object.fromEntries(held.map(l => [l.name, l.message])) };
57
+ })() : undefined;
58
+ conditions.push(...held.map(l => `NOT (${l.sql})`));
59
+ return { sql: `UPDATE ${d.ident(s.name)} SET status = '${to}', published_at = ${verb === "publish" ? clock : "NULL"}, version = version + 1, updated_at = ${clock} WHERE ${conditions.join(" AND ")} RETURNING id, version, status, ${d.wireTimestamp(d.ident("published_at")).join("")} AS published_at`, binds: recipes, row: true, ...(lock ? { lock } : {}) };
47
60
  };
48
61
  values.add("now");
49
62
  const caller = q([statement(false)]), trusted = q([statement(true)]);
@@ -1,6 +1,7 @@
1
1
  import { databaseImport, q, viewImports } from "../domain/entry.js";
2
2
  import { authLines, conversions } from "../domain/input.js";
3
3
  import { boundaryPage, outputConversions } from "../domain/columns.js";
4
+ import { pathTemplate as template, routeOrder, routeSource } from "../kernel/route-pattern.js";
4
5
  export function emitRest(plan, lowered, target, description) {
5
6
  const guard = (requires, path, binds = [], trustedOnly = false) => {
6
7
  const lines = authLines(requires, path, binds, !target.endsWith("postgres"), trustedOnly);
@@ -15,9 +16,8 @@ export function emitRest(plan, lowered, target, description) {
15
16
  let hasWire = false;
16
17
  const convert = (code) => code ? `, convert: ${code}` : "";
17
18
  const route = (method, path) => {
18
- const keys = [];
19
- const source = path.split("/").map(segment => segment.startsWith("{") && segment.endsWith("}") ? (keys.push(segment.slice(1, -1)), "([^/]+)") : encodeURIComponent(segment).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("/");
20
- return `method: ${q(method)}, pattern: new RegExp(${q("^" + source + "/?$")})${keys.length ? `, keys: ${q(keys)}` : ""}`;
19
+ const { source, keys } = routeSource(path);
20
+ return `method: ${q(method)}, pattern: new RegExp(${q(source)})${keys.length ? `, keys: ${q(keys)}` : ""}`;
21
21
  };
22
22
  for (const [name, view] of Object.entries(plan.views)) {
23
23
  if (view.surface !== "public")
@@ -28,7 +28,7 @@ export function emitRest(plan, lowered, target, description) {
28
28
  hasWire ||= wire.length > 0;
29
29
  views.push(`{ ${route("GET", `/api/views/${name}`)}, target: ${q({ kind: "view", name })}${guard(view.requires, `views/${name}`, (lowered.views[name]?.public ?? lowered.views[name]?.caller)?.binds)}, query: query${id}, page: ${boundaryPage(`page${id}`, wire)}${convert(conversions(view.input?.properties, (lowered.views[name]?.public ?? lowered.views[name]?.caller)?.binds ?? [], target))} }`);
30
30
  }
31
- const triggers = Object.values(plan.triggers).filter(t => t.source.kind === "http").sort((a, b) => a.source.kind === "http" && b.source.kind === "http" ? (a.source.path.match(/\{/g)?.length ?? 0) - (b.source.path.match(/\{/g)?.length ?? 0) : 0);
31
+ const triggers = Object.values(plan.triggers).filter(t => t.source.kind === "http").sort((a, b) => a.source.kind === "http" && b.source.kind === "http" ? routeOrder(a.source.path, b.source.path) : 0);
32
32
  for (const trigger of triggers) {
33
33
  if (trigger.source.kind !== "http")
34
34
  continue;
@@ -40,7 +40,7 @@ export function emitRest(plan, lowered, target, description) {
40
40
  const type = [procedure.input?.properties?.[key]?.type].flat().find(t => t && t !== "null");
41
41
  return type === "integer" || type === "number" ? [[key, "number"]] : type === "boolean" ? [[key, "boolean"]] : [];
42
42
  }));
43
- procedures.push(`{ ${route(method, path)}, id: ${q(`rest:${method} ${path.replace(/\{([^}]+)\}/g, ":$1")}`)}, target: ${q({ kind: "procedure", name })}${description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? ", readOnly: true" : ""}${guard(procedure.requires, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), procedure.trustedOnly)}${Object.keys(params).length ? `, params: ${q(params)}` : ""}, validate: validate${id}${convert(conversions(procedure.input?.properties, lowered.procedures[name]?.caller?.flatMap(s => s.binds) ?? [], target))}, run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `procedure${id}`} }`);
43
+ procedures.push(`{ ${route(method, path)}, id: ${q(`rest:${method} ${template(path)}`)}, target: ${q({ kind: "procedure", name })}${description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? ", readOnly: true" : ""}${guard(procedure.requires, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), procedure.trustedOnly)}${Object.keys(params).length ? `, params: ${q(params)}` : ""}, validate: validate${id}${convert(conversions(procedure.input?.properties, lowered.procedures[name]?.caller?.flatMap(s => s.binds) ?? [], target))}, run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `procedure${id}`} }`);
44
44
  }
45
45
  if (hasWire)
46
46
  imports.push('import { wireRows } from "@aotter/mantle/contracts";');
@@ -3,13 +3,14 @@ import { declaredType, inputType, nativeType } from "../domain/columns.js";
3
3
  import { q } from "../domain/entry.js";
4
4
  import { expand, SCOPE, TTL } from "../kernel/store.js";
5
5
  import { refChecks, userScopeTerm } from '../build/compiler.js';
6
+ import { publishRequired } from "./publishing.js";
6
7
  const SCALAR = new Set(["text", "real", "float8", "integer", "int4", "int8", "bool", "timestamptz", "date"]);
7
8
  const core = (s, schemas) => {
8
9
  const update = writePolicy(s, "update", { lock: true });
9
10
  return { caller: rowPolicy(s, "caller", schemas), trusted: rowPolicy(s, "trusted", schemas), fills: writePolicy(s, "insert").fills, sets: update.sets, lock: update.lock };
10
11
  };
11
12
  const probe = { scope: SCOPE, ttl: TTL, ttlSeconds: 0, publishing: true };
12
- export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
13
+ export function emitStores(schemas, isSqlite, newId, refuse, required = false, locks = {}) {
13
14
  const all = Object.values(schemas).sort((a, b) => a.name < b.name ? -1 : 1);
14
15
  if (!all.length && !required)
15
16
  return undefined;
@@ -29,13 +30,17 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
29
30
  const ref = refChecks(s, schemas);
30
31
  const fact = {
31
32
  ...(s.userScopes?.length ? { u: s.userScopes.map(u => userScopeTerm(u, true)) } : {}),
32
- ...(s.scope ? { s: s.scope } : {}), ...(s.ttl ? { ttl: [s.ttl, s.ttlSeconds ?? 0] } : {}), ...(s.publishing ? { pub: 1 } : {}),
33
+ ...(s.scope ? { s: s.scope } : {}), ...(s.ttl ? { ttl: [s.ttl, s.ttlSeconds ?? 0] } : {}), ...(s.publishing ? { pub: 1, ...(publishRequired(s).length ? { req: Object.fromEntries(publishRequired(s)) } : {}) } : {}),
33
34
  ...(ts.length ? { ts } : {}), ...(dates.length ? { d: dates } : {}),
34
35
  ...(s.search?.length ? { search: s.search } : {}),
35
36
  ...(ref.length ? { ref } : {}),
37
+ ...(locks[s.name.toLowerCase()]?.length ? { lock: locks[s.name.toLowerCase()] } : {}),
36
38
  ...(parent ? { parent: { on: s.translates.on.toLowerCase(), ...(parent.scope ? { scope: [parent.scope, s.scope] } : {}), table: s.translates.parent.toLowerCase(), visible: rowPolicy(s, "caller", schemas).find(t => t[1] === "parent"), caller: rowPolicy(parent, "caller", schemas), trusted: rowPolicy(parent, "trusted", schemas), fields: inherited, ts: inherited.filter(c => parent.fields[c] === "timestamptz"), d: inherited.filter(c => parent.fields[c] === "date") } } : {}),
37
39
  };
38
40
  const expected = q([core(s, schemas), writePolicy(s, "delete", { lock: true }).lock]), got = q([expand(rules, fact), rules.lock]);
41
+ for (const verb of ["update", "delete"])
42
+ if (q(writePolicy(s, verb).locks.map(l => l[0])) !== q((fact.lock ?? []).filter(l => l.on.includes(verb)).map(l => l.name)))
43
+ refuse(`schemas/${s.name}`, `Core's ${verb} locks for this Schema differ from the Store's.`);
39
44
  if (expected !== got)
40
45
  refuse(`schemas/${s.name}`, `Core's row rules for this Schema differ from the shared rules (${expected} vs ${got}); the generic Store cannot serve it.`);
41
46
  const indexed = new Set([...(s.publishing ? [[...(s.scope ? [s.scope] : []), "published_at", "id"]] : []), ...(s.indexes ?? []), ...(s.unique ?? [])].map(i => i[0] === s.scope ? i[1] : i[0]).filter((c) => !!c));
@@ -45,7 +50,7 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
45
50
  const scalar = (c) => SCALAR.has(type(c));
46
51
  const writable = declared.filter(c => !(s.userScopes ?? []).some(u => u.column === c) && (scalar(c) || type(c) === "json"));
47
52
  const input = (c) => `${inputType(type(c))}${nonNull(c) ? "" : " | null"}`;
48
- tables.push(`${key(s.name)}: { row: ${of([...names, ...geo], c => c === "id" ? "string" : nativeType(geo.includes(c) ? "real" : type(c), isSqlite, nonNull(c)))}${inherited.length ? ` & { ${inherited.map(c => `${key(c)}?: ${nativeType(inheritedType(c), isSqlite)};`).join(" ")} }` : ""}; fields: ${of(names.filter(scalar), c => inputType(type(c)))}; set: ${of(writable, input)}${s.userScopes?.length ? `; insert: ${of([...writable, ...s.userScopes.map(u => u.column)], input)}` : ""}; required: ${union(s.publishing ? [] : writable.filter(c => required.has(s.names?.[c] ?? c)))}; order: ${union(["id", ...names.filter(c => c !== "id" && scalar(c) && indexed.has(c) && (nonNull(c) || s.publishing && c === "published_at"))])} }`);
53
+ tables.push(`${key(s.name)}: { row: ${of([...names, ...geo], c => c === "id" ? "string" : c === "version" ? "number" : nativeType(geo.includes(c) ? "real" : type(c), isSqlite, nonNull(c)))}${inherited.length ? ` & { ${inherited.map(c => `${key(c)}?: ${nativeType(inheritedType(c), isSqlite)};`).join(" ")} }` : ""}; fields: ${of(names.filter(scalar), c => inputType(type(c)))}; set: ${of(writable, input)}${s.userScopes?.length ? `; insert: ${of([...writable, ...s.userScopes.map(u => u.column)], input)}` : ""}; required: ${union(s.publishing ? [] : writable.filter(c => required.has(s.names?.[c] ?? c)))}; order: ${union(["id", ...names.filter(c => c !== "id" && scalar(c) && indexed.has(c) && (nonNull(c) || s.publishing && c === "published_at"))])} }`);
49
54
  facts.push(`${q(s.name)}: ${q(fact)}`);
50
55
  }
51
56
  return { tables: `{ ${tables.join("; ")} }`, rules: q(rules), facts: `{ ${facts.join(", ")} }` };
@@ -39,5 +39,5 @@ shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchas
39
39
 
40
40
  Every example uses two roles: `admin` (the admin layer) and `user`.
41
41
 
42
- [Reference service](https://github.com/aotter/mantle/tree/v0.2.0/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
42
+ [Reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.2/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
43
43
  Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
@@ -12,15 +12,49 @@ cd my-app
12
12
  pnpm install
13
13
  # add manifests/*.yaml (mantle help four-atoms; patterns: mantle help examples)
14
14
  pnpm generate # writes generated/ and lists any `handler: { ref }` to implement in src/handlers.ts
15
- pnpm types && pnpm check
15
+ pnpm types && pnpm check # each builds once (see Which steps build)
16
16
  cp .dev.vars.example .dev.vars
17
- pnpm db:init # add seed.sql and run pnpm db:seed for fixtures
18
- pnpm dev # one-time codes print to this log
19
- pnpm smoke # your smoke.mjs on smoke-kit.mjs (see Files you edit)
20
- pnpm accept # hand each role to a fresh evaluator (see Independent acceptance)
21
- pnpm test # Admin MCP App check: needs Chrome (npx playwright-core install chromium, or MANTLE_TEST_CHROMIUM=<executable>)
17
+ pnpm db:init # optional seed.sql for static reference data, then pnpm db:seed (see Seed data)
18
+ pnpm dev # builds, rebuilds on change; one-time codes print to this log
19
+ pnpm smoke # builds; your smoke.mjs on smoke-kit.mjs (see Files you edit)
20
+ pnpm accept # builds; hands each role to a fresh evaluator (see Independent acceptance)
21
+ pnpm test # Admin MCP App check of the last build: needs Chrome (npx playwright-core install chromium, or MANTLE_TEST_CHROMIUM=<executable>)
22
22
  ```
23
23
 
24
+ ### Which steps build
25
+
26
+ `wrangler.jsonc` sets `mantle bundle src/index.ts dist` as Wrangler's custom build. These steps run it (marked
27
+ **builds**); the others do not:
28
+
29
+ | Step | Builds? |
30
+ |---|---|
31
+ | `pnpm generate`, `pnpm db:init`, `pnpm db:seed` | no |
32
+ | `pnpm test` | no; it tests the last build |
33
+ | `pnpm types` (`wrangler types`) | **builds**: Wrangler reads the built entry's exports for Durable Object and service binding types |
34
+ | `pnpm check` | **builds** once, in `wrangler types --check`; `generate --check` and `tsc` do not |
35
+ | `pnpm build` | **builds** |
36
+ | `pnpm dev` | **builds** at start, then again on every change under `src/` or `generated/` |
37
+ | `pnpm smoke`, `pnpm accept` | **build** once each: both start `wrangler dev` |
38
+
39
+ Wrangler has no option to skip the custom build for `wrangler types`. The Admin console is rebuilt only when the
40
+ installed Admin packages change or the app has `src/admin.tsx`, so a repeated build mostly rebuilds the Worker.
41
+
42
+ First run, after `pnpm install` and your manifests: `pnpm generate`, `pnpm types`, `pnpm check`,
43
+ `cp .dev.vars.example .dev.vars` (`pnpm dev` needs the secrets `wrangler.jsonc` requires), `pnpm db:init`, then
44
+ `pnpm dev` or `pnpm smoke`. Right after `pnpm types`, `pnpm check` repeats its build; run it anyway, because it is the
45
+ same check CI and hand-off use.
46
+
47
+ Routine edit, with `pnpm dev` running:
48
+
49
+ 1. Edit `manifests/*.yaml`, then `pnpm generate`. Wrangler does not watch `manifests/`, so the last build keeps
50
+ serving until something under `src/` or `generated/` changes. From the next change under `src/` until you
51
+ generate, each change prints one line, `Generated output in generated is stale (...)`, and the dev server holds
52
+ requests instead of running stale code. Writing `generated/` rebuilds. If you undo the manifest edit instead,
53
+ nothing rebuilds until the next change under `src/` or a restart of `pnpm dev`.
54
+ 2. `pnpm types` only when `generate` or you changed `wrangler.jsonc` (bindings, secrets, vars).
55
+ 3. Edits under `src/` rebuild by themselves.
56
+ 4. Before handing off: `pnpm check`, then `pnpm smoke`.
57
+
24
58
  npm works too (`npm install`, `npm run …`). `package.json` pins `@better-auth/core` and `@better-auth/utils` even though
25
59
  the shell does not import them: they are the exact versions the SDK's Better Auth peers require, and without them a fresh
26
60
  install resolves newer ones (npm ERESOLVE, or a second Better Auth type tree under pnpm). Keep every Better Auth package
@@ -47,6 +81,19 @@ HTTP-only Procedures will keep generating with a warning.
47
81
 
48
82
  Search for `AGENT:` to find what an app must fill in.
49
83
 
84
+ ## Seed data
85
+
86
+ Create test records through the app: your `smoke.mjs` or an `accept.mjs` fixtures module calls the app's own REST, MCP
87
+ or Admin operations as a signed-in user, so Mantle fills `id`, `version`, `created_at`, `updated_at`, `author_id`
88
+ and the publishing `status`/`published_at`. Keep `seed.sql` for static reference data no surface writes; native SQL
89
+ must set those columns itself, timestamps as integer microseconds on D1. An omitted `updated_at` stores `0`, which
90
+ Admin shows as 1970. `mantle help local-testing` (Seed data) has a complete seed:
91
+
92
+ ```sql
93
+ INSERT INTO categories (id, version, created_at, updated_at, name) VALUES
94
+ ('books', 1, 1767225600000000, 1767225600000000, 'Books');
95
+ ```
96
+
50
97
  ## Local testing notes
51
98
 
52
99
  - Better Auth allows 3 OTP requests per minute per client IP. `smoke-kit.mjs` gives each user its own
@@ -60,7 +107,8 @@ Search for `AGENT:` to find what an app must fill in.
60
107
  `mantle help local-testing` lists what the smoke covers: every Procedure at its edges, each caller's isolation,
61
108
  visitor pages without JavaScript, MCP discovery and Admin's MCP App.
62
109
  Scripted smoke is not independent acceptance; before reporting a user-facing
63
- deliverable done, follow the independent acceptance guide: `mantle help agent-workflow-acceptance`.
110
+ deliverable done, follow the independent acceptance guide: `mantle help agent-workflow-acceptance`. Hand off with
111
+ its layered report ("Report done-ness in layers"): smoke and `pnpm test` are layer 2, `pnpm accept` rounds layer 3.
64
112
 
65
113
  ### Independent acceptance
66
114
 
@@ -115,7 +163,7 @@ and compares exact names without calling business tools. JSON/SSE protocol failu
115
163
  unexpected/missing tools and repeated/excessive pagination fail; it never obtains credentials or mounts routes.
116
164
 
117
165
  This local cookie test does not prove OAuth Bearer or an external client's connection. Test those separately
118
- with supplied credentials and record their results. For each declared interface, the existing handoff/README/test
119
- report must say verified (command, identity and result), intentionally not exposed, or deferred/unverified with a
120
- reason. WebMCP-only applications remain valid; an interrupted preview must retain the remote acceptance item
166
+ with supplied credentials and record their results as layer 4 of the layered report. For each declared interface,
167
+ the existing handoff/README/test report must say verified (command, identity and result), intentionally not exposed,
168
+ or deferred/unverified with a reason. WebMCP-only applications remain valid; an interrupted preview must retain the remote acceptance item
121
169
  as deferred rather than silently claiming completion.
@@ -15,27 +15,27 @@
15
15
  "accept": "node accept.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@aotter/mantle": "0.2.0",
19
- "@aotter/mantle-ui": "0.2.0",
18
+ "@aotter/mantle": "0.2.1-alpha.2",
19
+ "@aotter/mantle-ui": "0.2.1-alpha.2",
20
20
  "@better-auth/cimd": "1.7.7",
21
21
  "@better-auth/core": "1.7.7",
22
22
  "@better-auth/mcp": "1.7.7",
23
23
  "@better-auth/oauth-provider": "1.7.7",
24
24
  "@better-auth/utils": "0.4.2",
25
25
  "@modelcontextprotocol/ext-apps": "2.0.0",
26
- "@modelcontextprotocol/server": "2.2.0",
26
+ "@modelcontextprotocol/server": "2.3.1",
27
27
  "better-auth": "1.7.7",
28
- "@aotter/mantle-admin": "0.2.0",
28
+ "@aotter/mantle-admin": "0.2.1-alpha.2",
29
29
  "react": "19.3.0",
30
30
  "react-dom": "19.3.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@cloudflare/workers-types": "5.20260904.1",
33
+ "@cloudflare/workers-types": "5.20261027.1",
34
34
  "@types/node": "^26",
35
35
  "@types/react": "^19.3.0",
36
36
  "playwright-core": "1.62.1",
37
37
  "typescript": "^6.0.3",
38
- "wrangler": "4.129.0"
38
+ "wrangler": "4.153.0"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=22.22"
@@ -9,6 +9,7 @@ spec:
9
9
  # It also refuses an officer lowering capacity below the seats already taken.
10
10
  checks: ["seats_taken <= capacity"]
11
11
  indexes: [[startsAt, id]]
12
+ searchableFields: [title]
12
13
  uiSchema: { list: { primaryField: title, columns: [startsAt, capacity, seats_taken, closed] } }
13
14
  schema:
14
15
  type: object
@@ -54,6 +55,7 @@ spec:
54
55
  uniqueIndexes: [[email]]
55
56
  # Better Auth lowercases emails; the check refuses a mixed-case entry that would never match.
56
57
  checks: ["email = lower(email)"]
58
+ searchableFields: [email, name]
57
59
  uiSchema: { list: { primaryField: email, columns: [name] } }
58
60
  schema:
59
61
  type: object
@@ -1,6 +1,7 @@
1
1
  -- Local fixture only. Production publishing goes through the generated Procedures.
2
+ -- Every system column is set: timestamps are microseconds since the Unix epoch (2026-01-01T00:00:00Z); 0 would show as 1970.
2
3
  INSERT INTO posts (id, version, created_at, updated_at, title, body, status, published_at) VALUES
3
- ('home', 1, 0, 0, 'Posts', '# Posts — browse the HTML index for articles.', 'published', 0),
4
- ('hello', 1, 0, 0, 'Hello <world>', '# Hello world', 'published', 0),
5
- ('second', 1, 0, 0, 'Second post', '# Second post', 'published', 0),
6
- ('draft', 1, 0, 0, 'Hidden draft', '# Draft', 'draft', NULL);
4
+ ('home', 1, 1767225600000000, 1767225600000000, 'Posts', '# Posts — browse the HTML index for articles.', 'published', 1767225600000000),
5
+ ('hello', 1, 1767225600000000, 1767225600000000, 'Hello <world>', '# Hello world', 'published', 1767225600000000),
6
+ ('second', 1, 1767225600000000, 1767225600000000, 'Second post', '# Second post', 'published', 1767225600000000),
7
+ ('draft', 1, 1767225600000000, 1767225600000000, 'Hidden draft', '# Draft', 'draft', NULL);
@@ -9,6 +9,7 @@ spec:
9
9
  uniqueIndexes: [[department]]
10
10
  # An approval that would overspend fails this check and the whole approval rolls back.
11
11
  checks: ["remaining >= 0"]
12
+ searchableFields: [department]
12
13
  uiSchema: { list: { primaryField: department, columns: [remaining] } }
13
14
  schema:
14
15
  type: object
@@ -25,6 +26,7 @@ spec:
25
26
  title: Purchase requests
26
27
  lifecycle: operational
27
28
  indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id]]
29
+ searchableFields: [title, department]
28
30
  # Sidebar: in the Approvals group (src/admin.tsx), after the queue of requests waiting for a decision.
29
31
  uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { group: approvals, order: 2 } }
30
32
  schema:
@@ -50,6 +52,7 @@ spec:
50
52
  indexes: [[createdAt, id]]
51
53
  # One row per spending request.
52
54
  uniqueIndexes: [[requestId]]
55
+ searchableFields: [department]
53
56
  uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] } }
54
57
  schema:
55
58
  type: object
@@ -44,8 +44,11 @@ An existing S3 signer (for example an application already using aws4fetch) fits
44
44
  this port. This package adds no AWS SDK. The signer must return a SigV4
45
45
  query-signed PUT URL and preserve `X-Amz-Expires`; never give its credentials to
46
46
  the client. Configure bucket CORS to allow PUT from the application's origins
47
- and the returned Content-Type header. Serve the `committed/` prefix on the public domain; these keys differ from 0.2's `{purpose}/{group}/{role}` keys. Keep `uploads/` private/unserved on the
48
- public domain and add a bucket lifecycle rule for abandoned upload keys.
47
+ and the returned Content-Type header. Uploads go to `uploads/<purpose>/<group>/<role>.<ext>` and each commit copies them
48
+ to `committed/<group>/<attempt>/<role>.<ext>`. Serve the `committed/` prefix on the public domain. Keep `uploads/`
49
+ private/unserved on the public domain and add a bucket lifecycle rule for abandoned upload keys. Mantle 0.1.x wrote
50
+ `<purpose>/<group>/<role>.<ext>` keys (`<group>/<role>.<ext>` when the purpose was not a plain slug); an asset reads its keys from the stored `storageKey` and `publicUrl` of each
51
+ variant, so 0.1.x assets keep working while their prefixes stay served.
49
52
 
50
53
  Commit checks every object's MIME and size, then rechecks the exact GET
51
54
  response used for the copy. Public keys cannot be reached by an upload URL. Each commit attempt has a separate
@@ -54,7 +57,7 @@ loser can neither overwrite nor remove the winner's objects. After SQL selects
54
57
  the durable asset, the losing attempt removes only its own unselected copies.
55
58
  Upload sources remain until the database commit succeeds, so an uncertain DB
56
59
  write can be reconciled/retried with the same UUID. Failed object deletion keeps
57
- the media row; retry the same id. An upload group expires after 15 minutes. Asset `createdAt` is the upload-group creation time (epoch milliseconds), not the later commit time.
60
+ the media row; retry the same id. An upload group expires after 15 minutes. Asset `createdAt` (`media_assets.created_at`) is the upload-group creation time in epoch milliseconds, not the later commit time; entry timestamps on D1 are microseconds ([reserved entry columns](../reference/schema.md#reserved-entry-columns)).
58
61
  No R2 upload or deployment is performed by generation.
59
62
 
60
63
  A positively identified native rollback deletes only that attempt's copied
@@ -22,6 +22,8 @@ under `secrets.required`, run `pnpm exec wrangler types --include-runtime=false`
22
22
  add `worker-configuration.d.ts` to `tsconfig.json` and commit it.
23
23
  `@cloudflare/workers-types` still supplies runtime types. Rerun after every binding
24
24
  change; `wrangler types --include-runtime=false --check` fails on drift.
25
+ Both run the custom build (`mantle bundle`), because Wrangler reads the built entry's exports
26
+ for Durable Object and service binding types; Wrangler has no option to skip it.
25
27
  Once `secrets.required` is set, `wrangler dev` loads only those keys from
26
28
  `.dev.vars`/`.env`, so declare every secret the app reads.
27
29
 
@@ -2,7 +2,27 @@
2
2
 
3
3
  Schemas default to `operational`: rows are live immediately and edit in place. Opt-in `publishing` adds `status` (draft/published/archived), nullable `published_at` and a scope-aware `(published_at, id)` index.
4
4
 
5
- Generated `<table>.publish`, `.unpublish` and `.archive` Procedures take `{ id, expectedVersion }`, require admin/system and perform one atomic UPDATE. Publish checks required fields, visibility, TTL, source state and OCC. A failed guard is `CONFLICT`. Store cannot write status/published_at directly. Admin permits generic publishing edits only on drafts; Store does not add that separate Admin editing rule.
5
+ Generated `<table>.publish`, `.unpublish` and `.archive` Procedures take `{ id, expectedVersion }`, require admin/system and perform one atomic UPDATE. Publish checks required fields, visibility, TTL, source state and OCC. A failed guard is `CONFLICT`. Store cannot write status/published_at directly. By default Admin's generic patch edits drafts only (published and archived entries are unpublished first), and a published entry is unpublished before it is deleted; both are an implicit lock named `publishing` that Admin shows before the edit, and neither applies to the transitions. A Store update may edit a published row in place but cannot clear a field `publish` requires: setting one to null is `INPUT_VALIDATION_FAILED` for every caller. Fields it leaves alone are not re-checked, so adding a field to `required` later does not block updates of posts published before it.
6
+
7
+ To let admins correct a published entry in place, opt in with `publishing: { liveEdits: true }`. A personal blog needs nothing more than one Schema:
8
+
9
+ ```yaml
10
+ apiVersion: cms.mantle.aotter.net/v2
11
+ kind: Schema
12
+ metadata: { name: posts }
13
+ spec:
14
+ title: Posts
15
+ lifecycle: publishing
16
+ publishing: { liveEdits: true }
17
+ schema:
18
+ type: object
19
+ required: [title, body]
20
+ properties:
21
+ title: { type: string }
22
+ body: { type: string }
23
+ ```
24
+
25
+ Admin then edits a published post with a "changes go live on save" notice. The save goes live on commit, `published_at` is kept and `updated_at` moves. The merged post must still pass `publish`'s checks, so emptying its title is refused with `INPUT_VALIDATION_FAILED`. An archived post stays draft-only: restore it to edit it. Deleting a published post still requires unpublishing, and [declared locks](../reference/schema.md#locks) still apply. `publishing` is refused on an operational Schema, which already edits in place. To prepare changes to published content without exposing them, split it into a publishing parent and operational revisions instead ([edit published content without downtime](../guides/revisions.md)).
6
26
 
7
27
  Public Views see published rows only. Other reads expose drafts only to admin/system. This policy applies to joins and nested queries too.
8
28
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  Use the [Worker quickstart](../start/quickstart-worker.md) for a complete native manifest and entry, [typed queries](../guides/typed-queries.md) for embedding, and [service composition](../cloudflare/service-entry.md) for optional surfaces.
4
4
 
5
- The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.0/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
5
+ The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.2/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
@@ -6,7 +6,7 @@ Schema `uiSchema` selects fields, widgets, list columns, enum tabs and child nav
6
6
 
7
7
  Admin Views provide custom read lists; their SELECT outputs, column lineage and `uiSchema.list` define display/search/filter metadata. Procedures and MCP admin Triggers define operations with declared authorization, input checks and optional row targets. A hidden button never authorizes an operation.
8
8
 
9
- Generic publishing edits apply to drafts; publishing transitions retain their atomic guards and OCC. Read-only collections refuse generic edits while declared Procedures can still run. Ref operations receive the caller/transaction-bound Store.
9
+ Generic publishing edits apply to drafts unless the Schema sets `publishing: { liveEdits: true }` ([lifecycle](../concepts/lifecycle-and-locales.md)); publishing transitions retain their atomic guards and OCC. Read-only collections refuse generic edits while declared Procedures can still run. Ref operations receive the caller/transaction-bound Store.
10
10
 
11
11
  Use [`defineAdmin` components](native-admin.md#customising-admin) for app-owned cells, inputs, record asides and actions; server work is a Procedure, a View or an application route. Keep exact conflict/uncertain-outcome handling in the shared UI controller.
12
12
 
@@ -13,20 +13,20 @@ carries.
13
13
  The Mantle plugin carries one OSS authoring skill, `mantle`, and local
14
14
  acceptance references. It does not configure a hosted-service connection.
15
15
  Install from the Git tag matching the exact SDK version you selected (currently
16
- `v0.2.0`). Pin the tag to keep instructions aligned with the application's
16
+ `v0.2.1-alpha.2`). Pin the tag to keep instructions aligned with the application's
17
17
  SDK. A skill install does not upgrade the application's SDK.
18
18
 
19
19
  For Codex, pin the marketplace checkout:
20
20
 
21
21
  ```sh
22
- codex plugin marketplace add aotter/mantle --ref v0.2.0
22
+ codex plugin marketplace add aotter/mantle --ref v0.2.1-alpha.2
23
23
  codex plugin add mantle@mantle
24
24
  ```
25
25
 
26
26
  For Claude Code, first clone the release into a directory you will keep:
27
27
 
28
28
  ```sh
29
- git clone --depth 1 --branch v0.2.0 https://github.com/aotter/mantle.git mantle-plugin
29
+ git clone --depth 1 --branch v0.2.1-alpha.2 https://github.com/aotter/mantle.git mantle-plugin
30
30
  ```
31
31
 
32
32
  Then, in Claude Code, use that checkout's absolute path:
@@ -40,9 +40,12 @@ Cursor and GitHub Copilot use their plugin manifests from the same tagged
40
40
  checkout. Without a plugin host, install only the consumer skill by name:
41
41
 
42
42
  ```sh
43
- npx skills add https://github.com/aotter/mantle/tree/v0.2.0 --skill mantle
43
+ npx skills add aotter/mantle@mantle
44
44
  ```
45
45
 
46
+ Without a version it follows `main`, the latest stable release. To match the
47
+ application's SDK exactly, add its release tag: `npx skills add aotter/mantle#v0.2.1-alpha.2@mantle`.
48
+
46
49
  When upgrading the SDK, update the marketplace ref or local tagged checkout and
47
50
  refresh/reinstall the plugin using the host's native workflow. Verify the loaded
48
51
  manifest version and skill source. Do not reset a checkout containing local edits.
@@ -53,14 +56,15 @@ project and copies no handbook.
53
56
 
54
57
  ## 2. Install the SDK, then read its own docs
55
58
 
56
- Use the version the project already pins. For a new project, install stable
57
- 0.2.0 exactly:
59
+ Use the version the project already pins. For a new project, install this
60
+ handbook's version exactly. 0.2.1-alpha.2 is a prerelease; stable 0.2.0 remains the
61
+ production line, and its own tagged handbook describes it.
58
62
 
59
63
  ```sh
60
- pnpm add --save-exact @aotter/mantle@0.2.0
64
+ pnpm add --save-exact @aotter/mantle@0.2.1-alpha.2
61
65
  ```
62
66
 
63
- Add optional `@aotter/mantle-ui@0.2.0` and `@aotter/mantle-admin@0.2.0` only
67
+ Add optional `@aotter/mantle-ui@0.2.1-alpha.2` and `@aotter/mantle-admin@0.2.1-alpha.2` only
64
68
  when the selected surfaces need them. Every selected `@aotter/mantle*` package
65
69
  stays at the same exact version. Read, from the application:
66
70
 
@@ -81,6 +85,7 @@ the skill's GitHub copy and over any online handbook.
81
85
  | `docs/skills/update/SKILL.md` | upgrading `@aotter/mantle`, including from 0.1.x |
82
86
  | `docs/skills/provision/SKILL.md` | deploying, with production sign-in |
83
87
  | `docs/skills/media-gc/SKILL.md` | auditing or removing stale R2 uploads |
88
+ | `docs/skills/performance/SKILL.md` | after acceptance converges, or when a page, first request or query is slow |
84
89
 
85
90
  0.2.0 has no `mantle skills` command: nothing is copied into the project.
86
91
  An agent reads these files from `node_modules` when the task calls for them.