Fix: forced rebuild index handling

And also bumps index version to force reindex (due to 'relation' introduction and 'link' elimination)
This commit is contained in:
Zef Hemel
2026-05-22 15:56:00 +02:00
parent 20af5a98ac
commit 370bd33619
7 changed files with 354 additions and 48 deletions
+40 -20
View File
@@ -124,6 +124,9 @@ export class Client {
// Track if plugs have been updated since sync cycle
fullSyncCompleted = false;
private versionMismatchNotified = false;
// True once we've confirmed the server reports the same publicVersion as
// this client bundle and the plugs the server is shipping are aligned with the running build.
private versionsInSync = false;
// Set to true once the system is ready (plugs loaded)
public systemReady: boolean = false;
@@ -202,8 +205,11 @@ export class Client {
);
// Seed the full-index readiness flag from persistent state so a
// warm reload doesn't unnecessarily flash loading spinners.
this.fullIndexCompleted = await this.objectIndex.hasFullIndexCompleted();
// warm reload doesn't unnecessarily flash loading spinners. A stale
// stored version (older than `desiredIndexVersion`) still counts as
// ready: the entries are queryable while `ensureFullIndex` waits for
// sync + version confirmation. See `ObjectIndex.isIndexAvailable`.
this.fullIndexCompleted = await this.objectIndex.isIndexAvailable();
// After the initial index completes (object_index.ts dispatches this),
// mark the flag and — if the page list cache previously took the
@@ -303,6 +309,11 @@ export class Client {
this.systemReady = true;
this.maybeDispatchWidgetsReady();
// When the service worker is disabled check for an up-to-date index immediately
if (this.bootConfig.disableServiceWorker) {
void this.objectIndex.ensureFullIndex(this.space);
}
this.initHeadlessRuntime();
// Load space snapshot and enable events
@@ -545,10 +556,10 @@ export class Client {
const result = await evalStatement(ast, scriptEnv, sf);
const returnValue =
result &&
typeof result === "object" &&
"ctrl" in result &&
result.ctrl === "return" &&
Array.isArray(result.values)
typeof result === "object" &&
"ctrl" in result &&
result.ctrl === "return" &&
Array.isArray(result.values)
? result.values[0]
: result;
return (await Promise.resolve(luaValueToJS(returnValue, sf))) ?? null;
@@ -594,13 +605,13 @@ export class Client {
async updatePageListCache() {
console.log("Updating page list cache");
// Check if the initial sync has been completed
const initialIndexCompleted =
await this.objectIndex.hasFullIndexCompleted();
// Use the looser `isIndexAvailable` check: stale-but-present index
// entries are still queryable, so we can take the index branch.
const indexAvailable = await this.objectIndex.isIndexAvailable();
let allPages: PageMeta[] = [];
if (initialIndexCompleted) {
if (indexAvailable) {
console.log("Initial index complete, loading full page list via index.");
// Fetch indexed pages
allPages = await this.queryLuaObjects<PageMeta>("page", {});
@@ -661,7 +672,7 @@ export class Client {
// transform-applied values (the index branch). The fallback branch
// produces raw page meta without pageDecoration, so we keep
// showing loading widgets until the index is back.
if (initialIndexCompleted) {
if (indexAvailable) {
this.pageListLoaded = true;
this.maybeDispatchWidgetsReady();
}
@@ -934,9 +945,9 @@ export class Client {
console.warn("Not loading custom styles, since space style is disabled");
return;
}
if (!(await this.objectIndex.hasFullIndexCompleted())) {
if (!(await this.objectIndex.isIndexAvailable())) {
console.warn(
"Not loading custom styles, since full indexing has not completed yet",
"Not loading custom styles, since no index is available yet",
);
return;
}
@@ -1014,9 +1025,13 @@ export class Client {
case "space-sync-complete": {
const isFirstSync = !this.fullSyncCompleted;
this.fullSyncCompleted = true;
// Trigger version-bump reindex if needed (must happen here, not in a plug,
// because the plug may not be loaded yet when the first sync completes)
void this.objectIndex.ensureFullIndex(this.space);
// Only trigger a version-bump reindex once we've also confirmed the
// server is on the same publicVersion as this client — otherwise the
// reindex could run against stale plug code that's about to be
// replaced by the in-progress upgrade.
if (this.versionsInSync) {
void this.objectIndex.ensureFullIndex(this.space);
}
if (isFirstSync && message.operations > 0) {
// First sync pulled new content — reload the current page
@@ -1047,10 +1062,15 @@ export class Client {
break;
}
case "server-version": {
if (
message.serverVersion !== publicVersion &&
!this.versionMismatchNotified
) {
if (message.serverVersion === publicVersion) {
const wasInSync = this.versionsInSync;
this.versionsInSync = true;
// If sync already completed before we learned versions were aligned,
// kick off the deferred reindex check now.
if (!wasInSync && this.fullSyncCompleted) {
void this.objectIndex.ensureFullIndex(this.space);
}
} else if (!this.versionMismatchNotified) {
this.versionMismatchNotified = true;
this.ui.flashNotification(
"A new version of SilverBullet client is available. A reload or two is required to update.",
+2 -2
View File
@@ -193,9 +193,9 @@ export class ClientSystem {
console.info("Space Lua scripts are disabled, skipping loading scripts");
return;
}
if (!(await this.objectIndex.hasFullIndexCompleted())) {
if (!(await this.objectIndex.isIndexAvailable())) {
console.info(
"Not loading space scripts, since full indexing has not completed yet",
"Not loading space scripts, since no index is available yet",
);
return;
}
+43 -13
View File
@@ -30,7 +30,7 @@ const pageKey = "ridx";
const indexVersionKey = ["$indexVersion"];
// Bump this one every time a full reindex is needed
const desiredIndexVersion = 9;
const desiredIndexVersion = 10;
type TagDefinition = {
tagPage?: string;
@@ -77,9 +77,9 @@ export class ObjectIndex {
indexStarted = true;
});
// Handle initial index completion
void this.hasFullIndexCompleted().then((hasCompleted) => {
if (!hasCompleted) {
// Handle initial index completion for fresh installs only.
void this.getCurrentIndexVersion().then((currentVersion) => {
if (currentVersion === undefined) {
const emptyQueueHandler = async () => {
console.log("Index queue empty, checking if index is complete");
// Theoretically we could get empty queue notifications before the file:listed event has been triggered, so let's account for this
@@ -328,20 +328,39 @@ export class ObjectIndex {
return this.ds.get([indexKey, tag, this.cleanKey(ref, page), page]);
}
private reindexingForVersionBump = false;
async ensureFullIndex(space: Space) {
const currentIndexVersion = await this.getCurrentIndexVersion();
if (!currentIndexVersion) {
if (currentIndexVersion === undefined) {
console.log("No index version found, assuming fresh install");
return;
}
if (
// If the index version is less than the desired version
currentIndexVersion < desiredIndexVersion &&
// And the index queue is empty (meaning no indexing is ongoing)
(await this.mq.isQueueEmpty("indexQueue"))
) {
if (currentIndexVersion >= desiredIndexVersion) {
return;
}
// Guard against concurrent invocations (e.g. one from
// `space-sync-complete` and another from a follow-up `server-version`
// SW message) — only one version-bump reindex should run at a time.
if (this.reindexingForVersionBump) {
return;
}
this.reindexingForVersionBump = true;
try {
// Wait out any indexing currently in flight (e.g. just-synced files
// being indexed) so we don't fight the worker over the queue.
await this.mq.awaitEmptyQueue("indexQueue");
// Re-read the version in case a concurrent path already bumped it
// while we were waiting.
const versionNow = await this.getCurrentIndexVersion();
if (versionNow !== undefined && versionNow >= desiredIndexVersion) {
return;
}
console.info(
"[index]",
"Performing a full space reindex, this could take a while...",
@@ -353,6 +372,8 @@ export class ObjectIndex {
// Dispatch an editor:reloadState event to reload the editor state (render widgets etc.)
void this.eventHook.dispatchEvent("editor:reloadState");
} finally {
this.reindexingForVersionBump = false;
}
}
@@ -379,8 +400,17 @@ export class ObjectIndex {
return (await this.ds.get(indexVersionKey)) >= desiredIndexVersion;
}
private getCurrentIndexVersion() {
return this.ds.get(indexVersionKey);
public getCurrentIndexVersion(): Promise<number | undefined> {
return this.ds.get(indexVersionKey) as Promise<number | undefined>;
}
/**
* True once any full indexing pass has ever completed for this
* space, regardless of whether the stored version matches the current
* `desiredIndexVersion`.
*/
public async isIndexAvailable(): Promise<boolean> {
return (await this.getCurrentIndexVersion()) !== undefined;
}
async awaitIndexQueueDrain(): Promise<void> {
+12 -11
View File
@@ -18,9 +18,9 @@ export type SBServer = {
type SBFixtures = {
spaceFiles: Record<string, string>;
disableServiceWorker: boolean;
sbServer: SBServer;
sbPage: Page;
sbPageWithSync: Page;
};
async function getFreePort(): Promise<number> {
@@ -50,8 +50,9 @@ async function waitForServer(url: string, timeoutMs = 30_000): Promise<void> {
export const test = base.extend<SBFixtures>({
spaceFiles: [{}, { option: true }],
disableServiceWorker: [true, { option: true }],
sbServer: async ({ spaceFiles }, use) => {
sbServer: async ({ spaceFiles, disableServiceWorker }, use) => {
const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-"));
// Seed space with files
@@ -69,6 +70,12 @@ export const test = base.extend<SBFixtures>({
{
cwd: join(import.meta.dirname, ".."),
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...(disableServiceWorker
? { SB_DISABLE_SERVICE_WORKER: "1" }
: {}),
},
},
);
@@ -117,18 +124,12 @@ export const test = base.extend<SBFixtures>({
await gotoSilverBulletPage(page, sbServer);
await use(page);
},
sbPageWithSync: async ({ sbServer, page }, use) => {
await page.goto(sbServer.url);
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
await use(page);
},
});
/**
* Navigate to a SilverBullet page in the test space and wait for the editor
* to be visible. Disables the service worker (`?enableSW=0`) to match how the
* `sbPage` fixture boots, so the test environment is consistent across tests.
* to be visible. The test server runs with `SB_DISABLE_SERVICE_WORKER=1`, so
* the boot path skips the service worker for deterministic test behavior.
*
* `pagePath` is the SilverBullet page name without the `.md` extension. Pass
* an empty string (the default) to land on the index page. Each path segment
@@ -140,7 +141,7 @@ export async function gotoSilverBulletPage(
pagePath = "",
): Promise<void> {
const encoded = pagePath.split("/").map(encodeURIComponent).join("/");
await page.goto(`${sbServer.url}/${encoded}?enableSW=0&headless=1`);
await page.goto(`${sbServer.url}/${encoded}?headless=1`);
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
await waitForEditorReady(page);
}
+255
View File
@@ -0,0 +1,255 @@
import type { Page } from "@playwright/test";
import { expect, gotoSilverBulletPage, test } from "./fixtures.ts";
/**
* Tests for the `desiredIndexVersion` upgrade path.
*
* `client/data/object_index.ts` carries a `desiredIndexVersion` constant.
* Bumping it should cause the next-loaded client to run a full space
* reindex, but only once the client is sure it is on the same build as
* the server (and, for the SW-enabled path, the initial space sync has
* completed). These tests simulate "an old client booted after the
* server upgrade" by writing a stale value into the local datastore
* before reloading, then verifying that a real reindex actually fires
* (not just that the stored version number bumps — see the "Performing
* a full space reindex" log line).
*/
async function readIndexVersion(page: Page): Promise<number | undefined> {
return await page.evaluate(async () => {
const client = (globalThis as any).client;
if (!client) return undefined;
return (await client.ds.get(["$indexVersion"])) as number | undefined;
});
}
async function writeIndexVersion(page: Page, version: number): Promise<void> {
await page.evaluate(
async (v) => {
const client = (globalThis as any).client;
if (!client) throw new Error("client not initialized");
await client.ds.set(["$indexVersion"], v);
},
version,
);
}
async function waitForIndexVersionAtLeast(
page: Page,
min: number,
timeoutMs = 30_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const v = await readIndexVersion(page);
if (typeof v === "number" && v >= min) return;
await new Promise((r) => setTimeout(r, 250));
}
const final = await readIndexVersion(page);
throw new Error(
`Index version did not reach ${min} within ${timeoutMs}ms (last value: ${String(final)})`,
);
}
async function waitForEditor(page: Page): Promise<void> {
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
}
async function readFullIndexCompleted(page: Page): Promise<boolean | undefined> {
return await page.evaluate(() => {
const client = (globalThis as any).client;
if (!client) return undefined;
return client.fullIndexCompleted as boolean;
});
}
/**
* Polls the live widget render-mode signals on the client. The
* `widgetRenderMode` helper (see client/codemirror/util.ts) returns
* "loading" whenever any of `systemReady`, `clientSystem.scriptsLoaded`,
* `fullIndexCompleted` or `pageListLoaded` is false; this captures the
* same set so a stale-version reload can prove the widgets don't drop
* back into loading mode.
*/
async function waitForWidgetsReady(page: Page, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let last: unknown = undefined;
while (Date.now() < deadline) {
last = await page.evaluate(() => {
const client = (globalThis as any).client;
if (!client) return null;
return {
systemReady: !!client.systemReady,
scriptsLoaded: !!client.clientSystem?.scriptsLoaded,
fullIndexCompleted: !!client.fullIndexCompleted,
pageListLoaded: !!client.pageListLoaded,
};
});
if (
last &&
typeof last === "object" &&
(last as Record<string, boolean>).systemReady &&
(last as Record<string, boolean>).scriptsLoaded &&
(last as Record<string, boolean>).fullIndexCompleted &&
(last as Record<string, boolean>).pageListLoaded
) {
return;
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(
`Widgets did not reach the ready state within ${timeoutMs}ms (last state: ${JSON.stringify(last)})`,
);
}
/**
* Capture all `console` messages the page emits. The returned `messages`
* array is updated live; callers can check for substrings after the
* behavior of interest has had a chance to run.
*/
function captureConsole(page: Page): { messages: string[] } {
const messages: string[] = [];
page.on("console", (msg) => {
messages.push(msg.text());
});
return { messages };
}
const REINDEX_LOG = "Performing a full space reindex";
async function waitForReindexLog(
consoleState: { messages: string[] },
timeoutMs = 30_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (consoleState.messages.some((m) => m.includes(REINDEX_LOG))) return;
await new Promise((r) => setTimeout(r, 100));
}
const tail = consoleState.messages.slice(-50).join("\n ");
throw new Error(
`Did not observe a "${REINDEX_LOG}" log line within ${timeoutMs}ms.\nLast console output:\n ${tail}`,
);
}
/** Marker we embed in a `space-style` block to look for in the DOM. */
const SPACE_STYLE_MARKER = "stale-reindex-marker-color: rebeccapurple";
async function customStylesContent(page: Page): Promise<string> {
return (await page.locator("#custom-styles").innerHTML()) ?? "";
}
test.describe("index version upgrade (service worker disabled)", () => {
test.use({
spaceFiles: {
"index.md": "# Index\nFirst page in the upgrade-test space.",
"Other.md": "# Other\nSee [[index]] for the entry point.",
"Styles.md":
"# Styles\n\n```space-style\n/* " + SPACE_STYLE_MARKER + " */\n```\n",
},
});
test("stale index version triggers a real reindex on next boot", async ({ sbServer, page }) => {
const consoleState = captureConsole(page);
// First boot: let the initial indexing run to completion so we know
// what value the current build considers "desired".
await gotoSilverBulletPage(page, sbServer);
await waitForEditor(page);
await waitForIndexVersionAtLeast(page, 1);
const baseline = await readIndexVersion(page);
expect(typeof baseline).toBe("number");
expect(baseline).toBeGreaterThan(0);
// First boot is a fresh install (no stored version), so there
// should be NO "Performing a full space reindex" log line yet.
expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe(false);
// Simulate "this client was previously running an older build that
// stamped an older desiredIndexVersion into the datastore".
await writeIndexVersion(page, 1);
expect(await readIndexVersion(page)).toBe(1);
// Re-navigate (the SW-disabled path runs the reindex check at the
// end of `Client.init()`).
await gotoSilverBulletPage(page, sbServer);
await waitForEditor(page);
// A stale-but-present index should not flip widgets into the
// loading/spinner mode at boot — the existing entries are still
// queryable until the actual reindex starts. (Before this fix the
// boot-time flag was set from `hasFullIndexCompleted()`, which is
// `stored >= desired` and therefore false during the wait, and
// `updatePageListCache` would take the fallback branch and leave
// `pageListLoaded` false.)
expect(await readFullIndexCompleted(page)).toBe(true);
await waitForWidgetsReady(page);
// Custom styles must also load from the stale-but-present index —
// before this was wired up, `loadCustomStyles` bailed out on the
// strict `hasFullIndexCompleted` check and the `#custom-styles`
// element stayed empty.
expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER);
// Critical assertion: a real reindex must actually have run, not
// just the stored version having been silently bumped.
await waitForReindexLog(consoleState);
await waitForIndexVersionAtLeast(page, baseline as number);
expect(await readIndexVersion(page)).toBe(baseline);
});
});
test.describe("index version upgrade (service worker enabled)", () => {
// SW + IndexedDB interactions are flaky in Firefox's Playwright
// implementation; mirror what pwa-offline does.
test.skip(({ browserName }) => browserName !== "chromium", "SW path only runs on Chromium");
test.describe.configure({ retries: 2 });
test.use({
disableServiceWorker: false,
spaceFiles: {
"index.md": "# Index\nFirst page in the SW-enabled upgrade test.",
"Other.md": "# Other\nLink back to [[index]].",
"Styles.md":
"# Styles\n\n```space-style\n/* " + SPACE_STYLE_MARKER + " */\n```\n",
},
});
test("stale index version triggers a real reindex after sync + version match", async ({ sbServer, page }) => {
const consoleState = captureConsole(page);
// First boot with the SW active: navigate without `?headless=1` so
// the service worker actually registers.
await page.goto(sbServer.url);
await waitForEditor(page);
await waitForIndexVersionAtLeast(page, 1);
const baseline = await readIndexVersion(page);
expect(typeof baseline).toBe("number");
expect(baseline).toBeGreaterThan(0);
expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe(false);
await writeIndexVersion(page, 1);
expect(await readIndexVersion(page)).toBe(1);
// Re-navigate. The SW-enabled path triggers the reindex once both
// `space-sync-complete` and a matching `server-version` message
// have been observed.
await page.goto(sbServer.url);
await waitForEditor(page);
// Stale-but-present index should keep widgets in their ready state
// during the wait for sync + version-match (see the SW-disabled
// test for the rationale).
expect(await readFullIndexCompleted(page)).toBe(true);
await waitForWidgetsReady(page);
// Custom styles must also load from the stale-but-present index —
// before this was wired up, `loadCustomStyles` bailed out on the
// strict `hasFullIndexCompleted` check and the `#custom-styles`
// element stayed empty.
expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER);
await waitForReindexLog(consoleState);
await waitForIndexVersionAtLeast(page, baseline as number);
expect(await readIndexVersion(page)).toBe(baseline);
});
});
+2
View File
@@ -78,6 +78,8 @@ test.describe("PWA offline support", () => {
test.describe.configure({ retries: 2 });
test.use({
// This suite exercises real SW behavior, so it needs the SW running.
disableServiceWorker: false,
spaceFiles: {
"index.md": "# Offline Test Space\nThis content should survive offline.",
"TestPage.md": "# Test Page\nOffline page content here.",
-2
View File
@@ -90,8 +90,6 @@ It does this by intercepting HTTP calls coming from the client aimed at the serv
The service worker embeds a [[Sync]] engine, that based on configuration constantly keeps a local copy of your files in sync with the server. To make the sync status visible, it emits events to the Client.
For debugging purposes you can disable the service worker by adding `?enableSW=0` to your URL. This disabling is persistent, to re-enable it use `?enableSW=1`.
# Server
The server has only three jobs: