fix: materialize plugin-declared storage indexes - #2308
Conversation
The index-materialization module (storage-indexes.ts, _plugin_indexes tracking table) existed but had no production callers: manifest index declarations were only used as a query-validation allowlist, so every PluginStorageRepository.query ordering by a JSON field ran as a full scan plus temp B-tree sort — 16-20 ms of synchronous work per audit-log dashboard load on an audited 11 MiB production table. Two coordinated changes: - Wire syncStorageIndexes into every plugin lifecycle moment: marketplace and registry install/update create declared indexes, uninstall drops them, and configured (in-config) plugins — which have no install handler — sync once per process on the scheduler tick, off the request path. - Change the generated index shape from a partial index (WHERE plugin_id/collection literals) to a composite non-partial one (plugin_id, collection, expr...). SQLite never chooses the partial shape under bound parameters without ANALYZE statistics, which D1 never collects — so even wired up, the old shape indexed nothing in practice. Unique-index scoping is preserved by the leading columns. No deployed database has old-shape indexes (nothing ever created them). On Postgres the composite index accelerates WHERE filters but not the jsonb ORDER BY (the order expression uses '->' while the index uses '->>'); SQLite/D1 — where the measured problem lives — serves both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: e71eccb The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This PR fixes a real, measured problem: plugin-declared storage indexes were never created, so plugin storage queries fell back to full scans. The approach is sound and fits EmDash’s architecture—lifecycle hooks in marketplace/registry handlers, plus one idempotent scheduler-time sync for configured plugins that have no install path. I focused on the SQL generation, the lifecycle wiring, and the tests.
Headline: the implementation is close, but one validation bug undermines the fix for valid plugin manifests. generateCreateIndexSql() validates the collection name with validateIdentifier(), which only allows /^[a-z][a-z0-9_]*$/. The manifest schema records storage: z.record(z.string(), …), and the repository treats collection as an opaque text value, so a perfectly valid collection name such as form-submissions (or any uppercase/kebab-case name) causes index creation to throw, get swallowed, and leave the plugin’s storage queries un-indexed. Because syncDeclaredStorageIndexes() logs and swallows errors, this fails silently—exactly the kind of gap the PR is trying to close.
A few inline comments also read like PR prose rather than evergreen code explanation and should be trimmed per AGENTS.md comment discipline.
The tests are generally good: they reproduce the production query plan, assert idempotency, cover unique indexes, and exercise the marketplace install/uninstall path. I didn’t run the suite (no shell), but the test cases look capable of failing on real regressions.
Findings
-
[needs fixing]
packages/core/src/plugins/storage-indexes.ts:52validateIdentifier(collection, "collection name")rejects collection names that the manifest schema and repository accept. The manifest declaresstorage: z.record(z.string(), …)andPluginStorageRepositoryuses the collection as a plaintextvalue, so names likeform-submissionsare valid but will cause index creation to throw anIdentifierError, get swallowed bysyncDeclaredStorageIndexes(), and leave the plugin without indexes.The collection value is never interpolated into SQL as an identifier; it only appears inside the generated index name, which is safely quoted by
sql.ref(...). Remove this overly strict validation.validatePluginIdentifier(pluginId, "plugin ID"); for (const field of fields) { validateJsonFieldName(field, "index field name"); } -
[suggestion]
packages/core/src/emdash-runtime.ts:704This JSDoc contains reviewer-facing justification and an explicit
"unlike X"comparison, which AGENTS.md calls out as comment discipline to avoid. The method name already conveys what it does; the remaining prose is a weaker version of the PR description and will stale quickly. Trim it to the useful invariant./** * Materialize plugin-declared storage indexes once per process. * * Called from the scheduler path, not from request handlers. */ -
[suggestion]
packages/core/src/api/handlers/marketplace.ts:482Comment restates the call and its failure mode; the function name and JSDoc already communicate this. Remove to keep the call site clean.
await syncDeclaredStorageIndexes(db, [bundle.manifest]); -
[suggestion]
packages/core/src/api/handlers/marketplace.ts:723Comment is PR-prose justification; the surrounding
updatecontext and function name make the intent clear. Remove.await syncDeclaredStorageIndexes(db, [bundle.manifest]); -
[suggestion]
packages/core/src/api/handlers/marketplace.ts:799Comment is a blend of restatement and reviewer-oriented rationale. The orphan-cost note is the only non-obvious point, but it belongs in the function JSDoc (or nowhere, since the orphaned-index cost is the whole reason for
removeAllPluginIndexes). Keep the call clean.await removeAllPluginIndexes(db, pluginId); -
[suggestion]
packages/core/src/api/handlers/registry.ts:1188Same as the marketplace install comment—restatement of the function call. Remove.
await syncDeclaredStorageIndexes(db, [bundle.manifest]); -
[suggestion]
packages/core/src/api/handlers/registry.ts:1298Same as the marketplace uninstall comment; keep the call site clean.
await removeAllPluginIndexes(db, pluginId); -
[suggestion]
packages/core/src/api/handlers/registry.ts:1664Comment justifies why the call exists; the function name and the surrounding
updatecode explain this. Remove.await syncDeclaredStorageIndexes(db, [bundle.manifest]);
The manifest schema allows arbitrary storage collection keys and the repository stores collection as opaque text, but the index generator validated it as a SQL identifier — a kebab-case collection like form-submissions threw, the error was swallowed, and the plugin was left without indexes. The collection only appears inside the index name, which sql.ref quotes, so no identifier constraint applies. Also trims review-flagged call-site comments to keep the handlers clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed in 8f27ad6:
Plugin/marketplace/registry suites: 176 passed. |
Sandboxed marketplace/registry plugins load into the sandboxedPlugins map and never join allPipelinePlugins, so the once-per-process tick sync skipped them — plugins installed before this feature shipped would never get their declared indexes (the install/update handlers only cover new installs). Carry the manifest's storage declarations in the manifest cache populated at bundle load, and include them in the tick sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
One more substantive fix in 83f894b, found by an adversarial review pass over this branch: the tick sync never covered sandboxed plugins. Marketplace/registry plugins in normal sandbox mode (the only mode on Cloudflare Workers) load into the Three smaller review findings I'm noting rather than fixing here, since all are pre-existing properties of the index machinery rather than this PR's wiring:
Happy to file these as a follow-up issue or address them here if maintainers prefer. |
The previous commit removed collection validation outright to unblock kebab-case names, which dropped the injection-shape rejection an existing unit test guards. sql.ref does neutralize such names — it doubles embedded quotes — but the defensive check is still worth keeping. Validate against the manifest key charset instead: letters, digits, underscores and hyphens, either case. form-submissions indexes, and quote/semicolon-bearing names are still rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
CI caught that my earlier fix went too far — worth flagging since it touches the security posture. Removing so there was no exploitable path. But deleting a security test to make a change pass is the wrong resolution, so fc6d8e6 takes the middle road instead: a new |
fc6d8e6 to
e71eccb
Compare
What does this PR do?
Fixes plugin-declared storage indexes never being created — and never being usable even if they had been.
The materialization machinery already exists (
plugins/storage-indexes.ts, the_plugin_indexestracking table from migration 004), but it has zero production callers: manifeststorage.indexesdeclarations are consumed only as a query-validation allowlist. SoPluginStorageRepository.query()ordering by a JSON field — e.g. the audit-log plugin'sorderBy: { timestamp: "desc" }on its dashboard widget — runs as a full-table scan plus temp B-tree sort. Measured on the audited production deployment (Macabro festival site, emdash 0.31.1, 11 MiB_plugin_storage): 16.5–20.5 ms of synchronous main-thread work on every admin dashboard load, and_plugin_indexesempty.Two coordinated changes:
Wire the sync into every plugin lifecycle moment. A new
syncDeclaredStorageIndexes()(logs per-collection failures, never throws — a missing index is a performance problem, not a correctness one) runs on marketplace install/update, registry install/update, and index drops run on both uninstalls. Configured (in-config) plugins have no install handler at all, so they sync once per process on the scheduler tick — the cron path, never the request path, per the hot-path query rule.Fix the index shape. The existing (never-invoked) generator built partial indexes (
WHERE plugin_id = 'x' AND collection = 'y'via literals). Verified empirically: SQLite does not choose a partial index under bound parameters unless ANALYZE has run — and D1 never runs ANALYZE — so even wired up, the old shape would have indexed nothing in production. The new shape is a composite non-partial index(plugin_id, collection, json_extract(data, '$.field')), which SQLite selects in every scenario and which also eliminates the temp B-tree for ORDER BY. Unique-index semantics are preserved (uniqueness stays scoped per plugin+collection via the leading columns). No migration is needed: no deployed database has old-shape indexes, because nothing ever created them — that is this bug.Postgres note: the composite index accelerates WHERE filters there, but not the
jsonbORDER BY (ordering uses->for numeric-correct sorting while the index expression uses->>; one index expression cannot match both). SQLite/D1 — where the measured problem lives — serves both. Repositoryquery()semantics, results, and cursor behavior are unchanged; only the plan changes.Found during a measured database audit of a production deployment.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. — n/a: no admin UI strings changedAI-generated code disclosure
Screenshots / test output
Failing first (on
main, before the fix) — the query-plan regression test reproduces the exact production plan:After the fix — plugin/marketplace/registry suites all green, and the full
packages/coresuite matches themainbaseline (the singlevirtual-modules.test.tsfailure is pre-existing on a clean checkout in this environment — macOS temp-dir realpath mismatch):🤖 Generated with Claude Code