Skip to content
Merged
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
7 changes: 7 additions & 0 deletions SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ Two concurrent changes that minimise the gratuitous local↔worker divergence:

- **Drop `collection_name` from `entities_fts`.** Each local collection has its own SQLite file, so collection scoping is implicit — the column was written but never queried. FTS5 has no DROP COLUMN, so detect via `PRAGMA table_info` then drop+recreate. Final 7-column shape: `entity_id UNINDEXED, external_id UNINDEXED, entity_type UNINDEXED, title, content, tags, attachment_text` — same as the worker. SyncEngine re-INSERTs FTS rows on the next sync.

### v5 — Repair inconsistent FTS migration state

Some databases were stamped at v4 while retaining the pre-v3 FTS shape. The
repair migration verifies the actual columns rather than trusting only
`schema.version`, then rebuilds the canonical 7-column table and preserves
existing indexed rows where possible.

- **Drop `AUTOINCREMENT` from `entities.id`.** The historical local table used `INTEGER PRIMARY KEY AUTOINCREMENT` while the worker uses plain `INTEGER PRIMARY KEY`; we never depended on the no-id-reuse guarantee that `AUTOINCREMENT` provides (FTS rows are deleted alongside their entity row before any new INSERT, so collisions can't occur). The migration detects the historical shape via `sqlite_master.sql`, builds a clone table with the new shape, copies all rows preserving their existing ids, drops the original, renames the clone, and rebuilds the indexes. Wrapped in `BEGIN/COMMIT` — partial failure rolls back.

Final FTS bm25 weights used by `SearchIndexer.search()`: `title=10, tags=5, body=1, attachment=0.25` (UNINDEXED columns get weight `1.0` but never match). `snippet()` highlights column index 4 (content). Worker `bm25()` and `snippet()` are kept identical per the parity rule.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@
"@types/js-yaml": "^4.0.9",
"js-yaml": "^4.1.1"
},
"version": "0.10.9"
"version": "0.10.15"
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@frozenink/cli",
"version": "0.10.9",
"version": "0.10.15",
"private": true,
"type": "module",
"main": "src/index.ts",
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/commands/__tests__/wrangler-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { encodeR2ObjectKey, shouldRetryR2UploadWithWrangler } from "../wrangler-api";

describe("encodeR2ObjectKey", () => {
test("preserves path separators and encodes reserved characters in each segment", () => {
expect(encodeR2ObjectKey("mantisbt/content/issues/assets/2709-mantis bugnote..JPG"))
.toBe("mantisbt/content/issues/assets/2709-mantis%20bugnote..JPG");
});
});

