Skip to content

feat: merge v3 document model architecture into main#920

Merged
lane711 merged 225 commits into
mainfrom
v3
Jun 18, 2026
Merged

feat: merge v3 document model architecture into main#920
lane711 merged 225 commits into
mainfrom
v3

Conversation

@lane711

@lane711 lane711 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Merges the v3 branch into main, replacing the 2.x codebase with the document model architecture.

What's changing

  • Unified document repository replacing per-feature tables (content, media, testimonials, etc.)
  • Document model: document_types, documents, document_references, document_facets, document_permissions
  • Code-only collection registry (no DB table)
  • Media module migrated to MediaDocumentService
  • Better Auth (session + RBAC)
  • Versioning opt-in per document type
  • Cache plugin with document model support
  • All plugins migrated to document model

Version

3.0.0-beta.2 — publishes under beta dist-tag on npm, not latest. Existing create-sonicjs users on 2.x are unaffected.

Pre-merge checklist

  • CI green on v3 (1529 tests passing, 0 failed)
  • #915 main-only fix already present in v3
  • #898 docs fix merged to v3
  • #891, #893 bugs tracked as v3 issues
  • #892 closed (not applicable to v3)

lane711 and others added 30 commits June 1, 2026 15:15
Replace the hand-wired, position-sensitive plugin route mounting in app.ts
with a shared, synchronous primitive. Plugins previously had to be wired into
core app.ts by hand, each guarded by a "MUST be registered BEFORE /admin/plugins"
comment, so any plugin relying on PluginBuilder.addRoute() (e.g. global-variables,
shortcodes) was never mounted and 404'd in production.

- Add plugins/mount.ts: registerPluginRoutes() + mountPlugin() +
  PluginRegisterMustBeSyncError. Synchronous (Hono's SmartRouter locks after the
  first request), priority-ordered, with duplicate-path warnings. Typed against a
  structural MountablePlugin to sidestep the src-vs-dist Plugin identity clash.
- app.ts: the 7 copy-pasted route-mounting blocks become two registerPluginRoutes()
  calls. Mount globalVariablesPlugin + shortcodesPlugin (fixes #758). Mount
  config.plugins.register user plugins before the /admin catch-all so consumers
  never edit core (#829, #621).
- disableAll now consistently gates all plugin mounting (core + user), matching
  bootstrap behavior and the documented intent; resolves the #829 review mismatch.
- types.ts: add sync-only Plugin.register?(app). app.ts: add
  SonicJSConfig.plugins.register; deprecate directory/autoLoad no-ops.

Tests: mount.test.ts (16 unit incl. sync-guard + catch-all shadowing) and
mount-integration.test.ts (5 incl. #758 regression + disableAll matrix).
Full core suite 1498 passed, 0 failed; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 2)

Hooks were dead metadata at runtime: HookSystemImpl only exists inside
PluginManager, which is never instantiated in the running app, and nothing
dispatches lifecycle events. This lands the typed-hook + async-wiring foundation
so plugin hooks and onBoot can be wired for real (the async half of two-phase
boot; route mounting is the sync half from Phase 1).

- plugins/hooks/catalog.ts: typed event catalog (6 content + 3 auth events) ->
  payload types; HookEventName / HookPayload<E> / HOOK_EVENT_NAMES / isKnownHookEvent.
- plugins/hooks/typed-hooks.ts: createTypedHooks() -> { on<E>, dispatch<E> } with
  catalog inference; structural HookSystemLike so HookSystemImpl/ScopedHookSystem
  both satisfy it without casts.
- plugins/hooks/hook-system-singleton.ts: get/set/has/reset + getTypedHooks.
  Throw-before-get, idempotent set (multi-app/test safe), reset for isolation;
  env-independent access for cron (Phase 4).
- plugins/wire.ts: wireRegisteredPlugins() subscribes all hooks[] then runs each
  onBoot (per-plugin error isolation); structural WirablePlugin; createPluginWirer
  once-guard for the lazy first-request trigger.

Tests: typed-hooks.test.ts (11 runtime + a tsc-validated type-level block proving
narrowed payloads and rejected unknown events/fields), wire.test.ts (8: subscribe
-> dispatch, all-hooks-before-any-onBoot ordering, error isolation, once-guard
under concurrency). Full core suite 1517 passed, 0 failed; tsc clean.

Activating the wiring in the live app (eager setHookSystem, first-request wire,
real dispatch sites) is deferred to Phase 2b with dedicated integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the hook system to life. Previously hooks were dead metadata at runtime;
now createSonicJSApp wires plugins for real:

- Eager setHookSystem(new HookSystemImpl()) at construction publishes the
  singleton (env-independent access for cron later).
- Core plugins extracted into shared corePluginsBeforeCatchAll /
  corePluginsAfterCatchAll arrays, reused for both route mounting and wiring so
  they never drift (no Plugin[] annotation -> dodges the src/dist Plugin identity
  clash; both consumers are structural).
- A once-guarded first-request middleware (after bootstrap) runs
  wireRegisteredPlugins exactly once: subscribes every core + user plugin's
  hooks[] and runs onBoot. Error-isolated; skipped under disableAll.

The first request now subscribes the real core plugin hooks. They stay inert
until dispatch sites are added, so no existing behavior changes -- but the
infrastructure is proven end-to-end.

Tests: wire-integration.test.ts (4): singleton published at construction; first
request runs onBoot + subscribes hooks (verified by dispatching content:create);
wires exactly once across 3 requests; disableAll skips wiring. Full core suite
1521 passed, 0 failed; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the isolation boundary that Strapi (namespacing only) and Payload (full
config access) lack: a plugin declares the capabilities it needs, and the host
hands it a context whose powerful accessors are gated by those declarations.

- plugins/capabilities.ts: Phase 1 vocabulary (FIXED_CAPABILITIES + parameterized
  db:<table>), isKnownCapability/validateCapabilities, SonicCapabilityError,
  hasCapability/assertCapability, and createCapabilityContext() whose accessors are
  lazy throwing getters -- ctx.email throws SonicCapabilityError unless email:send
  was declared.
- plugins/singletons/service-singleton.ts: createServiceSingleton<T>(label)
  generalizing the hook-system-singleton pattern (throw-before-get, idempotent set,
  reset). Env-independent so cron/scheduled() handlers can reach services.

Pure infrastructure, no behavior change. Providers + gated context get wired into
the live app with definePlugin() in Phase 5.

Tests: capabilities.test.ts (19): db:<table> matching, granted/denied gating,
cache read-or-write, lazy providers, and the singleton contract (throw-before-get,
idempotent set, cron-reachable without env). Full core suite 1540 passed, 0 failed;
tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…se 4)

Let plugins declare scheduled work as data and dispatch it from the Worker's
scheduled() handler. Like Payload's jobs queue, declaring a schedule runs nothing
by itself: on Workers the execution mechanism is a Cron Trigger delivered to
scheduled(), which this fans out to matching plugins.

- plugins/cron.ts: CronDeclaration ({ schedule, hookFamily }) + structural
  CronablePlugin (crons[] + async onCronTick). collectCrons/collectCronSchedules
  flatten declarations (wrangler sync + diagnostics). dispatchCronTick() matches a
  fired expression to plugins, tags each onCronTick with the matched hookFamily
  (one call per matching declaration), error-isolated. createScheduledHandler()
  returns a CF scheduled(controller, env, ctx) handler reaching services via the
  env-independent singletons (cron has no c.env), passing Worker env through and
  waitUntil-ing the work.

Tests: cron.test.ts (12): collect/flatten, match-only-fired-cron, well-formed
event, unmatched reporting, multi-cron fan-out, error isolation, and the scheduled
handler (dispatch + waitUntil + env passthrough + lazy list + disabled no-op).
Full core suite 1552 passed, 0 failed; tsc clean.

Wiring scheduled() into the consumer Worker export + wrangler trigger generation
is a DX change deferred to the definePlugin()/docs bundle (Phase 5/7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typed authoring entry point that unifies the foundation: a plugin is declared
once and consumed unchanged by mount (routes/register), wire (onBoot), and cron
(crons/onCronTick), plus the legacy metadata fields the admin/registry read.

- plugins/sdk/define-plugin.ts: definePlugin(input) -> DefinedPlugin. Normalizes
  id -> name, validates declared capabilities (warns on unknown), marks output
  __sonicV3 (+ isDefinedPlugin guard). onBoot/onCronTick receive an ENRICHED
  context { hooks, cap, env, raw }: a typed hook facade (ctx.hooks.on with narrowed
  payloads) and the capability-gated service context (ctx.cap.email throws
  SonicCapabilityError without email:send). The runtime still passes the plain
  boot/cron context; definePlugin wraps the author fns. Host providers ride on
  raw.providers.
- Exported from plugins/index.ts.

Purely additive — nothing in core uses it yet. Tests: define-plugin.test.ts (13)
+ define-plugin-integration.test.ts (3: mounts via createSonicJSApp, honors
disableAll, onBoot runs exactly once on first request). Full suite 1565 passed,
0 failed; tsc clean.

Next: migrate email onto definePlugin (5b), fix magic-link (5c), add content/auth
dispatch sites (5d) — all behavior-changing, separately tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…b-1)

Today email is fractured: OTP hardcodes Resend, email-templates uses SendGrid,
magic-link calls a registry (c.env.plugins?.get('email')) that was never built,
and password-reset doesn't send at all (it returns the reset link in the JSON
response — a token leak). There is no single chokepoint and no core email_log.

