Skip to content

Commit 350ef62

Browse files
authored
Merge pull request #983 from SonicJs-Org/lane711/v3-beta-issue-triage
fix: media count/search, create-folder, plugin sidebar/pruning, save-in-place, role sidebar, block IDs, wrangler config, field projection, docs types (#638 #639 #640 #642 #783 #831 #835 #888 #890 #928 #929 #931 #971 #970)
2 parents afa2323 + ab6bf15 commit 350ef62

25 files changed

Lines changed: 711 additions & 89 deletions

my-sonicjs-app/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
"deploy": "wrangler deploy",
1414
"db:migrate": "wrangler d1 migrations apply DB",
1515
"db:migrate:local": "wrangler d1 migrations apply DB --local",
16-
"db:studio": "drizzle-kit studio",
1716
"type-check": "tsc --noEmit",
1817
"test": "vitest --run",
1918
"test:watch": "vitest",
@@ -36,8 +35,6 @@
3635
"devDependencies": {
3736
"@cloudflare/workers-types": "^4.20250620.0",
3837
"@types/node": "^20.19.1",
39-
"drizzle-kit": "^0.30.0",
40-
"drizzle-orm": "^0.45.2",
4138
"hono": "^4.12.26",
4239
"typescript": "^5.8.3",
4340
"vitest": "^4.1.9",

my-sonicjs-app/scripts/generate-cron-triggers.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,18 @@ const allPlugins: Array<{ name?: string; crons?: Array<{ schedule: string }> }>
2626

2727
const schedules = [...new Set(allPlugins.flatMap((p) => p.crons ?? []).map((c) => c.schedule))].sort()
2828

29-
const wranglerPath = resolve(__dirname, '..', 'wrangler.toml')
29+
// Support wrangler.toml, wrangler.jsonc, or wrangler.json
30+
import { existsSync } from 'fs'
31+
const candidates = ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json']
32+
const wranglerPath = candidates.map(f => resolve(__dirname, '..', f)).find(p => existsSync(p))
33+
if (!wranglerPath) {
34+
console.error('[cron-triggers] No wrangler config found (wrangler.toml / wrangler.jsonc / wrangler.json)')
35+
process.exit(1)
36+
}
37+
if (!wranglerPath.endsWith('.toml')) {
38+
console.error('[cron-triggers] wrangler.jsonc/json detected — cron trigger codegen only supports wrangler.toml. Skipping.')
39+
process.exit(0)
40+
}
3041
const current = readFileSync(wranglerPath, 'utf8')
3142

3243
const isCheck = process.argv.includes('--check')

my-sonicjs-app/wrangler.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ workers_dev = true
1313
# Note: database_name and database_id are automatically updated by GitHub Actions
1414
[[d1_databases]]
1515
binding = "DB"
16-
database_name = "sonicjs-worktree-lane711-menu-plugin-plan"
17-
database_id = "2bffa108-2c31-4710-9a04-8b4f97b16b72"
16+
database_name = "sonicjs-worktree-lane711-v3-beta-issue-triage"
17+
database_id = "02e853a0-e3cc-49fb-990e-2b679513f10a"
1818
migrations_dir = "./migrations"
1919

2020
# R2 Bucket for media storage (using CI bucket)

packages/core/bin/db-reset.js

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,16 @@ async function prompt(question) {
4949
});
5050
}
5151

