Skip to content

Commit 3b0c8ef

Browse files
treodenclaude
andcommitted
feat(metafield): theme integration + single-field setter fix
Let themes declare custom fields and render their values, and fix the long-broken single-field metafield setters. Provisioning - theme.json gains `metafieldDefinitions[]`, seeded through the theme installer at `theme:active` and re-ensured idempotently at server boot (covers baked-config deploys that never run the CLI). Strict manifest schema; `required:true` refused; conflict + orphan reporting. - Per-theme attribution via a nullable `metafield_definition .provisioned_by_theme` column (base Version-1.0.7) — no separate table. Drives the deletion guard, `theme:export` round-trip, and `theme:status`; orphan/retired states derive at read time. Uninstall leaves definitions in place. Storefront read path - `<Metafield>` component (components/common/metafield): purely presentational, developer supplies the entity; render prop + scalar default; hidden fields render nothing; defaults from the manifest placeholder. Fed by a manifest-descriptor projection delivered through the appConfig eContext seam. - Request-scoped definition cache stashed at both GraphQL context sites so grids don't multiply definition queries. Default productView/categoryView masters select `metafields`. Owners - Blog (`blog_post`, `blog_category`) wired end to end: fold processors, payload schemas, GraphQL, prune subscribers, admin cards, masters. Page builder - V1.1 guideline drawer: "Live data" chrome + informational drawer with definition status, create-if-missing, immutable-conflict banner, and deep links. Values are live entity data — never edited from the builder. Bug fix - The six single-field setters (setProductMetafield, …) shared an untyped `jsonb_build_object` key param and failed to parse on every call. Now routed through a shared `buildMetaDataUpdate` (::text cast); a blank value removes the key instead of storing a JSON null residue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f06495 commit 3b0c8ef

69 files changed

Lines changed: 3490 additions & 178 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/evershop/src/bin/lib/startUp.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import config from 'config';
55
import spawn from 'cross-spawn';
66
import { error, debug } from '../../lib/log/logger.js';
77
import { Handler } from '../../lib/middleware/Handler.js';
8+
import { provisionActiveThemeMetafields } from '../../lib/theme/provisionOnBoot.js';
89
import { lockHooks } from '../../lib/util/hookable.js';
910
import isDevelopmentMode from '../../lib/util/isDevelopmentMode.js';
1011
import { lockRegistry } from '../../lib/util/registry.js';
@@ -54,6 +55,14 @@ export const start = async function start(context, cb) {
5455
process.exit(0);
5556
}
5657

58+
/**
59+
* Theme metafield provisioning — must run AFTER migrations (bootstraps run
60+
* before them, so the tables don't exist yet on a fresh DB) and only in the
61+
* main web process (the cron/subscriber children below never reach this).
62+
* Idempotent; never throws.
63+
*/
64+
await provisionActiveThemeMetafields();
65+
5766
/**
5867
* Get port from environment and store in Express.
5968
*/