This lands the chokepoint (additive; no call sites switched yet):

- services/email: EmailProvider interface + built-in Resend / SendGrid / Console
  providers. A dev can pass ANY EmailProvider implementation — "use whatever
  provider you want." EmailService.send() normalizes the message, calls the
  provider, and records every attempt in email_log (best-effort; logging never
  fails a send; a throwing provider is surfaced as a structured failure).
- resolveEmailProvider precedence: explicit instance > named built-in > env
  auto-detect (RESEND_API_KEY, then SENDGRID_API_KEY) > Console. An unconfigured
  choice degrades to Console with a warning, so a missing key becomes
  "logged, not delivered" — never a silent token leak.
- email-service singleton (via createServiceSingleton) for env-independent
  access from cron / scheduled() reconciliation.
- Core email_log table: Drizzle schema + migration 037 (bundled). Epoch-ms
  integer timestamps; columns for provider/provider_id/error/flow/metadata plus
  failed_at_send and delivery_state/delivery_synced_at for the reconciliation cron.

Tests: email-service.test.ts (19): normalization, sent/failed logging + the
failed_at_send path, throwing-provider isolation, logging-never-fails-send,
Resend/SendGrid/Console providers (mocked fetch), and resolveEmailProvider
precedence incl. the console degrade. Full core suite 1584 passed, 0 failed; tsc clean.

Wiring it in + migrating the call sites (OTP, magic-link, and closing the
password-reset leak) is the next increment (Phase 5b-2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 5b-2)

Wires the provider-agnostic EmailService into the app and migrates the two real
send paths, fixing a token-leak security bug along the way.

- app.ts: new `email` config ({ provider | providerName | from }) so devs choose
  any provider. On first request the app resolves a provider (config > env
  auto-detect > console) and publishes the EmailService singleton; its init is
  isolated in its own try/catch so it can never block plugin wiring. The
  capability boot context now resolves `ctx.cap.email` to this service.
- SECURITY (auth.ts): POST /auth/request-password-reset no longer returns
  `reset_link` in the JSON response — that leaked a valid reset token to any
  caller. It now emails the link via EmailService (flow: 'password-reset') and
  returns only the generic, enumeration-safe message. Delivery failure does not
  change the response.
- magic-link: replace the broken `c.env.plugins?.get('email')` lookup (a registry
  that never existed, so links were only console-logged) with
  getEmailService().send({ flow: 'magic-link' }).

Tests: email-wiring-integration.test.ts (3) through the real createSonicJSApp —
provider initialized on first request; reset response omits reset_link AND the
token, sending via email instead; unknown email stays generic and sends nothing.
Also null-safe initEmailService (fixes wire-integration when requests carry no
env). Full core suite 1587 passed, 0 failed; tsc clean.