describe("shouldRetryR2UploadWithWrangler", () => {
test("recognizes an edge-generated HTML 403", () => {
expect(shouldRetryR2UploadWithWrangler(403, "<!DOCTYPE html><html><body>Forbidden</body></html>"))
.toBe(true);
});

test("leaves normal API errors unchanged", () => {
expect(shouldRetryR2UploadWithWrangler(403, '{"errors":[{"message":"forbidden"}]}'))
.toBe(false);
expect(shouldRetryR2UploadWithWrangler(500, "<html>Server error</html>"))
.toBe(false);
});
});
9 changes: 5 additions & 4 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
saveNamedCredentials,
removeNamedCredentials,
MetadataStore,
toR2SafeKey,
} from "@frozenink/core";
import { getPublishCredentialKey } from "./publish-credentials";
import type { EntityData } from "@frozenink/core";
Expand Down Expand Up @@ -466,7 +467,7 @@ export async function publishCollections(
const uiUploads: Array<{ r2Key: string; fullPath: string }> = [];
if (existsSync(uiDistDir)) {
for (const file of collectFiles(uiDistDir)) {
uiUploads.push({ r2Key: `_ui/${file.relativePath}`, fullPath: file.fullPath });
uiUploads.push({ r2Key: toR2SafeKey(`_ui/${file.relativePath}`), fullPath: file.fullPath });
}
}
if (uiUploads.length > 0) {
Expand Down Expand Up @@ -496,21 +497,21 @@ export async function publishCollections(
const collectionDir = join(home, "collections", colName);
for (const file of collectFiles(join(collectionDir, "content"))) {
if (file.relativePath.endsWith(".md") || file.relativePath.endsWith(".yml")) continue;
uploads.push({ r2Key: `${colName}/content/${file.relativePath}`, fullPath: file.fullPath });
uploads.push({ r2Key: toR2SafeKey(`${colName}/content/${file.relativePath}`), fullPath: file.fullPath });
}
// Upload the sibling attachments/ directory — the worker serves these at
// /api/attachments/{name}/* and the UI rewrites markdown image paths to
// that endpoint. Without this, embedded images 404 on the published site.
const attachmentsDir = join(collectionDir, "attachments");
if (existsSync(attachmentsDir)) {
for (const file of collectFiles(attachmentsDir)) {
uploads.push({ r2Key: `${colName}/attachments/${file.relativePath}`, fullPath: file.fullPath });
uploads.push({ r2Key: toR2SafeKey(`${colName}/attachments/${file.relativePath}`), fullPath: file.fullPath });
}
}
}
if (existsSync(uiDistDir)) {
for (const file of collectFiles(uiDistDir)) {
uploads.push({ r2Key: `_ui/${file.relativePath}`, fullPath: file.fullPath });
uploads.push({ r2Key: toR2SafeKey(`_ui/${file.relativePath}`), fullPath: file.fullPath });
}
}

Expand Down
50 changes: 43 additions & 7 deletions packages/cli/src/commands/wrangler-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "path";
import { delimiter, dirname, join } from "path";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
import { homedir } from "os";
import { randomBytes } from "crypto";
Expand Down Expand Up @@ -55,9 +55,18 @@ async function runWrangler(
opts?: { cwd?: string; allowFailure?: boolean },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const bin = await resolveWranglerBin();
// A Dock/Spotlight-launched macOS app has a minimal PATH. Wrangler's npm
// executable uses `#!/usr/bin/env node`, so include its installation
// directory to make the matching Node runtime available to that shebang.
const path = [dirname(bin), process.env.PATH]
.filter(Boolean)
.join(delimiter);
const { stdout, stderr, exitCode } = await spawnProcess([bin, ...args], {
cwd: opts?.cwd,
env: { ...process.env },
// GUI apps launched from Spotlight often inherit `/` as their CWD. Wrangler
// creates `.wrangler/cache` relative to CWD, so keep it inside the active
// Frozen Ink workspace unless a caller explicitly chooses another folder.
cwd: opts?.cwd ?? getFrozenInkHome(),
env: { ...process.env, PATH: path },
});

if (exitCode !== 0 && !opts?.allowFailure) {
Expand Down Expand Up @@ -314,6 +323,26 @@ async function fetchCloudflareApi(

// --- R2 operations (bucket via wrangler CLI, objects via HTTP for speed) ---

/** Encode each key segment while preserving `/` as the R2 path delimiter. */
export function encodeR2ObjectKey(key: string): string {
return key.split("/").map(encodeURIComponent).join("/");
}

export function shouldRetryR2UploadWithWrangler(status: number, responseBody: string): boolean {
return status === 403 && /<html[\s>]/i.test(responseBody);
}

async function putR2ObjectWithWrangler(
bucket: string,
key: string,
filePath: string,
contentType?: string,
): Promise<void> {
const args = ["r2", "object", "put", `${bucket}/${key}`, "--file", filePath, "--remote"];
if (contentType) args.push("--content-type", contentType);
await runWrangler(args);
}

export async function createR2Bucket(name: string): Promise<void> {
try {
await runWrangler(["r2", "bucket", "create", name]);
Expand All @@ -333,7 +362,7 @@ export async function putR2Object(
): Promise<void> {
const body = readFileSync(filePath);
const res = await fetchCloudflareApi(({ apiToken, accountId }) => ({
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeURIComponent(key)}`,
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeR2ObjectKey(key)}`,
init: {
method: "PUT",
headers: {
Expand All @@ -345,6 +374,13 @@ export async function putR2Object(
}));
if (!res.ok) {
const text = await res.text();
// Cloudflare's edge can reject an otherwise valid direct REST upload with
// an HTML 403 for particular object names or payloads. Wrangler uses the
// supported R2 command path and handles those cases correctly.
if (shouldRetryR2UploadWithWrangler(res.status, text)) {
await putR2ObjectWithWrangler(bucket, key, filePath, contentType);
return;
}
const hint = res.status === 401
? " — the Cloudflare OAuth token looks expired. Run `wrangler login` again, or restart the desktop app to refresh the cached credentials."
: "";
Expand All @@ -359,7 +395,7 @@ export async function putR2String(
contentType: string = "text/plain",
): Promise<void> {
const res = await fetchCloudflareApi(({ apiToken, accountId }) => ({
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeURIComponent(key)}`,
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeR2ObjectKey(key)}`,
init: {
method: "PUT",
headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": contentType },
Expand All @@ -374,7 +410,7 @@ export async function putR2String(

export async function getR2String(bucket: string, key: string): Promise<string | null> {
const res = await fetchCloudflareApi(({ apiToken, accountId }) => ({
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeURIComponent(key)}`,
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeR2ObjectKey(key)}`,
init: {
method: "GET",
headers: { Authorization: `Bearer ${apiToken}` },
Expand All @@ -387,7 +423,7 @@ export async function getR2String(bucket: string, key: string): Promise<string |

export async function deleteR2Object(bucket: string, key: string): Promise<void> {
const res = await fetchCloudflareApi(({ apiToken, accountId }) => ({
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeURIComponent(key)}`,
url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucket}/objects/${encodeR2ObjectKey(key)}`,
init: {
method: "DELETE",
headers: { Authorization: `Bearer ${apiToken}` },
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/tui/sync-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SyncEngine,
ThemeEngine,
LocalStorageBackend,
resolveCredentials,
} from "@frozenink/core";
import {
createDefaultRegistry,
Expand Down Expand Up @@ -194,7 +195,7 @@ async function runJob(job: TuiSyncJob): Promise<void> {
try {
await crawler.initialize(
col.config as Record<string, unknown>,
col.credentials as Record<string, unknown>,
resolveCredentials(col.credentials),
);

const dbPath = getCollectionDbPath(name);
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
".": "./src/index.ts",
"./theme": "./src/theme/index.ts",
"./export": "./src/export/index.ts",
"./storage/r2-key": "./src/storage/r2-key.ts",
"./search/fts-query": "./src/search/fts-query.ts"
},
"scripts": {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/compat/__tests__/resources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from "bun:test";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { resolveWrangler } from "../resources";

const originalHome = process.env.HOME;
const originalNvmDir = process.env.NVM_DIR;
let testDir: string;

afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalNvmDir === undefined) delete process.env.NVM_DIR;
else process.env.NVM_DIR = originalNvmDir;
if (testDir) rmSync(testDir, { recursive: true, force: true });
});

describe("resolveWrangler", () => {
it("finds a Wrangler install under NVM when the app PATH omits it", async () => {
testDir = mkdtempSync(join(tmpdir(), "frozenink-wrangler-"));
process.env.HOME = testDir;
delete process.env.NVM_DIR;

const binDir = join(testDir, ".nvm", "versions", "node", "v20.19.5", "bin");
mkdirSync(binDir, { recursive: true });
const nodePath = join(binDir, "node");
const wranglerPath = join(binDir, "wrangler");
writeFileSync(nodePath, "#!/bin/sh\nexit 0\n");
writeFileSync(wranglerPath, "#!/bin/sh\nexit 0\n");
chmodSync(nodePath, 0o755);
chmodSync(wranglerPath, 0o755);

await expect(resolveWrangler(join(testDir, "packaged-app"))).resolves.toBe(wranglerPath);
});
});
20 changes: 19 additions & 1 deletion packages/core/src/compat/resources.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "fs";
import { existsSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { getModuleDir } from "./paths";
import { spawnProcess } from "./subprocess";
Expand Down Expand Up @@ -75,6 +75,19 @@ export function resolveUiDist(moduleDir: string): string | null {

const WRANGLER_INSTALL_URL = "https://developers.cloudflare.com/workers/wrangler/install-and-update/";

function getNvmNodeBinDirs(home: string): string[] {
const nvmDir = process.env.NVM_DIR || join(home, ".nvm");
const versionsDir = join(nvmDir, "versions", "node");
try {
return readdirSync(versionsDir)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.map((version) => join(versionsDir, version, "bin"))
.filter((binDir) => existsSync(join(binDir, "node")));
} catch {
return [];
}
}

/**
* Resolve the wrangler binary path.
*
Expand Down Expand Up @@ -107,18 +120,23 @@ export async function resolveWrangler(moduleDir: string, configuredPath?: string
// Dock-launched processes inherit a minimal PATH.
const home = process.env.HOME ?? "";
const candidates: string[] = [];
const nvmBinDirs = getNvmNodeBinDirs(home);
if (process.platform === "darwin") {
candidates.push(
"/opt/homebrew/bin/wrangler", // Homebrew on Apple Silicon
"/usr/local/bin/wrangler", // Homebrew on Intel / classic npm global
join(home, ".volta/bin/wrangler"), // Volta
join(home, ".bun/bin/wrangler"), // Bun global
join(home, ".npm-global/bin/wrangler"), // Custom npm global prefix
...nvmBinDirs.map((binDir) => join(binDir, "wrangler")),
);
} else if (process.platform === "linux") {
candidates.push(
"/usr/local/bin/wrangler",
join(home, ".volta/bin/wrangler"),
join(home, ".bun/bin/wrangler"),
join(home, ".npm-global/bin/wrangler"),
...nvmBinDirs.map((binDir) => join(binDir, "wrangler")),
);
}
for (const p of candidates) {
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/db/__tests__/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,42 @@ describe("runSyncMigrations", () => {
expect(idxNames).toContain("idx_entities_external");
expect(idxNames).toContain("idx_entities_folder");
});

it("v5 repairs a falsely-versioned legacy FTS table and preserves rows", () => {
const db = open();
runSyncMigrations(db, LOCAL_MIGRATIONS);
db.exec("DROP TABLE entities_fts");
db.exec(`
CREATE VIRTUAL TABLE entities_fts USING fts5(
collection_name UNINDEXED,
entity_id UNINDEXED,
external_id UNINDEXED,
entity_type UNINDEXED,
title,
content,
tags
);
`);
db.exec("INSERT INTO entities_fts (collection_name, entity_id, external_id, entity_type, title, content, tags) VALUES ('legacy', 1, 'issue:1', 'issue', 'Title', 'Body', 'tag')");
db.prepare("UPDATE metadata SET value = '4' WHERE key = ?").run(SCHEMA_VERSION_KEY);

runSyncMigrations(db, LOCAL_MIGRATIONS);

const cols = db
.prepare("PRAGMA table_info(entities_fts)")
.all() as Array<{ name: string }>;
expect(cols.map((c) => c.name)).toEqual([
"entity_id",
"external_id",
"entity_type",
"title",
"content",
"tags",
"attachment_text",
]);
const row = db
.prepare("SELECT external_id, title, content FROM entities_fts")
.get() as { external_id: string; title: string; content: string };
expect(row).toEqual({ external_id: "issue:1", title: "Title", content: "Body" });
});
});
Loading
Loading