Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ terraform/ # インフラ定義

- ルート `package.json` の `workspaces` は `packages/*` と `admin` のみを含む。`server/api`, `server/hocuspocus`, `server/mcp` は **意図的にルートの Bun workspace から外して**、個別の Bun プロジェクトとして管理する。
_Root `workspaces` covers only `packages/*` and `admin`. The three `server/*` services (`api`, `hocuspocus`, `mcp`) are intentionally kept **outside** the root Bun workspace and managed as standalone Bun projects._

### サーバ/クライアント間で共有する定数 / Sharing constants between server and client

- `packages/shared`(`@zedi/shared`)は、フロント・admin・サーバすべてで共通利用したいピュアな TypeScript 定数を集約するためのワークスペースパッケージ。React や Node 専用 API には依存させない。
_`packages/shared` (`@zedi/shared`) is a workspace package for pure TypeScript constants shared by client, admin, and (logically) server code. Keep it free of React or Node-only dependencies._
- フロント (`src/`) と `admin/` はワークスペース内なので `import { ... } from "@zedi/shared/..."` で直接利用できる。
_Workspace consumers (`src/`, `admin/`) import via `@zedi/shared/...`._
- `server/api` 等のサーバプロジェクトはワークスペース外なので `@zedi/shared` を **直接 import できない**。代わりに同じ値を当該サーバ内に二重定義し、フロント側の vitest が `fs.readFileSync` でサーバファイルを読んで両者の文字列一致を検証するドリフト検知テスト(例: `src/lib/tagCharacterClassSync.test.ts`)を置くことで CI で同期を担保する。
_Server projects (e.g. `server/api`) cannot import `@zedi/shared` because they are intentionally outside the workspace. Duplicate the constant inside the server source and add a client-side vitest (e.g. `src/lib/tagCharacterClassSync.test.ts`) that reads the server file via `fs.readFileSync` and asserts the two literals match. This keeps drift detectable in CI._
- 値を更新する際は **`packages/shared` とサーバ側コピーを同時に編集すること**。ドリフト検知テストが落ちたら、片方しか変更していないサインなのでもう一方も追従させる。
_When updating a shared value, edit `packages/shared` and the server-side copy together. If the drift test fails, the change touched only one side; sync the other._
- 理由 / Rationale:
- Railway の Dockerfile ビルドは「各サービスの Root Directory」を build context に取る (例: `server/mcp`)。ここからルート `bun.lock` を参照するのは面倒で、context をサービス単位に閉じるほうが再現性が高い。
_Railway Dockerfile builds take each service's Root Directory as the build context. Scoping `bun.lock` per service keeps the build self-contained and reproducible._
Expand Down
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"format:check": "prettier --check .",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run && vitest run --config packages/claude-sidecar/vitest.config.ts && vitest run --config server/hocuspocus/vitest.config.ts && vitest run --config server/mcp/vitest.config.ts",
"test:run": "vitest run && vitest run --config packages/shared/vitest.config.ts && vitest run --config packages/claude-sidecar/vitest.config.ts && vitest run --config server/hocuspocus/vitest.config.ts && vitest run --config server/mcp/vitest.config.ts",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
Expand Down Expand Up @@ -155,6 +155,7 @@
"@tiptap/starter-kit": "^3.20.0",
"@tiptap/y-tiptap": "^3.0.2",
"@xyflow/react": "^12.10.1",
"@zedi/shared": "workspace:*",
"@zedi/ui": "workspace:*",
"baseline-browser-mapping": "^2.9.19",
"better-auth": "^1.2.0",
Expand Down
18 changes: 18 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@zedi/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Source-of-truth constants shared between client (src, admin) and server packages. / クライアント (src, admin) とサーバ (server/api 等) で共有する定数モジュール。",
"exports": {
".": "./src/index.ts",
"./tagCharacterClass": "./src/tagCharacterClass.ts"
},
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"typescript": "^6.0.2",
"vitest": "^4.0.16"
}
}
9 changes: 9 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* `@zedi/shared` のエントリ。サーバ/クライアント/管理画面すべてで共有可能な
* ピュアな定数だけをここに集約する。React や Node 専用 API には依存しない。
*
* Entry point for `@zedi/shared`. Holds pure constants that can be imported
* from server, client, and admin code alike. Must not depend on React or
* Node-only APIs so the package stays universally importable.
*/
export { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js";
44 changes: 44 additions & 0 deletions packages/shared/src/tagCharacterClass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* `TAG_NAME_CHAR_CLASS` の最低限のスペックテスト。文字列リテラル仕様(正規表現
* の文字クラス内側のみ、グローバル/アンカー無し)と、組み立てた正規表現が
* 期待通りの字種を受理/拒否することを確認する。
*
* Lock down the minimum contract for `TAG_NAME_CHAR_CLASS`: it is the inner
* contents of a regex character class (no flags, no anchors), and a regex
* built from it accepts/rejects the documented script families.
*/
import { describe, it, expect } from "vitest";

import { TAG_NAME_CHAR_CLASS } from "./tagCharacterClass.js";

describe("TAG_NAME_CHAR_CLASS", () => {
it("contains no character-class brackets so callers can wrap it in `[...]`", () => {
// 完成した `[...]` ではなく中身だけを公開するという契約を固定する。
// Lock the "inner contents only" contract so wrappers stay correct.
expect(TAG_NAME_CHAR_CLASS.startsWith("[")).toBe(false);
expect(TAG_NAME_CHAR_CLASS.endsWith("]")).toBe(false);
});

it("accepts ASCII letters, digits, underscore, and hyphen", () => {
const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`);
expect(re.test("Foo_bar-1")).toBe(true);
expect(re.test("ABCxyz089")).toBe(true);
});

it("accepts hiragana, katakana, and CJK characters", () => {
const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`);
// ひらがな・カタカナ・漢字。
expect(re.test("ひらがな")).toBe(true);
expect(re.test("カタカナ")).toBe(true);
expect(re.test("日本語")).toBe(true);
expect(re.test("混合Mix日本語")).toBe(true);
});

it("rejects whitespace and ASCII punctuation outside the allowed set", () => {
const re = new RegExp(`^[${TAG_NAME_CHAR_CLASS}]+$`);
expect(re.test("has space")).toBe(false);
expect(re.test("dot.notation")).toBe(false);
expect(re.test("slash/sep")).toBe(false);
expect(re.test("emoji😀")).toBe(false);
});
});
36 changes: 36 additions & 0 deletions packages/shared/src/tagCharacterClass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* タグ名 (`#name`) として許容する文字の集合を 1 ヶ所に集約した定数。
* 正規表現の文字クラス (`[...]`) の **中身だけ** を提供する。完成した正規表現
* を共有しないのは、貼り付け検出 (client) と名前検証 (server) で
* フラグ・アンカー・先読みが異なるため。両者には同じ文字集合だけを揃えれば
* 十分で、用途依存の組み立ては各呼び出し側に任せる方がドリフトに強い。
*
* Single source of truth for the character set allowed inside a tag name
* (`#name`). Exposes only the **inner contents** of a regex character class
* (`[...]`). The full regex is intentionally not shared because the
* paste-detection regex (client) and the name-validation regex (server) need
* different flags, anchors, and look-arounds. Sharing only the character set
* keeps drift-prone surface area minimal.
*
* 含まれる字種 / Included scripts:
* - 半角英数字: `A-Za-z0-9`
* - 区切り: アンダースコア `_`、ハイフン `-`
* - ひらがな: U+3040..U+309F (`぀-ヿ` の前半)
* - カタカナ: U+30A0..U+30FF (`぀-ヿ` の後半)
* - CJK 統合漢字 + 拡張 A: U+3400..U+9FFF (`㐀-鿿`)
*
* 同期義務 / Sync obligation:
* - 本ファイルを編集したら、`server/api/src/services/ydocRenameRewrite.ts`
* の `TAG_NAME_CHAR_CLASS_STRING` も一致させること。`server/api` はワーク
* スペース外(自前の `bun.lock` を持つ Railway ビルド)なのでこの定数を
* 直接 import できない。代わりに `src/lib/tagCharacterClassSync.test.ts`
* が両者の文字列一致を CI でチェックする。
*
* When this file changes, also update
* `server/api/src/services/ydocRenameRewrite.ts`'s
* `TAG_NAME_CHAR_CLASS_STRING` to match. `server/api` lives outside the
* Bun workspace (its own `bun.lock` is consumed by Railway), so it cannot
* import this constant. `src/lib/tagCharacterClassSync.test.ts` enforces
* the equality in CI.
*/
export const TAG_NAME_CHAR_CLASS = "A-Za-z0-9_\\-぀-ヿ㐀-鿿";
13 changes: 13 additions & 0 deletions packages/shared/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"types": []
},
"include": ["src/**/*.ts"]
}
9 changes: 9 additions & 0 deletions packages/shared/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
root: import.meta.dirname,
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,50 @@ import { propagateTitleRename } from "../../services/titleRenamePropagationServi
* page_contents 行に入っているようなバイナリ Y.Doc を生成するヘルパー。
* Build an encoded Y.Doc blob shaped like a `page_contents.ydoc_state` row.
*/
function makeYdocWithWikiLink(title: string): Buffer {
function makeYdocWithWikiLink(title: string, targetId?: string): Buffer {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("default");
const paragraph = new Y.XmlElement("paragraph");
fragment.insert(0, [paragraph]);
const text = new Y.XmlText();
paragraph.insert(0, [text]);
text.insert(0, title, { wikiLink: { title, exists: true, referenced: false } });
const wikiLink: Record<string, unknown> = { title, exists: true, referenced: false };
if (targetId !== undefined) {
wikiLink.targetId = targetId;
}
text.insert(0, title, { wikiLink });
return Buffer.from(Y.encodeStateAsUpdate(doc));
}

/**
* 同名タイトルの 2 つのリンクを並べた Y.Doc を作るヘルパー。`targetId` で
* どちらが renamedPage を指すかを明示する。issue #737 の重複タイトルケース
* を検証する。
*
* Build a Y.Doc with two same-titled links discriminated by `targetId`.
* Used to verify the issue #737 scenario where a rename must touch only one
* of the two visually identical links.
*/
function makeYdocWithTwoSameTitleLinks(
title: string,
firstTargetId: string,
secondTargetId: string,
): Buffer {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("default");
const paragraph = new Y.XmlElement("paragraph");
fragment.insert(0, [paragraph]);
const text = new Y.XmlText();
paragraph.insert(0, [text]);
text.insert(0, title, {
wikiLink: { title, exists: true, referenced: false, targetId: firstTargetId },
});
// null を挟んで 2 つ目のマーク区間を独立させる(Yjs の format 継承を断つ)。
// Insert with `null` to break Yjs' formatting inheritance between segments.
text.insert(text.length, " and ", { wikiLink: null });
text.insert(text.length, title, {
wikiLink: { title, exists: true, referenced: false, targetId: secondTargetId },
});
return Buffer.from(Y.encodeStateAsUpdate(doc));
}

Expand All @@ -42,6 +78,33 @@ function decodeYdocWikiLinkTitle(buffer: Buffer): string | null {
return null;
}

/**
* `decodeYdocWikiLinkTitle` の同名リンク 2 つ版。`targetId` ごとにタイトルを
* 取り出し、どちらが書き換わったかを検証可能にする。
*
* Sibling helper to `decodeYdocWikiLinkTitle` that returns titles keyed by
* `targetId` so tests can assert which of the two same-titled links was
* rewritten and which was preserved.
*/
function decodeYdocWikiLinkTitlesByTargetId(buffer: Buffer): Record<string, string> {
const doc = new Y.Doc();
Y.applyUpdate(doc, new Uint8Array(buffer));
const fragment = doc.getXmlFragment("default");
const paragraph = fragment.get(0);
if (!(paragraph instanceof Y.XmlElement)) return {};
const text = paragraph.get(0);
if (!(text instanceof Y.XmlText)) return {};
const delta = text.toDelta() as Array<{ insert: unknown; attributes?: Record<string, unknown> }>;
const out: Record<string, string> = {};
for (const item of delta) {
const wl = item.attributes?.wikiLink as { title?: string; targetId?: string } | undefined;
if (wl?.title && wl.targetId) {
out[wl.targetId] = wl.title;
}
}
return out;
}

const PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000001";
const SOURCE_PAGE_ID = "11111111-aaaa-bbbb-cccc-000000000002";
const OWNER_ID = "owner-user-1";
Expand Down Expand Up @@ -337,4 +400,57 @@ describe("propagateTitleRename", () => {
// Ghost promotion path still ran (empty result here). / ゴースト昇格の経路は通る。
expect(result.ghostPromotionsCount).toBe(0);
});

it("rewrites only the link whose targetId matches the renamed page (issue #737)", async () => {
// 重複タイトル下のリネーム: ソースページ X が `[[Foo]]` を 2 回参照する。
// 1 つは renamedPage (PAGE_ID) を、もう 1 つは別ページ (OTHER_TARGET_ID)
// を `targetId` で指している。ID 一致の方だけを `[[Bar]]` に書き換え、
// もう一方は `[[Foo]]` のまま残ることを検証する。issue #737 案 A の本質。
// Same-title rename: source page X holds two `[[Foo]]` marks pointing to
// different pages via `targetId`. Only the mark whose `targetId` matches
// the renamed page should become `[[Bar]]`; the other must stay `[[Foo]]`.
// This is the core acceptance scenario for issue #737 (approach A).
const OTHER_TARGET_ID = "33333333-aaaa-bbbb-cccc-000000000003";
const originalYdoc = makeYdocWithTwoSameTitleLinks("Foo", PAGE_ID, OTHER_TARGET_ID);

const { db, chains } = createMockDb([
[{ sourceId: SOURCE_PAGE_ID }],
[], // FOR UPDATE
[{ pageId: SOURCE_PAGE_ID, ydocState: originalYdoc, version: 1 }],
[{ version: 2 }], // UPDATE page_contents
[], // UPDATE pages
PERSONAL_SCOPE_ROW,
[], // ghost candidates (none)
]);
const invalidate = vi.fn().mockResolvedValue(undefined);

const result = await propagateTitleRename(db as never, PAGE_ID, "Foo", "Bar", {
invalidateDocument: invalidate,
});

expect(result.sourcePagesAttempted).toBe(1);
expect(result.sourcePagesSucceeded).toBe(1);
expect(result.wikiLinkMarksUpdated).toBe(1);
expect(result.wikiLinkTextUpdated).toBe(1);
expect(invalidate).toHaveBeenCalledTimes(1);

// 書き戻された ydoc_state を読み取り、targetId ごとのタイトル分布を検証。
// Decode the persisted ydoc_state and check titles by `targetId`.
const updateChains = chains.filter((c) => c.startMethod === "update");
const pageContentsUpdate = updateChains.find((c) => {
const setArg = c.ops.find((op) => op.method === "set")?.args[0] as
| Record<string, unknown>
| undefined;
return setArg && "ydocState" in setArg;
});
const pcSetArg = pageContentsUpdate?.ops.find((op) => op.method === "set")?.args[0] as
| { ydocState: Buffer }
| undefined;
expect(pcSetArg?.ydocState).toBeInstanceOf(Buffer);
if (pcSetArg?.ydocState) {
const titles = decodeYdocWikiLinkTitlesByTargetId(pcSetArg.ydocState);
expect(titles[PAGE_ID]).toBe("Bar");
expect(titles[OTHER_TARGET_ID]).toBe("Foo");
}
});
});
Loading
Loading