OTP migration to the shared EmailService is deferred (it works today via Resend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ettings (Phase 5c)

Consolidates the last ad-hoc email sender. OTP previously read plugins.settings
and called Resend directly; it now goes through the provider-agnostic
EmailService, so OTP sends are logged to email_log like every other flow. It
stays synchronous (caller-direct) — the user can't proceed without the code.

Provider precedence in the app's init now also honors the admin Plugins-page
email config (API key in plugins.settings, not env), so existing installs keep
delivering: config.email > named built-in > env keys > admin-UI DB settings
(Resend) > console. initEmailService is async to read those DB settings.

- services/email/db-settings.ts: loadDbEmailSettings() (never throws) + dbSettingsFrom().
- app.ts: DB-aware async initEmailService.
- otp-login-plugin: send via getEmailService({ flow: 'otp' }).
- lint: silence @typescript-eslint/naming-convention for the colon-bearing hook
  event keys (catalog.ts) and the intentional __sonicV3 marker (define-plugin.ts);
  the husky pre-commit hook activated mid-effort and these slipped through earlier.

Tests: email-db-settings.test.ts (9). Full core suite 1596 passed, 0 failed; tsc clean.
Verified live: OTP request logs `[email:console] (otp) ...` and writes an email_log row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep-dive comparison of Mark's Infowall plugin SDK (/Users/lane/Dev/refs/infowall-ai-main)
vs the shipped SonicJS v3 framework, with a decisive convergence + production-readiness
roadmap. Produced via a multi-agent workflow (9 mappers -> 10 adversarially-verified
dimension comparisons -> synthesize/critique/revise), then hand-corrected: a synthesis
pass had wrongly reported Infowall as "out-of-tree/unopenable" (it searched this workspace
instead of the absolute path); the two crux dimensions (hook-event catalog, capability
vocabulary) plus topo-sort/once-guard/cron-registry claims were re-verified against the
real Infowall source.

Thesis: SonicJS is base-of-record (mounting wired, reset-link leak closed, provider-
agnostic email, better substrate); harvest Infowall's rigor (real hook dispatch sites,
capability enforcement at the subscription boundary, dependency topo-sort, live
cron+reconciliation). Three real long poles, all SonicJS-side: inert hook catalog (zero
production dispatch), HTTP-gated wire phase unreachable from scheduled(), missing hook-
subscription capability gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n-readiness

Execution-ready task breakdown (Phase 1 contract alignment → 2 dispatch+enforcement →
3 ordering/cron/reconciliation [PRODUCTION-READY] → 4 structure/distribution [FUTURE-PROOF]).
Each task: goal/files/change/tests/done-when + size + parallelism. Decisions locked:
SonicJS canonical, name-map hooks, before/after content events, separate cron, SonicJS
capability spellings + rename map, unified user.id actor shape, SonicJS substrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes a live inconsistency: content event payloads exposed `user.userId` while
auth payloads exposed `user.id`, so a plugin reading one field on the wrong
family got undefined. Introduce one canonical `HookActor { id, email, role? }`
used across all content + auth events; content's `userId` becomes `id`.

Type-level assertions (tsc-validated): content `user.id` is string, reading
`user.userId` is now an error, and content/auth actor shapes agree. Core suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lias window (T1.2)

Adopt the before/after content-event model (gate/transform vs side-effect), the
industry-standard shape Payload/Strapi/WordPress use:
- content:before:{create,update,delete} (handlers may mutate or throw to cancel)
- content:after:{create,update,delete,publish}; keep content:read
- add auth:magic-link:consumed, auth:otp:verified
- drop content:save (folded into update)

Ship as a breaking catalog change WITH a one-release deprecation window: the
legacy names (content:create/update/delete/publish/save) still compile and
subscribe via createTypedHooks().on() — they resolve to the canonical name and
emit a one-time deprecation warning. dispatch() is canonical-only (the host owns
dispatch sites). LegacyHookEventPayloads keeps legacy names typed to the canonical
payload; resolveHookEventName/isLegacyHookEvent/LEGACY_EVENT_ALIASES are exported.

Tests: before-hook mutation, legacy-alias fires-on-canonical-dispatch + warns once,
type-level proof that legacy names still compile to the canonical payload. Full
core suite 1598 passed, 0 failed; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-version / cross-fork capability portability. A plugin authored against a
different SDK spelling now loads against the canonical vocabulary without code
changes.

- CAPABILITY_RENAMES (deprecated→canonical) seeded with the sibling fork's
  spellings: storage:*→media:*, hooks.cron:register→cron:register,
  hooks.{auth,content-read,content-write,email-events}:register→canonical :subscribe.
- normalizeCapability(str)→Capability|null (rename then known-check) and
  normalizeCapabilities(list)→{capabilities, unknown} (dedupes, splits unknowns).
- Reserve hooks.email:subscribe in the vocabulary (rename target; gates the email
  event family once it ships).
- definePlugin now normalizes declared capabilities first, then warns on the ones
  still unknown — and DROPS unknowns from the granted set (an unrecognized name is
  not a granted capability). request:intercept has no canonical target and
  surfaces as unknown rather than silently gating nothing.

Tests: rename resolution, every rename target is itself known, dedupe + unknown
split, definePlugin normalizes storage:write→media:write without warning, unknowns
dropped. Full core suite 1605 passed, 0 failed; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ctx.cap.email now resolves to the real EmailService type at compile time — but
only when the plugin declared 'email:send'. Accessing an undeclared capability is
a compile error, shifting the existing runtime SonicCapabilityError left.

- definePlugin<const Caps extends readonly Capability[]>; capabilities inferred as
  a literal tuple (default readonly [] so omitting = nothing granted).
- CapabilityContext<Caps> with WhenGranted/WhenGrantedAny mapping each accessor to
  its service type, or a branded CapabilityNotDeclared type when absent (not
  `never`, which would be assignable to anything and defeat the check).
- createCapabilityContext is generic; runtime gating still uses the normalized set
  while the context TYPE reflects the declared tuple. EmailService imported
  type-only (no runtime coupling). CapabilityProviders.email typed () => EmailService.

Tests: tsc-validated narrowing — email:send → EmailService; other/empty caps →
compile error on ctx.cap.email. Full core suite 1605 passed, 0 failed; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a declarative `hooks` map to DefinePluginInput so plugins can subscribe to
lifecycle events without an onBoot body:

  definePlugin({ id, version, capabilities: ['hooks.content:subscribe'],
    hooks: { 'content:after:create': (payload) => { /* payload narrowed */ } } })

Each entry is keyed by a canonical HookEventName and the handler is narrowed to
that event's payload (DeclarativeHooks = { [E in HookEventName]?: TypedHookHandler<E> }).
definePlugin flattens the map into the wirable hooks[] array (wrapping each handler
to the raw shape, void-coalesced), so they subscribe through the existing wire
phase and fire on dispatch. Imperative ctx.hooks.on() in onBoot remains the
dynamic-subscription escape hatch.