packages/evershop/src/bin/theme/active.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import kleur from 'kleur';
1111
import ora from 'ora';
1212
import yargs from 'yargs';
1313
import { hideBin } from 'yargs/helpers';
14+
import {
15+
provisionThemeMetafields,
16+
type ProvisionResult
17+
} from '../../lib/metafield/provision.js';
1418
import { pool } from '../../lib/postgres/connection.js';
1519
import { dryRunDiff, installOrUpgrade } from '../../lib/theme/install.js';
1620
import {
@@ -160,6 +164,47 @@ function printCounts(counts: {
160164
);
161165
}
162166

167+
function printProvisionResult(result: ProvisionResult): void {
168+
if (
169+
result.seeded.length + result.adopted.length + result.retired.length >
170+
0
171+
) {
172+
console.log(
173+
` Metafields: ${result.seeded.length} seeded, ` +
174+
`${result.adopted.length} adopted, ${result.retired.length} retired`
175+
);
176+
for (const ref of result.retired) {
177+
console.log(
178+
kleur.dim(
179+
` retired '${ref}' — no longer declared by the manifest ` +
180+
`(definition left in place; still visible in entity forms)`
181+
)
182+
);
183+
}
184+
}
185+
if (result.conflicts.length > 0) {
186+
console.log(
187+
kleur.yellow(` Metafield conflicts: ${result.conflicts.length} (declaration skipped, existing definition kept):`)
188+
);
189+
for (const c of result.conflicts) {
190+
for (const d of c.details) {
191+
console.log(
192+
kleur.yellow(
193+
` ${c.ref}: ${d.field} declared=${JSON.stringify(d.declared)} ` +
194+
`existing=${JSON.stringify(d.incumbent)} — immutable; use a new key`
195+
)
196+
);
197+
}
198+
}
199+
}
200+
for (const w of result.warnings) {
201+
console.warn(kleur.yellow(` metafield: ${w.message}`));
202+
}
203+
for (const e of result.errors) {
204+
console.error(kleur.red(` metafield: ${e.message}`));
205+
}
206+
}
207+
163208
/**
164209
* Run the manifest install pipeline (validate → soft-warn → dry-run|apply).
165210
* Returns false to abort activation (validation failure), true to proceed.
@@ -197,6 +242,13 @@ async function runInstallPipeline(
197242
printCounts(diff.counts);
198243
console.log(` Conflicts: ${diff.conflicts.length}`);
199244
}
245+
const declared = manifest.metafieldDefinitions?.length ?? 0;
246+
if (declared > 0) {
247+
console.log(
248+
` Metafield definitions declared: ${declared} (ensured on ` +
249+
`activation and at every server boot — not part of the content diff)`
250+
);
251+
}
200252
return false; // dry run never proceeds to config write
201253
}
202254

@@ -255,6 +307,18 @@ async function runInstallPipeline(
255307
}
256308
}
257309
}
310+
311+
// Metafield definitions ensure — its own step and transaction, AFTER the
312+
// installer: three installer branches commit-and-return early (fresh
313+
// install, rejected, no-op) and the ensure must run on every outcome
314+
// except a rejected downgrade. Idempotent; also re-runs at server boot.
315+
const provision = await provisionThemeMetafields(
316+
themeId,
317+
manifest.metafieldDefinitions ?? [],
318+
pool
319+
);
320+
printProvisionResult(provision);
321+
258322
return true;
259323
}
260324

packages/evershop/src/bin/theme/status.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,28 @@ import { readManifest } from '../../lib/theme/manifest.js';
1111
import { assertValidThemeId } from '../../lib/theme/themeId.js';
1212
import { getActiveTheme } from '../../lib/util/getActiveTheme.js';
1313

14+
interface ProvisionRow {
15+
theme: string;
16+
c: number;
17+
}
18+
19+
/** Per-theme counts of theme-provisioned definitions (derived from the
20+
* provisioned_by_theme attribution column); [] on unmigrated DBs. */
21+
async function loadProvisionSummary(): Promise<ProvisionRow[]> {
22+
try {
23+
const { rows } = await pool.query<ProvisionRow>(
24+
`SELECT provisioned_by_theme AS theme, COUNT(*)::int AS c
25+
FROM metafield_definition
26+
WHERE provisioned_by_theme IS NOT NULL
27+
GROUP BY provisioned_by_theme
28+
ORDER BY provisioned_by_theme`
29+
);
30+
return rows;
31+
} catch {
32+
return []; // column absent (42703) — DB not migrated yet
33+
}
34+
}
35+
1436
/**
1537
* `theme:status [<theme-id>]` (spec 04 § 6.2).
1638
* - No arg: list every theme with installed content + the active marker.
@@ -40,6 +62,49 @@ async function listInstalled(): Promise<void> {
4062
` ${r.theme} updated ${new Date(r.updated_at).toISOString()}${marker}`
4163
);
4264
}
65+
66+
// Attribution survives uninstall (it lives on the definition rows, which
67+
// are left in place), so report per theme — definitions from a theme that
68+
// isn't active anymore are orphans the merchant may want to clean up.
69+
const provisions = await loadProvisionSummary();
70+
if (provisions.length > 0) {
71+
console.log(kleur.bold('Theme-provisioned metafield definitions:'));
72+
for (const p of provisions) {
73+
const orphan =
74+
p.theme !== active
75+
? kleur.yellow(' (orphaned — theme not active)')
76+
: '';
77+
console.log(` ${p.theme} ${p.c}${orphan}`);
78+
}
79+
}
80+
}
81+
82+
async function printThemeProvisions(id: string): Promise<void> {
83+
let rows: Array<{ owner_type: string; namespace: string; field_key: string }>;
84+
try {
85+
rows = (
86+
await pool.query<{
87+
owner_type: string;
88+
namespace: string;
89+
field_key: string;
90+
}>(
91+
`SELECT owner_type, namespace, field_key
92+
FROM metafield_definition
93+
WHERE provisioned_by_theme = $1
94+
ORDER BY owner_type, namespace, field_key`,
95+
[id]
96+
)
97+
).rows;
98+
} catch {
99+
return; // attribution column absent — DB not migrated yet
100+
}
101+
if (rows.length === 0) return;
102+
console.log(
103+
kleur.bold(` Provisioned metafield definitions (${rows.length}):`)
104+
);
105+
for (const r of rows) {
106+
console.log(` ${r.owner_type}.${r.namespace}.${r.field_key}`);
107+
}
43108
}
44109

45110
async function showDetail(id: string): Promise<void> {
@@ -49,14 +114,17 @@ async function showDetail(id: string): Promise<void> {
49114
[id]
50115
);
51116
if (state.rows.length === 0) {
117+
// Provisions survive uninstall — report them even without install state.
52118
console.log(`Theme '${id}' has no content installed.`);
119+
await printThemeProvisions(id);
53120
return;
54121
}
55122
const manifest = await readManifest(themeDir(id));
56123
if (!manifest) {
57124
console.log(
58125
`Theme '${id}' is installed (snapshot only; theme.json missing on disk).`
59126
);
127+
await printThemeProvisions(id);
60128
return;
61129
}
62130
const diff = await dryRunDiff(id, manifest, pool);
@@ -72,10 +140,17 @@ async function showDetail(id: string): Promise<void> {
72140
console.log(
73141
kleur.bold(`Theme '${id}' (version ${manifest.version}): `) + pending
74142
);
75-
console.log(` Added: ${c.widgets_added} widgets, ${c.placements_added} placements`);
76-
console.log(` Updated: ${c.widgets_updated} widgets, ${c.placements_updated} placements`);
77-
console.log(` Removed: ${c.widgets_removed} widgets, ${c.placements_removed} placements`);
143+
console.log(
144+
` Added: ${c.widgets_added} widgets, ${c.placements_added} placements`
145+
);
146+
console.log(
147+
` Updated: ${c.widgets_updated} widgets, ${c.placements_updated} placements`
148+
);
149+
console.log(
150+
` Removed: ${c.widgets_removed} widgets, ${c.placements_removed} placements`
151+
);
78152
console.log(` Conflicts: ${diff.conflicts.length}`);
153+
await printThemeProvisions(id);
79154
}
80155

81156
(themeId ? showDetail(themeId) : listInstalled())

packages/evershop/src/bin/theme/uninstall.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ async function main(): Promise<void> {
3131
console.log(
3232
` ${preview.changesets} draft changeset(s), ${preview.rollouts} rollout plan(s)`
3333
);
34+
if (preview.metafieldProvisions > 0) {
35+
console.log(
36+
kleur.yellow(
37+
` Note: ${preview.metafieldProvisions} metafield definition(s) provisioned by this theme are LEFT IN PLACE\n` +
38+
` (stored values survive; they stay visible in entity edit forms — see theme:status).`
39+
)
40+
);
41+
}
3442
for (const d of preview.draftDetails) {
3543
console.log(kleur.dim(` draft '${d.name}' (${d.opsCount} ops)`));
3644
}

packages/evershop/src/components/admin/metafield/MetafieldValueInput.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface FieldDescriptor {
3535
required?: boolean;
3636
validations?: Validation[];
3737
subFields?: FieldDescriptor[];
38+
appearance?: Record<string, unknown>;
3839
}
3940

4041
function choicesOf(field: FieldDescriptor): Array<string | number> | undefined {
@@ -145,13 +146,20 @@ function ScalarInput({
145146
isSubField?: boolean;
146147
}) {
147148
const validation = toValidationRules(field, !isSubField);
149+
// The theme's declared default (appearance.placeholder) renders as the
150+
// input hint so the merchant sees in the form exactly what the storefront
151+
// shows while the field is unset (theme-metafields design, Decision 5).
152+
const placeholder =
153+
typeof field.appearance?.placeholder === 'string'
154+
? field.appearance.placeholder
155+
: undefined;
148156
switch (field.type) {
149157
case 'long_text':
150158
return (
151159
<TextareaField
152160
name={name}
153161
rows={3}
154-
placeholder={_('Enter text')}
162+
placeholder={placeholder ?? _('Enter text')}
155163
validation={validation}
156164
/>
157165
);
@@ -160,7 +168,7 @@ function ScalarInput({
160168
<NumberField
161169
name={name}
162170
allowDecimals={false}
163-
placeholder="0"
171+
placeholder={placeholder ?? '0'}
164172
validation={validation}
165173
/>
166174
);
@@ -169,7 +177,7 @@ function ScalarInput({
169177
<NumberField
170178
name={name}
171179
allowDecimals
172-
placeholder="0"
180+
placeholder={placeholder ?? '0'}
173181
validation={validation}
174182
/>
175183
);
@@ -187,7 +195,7 @@ function ScalarInput({
187195
<InputField
188196
name={name}
189197
type="text"
190-
placeholder={_('Enter text')}
198+
placeholder={placeholder ?? _('Enter text')}
191199
validation={validation}
192200
/>
193201
);
@@ -198,19 +206,21 @@ function ScalarInput({
198206
function ListItemInput({
199207
type,
200208
value,
201-
onChange
209+
onChange,
210+
placeholder
202211
}: {
203212
type: string;
204213
value: unknown;
205214
onChange: (value: unknown) => void;
215+
placeholder?: string;
206216
}) {
207217
const str = value === undefined || value === null ? '' : String(value);
208218
if (type === 'long_text') {
209219
return (
210220
<Textarea
211221
rows={2}
212222
value={str}
213-
placeholder={_('Enter text')}
223+
placeholder={placeholder ?? _('Enter text')}
214224
onChange={(e) => onChange(e.target.value)}
215225
className="flex-1"
216226
/>
@@ -221,7 +231,7 @@ function ListItemInput({
221231
<Input
222232
type="number"
223233
step={type === 'integer' ? '1' : 'any'}
224-
placeholder="0"
234+
placeholder={placeholder ?? '0'}
225235
value={str}
226236
onChange={(e) =>
227237
onChange(e.target.value === '' ? '' : Number(e.target.value))
@@ -241,7 +251,9 @@ function ListItemInput({
241251
return (
242252
<Input
243253
type={inputType}
244-
placeholder={inputType === 'text' ? _('Enter text') : undefined}
254+
placeholder={
255+
placeholder ?? (inputType === 'text' ? _('Enter text') : undefined)
256+
}
245257
value={str}
246258
onChange={(e) => onChange(e.target.value)}
247259
className="flex-1"
@@ -303,6 +315,11 @@ function ListValueEditor({
303315
</div>
304316
<ListItemInput
305317
type={field.type}
318+
placeholder={
319+
typeof field.appearance?.placeholder === 'string'
320+
? field.appearance.placeholder
321+
: undefined
322+
}
306323
value={item}
307324
onChange={(v) =>
308325
commit(items.map((it, j) => (j === index ? v : it)))

0 commit comments

Comments
 (0)