Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/bench/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { BlockCache } from '@/data/blockCache'
import { Repo } from '@/data/internals/repo'
import type { PowerSyncDb } from '@/data/internals/commitPipeline'
import { instrumentDb, type DbCounters } from './harness'
import { staticDataExtensions } from '@/extensions/staticDataExtensions'
import { pluginDataExtensions } from '@/data/pluginDataExtensions'

export interface BenchEnv {
db: PowerSyncDb
Expand Down Expand Up @@ -79,7 +79,7 @@ export const setupBenchEnv = async (opts: SetupOptions = {}): Promise<BenchEnv>
await backfillBlockAliasesIfEmpty(backfillDb)
await applyLocalSchemaContributions(
backfillDb,
resolveLocalSchemaContributions(staticDataExtensions),
resolveLocalSchemaContributions(pluginDataExtensions),
)

let dbForRepo: PowerSyncDb = psDb as unknown as PowerSyncDb
Expand Down
81 changes: 49 additions & 32 deletions src/bootstrap/workspaceBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,49 +19,48 @@ const replaceHash = (hash: string): void => {
window.history.replaceState(null, '', nextHash)
}

// Resolve the static-extension runtime once per (repo, workspace).
// `workspaceLandingFacet` resolvers only need the kernel + static
// plugin contributions — dynamic plugins haven't loaded yet at this
// point in bootstrap, and we don't want to give them the power to
// redirect a user's first paint.
// Build the static-extension runtime for this bootstrap. It serves two roles:
// 1. It's installed into the Repo (`bootstrapWorkspace` below) as the
// source of plugin data ownership (types / mutators / processors /
// queries / system pages) — repo.tsx no longer installs a separate
// `staticDataExtensions` list, so this is where the Repo learns about
// plugin data before the bootstrap writes that need it.
// 2. Its `workspaceLandingFacet` resolvers decide the empty-layout
// landing block. Dynamic plugins haven't loaded yet at this point,
// and we don't want to give them the power to redirect first paint.
//
// Resolution goes through `resolveAppRuntimeSync` with the workspace's
// cached toggle overrides — NOT the bare collector — so a togglable
// boundary the user has disabled is honoured here too. Without this, a
// disabled non-essential plugin (e.g. `system:daily-notes`, the sole
// `workspaceLandingFacet` contributor) would still steer first paint.
// boundary the user has disabled is honoured: a disabled plugin's data is
// genuinely absent from the Repo (no "secretly enabled" data) and a
// disabled landing contributor (e.g. `system:daily-notes`) doesn't steer
// first paint.
//
// The cache keeps the cost down across re-entries via getInitialLayout's
// promise cache; entries are keyed by `repo.instanceId` + workspace +
// the override state, so a fresh Repo (new login), a workspace switch,
// or a mid-session toggle change (Settings dispatches
// `refreshAppRuntime`) all build a fresh runtime instead of replaying a
// stale one — otherwise a just-disabled daily-notes could still steer a
// later empty-layout navigation until a full reload.
const landingRuntimeCache = new Map<string, ReturnType<typeof resolveAppRuntimeSync>>()
const getLandingRuntime = (repo: Repo) => {
// Built FRESH every call — never cached. `repo.setFacetRuntime` MUTATES its
// argument (`adoptDurableContributionsFrom` copies the previous runtime's
// durable `user-data` buckets — user property schemas / types — onto it).
// Caching the object we hand to `setFacetRuntime` would let one workspace's
// adopted user-data accumulate on the shared instance and replay on a later
// bootstrap for another workspace, before that workspace's projectors clear
// and repopulate the bucket. A fresh object per bootstrap can't leak across
// workspaces. The build is cheap (the modules are already imported; this
// just walks the extension array), and getInitialLayout's promise cache
// already dedupes re-entry, so there's nothing to cache here. The same
// instance is reused for landing resolution below, so install and landing
// always agree on the contribution set.
const buildStaticAppRuntime = (repo: Repo) => {
const workspaceId = repo.activeWorkspaceId
const overrides = workspaceId
? readOverridesCache(workspaceId)
: new Map<string, boolean>()
// Sparse map (only entries diverging from manifest defaults), sorted
// for a stable key regardless of insertion order.
const overridesFingerprint = JSON.stringify(
[...overrides.entries()].sort(([a], [b]) => a.localeCompare(b)),
)
const cacheKey = `${repo.instanceId}:${workspaceId ?? ''}:${overridesFingerprint}`
const cached = landingRuntimeCache.get(cacheKey)
if (cached) return cached
const runtime = resolveAppRuntimeSync(staticAppExtensions({repo}), {
return resolveAppRuntimeSync(staticAppExtensions({repo}), {
overrides,
context: {
repo,
workspaceId,
safeMode: false,
},
})
landingRuntimeCache.set(cacheKey, runtime)
return runtime
}

// Walk landing resolvers in reverse (highest precedence last in the
Expand All @@ -71,10 +70,10 @@ const getLandingRuntime = (repo: Repo) => {
// block the user from booting the app.
const resolveLandingBlockId = async (
repo: Repo,
runtime: ReturnType<typeof buildStaticAppRuntime>,
workspaceId: string,
freshlyCreated: boolean,
): Promise<string | null> => {
const runtime = getLandingRuntime(repo)
const resolvers = runtime.read(workspaceLandingFacet)
for (let i = resolvers.length - 1; i >= 0; i -= 1) {
try {
Expand Down Expand Up @@ -113,6 +112,23 @@ export const bootstrapWorkspace = async ({
requestedHash,
requestedWorkspaceId,
}: WorkspaceBootstrapArgs): Promise<Block> => {
// Install the toggle-aware static-extension runtime into the Repo BEFORE
// any bootstrap write. This is now the Repo's source of plugin data
// ownership (types / mutators / processors / queries / system pages) —
// repo.tsx no longer installs a separate `staticDataExtensions` list. It
// must precede the writes below that depend on plugin data: ensureSystemPages
// reads `systemPagesFacet`, the onboarding seed triggers the references
// post-commit processor, and the daily-notes landing resolver calls
// `repo.addTypeInTx(DAILY_NOTE_TYPE)` (which throws if the type is
// unregistered). Resolved with the workspace's toggle overrides, so a
// disabled plugin's data is genuinely absent. AppRuntimeProvider re-installs
// the same tree (+ dynamic extensions) once it mounts; the contribution
// instances are shared, so that later swap reads as additive. Built fresh
// (not cached) because setFacetRuntime mutates it — see buildStaticAppRuntime.
// Reused for landing resolution below.
const staticRuntime = buildStaticAppRuntime(repo)
repo.setFacetRuntime(staticRuntime)

// Only NOW remember it as the default. Remembering a locked/waiting workspace
// would make the next empty-hash visit re-select it and render only the key
// gate (no switcher), trapping the user away from accessible spaces.
Expand Down Expand Up @@ -145,8 +161,9 @@ export const bootstrapWorkspace = async ({
// that seeds on `freshlyCreated` and then defers the landing target to
// daily-notes (see src/plugins/onboarding). That keeps first-run content
// out of the kernel and lets disabling the plugin remove it cleanly. The
// tutorial's typed demos seed against `repo.snapshotTypeRegistries()`,
// which is populated from `staticDataExtensions` at repo construction.
// tutorial's typed demos seed against `repo.snapshotTypeRegistries()`, which
// is populated from the toggle-aware static-extension runtime installed
// above — so the plugins' demo types are present for the seed.

// Resolve the layout-session block the app paints — the warm-start critical
// path. This chain is genuinely serial: each ui-state child's deterministic id
Expand Down Expand Up @@ -180,7 +197,7 @@ export const bootstrapWorkspace = async ({
// before we've even built a layout, and the sync runtime carries
// the same kernel + static plugin contributions
// AppRuntimeProvider's initial render uses.
const landingId = await resolveLandingBlockId(repo, workspaceId, freshlyCreated)
const landingId = await resolveLandingBlockId(repo, staticRuntime, workspaceId, freshlyCreated)
if (landingId) {
replaceHash(buildLayout(workspaceId, [landingId]))
await repo.tx(async tx => {
Expand Down
19 changes: 11 additions & 8 deletions src/context/repo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { useIsLocalOnly, useUser } from '@/components/Login'
import { ensurePowerSyncReady, getPowerSyncDb, syncObserverDepsFor } from '@/data/repoProvider'
import { User } from '@/types.js'
import { memoize } from 'lodash-es'
import { resolveFacetRuntimeSync } from '@/facets/facet.js'
import { staticDataExtensions } from '@/extensions/staticDataExtensions.js'
import { surfaceProcessorRejection } from '@/extensions/processorRejectionToast.js'
import { markStartup } from '@/utils/startupTimeline.js'

Expand All @@ -30,12 +28,17 @@ const initRepo = memoize(
user: {id: user.id, name: user.name},
syncObserverDeps: syncObserverDepsFor(user.id),
})
repo.setFacetRuntime(resolveFacetRuntimeSync(staticDataExtensions, {
repo,
workspaceId: null,
safeMode: false,
generation: 'repo-bootstrap',
}))
// The Repo comes up kernel-only (its constructor installs the kernel
// runtime via `installKernelRuntime`). Plugin data ownership is no
// longer installed here from a separate `staticDataExtensions` list —
// it's resolved (toggle-aware) from the single `staticAppExtensions`
// tree and installed in `bootstrapWorkspace`, before the bootstrap
// writes that need it (the daily-notes landing resolver, seedTutorial's
// references processor). Nothing between construction and bootstrap
// consumes non-kernel plugin data (resolveWorkspace / the access gate /
// role lookup are kernel-only). Doing the install workspace-side also
// makes it honour the workspace's toggle overrides, so a disabled
// plugin's data is genuinely absent rather than silently registered.
// Subscribe at bootstrap so user-surfaceable errors from any
// `repo.tx` call site (mutators, palette actions, bootstrap writes)
// route through the toast layer from the moment the repo exists. The
Expand Down
5 changes: 3 additions & 2 deletions src/data/kernelDataExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* `repo.mutate.<kernel>` / `repo.query.<kernel>` work immediately, and
* every later `repo.setFacetRuntime(runtime)` REPLACES that install with
* the merged kernel + plugin registry — so this extension must be
* present in every runtime (it is, via `staticDataExtensions`) to keep
* the kernel dispatch surfaces working after a swap.
* present in every runtime (it is, via `staticAppExtensions`, which the
* Repo install in `bootstrapWorkspace` and AppRuntimeProvider both
* resolve from) to keep the kernel dispatch surfaces working after a swap.
*
* Property schemas: the kernel descriptors live in `data/properties.ts`
* (plain consts); this extension surfaces them through
Expand Down
7 changes: 7 additions & 0 deletions src/data/localSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import {
type LocalSchemaDb,
} from './facets.ts'

/** Collect `localSchemaFacet` contributions from a UI-free extension list,
* via the facet system (NOT a hand-listed set of contributions). Used by
* the pre-observer / pre-React local-DDL path (`repoProvider`,
* `createTestDb`, the bench) off `pluginDataExtensions`. Toggle-blind
* (bare collector): local tables/triggers are provisioned regardless of a
* plugin's enabled state, so toggling one on mid-session never hits a
* missing table. */
export const resolveLocalSchemaContributions = (
extensions: readonly AppExtension[],
): readonly LocalSchemaContribution[] =>
Expand Down
14 changes: 14 additions & 0 deletions src/data/metricsConsoleHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import { Repo } from './repo'
import type { AppEffect } from '@/extensions/core'

interface MetricsConsoleAPI {
/** Raw frozen snapshot — same shape as `repo.metrics()`. */
Expand Down Expand Up @@ -136,3 +137,16 @@ export const ensureMetricsConsoleHook = (repo: Repo): void => {
'\n __omniliner.repo — the Repo instance itself',
)
}

/** App-effect wrapper for the devtools metrics hook. Installing the hook
* touches `window`, so it must run at the effect lifecycle (browser,
* post-mount) — NOT while building the extension tree, which the data
* layer / headless bench resolve in a non-browser context. Idempotent
* via the module `installed` flag, so the reconciler restarting it is a
* no-op. */
export const metricsConsoleHookEffect: AppEffect = {
id: 'data.metrics-console-hook',
start: ({repo}) => {
ensureMetricsConsoleHook(repo)
},
}
44 changes: 44 additions & 0 deletions src/data/pluginDataExtensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment node

import { describe, expect, it } from 'vitest'
import { resolveFacetRuntimeSync } from '@/facets/facet.js'
import { pluginDataExtensions } from '@/data/pluginDataExtensions.js'
import { propertyEditorOverridesFacet } from '@/data/facets.js'
import {
actionsFacet,
appMountsFacet,
blockRenderersFacet,
headerItemsFacet,
} from '@/extensions/core.js'
import { codeMirrorExtensionsFacet } from '@/editor/codeMirrorExtensions.js'
import { markdownExtensionsFacet } from '@/markdown/extensions.js'

// The data-only / graph-free invariant for `plugins/<name>/dataExtension.ts`:
// these modules feed the headless data layer (the local-schema DDL applied
// before the React tree mounts, and a future Node-only data runtime) and are
// imported by the node-env `createTestDb` glob, so they must NOT contribute UI
// surfaces or pull the React/CodeMirror graph. The createTestDb eval-safety
// test catches DOM access at module-eval (a throw); this catches the subtler
// leak — a stray action / editor / renderer contribution — which a React
// import almost always drags in but which would otherwise import fine under
// Node and pass every other test.
describe('pluginDataExtensions (data-only invariant)', () => {
it('discovers at least one plugin data extension', () => {
expect(pluginDataExtensions.length).toBeGreaterThan(0)
})

it('contributes no UI facets', () => {
const contributed = new Set(resolveFacetRuntimeSync(pluginDataExtensions).facetIds())
const uiFacets = [
actionsFacet,
blockRenderersFacet,
appMountsFacet,
headerItemsFacet,
codeMirrorExtensionsFacet,
markdownExtensionsFacet,
propertyEditorOverridesFacet,
]
const leaked = uiFacets.map(facet => facet.id).filter(id => contributed.has(id))
expect(leaked).toEqual([])
})
})
56 changes: 56 additions & 0 deletions src/data/pluginDataExtensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Every plugin's data extension, auto-discovered from the UI-free
* `plugins/<name>/dataExtension.ts` modules' DEFAULT export via
* `import.meta.glob`. This is the data layer's UI-free view of all plugin
* data — types, mutators, queries, processors, property schemas, and local
* schema — with no hand-maintained list.
*
* Why it exists / why it's UI-free:
* - The pre-React / pre-observer local-schema DDL path (`repoProvider`,
* `createTestDb`) resolves `localSchemaFacet` off this WITHOUT importing
* the React component tree (`staticAppExtensions`). Importing the tree
* into `createTestDb` would add ~2s of module eval to each of the ~90
* test files that use it; and it must run headless / Node-safe.
* - It's also the basis for a future headless data runtime: ALL plugin
* data is resolvable here UI-free, not just schema.
*
* Convention & invariant: a `dataExtension.ts` is **data only** and must
* stay graph-free — it default-exports the plugin's data `AppExtension`
* (types / mutators / queries / processors / schemas / local schema /
* value presets / graph-free effects) and imports NOTHING that pulls the
* React/app graph: no components, no property-editor UI, and no actions
* (an action handler that imports `@/utils/navigation` → React would drag
* the whole provider graph in — measured ~1.5s/import). Those all live in
* the plugin's `index.ts`. The Node-env `createTestDb.test` imports this
* glob, so it fails loudly if a `dataExtension.ts` touches the DOM at
* module-eval; keeping it data-only also keeps the import cheap.
* `import.meta.glob` is a Vite build-time transform (confirmed in the prod
* build; already used in `authoringCatalog.ts`), so this is not a
* Vite-runtime dependency.
*
* NOTE: this is the toggle-BLIND view (local schema is provisioned
* regardless of a plugin's enabled state). Plugin data OWNERSHIP that the
* Repo installs (mutators / types / queries) comes from the toggle-AWARE
* `staticAppExtensions` tree at bootstrap — NOT from here.
*/
import type { AppExtension } from '@/facets/facet.js'

const modules = import.meta.glob<AppExtension>('/src/plugins/*/dataExtension.ts', {
eager: true,
import: 'default',
})

export const pluginDataExtensions: readonly AppExtension[] =
Object.entries(modules).map(([path, ext]) => {
// A `dataExtension.ts` with no default export would otherwise vanish
// silently from the data layer (its local schema, processors, and types
// would simply never register) — fail loudly, naming the module instead.
if (!ext) {
throw new Error(
`Data extension "${path}" has no default export — every ` +
`plugins/<name>/dataExtension.ts must \`export default\` its data ` +
`AppExtension.`,
)
}
return ext
})
7 changes: 3 additions & 4 deletions src/data/queryRun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { createTestDb, resetTestDb, type TestDb } from '@/data/test/createTestDb
import { createTestRepo } from '@/data/test/createTestRepo'
import { kernelDataExtension } from '@/data/kernelDataExtension'
import { queriesFacet } from '@/data/facets'
import { staticDataExtensions } from '@/extensions/staticDataExtensions'
import { Repo } from '@/data/repo'

// Typed name for the composed helper the swap test exercises.
Expand Down Expand Up @@ -468,14 +467,14 @@ describe('QueryCtx.run', () => {
it('additive real-runtime swap reuses a kernel handle (instance-stability)', () => {
// The additive-skip rests on kernel/static query INSTANCES being stable
// across resolves, so AppRuntimeProvider's base→next reads as ADDITIVE
// (not REPLACE). Resolve the real static data extensions twice — plain
// (not REPLACE). Resolve the kernel data extension twice — plain
// ("base") and with one extra query appended ("next", standing in for a
// dynamic plugin) — and confirm a kernel handle survives the swap. If a
// query ever got wrapped per-resolve, the shared instances would differ
// → REPLACE → epoch bump → the handle would re-key and this fails.
const ctx = {repo, workspaceId: null, safeMode: false, generation: 'test'}
const baseQ = [...new Map(
resolveFacetRuntimeSync(staticDataExtensions, ctx).read(queriesFacet),
resolveFacetRuntimeSync([kernelDataExtension], ctx).read(queriesFacet),
).values()]
const extra = defineQuery<{tag: string}, string>({
name: 'plugin:integrationExtra',
Expand All @@ -485,7 +484,7 @@ describe('QueryCtx.run', () => {
})
const nextQ = [...new Map(
resolveFacetRuntimeSync(
[...staticDataExtensions, queriesFacet.of(extra, {source: 'plugin'})], ctx,
[kernelDataExtension, queriesFacet.of(extra, {source: 'plugin'})], ctx,
).read(queriesFacet),
).values()]

Expand Down
7 changes: 4 additions & 3 deletions src/data/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1700,9 +1700,10 @@ export class Repo {
* converging clients all land on the same rows.
*
* Reads off this Repo's own `facetRuntime` — which carries the data-layer
* contributions installed at construction (`staticDataExtensions`) — so no
* separate runtime resolution is needed. Awaited (not deferred): the pages
* must exist before the seed's references parse.
* contributions that `bootstrapWorkspace` installs (the toggle-aware
* `staticAppExtensions` runtime) before calling this — so no separate
* runtime resolution is needed. Awaited (not deferred): the pages must
* exist before the seed's references parse.
*/
async ensureSystemPages(workspaceId: string): Promise<void> {
if (!workspaceId) return
Expand Down
Loading
Loading