52-
function findWranglerToml() {
53-
// Look for wrangler.toml in current directory or parent directories
52+
function findWranglerConfig() {
53+
// Prefer wrangler.toml, then wrangler.jsonc, then wrangler.json
54+
const candidates = ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'];
5455
let dir = process.cwd();
5556
for (let i = 0; i < 5; i++) {
56-
const tomlPath = join(dir, 'wrangler.toml');
57-
if (existsSync(tomlPath)) {
58-
return { path: tomlPath, dir };
57+
for (const filename of candidates) {
58+
const configPath = join(dir, filename);
59+
if (existsSync(configPath)) {
60+
return { path: configPath, dir, format: filename.endsWith('.toml') ? 'toml' : 'json' };
61+
}
5962
}
6063
dir = dirname(dir);
6164
}
@@ -66,16 +69,16 @@ async function main() {
6669
log('\n🔧 SonicJS Database Reset Tool', colors.cyan + colors.bold);
6770
log('================================\n', colors.cyan);
6871

69-
// Find wrangler.toml
70-
const wranglerInfo = findWranglerToml();
72+
// Find wrangler config (toml, jsonc, or json)
73+
const wranglerInfo = findWranglerConfig();
7174
if (!wranglerInfo) {
72-
log('Error: Could not find wrangler.toml in current or parent directories', colors.red);
75+
log('Error: Could not find wrangler.toml / wrangler.jsonc / wrangler.json in current or parent directories', colors.red);
7376
log('Please run this command from your SonicJS project directory.', colors.yellow);
7477
process.exit(1);
7578
}
7679

77-
const { path: wranglerPath, dir: projectDir } = wranglerInfo;
78-
log(`Found wrangler.toml at: ${wranglerPath}`, colors.green);
80+
const { path: wranglerPath, dir: projectDir, format: wranglerFormat } = wranglerInfo;
81+
log(`Found wrangler config at: ${wranglerPath}`, colors.green);
7982

8083
// Change to project directory
8184
process.chdir(projectDir);
@@ -176,22 +179,36 @@ async function main() {
176179

177180
log(`\nDatabase ID: ${dbId}`, colors.green);
178181

179-
// Update wrangler.toml with the new database ID
180-
log('\nUpdating wrangler.toml...', colors.yellow);
182+
// Update wrangler config with the new database ID
183+
const configBasename = wranglerPath.split('/').pop();
184+
log(`\nUpdating ${configBasename}...`, colors.yellow);
181185
try {
182186
let wranglerContent = readFileSync(wranglerPath, 'utf-8');
183-
wranglerContent = wranglerContent.replace(
184-
/database_id\s*=\s*"[^"]*"/,
185-
`database_id = "${dbId}"`
186-
);
187-
wranglerContent = wranglerContent.replace(
188-
/database_name\s*=\s*"[^"]*"/,
189-
`database_name = "${dbName}"`
190-
);
187+
if (wranglerFormat === 'toml') {
188+
// TOML syntax: database_id = "value"
189+
wranglerContent = wranglerContent.replace(
190+
/database_id\s*=\s*"[^"]*"/,
191+
`database_id = "${dbId}"`
192+
);
193+
wranglerContent = wranglerContent.replace(
194+
/database_name\s*=\s*"[^"]*"/,
195+
`database_name = "${dbName}"`
196+
);
197+
} else {
198+
// JSON/JSONC syntax: "database_id": "value"
199+
wranglerContent = wranglerContent.replace(
200+
/"database_id"\s*:\s*"[^"]*"/,
201+
`"database_id": "${dbId}"`
202+
);
203+
wranglerContent = wranglerContent.replace(
204+
/"database_name"\s*:\s*"[^"]*"/,
205+
`"database_name": "${dbName}"`
206+
);
207+
}
191208
writeFileSync(wranglerPath, wranglerContent);
192-
log('Updated wrangler.toml successfully', colors.green);
209+
log(`Updated ${configBasename} successfully`, colors.green);
193210
} catch (error) {
194-
log(`Error updating wrangler.toml: ${error.message}`, colors.red);
211+
log(`Error updating ${configBasename}: ${error.message}`, colors.red);
195212
process.exit(1);
196213
}
197214

packages/core/src/__tests__/utils/blocks.test.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('blocks utils', () => {
2525
})
2626
})
2727