Tests: declarative hook flattens + fires after wiring; type-level per-event payload
narrowing + unknown-event-key rejection. Full core suite 1607 passed, 0 failed;
tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(T1.6)

The DB-settings (admin Plugins page) email path hardcoded `new ResendProvider`,
so it was Resend-locked and skipped the degrade-to-Console safety, and it dropped
the configured replyTo.

- app.ts initEmailService: resolve the admin-UI key via resolveEmailProvider
  ({ providerName: 'resend', env: { ...env, RESEND_API_KEY } }) for consistent
  provider selection + safe degrade; the no-key branch also goes through the
  resolver (console fallback + warning) instead of constructing ConsoleProvider.
- EmailService gains defaultReplyTo, applied when a message omits replyTo; wired
  from DbEmailSettings.replyTo so admin-configured reply-to is honored.

Tests: defaultReplyTo applied + per-message override. Full core suite 1608 passed,
0 failed; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add enterprise document repository POC plan
- Define four-table schema for document types, documents, values, and permissions
- Outline implementation phases and test strategy

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
Adds the document repository migration (037), type registry, CRUD
service, projection (facets/references), permissions, and repository
chokepoint. All multi-statement writes use db.batch for atomicity;
version_number is SQL-derived; facet/reference inserts chunk under D1's
100-param limit. 20 unit tests cover draft/publish two-axis model,
deny-wins ACL, tenant isolation, and param-limit chunking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds authenticated admin CRUD routes (/admin/documents) and a
read-only public API (/api/documents). Both support cursor pagination
on (updated_at, id), scalar filters via generated columns
(?filter[field]=value), facet filters (?facet[tags]=homepage), and
configurable sort order. Admin routes cover create, save-draft, publish,
unpublish, soft-delete, and reindex. Public routes enforce the
scheduled_at / expires_at time window. Routes wired into app.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dispatch-event.ts helper (T2.1–T2.3): a route-facing typed dispatch helper
that safely extracts executionCtx from the Hono context (throws in non-Workers
environments). fire-and-forget mode runs via waitUntil; in-band mode awaits the
handler chain so before-hooks can mutate the payload.

Auth dispatch sites (T2.1): auth:registration:completed, auth:password-reset:requested
(carries resetToken for custom notification plugins), auth:password-reset:completed.
Dispatched from both JSON + HTML registration routes and both reset routes.

Magic-link + OTP dispatch sites (T2.2): auth:magic-link:consumed on successful
magic-link verify; auth:otp:verified on successful OTP verify.

Content dispatch sites (T2.3): content:before:create/update/delete (in-band,
payload mutations flow through to the write); content:after:create/update/delete
and content:after:publish (fire-and-forget, side-effect plugins). content:read
dispatched on GET /:id (fire-and-forget).

Capability gate (T2.4): HOOK_CAPABILITY_MAP added to capabilities.ts mapping
every catalog event to its required subscription capability. Wire phase A now
enforces the gate for v3 plugins (capabilities !== undefined); old PluginBuilder
plugins are exempt for backwards compatibility. Non-strict mode warns; strict
mode records a SonicCapabilityError in WireResult.

SonicCapabilityError.accessedApi (T2.5): optional field, non-breaking.

No-dispatch-site CI guard (T2.6): test asserts every HOOK_EVENT_NAMES entry has
at least one dispatchHookEvent() call in a non-test source file. Fails if a
catalog event ships without a real production dispatch site.

Wire-integration rewrite (T2.7): removed manual hooks.dispatch() call; test
now fires via a minimal Hono app calling dispatchHookEvent() — the same path
production routes use. Would fail if dispatchHookEvent were removed from routes.

