Compare commits
5 Commits
feature/of
...
fix/262-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67312a3753 | ||
|
|
96b9ec11d6 | ||
|
|
f8d26420eb | ||
|
|
5c1187b864 | ||
|
|
14f83abe78 |
14
.github/workflows/develop.yml
vendored
14
.github/workflows/develop.yml
vendored
@@ -75,7 +75,9 @@ jobs:
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
@@ -88,7 +90,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
@@ -135,7 +138,9 @@ jobs:
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
@@ -148,7 +153,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -27,7 +27,9 @@ jobs:
|
||||
# TEST_*_URL overrides are needed.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost_dev_pw
|
||||
@@ -40,7 +42,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
# via mirror.gcr.io (see postgres note above).
|
||||
image: mirror.gcr.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconEyeOff,
|
||||
IconClearFormatting,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
@@ -117,6 +118,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||
icon: IconEyeOff,
|
||||
},
|
||||
{
|
||||
name: "Clear formatting",
|
||||
// Action, not a toggle — never show an active/highlighted state.
|
||||
isActive: () => false,
|
||||
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
|
||||
command: () => props.editor.chain().focus().unsetAllMarks().run(),
|
||||
icon: IconClearFormatting,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
@@ -77,7 +77,9 @@ describe('resolveKeyField (write-only key payload)', () => {
|
||||
|
||||
describe('nextReindexPollInterval', () => {
|
||||
const INTERVAL = 5000;
|
||||
const base = { now: 1_000, intervalMs: INTERVAL };
|
||||
// `seenActive: true` is the steady state for most of a run — a poll has
|
||||
// observed `reindexing === true` (the server pre-seeds it from enqueue time).
|
||||
const base = { now: 1_000, intervalMs: INTERVAL, seenActive: true };
|
||||
|
||||
it('does not poll when no reindex deadline is set', () => {
|
||||
expect(
|
||||
@@ -111,7 +113,7 @@ describe('nextReindexPollInterval', () => {
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('stops once the run is finished AND fully indexed', () => {
|
||||
it('stops once the run is finished AND fully indexed (after having been active)', () => {
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
@@ -121,11 +123,29 @@ describe('nextReindexPollInterval', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT stop on the stale pre-reindex snapshot (fully indexed, never seen active)', () => {
|
||||
// Regression for #262: right after "Reindex now" the client still holds the
|
||||
// PRE-reindex settings (an already fully-indexed workspace reads as
|
||||
// reindexing=false, indexed>=total). Without the seenActive gate this looked
|
||||
// "done" and stopped polling on the very first tick, freezing the counter at
|
||||
// 0 until a manual reload. The fresh window has not observed the active run,
|
||||
// so polling must continue until the first real poll lands.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
seenActive: false,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
}),
|
||||
).toBe(INTERVAL);
|
||||
});
|
||||
|
||||
it('keeps polling within the deadline when not yet done and no active flag', () => {
|
||||
// First poll right after enqueue, before the worker publishes progress.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
seenActive: false,
|
||||
deadline: 10_000,
|
||||
status: { reindexing: false, indexedPages: 0, totalPages: 478 },
|
||||
}),
|
||||
@@ -138,12 +158,15 @@ describe('nextReindexPollInterval', () => {
|
||||
deadline: 1_000,
|
||||
now: 2_000, // past the deadline
|
||||
intervalMs: INTERVAL,
|
||||
seenActive: true,
|
||||
status: { reindexing: true, indexedPages: 200, totalPages: 478 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('stops on an empty workspace (0 of 0) once the run is finished', () => {
|
||||
// The pre-seed publishes reindexing=true even for 0 pages, so a poll sees the
|
||||
// run active before the worker clears -> seenActive latches true.
|
||||
expect(
|
||||
nextReindexPollInterval({
|
||||
...base,
|
||||
@@ -156,26 +179,46 @@ describe('nextReindexPollInterval', () => {
|
||||
|
||||
describe('isReindexComplete', () => {
|
||||
it('false when no status yet', () => {
|
||||
expect(isReindexComplete(undefined)).toBe(false);
|
||||
expect(isReindexComplete(undefined, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('false while a run is still active (even at indexed==total)', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }),
|
||||
isReindexComplete(
|
||||
{ reindexing: true, indexedPages: 478, totalPages: 478 },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('false when finished but not yet fully indexed', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }),
|
||||
isReindexComplete(
|
||||
{ reindexing: false, indexedPages: 120, totalPages: 478 },
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('true once finished and fully indexed', () => {
|
||||
it('true once finished and fully indexed (after having been active)', () => {
|
||||
expect(
|
||||
isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }),
|
||||
isReindexComplete(
|
||||
{ reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('false on the stale pre-reindex snapshot: finished+fully indexed but never seen active', () => {
|
||||
// The just-started edge: the gate keeps this from clearing the poll deadline
|
||||
// before the first post-reindex poll arrives.
|
||||
expect(
|
||||
isReindexComplete(
|
||||
{ reindexing: false, indexedPages: 478, totalPages: 478 },
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReindexButtonLoading', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -185,14 +185,23 @@ type ReindexStatus = Pick<
|
||||
* has finished AND everything is indexed (server cleared its progress record and
|
||||
* fell back to the DB coverage count), or the deadline cap is hit — the cap
|
||||
* always wins so a stuck/never-clearing progress record can't poll forever.
|
||||
*
|
||||
* `seenActive` guards the just-started window: right after "Reindex now" the
|
||||
* client still holds the PRE-reindex settings snapshot, which for an already
|
||||
* fully-indexed workspace reads as `reindexing=false, indexed>=total`. Treating
|
||||
* that stale snapshot as "done" would stop polling before the first post-reindex
|
||||
* poll ever lands (counter frozen at 0). So completion is only honored once a
|
||||
* poll has actually observed the active run (the enqueue-time pre-seed makes
|
||||
* `reindexing=true` visible from the first poll until the run truly clears).
|
||||
*/
|
||||
export function nextReindexPollInterval(args: {
|
||||
deadline: number | null;
|
||||
now: number;
|
||||
intervalMs: number;
|
||||
status?: ReindexStatus;
|
||||
seenActive: boolean;
|
||||
}): number | false {
|
||||
const { deadline, now, intervalMs, status } = args;
|
||||
const { deadline, now, intervalMs, status, seenActive } = args;
|
||||
if (deadline === null) return false;
|
||||
// Cap always wins.
|
||||
if (now > deadline) return false;
|
||||
@@ -200,20 +209,33 @@ export function nextReindexPollInterval(args: {
|
||||
if (status?.reindexing) return intervalMs;
|
||||
// Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse
|
||||
// isReindexComplete so the completeness check lives in exactly one place.
|
||||
if (isReindexComplete(status)) return false;
|
||||
if (isReindexComplete(status, seenActive)) return false;
|
||||
// Within the deadline and not yet done → keep polling.
|
||||
return intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the reindex poll deadline should be cleared: the server reports no
|
||||
* active run AND the count is complete. The single source of truth for the
|
||||
* "reindex finished" check — `nextReindexPollInterval` reuses it for its stop
|
||||
* condition (sans the cap, which the effect handles via time).
|
||||
* Whether the reindex poll deadline should be cleared: a poll has observed the
|
||||
* active run (`seenActive`) AND the server now reports no active run AND the
|
||||
* count is complete. The single source of truth for the "reindex finished"
|
||||
* check — `nextReindexPollInterval` reuses it for its stop condition (sans the
|
||||
* cap, which the effect handles via time).
|
||||
*
|
||||
* The `seenActive` requirement is what keeps the STALE pre-reindex snapshot
|
||||
* (already fully indexed → `reindexing=false, indexed>=total`) from being read
|
||||
* as "finished" in the window before the first post-reindex poll arrives. Once
|
||||
* a poll has seen `reindexing=true` (guaranteed by the server's enqueue-time
|
||||
* pre-seed for the whole run), this flips to a genuine completion check.
|
||||
*/
|
||||
export function isReindexComplete(status?: ReindexStatus): boolean {
|
||||
export function isReindexComplete(
|
||||
status: ReindexStatus | undefined,
|
||||
seenActive: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!!status && !status.reindexing && status.indexedPages >= status.totalPages
|
||||
seenActive &&
|
||||
!!status &&
|
||||
!status.reindexing &&
|
||||
status.indexedPages >= status.totalPages
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,6 +312,14 @@ export default function AiProviderSettings() {
|
||||
const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing
|
||||
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
|
||||
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
|
||||
// Whether any poll in the CURRENT window has actually observed the active run
|
||||
// (`reindexing === true`). Reset when a new reindex is kicked off. Gates the
|
||||
// completion check so the STALE pre-reindex snapshot (an already fully-indexed
|
||||
// workspace reads as `reindexing=false, indexed>=total`) can't be mistaken for
|
||||
// "finished" before the first post-reindex poll lands — which would freeze the
|
||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||
// trigger a render and is only ever read where `reindexing` is already false.
|
||||
const reindexSeenActiveRef = useRef(false);
|
||||
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||
@@ -298,6 +328,7 @@ export default function AiProviderSettings() {
|
||||
now: Date.now(),
|
||||
intervalMs: REINDEX_POLL_INTERVAL,
|
||||
status: query.state.data,
|
||||
seenActive: reindexSeenActiveRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -305,12 +336,17 @@ export default function AiProviderSettings() {
|
||||
// unmount because the deadline state goes away with the component.
|
||||
useEffect(() => {
|
||||
if (reindexDeadline === null) return;
|
||||
// "Done" matches the refetchInterval stop condition: the server reports no
|
||||
// active run AND the count is complete (indexed >= total, incl. an empty
|
||||
// workspace 0 >= 0), so the deadline clears promptly instead of waiting out
|
||||
// the cap. While `reindexing` is still true we keep the deadline so polling
|
||||
// continues for the whole run.
|
||||
if (isReindexComplete(settings)) {
|
||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||
// completion check below (and the refetchInterval's) only fires once the run
|
||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||
if (settings?.reindexing) reindexSeenActiveRef.current = true;
|
||||
// "Done" matches the refetchInterval stop condition: a poll has observed the
|
||||
// active run AND the server now reports no active run AND the count is
|
||||
// complete (indexed >= total, incl. an empty workspace 0 >= 0), so the
|
||||
// deadline clears promptly instead of waiting out the cap. While `reindexing`
|
||||
// is still true (or no poll has seen it active yet) we keep the deadline so
|
||||
// polling continues for the whole run.
|
||||
if (isReindexComplete(settings, reindexSeenActiveRef.current)) {
|
||||
setReindexDeadline(null);
|
||||
return;
|
||||
}
|
||||
@@ -1117,8 +1153,13 @@ export default function AiProviderSettings() {
|
||||
reindexMutation.mutate(undefined, {
|
||||
// Begin bounded polling so the counter climbs as the async
|
||||
// background job indexes (it does not update on its own).
|
||||
onSuccess: () =>
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS),
|
||||
// Clear the "seen active" latch first so this fresh window
|
||||
// doesn't inherit a previous run's completion state and stop
|
||||
// immediately.
|
||||
onSuccess: () => {
|
||||
reindexSeenActiveRef.current = false;
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -12,14 +12,6 @@ function sanitizeMdLinkText(value: string): string {
|
||||
.replace(/[\r\n]+/g, ' ');
|
||||
}
|
||||
|
||||
// Escape a value placed inside a double-quoted HTML attribute (img src/alt/
|
||||
// data-caption in the raw-HTML image fallback). Only & and " are special in
|
||||
// that context; escaping them is idempotent because parse5/marked decode them
|
||||
// back on re-import.
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Tags turndown treats as void (self-closing). Footnote references render as an
|
||||
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
||||
// without marking it void, turndown's blank-node removal drops it before our
|
||||
|
||||
@@ -74,6 +74,7 @@ const HOST_CONTRACT_METHODS = [
|
||||
"unsharePage",
|
||||
"restorePageVersion",
|
||||
"transformPage",
|
||||
"stashPage",
|
||||
// write (comment)
|
||||
"createComment",
|
||||
"resolveComment",
|
||||
|
||||
Reference in New Issue
Block a user