28-
it('parses blocks JSON with discriminator key', () => {
28+
it('parses blocks JSON with discriminator key and assigns stable blockId', () => {
2929
const config = {
3030
blocks: {
3131
text: { properties: { heading: { type: 'string' } } }
@@ -39,10 +39,31 @@ describe('blocks utils', () => {
3939
const parsed = parseBlocksValue(input, config)
4040

4141
expect(parsed.errors).toEqual([])
42-
expect(parsed.value).toEqual([{ blockType: 'text', heading: 'Hello' }])
42+
expect(parsed.value).toHaveLength(1)
43+
expect(parsed.value[0].blockType).toBe('text')
44+
expect(parsed.value[0].heading).toBe('Hello')
45+
expect(parsed.value[0].blockId).toMatch(/^blk_[0-9a-f]{12}$/)
4346
})
4447

45-
it('converts legacy blockType/data shape', () => {
48+
it('preserves existing blockId on re-parse (reorder/edit)', () => {
49+
const config = {
50+
blocks: {
51+
text: { properties: { heading: { type: 'string' } } }
52+
},
53+
discriminator: 'blockType'
54+
}
55+
const existingId = 'blk_aabbccddeeff'
56+
const input = JSON.stringify([
57+
{ blockId: existingId, blockType: 'text', heading: 'Hello' }
58+
])
59+
60+
const parsed = parseBlocksValue(input, config)
61+
62+
expect(parsed.errors).toEqual([])
63+
expect(parsed.value[0].blockId).toBe(existingId)
64+
})
65+
66+
it('converts legacy blockType/data shape and assigns blockId', () => {
4667
const config = {
4768
blocks: {
4869
text: { properties: { heading: { type: 'string' } } }
@@ -56,7 +77,9 @@ describe('blocks utils', () => {
5677
const parsed = parseBlocksValue(input, config)
5778

5879
expect(parsed.errors).toEqual([])
59-
expect(parsed.value).toEqual([{ blockType: 'text', heading: 'Hello' }])
80+
expect(parsed.value[0].blockType).toBe('text')
81+
expect(parsed.value[0].heading).toBe('Hello')
82+
expect(parsed.value[0].blockId).toMatch(/^blk_[0-9a-f]{12}$/)
6083
})
6184

6285
it('reports errors for invalid JSON and missing discriminator', () => {
@@ -71,6 +94,6 @@ describe('blocks utils', () => {
7194
expect(invalidJson.errors).toEqual(['Blocks value must be valid JSON'])
7295

7396
const missingDiscriminator = parseBlocksValue([{ heading: 'Hello' }], config)
74-
expect(missingDiscriminator.errors).toEqual(['Block #1 is missing \"blockType\"'])
97+
expect(missingDiscriminator.errors).toEqual(['Block #1 is missing "blockType"'])
7598
})
7699
})

packages/core/src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp {
678678
const userPlugins = (config.plugins?.register ?? []).filter(
679679
(p: any) => !PLUGIN_REGISTRY[p.id]
680680
)
681-
setPluginMenu(userPlugins.flatMap((p: any) => (p.menu ?? [])))
681+
setPluginMenu(userPlugins.flatMap((p: any) => (p.menu ?? []).map((m: any) => ({ ...m, pluginId: p.id }))))
682682
setPluginDefinitions(allMountedPlugins)
683683
}
684684

packages/core/src/middleware/plugin-menu.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ export function pluginMenuMiddleware() {
7777
}
7878

7979
// Collect menu items from active plugins using the registry
80+
const db = c.env.DB
8081
let activeMenuItems: Array<{ label: string; path: string; icon?: string; order: number }> = []
8182
try {
82-
const db = c.env.DB
8383
const pluginIds = REGISTRY_MENU_PLUGINS.map(p => p.id)
8484
if (pluginIds.length > 0) {
8585
const placeholders = pluginIds.map(() => '?').join(',')
@@ -107,14 +107,34 @@ export function pluginMenuMiddleware() {
107107
// DB not ready or plugin table doesn't exist yet
108108
}
109109

110-
// Append user plugin menu items from the singleton.
111-
// The singleton is populated with ONLY config.plugins.register entries (user plugins),
112-
// so no DB active-check is needed — user plugins are always mounted when configured.
110+
// Append user plugin menu items from the singleton (config.plugins.register entries).
111+
// Must DB-check active status — a plugin disabled via the admin UI stays in config
112+
// but its document record is set to inactive (issue #971).
113113
const user = c.get('user') as { role?: string } | undefined
114114
const singletonItems = resolvePluginMenuItems(user ? { role: user.role } : undefined)
115+
116+
// Collect unique pluginIds that have entries, query their active status in one shot.
117+
const singletonPluginIds = [...new Set(
118+
singletonItems.map(i => i.pluginId).filter((id): id is string => !!id)
119+
)]
120+
let activeSingletonIds = new Set<string>()
121+
try {
122+
if (singletonPluginIds.length > 0) {
123+
const ph = singletonPluginIds.map(() => '?').join(',')
124+
const result = await db.prepare(
125+
`SELECT slug FROM documents WHERE type_id = 'plugin' AND tenant_id = 'default' AND slug IN (${ph}) AND q_plugin_status = 'active' AND is_current_draft = 1 AND deleted_at IS NULL`
126+
).bind(...singletonPluginIds).all()
127+
activeSingletonIds = new Set((result.results || []).map((r: any) => r.slug))
128+
}
129+
} catch {
130+
// DB not ready — fail open so singleton items without pluginId still render
131+
}
132+
115133
const existingPaths = new Set(activeMenuItems.map(i => i.path))
116134
for (const item of singletonItems) {
117-
if (!existingPaths.has(item.path)) {
135+
// If item has a pluginId, require it to be active; items without pluginId always show.
136+
const isActive = !item.pluginId || activeSingletonIds.has(item.pluginId)
137+
if (isActive && !existingPaths.has(item.path)) {
118138
activeMenuItems.push({ label: item.label, path: item.path, icon: item.icon, order: item.order })
119139
}
120140
}

packages/core/src/plugins/wire.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,79 @@ export async function wireRegisteredPlugins(
166166
// so the admin view stays in sync with what's actually running. Errors are
167167
// non-fatal — DB unavailable during cron-first boot should not break wiring.
168168
if (context.env?.DB) {
169-
reflectWiredPlugins(valid, context.env.DB as D1DatabaseLike).catch((err) => {
169+
const db = context.env.DB as D1DatabaseLike
170+
reflectWiredPlugins(valid, db).catch((err) => {
170171
console.warn('[plugins] DB reflection failed (non-fatal):', err)
171172
})
173+
// Phase D (best-effort): deactivate user plugins that were removed from config.
174+
// Any non-core active plugin document not in the current wired list is stale.
175+
pruneStaleUserPlugins(valid, db).catch((err) => {
176+
console.warn('[plugins] Stale plugin pruning failed (non-fatal):', err)
177+
})
172178
}
173179

174180
return result
175181
}
176182

177183
/** Minimal D1 interface needed for reflection (avoids a hard @cloudflare/workers-types dep). */
178184
interface D1DatabaseLike {
179-
prepare(sql: string): { bind(...args: unknown[]): { run(): Promise<unknown> } }
185+
prepare(sql: string): {
186+
bind(...args: unknown[]): {
187+
run(): Promise<unknown>
188+
all(): Promise<{ results: Array<Record<string, unknown>> }>
189+
}
190+
all(): Promise<{ results: Array<Record<string, unknown>> }>
191+
}
180192
batch(stmts: unknown[]): Promise<unknown>
181193
}
182194

195+
/**
196+
* Best-effort: deactivate non-core plugin documents whose plugin is no longer
197+
* present in config.plugins.register. Runs after reflectWiredPlugins so the
198+
* wired set is the authoritative "what's configured" list.
199+
*
200+
* Only touches documents where json_extract(data, '$.isCore') is falsy — core
201+
* plugins bootstrapped by PluginBootstrapService are never pruned.
202+
*/
203+
async function pruneStaleUserPlugins(wired: WirablePlugin[], db: D1DatabaseLike): Promise<void> {
204+
const wiredIds = new Set(wired.map(p => (p as any).id ?? p.name).filter(Boolean))
205+
if (wiredIds.size === 0) return
206+
207+
const now = Math.floor(Date.now() / 1000)
208+
209+
// Find active non-core plugin documents whose slug is not in the wired set.
210+
const rows = await db
211+
.prepare(
212+
`SELECT slug FROM documents
213+
WHERE type_id = 'plugin' AND tenant_id = 'default'
214+
AND is_current_draft = 1 AND deleted_at IS NULL
215+
AND q_plugin_status = 'active'
216+
AND (json_extract(data, '$.isCore') = 0 OR json_extract(data, '$.isCore') IS NULL)`
217+
)
218+
.all()
219+
220+
for (const row of rows.results ?? []) {
221+
const slug = row.slug as string
222+
if (!slug || wiredIds.has(slug)) continue
223+
// Set status to inactive in the data JSON and update the generated column.
224+
try {
225+
await db
226+
.prepare(
227+
`UPDATE documents
228+
SET data = json_set(data, '$.status', 'inactive'),
229+
updated_at = ?
230+
WHERE slug = ? AND type_id = 'plugin' AND tenant_id = 'default'
231+
AND is_current_draft = 1 AND deleted_at IS NULL`
232+
)
233+
.bind(now, slug)
234+
.run()
235+
console.log(`[plugins] Deactivated stale plugin "${slug}" (removed from config)`)
236+
} catch {
237+
// Non-fatal per-row — continue with others
238+
}
239+
}
240+
}
241+
183242
/**
184243
* Best-effort: register each wired plugin in the `plugins` table so the admin
185244
* can see and manage it. New plugins start as 'inactive' — an admin must

packages/core/src/routes/admin-content.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,10 +1412,33 @@ adminContentRoutes.put('/:id', async (c) => {
14121412
else if (pub) await svc.unpublish(pub.id)
14131413

14141414
await getCacheService(CACHE_CONFIGS.content!).invalidate(`content:list:*`)
1415+
1416+
const isHTMX = c.req.header('HX-Request') === 'true'
1417+
const referrerParams = formData.get('referrer_params') as string | null
1418+
const listUrl = referrerParams
1419+
? `/admin/content?${referrerParams}`
1420+
: `/admin/content?collection=${dcoll.id}`
1421+
1422+
if (action === 'save_and_close') {
1423+
return isHTMX
1424+
? c.text('', 200, { 'HX-Redirect': listUrl })
1425+
: c.redirect(listUrl)
1426+
}
1427+
1428+
// save / save_and_publish: stay on page, return inline success banner
1429+
if (isHTMX) {
1430+
const label = status === 'published' ? 'Content published successfully!' : 'Content saved successfully!'
1431+
return c.html(html`<div id="form-messages">
1432+
<div class="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 mb-4 flex items-center gap-3 border border-green-200 dark:border-green-800">
1433+
<svg class="h-5 w-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
1434+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
1435+
</svg>
1436+
<span class="text-sm font-medium text-green-800 dark:text-green-200">${label}</span>
1437+
</div>
1438+
</div>`)
1439+
}
14151440
const redirectUrl = `/admin/content/${id}/edit?success=Content updated successfully!`
1416-
return c.req.header('HX-Request') === 'true'
1417-
? c.text('', 200, { 'HX-Redirect': redirectUrl })
1418-
: c.redirect(redirectUrl)
1441+
return c.redirect(redirectUrl)
14191442
}
14201443
}
14211444

0 commit comments

Comments
 (0)