Tests: +14 new tests. Full suite 1622 passed, 0 failed; tsc + lint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds HTMX-driven admin pages at /admin/documents/ui:
- Document type selector landing page
- Per-type document list with status filter and cursor pagination
- Create/edit form with dynamic fields from queryable_fields config
- Publish/unpublish controls with "edit while published" state banner
- Version history lazy-loaded via HTMX reveal trigger
- Soft-delete / hard-erase (PII types) from list row
- Role-aware UI: destructive actions hidden for non-admin users
- Form submissions use POST+_method=PUT (no JS required for core flow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…observability (T3.1–T3.6)

T3.1 — Dependency topo-sort + cycle detection:
- `plugins/topo-sort.ts`: DFS topo-sort with `visiting` stack, throws
  `PluginDependencyCycleError` on cycles, warns/throws on missing dep ids (strict).
- `MountablePlugin` + `WirablePlugin` extended with optional `id` + `dependencies`.
- `registerPluginRoutes` + `wireRegisteredPlugins` now sort by dependencies by
  default (sortByDependencies=true); old-style plugins without `dependencies`
  keep their original declaration order.

T3.2 — bootIsolate extraction:
- HTTP middleware body factored into `boot: BootIsolateFn` closure — the same
  once-guarded `initEmailService` + `wirePlugins` call, now exposed on the
  returned `SonicJSApp` object.
- `SonicJSApp` type extended with `readonly boot: BootIsolateFn`.
- The HTTP middleware now calls `boot(c.env)`, sharing the once-guard with any
  other caller (cron, test harness).

T3.3 — Wire scheduled() end-to-end:
- `createScheduledHandler` gains optional `boot?` parameter. Called before the
  first cron dispatch so a cron-first cold isolate has a populated hook bus and
  reachable email service; warm isolates return instantly (once-guard).
- `my-sonicjs-app/src/index.ts` restructured to export `{ fetch, scheduled }` —
  a proper Worker object instead of a bare Hono app. `scheduled` wires through
  `app.boot`.

T3.4 — wrangler.toml [triggers] codegen:
- `plugins/generate-triggers.ts`: `parseCronTriggers`, `updateWranglerTriggers`,
  `generateTriggersComment` utilities.
- `my-sonicjs-app/scripts/generate-cron-triggers.ts`: a tsx script that reads
  plugin `crons[]` and writes the `[triggers]` section; `--check` mode for CI.

T3.5 — Per-provider reconciliation + observability migration:
- `EmailProvider.reconcile?()` optional method for delivery-state backfill.
- `EmailLogRow` type for reconciliation inputs.
- Migration `038_email_log_observability.sql`: adds `user_id`, `context_type`,
  `context_id`, `tenant_id`, `delivery_state`, `delivery_synced_at` (all
  nullable, no defaults — forward-only D1 / NULL-safe). Partial index for
  reconciliation queries; per-user history index.

T3.6 — CloudflareEmailProvider:
- `services/email/providers/cloudflare.ts`: `CloudflareEmailProvider` wraps the
  `send_email` Workers binding (MailChannels / CF Email Routing).

New exports: `createScheduledHandler`, `dispatchCronTick`, `collectCrons`,
`collectCronSchedules`, `getHookSystem`, `topoSort`, `PluginDependencyCycleError`,
`CloudflareEmailProvider`, `parseCronTriggers`, `updateWranglerTriggers`,
`BootIsolateFn`.

Tests: +25 (topo-sort: 11, boot-isolate: 8, generate-triggers: 6).
Full suite: 1647 passed, 0 failed; tsc + lint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds bootstrapDocumentTypes() called during app startup (idempotent).
Registers faq, testimonial, contact_message, and media_asset types
with their queryable field configs so /admin/documents/ui is populated
on first run without manual seeding. Migration 037 runs first, then
types are registered; both paths are gracefully skipped if already done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds seed-documents.ts with 5 FAQs (4 published, 1 draft),
4 testimonials (3 published, 1 draft), and 3 contact messages.
Wired into setup-worktree-db.sh so npm run workspace seeds everything
in one step. Also available standalone: npm run seed:documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drops the legacy testimonials table (migration 038) and repoints all
data access to the document repository (type_id = 'testimonial').

Changes:
- 038_drop_testimonials.sql: DROP TABLE IF EXISTS testimonials
- admin-testimonials.ts: CRUD now queries documents table via
  DocumentsService / raw D1 SQL; same URL paths and template interface
- testimonials/index.ts: public /api/testimonials routes rewritten;
  JSON response shape preserved (id is now string rootId);
  removed addModel() — no dedicated table
- Templates: id field widened from number to string

The testimonials admin UI (/admin/testimonials) and public API
(/api/testimonials) continue to work; only the storage tier changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
seed-documents.ts runs before the first HTTP request, so migration 037
(which creates document_types) must be in the Wrangler migrations folder
or the seed fails. Copying both 037 and 038 here ensures setup:db applies
them before the seed script runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ening (T4.1–T4.5)

T4.1 — Stop committing dist/ + fix src/dist type identity:
- Root .gitignore: add `packages/*/dist/` so build artifacts are never committed.
- `git rm -r --cached packages/core/dist/`: stop tracking existing dist files.
- tsconfig.json: add `@sonicjs-cms/core` → `./src` path alias so in-tree
  self-imports from core plugins resolve to the same types as the rest of src,
  eliminating the dual `Plugin` identity that forced structural casts everywhere.

T4.3 — Versioning / semver compat gate:
- `DefinePluginInput.sonicjsVersionRange?`: semver range the plugin declares for
  SonicJS core compatibility (e.g. `'^3.0.0'`).
- `definePlugin()` validates the plugin's own `version` field (warns on invalid
  semver) and checks `sonicjsVersionRange` against the running core version at
  definition time (warns on mismatch). Both use a minimal in-tree semver helper
  — no npm `semver` dep (bundle-size constrained on Workers).
- `DefinedPlugin.sonicjsVersionRange` carries the range through to the runtime.

T4.4 — DB activation reflection + email_log admin browser:
- `wire.ts` Phase C (best-effort): after wiring, upserts each booted plugin into
  the `plugins` DB table (`INSERT ... ON CONFLICT DO UPDATE`) so the admin view
  reflects what is actually running. Non-fatal — a DB error in reflection never
  aborts wiring.
- `/admin/settings/email-log`: paginated HTML browser showing all email_log rows
  with status, delivery_state, flow, provider, recipient, subject, and user.
  Uses migration 037+038 columns; renders an empty-state if the table is missing.
- `/admin/settings/email-log/api`: JSON endpoint for the same data, filterable
  by flow/status, paginated by limit/offset.

T4.5 — Shared author mock harness:
- `__tests__/utils/mock-factories.ts`: typed mock primitives for plugin authors:
  - `makeMockD1Database(opts)` — D1-shaped mock (static rows or resolver fn)
  - `makeMockKVNamespace()` — in-memory KV with put/get/delete/list
  - `makeMockHonoContext(opts)` — Hono context mock with json/html/redirect/vars
  - `makeMockEmailProvider()` — recording EmailProvider, captures `.sent[]`
  - `makeMockHookSystem()` — a real HookSystemImpl instance (not a stub)
  Previously 5+ inline fakes existed with varying shapes; this replaces them.

Tests: +18 (T4.1 type-identity check, T4.3 semver gate 5-case, T4.5 mocks 12-case).
Full suite: 1665 passed, 0 failed; tsc (non-test files) clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lane711 and others added 29 commits June 12, 2026 17:20
Hides the Email Log menu entry; routes and APIs remain intact.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Port derived from worktree dir name via cksum % 1000 + 9000.
Same port every restart; no manual config needed per worktree.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…reenfield installs (#886)

- New lexical-editor plugin using definePlugin (v3 SDK), activated by default
- Blog post collection content field changed from quill to lexical type
- isPluginActive reads from documents table (authoritative) — removes dependency on
  plugins table which may not exist on fresh installs
- loadContentEditorFlags includes lexical alongside tinymce/quill/mdxeditor so
  document-backed edit forms get lexicalEnabled correctly
- PluginBootstrapService auto-installs plugins with defaultActive:true from manifest
- manifest-registry generator now emits defaultActive field from manifest.json
- Better Auth baseURL derived from request when BETTER_AUTH_URL env var absent
  (fixes warning on dev with random/deterministic per-worktree ports)
- test-cleanup wraps DELETE FROM media in try/catch (table decommissioned)
- activatePlugin/deactivatePlugin sync plugins table for legacy isPluginActive callers
- getActivePlugins reads from documents table
- pencil-square icon added to plugin-menu ICON_SVG map

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
)

Internal collections (source=system/plugin or cfg.internal=true)
were leaking into the dropdown. Extracts canonical filtering into
collection-loader so /admin/collections and the new-content menu
share one definition of "visible collection".

- add isCodeCollectionInternal / isDbDocTypeInternal predicates
- add getVisibleCollections(db) using those predicates
- admin-collections.ts uses shared predicates (no logic change)
- admin-content.ts passes newContentCollections to template
- content-list template renders dropdown from newContentCollections

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(media): show plugin-not-enabled warning in media selector

When media plugin is inactive or uninstalled, /admin/media/selector
now returns a friendly warning with a link to /admin/plugins instead
of crashing with "Error loading media files".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update worktree db config and regen migrations bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ests (#895)

- Plugin filter state (category, status, search, sort) now syncs to URL
  params via history.replaceState so page reload after install retains
  the user's selection; initFiltersFromURL restores state on load.

- Migrate 02c-otp-login.spec.ts to Better Auth emailOTP endpoints:
  POST /auth/email-otp/send-verification-otp + POST /auth/sign-in/email-otp

- Migrate 02d-magic-link-auth.spec.ts to Better Auth magicLink endpoints:
  POST /auth/sign-in/magic-link + GET /auth/magic-link/verify

- Add 79-plugin-filter-url.spec.ts (7 E2E tests covering URL sync,
  reload persistence, multi-select, and sort param behaviour).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(admin): sidebar plugin sub-nav shows only active plugins

The sidebar fell back to resolvePluginMenuItems() from the singleton,
which is populated at startup with ALL registered plugins regardless of
status. Replace the fallback with an empty array so the DYNAMIC_PLUGIN_MENU
marker is always rendered when no dynamicMenuItems are passed, letting
pluginMenuMiddleware inject only DB-active plugins via post-render replacement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): use plugin id (not codeName) for active-status DB query

REGISTRY_MENU_PLUGINS now maps p.id instead of p.codeName so the slug
IN-list matches how plugins are stored in the documents table (slug = id).
Fixes ai-search and any plugin where id != codeName showing as inactive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Reduces console noise during dev. Retains 'Build complete!' message.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Adds a new `user` field type that lets editors link a blog post author
to a system user via live autocomplete, or type a free-form custom name.

- New `user` FieldType in collection-config
- `blog-posts.collection.ts` author field changed string → user
- `dynamic-field.template.ts`: renders text+hidden input pair with HTMX
  autocomplete dropdown (zinc-800 dark bg, box-shadow, no border flash)
- `admin-api.ts`: GET /admin/api/users/search (JSON), /search-html (HTMX
  buttons), /:id (display name lookup for reload)
- On load: fetches display name from user ID so edit page shows name not slug
- On submit: syncs display value → hidden when typing custom (non-user) name
- 7 Playwright E2E tests covering API, field render, dropdown, and custom name

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…es (#899)

* feat(email): v3 email-plugin — CF Email Service + auth-event hooks + templates

Port of mmcintosh/sonicjs-infowall-merge email plugin work:
- v3 email-plugin using definePlugin SDK (replaces legacy PluginBuilder)
- Auth-event hooks: registration, password-reset requested/completed
- Cron reconciliation hook (every 5 min, email-reconciliation family)
- CF Email Service adapter + CfGraphqlClient for delivery polling
- Email log service + reconciliation service
- SonicJS-dark transactional email templates (_layout, welcome, otp,
  password-reset, password-changed, test-email, verification, invitation)
- Admin routes: /admin/email/settings, /admin/email/test
- Unit tests for all services, hooks, and templates

Source commits (mmcintosh/sonicjs-infowall-merge):
  f23e11ed feat(email): v3 email-plugin — CF Email Service + auth-event hooks
  3ff50a54 feat(email): SonicJS-dark visual identity for transactional emails
  e9e22e4d feat(email): add /admin/settings/email tab
  b29136de feat(email): self-hosted setup UX
  a2eb99f3 feat(auth): email verification flow
  (+ 18 follow-up fixes and cleanups)

Co-Authored-By: Lane Campbell <ldc0618@gmail.com>

* chore(email): adapt mmcintosh plugin to our type system + plugin SDK

Bridges the type/API gap between mmcintosh/sonicjs-infowall-merge's email
plugin (prev commit) and our canonical v3 plugin SDK:

- index.ts: switch to onBoot + onCronTick (our SDK pattern); auth hooks
  registered with env-closure factory functions so handlers get D1 access;
  capability names corrected (hooks.cron:register→cron:register,
  hooks.auth:register→hooks.auth:subscribe); displayName→name
- hooks: rewrite to factory pattern makeOn*() capturing env; fix arg order
  (ctx,event)→(payload); map event.userId→payload.user.id; fix SendResult
  shape (status/errorCode→ok/error); fix EmailMessage.purpose→flow;
  default expiresAt (1h) and when (Date.now()) for fields not in payloads
- on-cron-tick: fix arg order (ctx,event)→(event,ctx); replace typed
  import with structural { env? } shape; guard ctx.env possibly undefined
- routes/admin: fix email-service-singleton path (add email/ subdir);
  replace permissions.hasRole('admin') with user.role check; adapt send()
  call to our EmailMessage shape; fix result.ok check
- services/site-config: remove getCategorySettings() (not in our
  SettingsService); use env.PUBLIC_URL directly
- sdk/types: add EmailService/SendEmailOptions/SendEmailResult types for
  the plugin's CF Email Service EmailServiceImpl
- core-plugins/index: re-export emailPluginV3 as emailPlugin for compat
- app.ts: import emailPluginV3 as emailPlugin

No behavior change for Resend/SendGrid deployments. CF Email Service path
requires EMAIL + CF_ZONE_ID bindings (unchanged from mmcintosh design).

* feat(email): provider selector — Resend or CF Email Service, env var overrides

- Add `provider` field to EmailSettings (resend | cloudflare) with per-provider
  conditional fields in the admin UI (Resend API key vs CF Account ID / token)
- Amber env var override notes on every overrideable field in settings UI
- Wire CloudflareEmailProvider into app.ts initEmailService — detects real CF
  binding (object) vs string var, reads new resendApiKey alongside legacy apiKey
- Register email_log document type in onBoot to satisfy FK constraint on send
- Fix EmailSettingsService.load() to catch D1 errors (table missing pre-migration)
- Surface real CF error messages in test email UI instead of generic fallback
- Fix adminMenu path (/admin/plugins/email) and regenerate manifest-registry
- wrangler.toml: add [[send_email]] binding with remote = true

Co-Authored-By: MarkMac <mmcintosh@infowall.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: MarkMac <mmcintosh@infowall.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(www): update all docs and blog posts from v2 to v3

Comprehensive audit and update of the SonicJS documentation website
covering 63 files across doc pages, blog tutorials, guides, and comparisons.

Key changes:
- Delete 8 orphan "Protocol" chat-template pages (groups, messages, contacts,
  conversations, errors, attachments, sdks, pagination)
- Global: scaffold cmd → npx create-sonicjs@latest; /api/content/<x> →
  /api/<x>; R2 BUCKET→MEDIA_BUCKET; db:migrate:prod→db:migrate; demo creds
- PluginBuilder.create/addRoute/addHook/lifecycle/build → definePlugin() +
  registerPlugins() across all affected pages and posts
- defineCollection({fields}) → CollectionConfig with schema.properties
- Database page: Drizzle framed as content layer → Document Model (5 tables,
  q_* generated columns, 2 migrations); remove users/collections/content tables
- Collections page: delete all UI-creation content; code-only via
  registerCollections(); /admin/collections read-only
- Plugins/development guide: full rewrite from PluginBuilder/manifest.json
  to definePlugin SDK (register, menu, onBoot, capabilities, document_type)
- Blog guides: plugins-extending full rewrite; plugin-architecture and
  d1-database-deep-dive premise-level corrections; getting-started, building-
  a-blog, building-rest-api, deploy, file-uploads blockers fixed
- Changelog/home: v3.0.0-beta entries added; LATEST badge moved to v3
- Roadmap: shipped features moved (multi-tenant, 2FA, Lexical, definePlugin)
- Auth: JWT sections preserved (hybrid JWT+Better Auth is real); only fix
  wrong demo creds, add author role, fix createSonicJS→registerPlugins
- Comparison posts: PluginBuilder blocks, stale API paths, dated roadmap
  quarters, "multi-tenant still building" all corrected
- Tier 3: security X-Frame-Options→SAMEORIGIN; telemetry endpoint/opt-out;
  field-types document model note; workflow/webhooks experimental banners;
  forms contentId→null; ai-agents duplicate block removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(www): resolve VERIFY queue + fix hook names and config interface

- Fix wrong hook event names: content:beforeSave/afterSave → content:save,
  content:afterPublish → content:publish, content:beforeReturn → content:read
  (verified against HOOKS constant in mogadishu-v4 packages/core)
- Fix SonicJSConfig.plugins interface: directory/autoLoad → register:Plugin[]
  (the real v3 field; directory/autoLoad were from old main-branch interface)
- Restore adminAccessRoles to SonicJSConfig (real field, was incorrectly removed)
- Fix plugins/core auth config: remove fictional tokenExpiration/refreshExpiration
  settings; clarify env-var-based config + no separate refreshToken in response
- Fix defineCollection → CollectionConfig in field-types, directus-vs-payload,
  strapi-vs-contentful, best-open-source-practice blog posts
- Fix coding-standards: add Document Model vs Drizzle boundary note
- Fix templating: annotate admin-layout-v2 as legacy, docs-layout as non-existent
- Fix plugins/core: auth plugin config block updated to v3 env-var pattern
- Remove resolved TODO comment in caching strategy guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…del (#901)

plugin_activity_log table was defined in schema.ts but never created in
any migration, so all logActivity() inserts silently failed (try/catch)
and the Activity Log tab was always empty.

- Register plugin_activity document type in bootstrapDocumentTypes with
  q_plugin_activity_plugin_id and q_plugin_activity_action queryable fields
- logActivity() now inserts into documents table (type_id=plugin_activity)
- getPluginActivity() queries documents via json_extract(data, '$.pluginId')
- Fix route mapping: derive message from details JSON, pass userId not user_email
- Guard item.message in template to prevent "undefined" text rendering
- Add E2E spec 68 for plugin activity log tab

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Dead code — plugin had no runtime behavior (commented-out routes, console.log-only activate/deactivate). The static component showcase page served no purpose that reading existing template files doesn't already cover.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace analytics_events table with analytics_event document type.
EventTrackingService now writes via DocumentsService.create() and
queries raw SQL against documents table. Remove hardcoded stub routes.
Bootstrap registers analytics_event type with q_evt_* generated columns.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
#904)

Database Tools functionality is built into core — the plugin entry
had is_core: false, causing it to appear in the Plugins tab instead
of the System tab.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Legacy feature table, not document model — violates v3 architecture.
No active users, no tests, no migration path planned.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove seed data generator plugin: delete plugin directory, drop
/admin/seed-data route from app.ts, remove export and ID from
core-plugins index, remove isSeedDataPlugin UI block from admin
plugin settings template.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…el (#908)

- Gate "Profile Information" on edit/new user forms behind both
  defineUserProfile() being called AND plugin being active in DB
- Remove hardcoded bio field from new user form; bio now belongs
  in the profile model if the developer wants it
- Plugin detail page explains code-defined data model pattern,
  points dev to my-sonicjs-app/src/user-profile.model.ts with
  defineUserProfile() example and live configured-fields table
- Add user-profile.model.ts stub to starter app (commented out
  by default); index.ts imports it as the canonical edit point
- Remove legacy my-sonicjs-app Drizzle user_profiles table schema
  and CRUD route (conflicts with document model; was dead code)
- New user form falls back to showing all profile fields when
  registrationFields not specified (matches edit form behavior)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace legacy security_events table with documents repository.

- Rewrite SecurityAuditService to write via DocumentsService and query
  documents table using q_sa_* virtual generated columns
- Register security_event document type in bootstrapDocumentTypes with
  6 queryable fields (event_type, severity, user_id, email, ip_address, blocked)
- Fix pre-existing routing bug: mount /api/security-audit and
  /admin/plugins/security-audit routes before generic /api/:collection/:id
  catch-all to prevent 404s on plugin-specific API endpoints
- Export admin/api route handlers from plugin index
- Add E2E spec 80 covering dashboard, event log, API stats/events, settings

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Mark workflow-plugin as hidden in manifest.json so the auto-generator
skips it. Routes are dead code; plugin is not on the critical path for
the document model migration.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(content-form): prevent nested form UI on HTMX validation errors

Validation error responses returned the full admin layout page, which
HTMX swapped into #form-messages — causing the entire page to render
inside itself. Fix uses HX-Retarget + HX-Reswap headers to replace
#content-form-page (the form content wrapper) with a partial response
that omits the layout. Also removes the duplicate #form-messages div
inside the form (invalid HTML).

Affected paths: POST /admin/content, PUT /admin/content/:id (both
document-backed and legacy variants).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(setup): local-only fallback when Cloudflare auth unavailable

setup-worktree-db.sh now detects missing Cloudflare credentials via
`wrangler whoami` and falls back to a local-only path: skips remote D1
create/migrate, applies migrations with --local, and seeds admin via
seed-local.mjs (better-sqlite3 + node:crypto, no getPlatformProxy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): assign RBAC role on registration to allow admin access

New users registered via the sign-up form got `role='admin'` in
`auth_user` but no `rbac_user_roles` document, so `requireRbac('portal',
'access')` returned false and redirected with "You do not have
permission to access this area".

Add `RbacService.addUserRoleByName(userId, role)` immediately after
user creation in the registration handler, matching the pattern already
used by the seed endpoint.

Also renames root `workspace` script → `reset` and adds `--local` flag
to `wrangler dev` for offline-only setups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace legacy `redirects`/`redirect_analytics` tables with the document
model. All redirect data now lives in `documents` (type_id='redirect'),
with `q_redir_*` virtual columns for source, destination, status_code,
is_active, and match_type.

- RedirectService rewritten to query documents table; hit analytics
  stored as hitCount/lastHitAt in document data JSON
- Middleware recordHitAsync and warmRedirectCache updated to use documents
- Plugin onBoot registers 'redirect' document type via DocumentTypeRegistry
  (auto-creates virtual columns via ensureScalarSchema)
- Mount redirect middleware (app.use('*')) so HTTP redirects actually fire
- Add duplicate source URL check in create()
- Export redirectPlugin from core and register in app entry point
- Add E2E spec (80-redirect-management.spec.ts): 7 tests covering page
  load, create, HTTP redirect firing, edit, delete, and duplicate error

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(versioning): make versioning opt-in per document type

Document types default to edit-in-place (no history rows).
Set `settings.versioning: true` or `CollectionConfig.versioning: true`
to opt in to version history and restore.

- saveDraft skips version row creation when versioning=false
- Versioning plugin routes 404 for non-opted-in types
- Content/documents forms show single "Update" button when versioning off
- Collections admin lists Versioning column; drops Source and Created
- blog_post defaults versioning off
- faq collection added as versioning-on example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ui): constrain date picker width and improve dark mode icon

- Date/datetime inputs get !w-auto to override w-full — sizes to content
- dark:[color-scheme:dark] makes native calendar icon render white on dark bg
- Versioning column badges match content status style (green/zinc ring pills)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(collections): remove faq example, simplify blog-posts schema

faq collection served as versioning demo — removed since opt-in is
now documented via settings.versioning. blog-posts trimmed to core
fields (excerpt, featuredImage, status fields dropped).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- Flip admin-media and api-media reads/writes to document-primary via MediaDocumentService
- Register media_asset document type in bootstrapDocumentTypes (seed)
- Add getByRootId, softDeleteRoot, updateMetadata, search to MediaDocumentService
- Legacy media table writes wrapped in non-fatal try/catch (R12 compat)
- doc.rootId is canonical file id; stored as media.id for dual-write
- Rewrite admin-media and api-media integration tests to assert on documents table
- Add E2E spec 81-media-document-model.spec.ts
- Copy URL button label → "Copy Relative URL"

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… opt-in (#916)

* fix(api): gate collection in meta behind ?include=collection

Reduces default payload size; callers opt in via ?include=collection.
Cache key updated to prevent cross-contamination between variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: merge origin/v3 (versioning opt-in, simplified blog-posts, media document model)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…diness) (#894)

* fix(v3): restore test gate + fix document-model regressions for prod readiness

Re-enable the disabled test suite and fix the bugs it was hiding across the
content, media, auth, and settings paths of the v3 document model.

Test gate:
- Un-stub packages/core test scripts (real vitest); CI re-enables type-check + triggers on v3
- Fix the real-DB harness (d1-sqlite applyScalarSchema) and migrate tests to the code-only
  collection registry / current APIs -> 1530 unit tests green, 0 failed

Runtime bugs fixed (all live-verified):
- test-cleanup: stop querying the dropped `collections` table; guard legacy tables;
  fail-closed environment gate
- /api/documents: register before the /api/:collection wildcard that shadowed it (was 404)
- /api OpenAPI: report the real package version (was hardcoded 0.1.0)
- admin content: stop leaking internal/auth document types into the content-model filter
  dropdown and the new-content collection selector
- media: flip api-media + admin-media to the document model (media_asset) so greenfield media
  upload/library/delete no longer 500; seed the media_asset document type (was missing -> FK fail)
- auth: isRegistrationEnabled reads the plugin document instead of the dropped `plugins` table,
  so "disable registration" is actually enforced (was a silent no-op)

E2E:
- Fix loginAsAdmin (Better Auth Origin) and the createTestContent helper hang (collections
  are code-only); refresh stale specs (03 dashboard -> content/analytics, 05 content,
  23 content-api-crud auth, 68 profile)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(v3): QA prod-readiness — auth, dashboard, E2E test fixes

Core fixes:
- auth.ts: delegate POST /auth/login to Better Auth sign-in/email
- admin-users.ts: redirect /admin → /admin/dashboard (was /admin/content)
- app.ts: mount adminDashboardRoutes; move registerPluginRoutes(testimonials)
  before app.route('/api') so /:collection catch-all doesn't shadow it
- csrf.ts: exempt /test-seed-defaults from CSRF check
- test-cleanup.ts: add POST /test-seed-defaults for E2E global-setup

New:
- page-blocks.collection.ts: code-only collection for TDZ/blocks E2E tests
- my-sonicjs-app/src/index.ts: register pageBlocksCollection

E2E fixes:
- global-setup.ts: call /test-seed-defaults after cleanup to restore welcome post
- 05-content: use form#content-form (strict mode fix — 3 forms on page)
- 27-settings-general: button text is "Save Changes" not "Save All Changes"
- 39-slug-generation: set lexical hidden input via page.evaluate() (not textarea)
- 41-reference-fields: skip (field editor removed for code-only collections)
- 42-dynamic-field-tdz: fix seoTitleInput selector (skip hidden type="hidden");
  use toHaveCount for hidden inputs; text block now has heading+body fields
- 65-default-blog-post-seed: handled by test-seed-defaults
- 66-auth-better-auth: fix seed-admin assertion (body.users not body.user)
- 71-home-link-redirect: update URL expectation + skip catalyst nav tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

High-level feasibility/effort analysis for running SonicJS outside
Cloudflare. Key findings: no Durable Objects in use, D1->SQLite shim
already exists as a test util, tiny R2/Queue surface. Tiered
recommendation (Docker/SQLite -> Turso -> Postgres) with the deployment
targets each tier unlocks.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lane711 lane711 merged commit 5b69bff into main Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant