diff --git a/docs/public-knowledge.md b/docs/public-knowledge.md index 8c8fdcd..4ca592b 100644 --- a/docs/public-knowledge.md +++ b/docs/public-knowledge.md @@ -26,6 +26,11 @@ record keys while the manifest preserves their stable slug-to-URI mapping. Existing records retain their historical keys. A missing source never means automatic deletion. Withdrawal requires a separate explicit operation. +Historical document records with pre-TID keys remain manifest-owned and use a +narrow compatibility path when their content changes. The worker sends only +those CID-guarded updates in validation-disabled batches. New records and +TID-keyed updates keep server validation enabled. + ## Source layout The pipeline has three file surfaces: diff --git a/scripts/sync-knowledge-atproto.ts b/scripts/sync-knowledge-atproto.ts index ec18307..e9337c5 100644 --- a/scripts/sync-knowledge-atproto.ts +++ b/scripts/sync-knowledge-atproto.ts @@ -11,6 +11,7 @@ import { } from "../src/knowledge.ts"; import { nextStandardSiteRkey, + shouldValidateStandardSiteWrite, type KnowledgeAtprotoManifest, } from "../src/knowledge-atproto.ts"; import { @@ -391,36 +392,49 @@ async function applyWrites( accessJwt: string, plans: Array>, ): Promise { - const writes = plans.flatMap((plan) => { + const operations = plans.flatMap((plan) => { const write = toWrite(plan); - return write ? [write] : []; + if (!write || (plan.action !== "create" && plan.action !== "update")) return []; + return [{ + write, + validate: shouldValidateStandardSiteWrite({ + collection: plan.collection, + action: plan.action, + rkey: plan.rkey, + }), + }]; }); - if (writes.length === 0) return { unchanged: true }; - const batches: Array = []; - for (const write of writes) { + if (operations.length === 0) return { unchanged: true }; + const batches: Array<{ validate: boolean; writes: Array> }> = []; + for (const operation of operations) { const current = batches.at(-1); - const candidate = [...(current ?? []), write]; + const candidate = [...(current?.writes ?? []), operation.write]; const bytes = Buffer.byteLength(JSON.stringify({ repo: CAMERON_DID, writes: candidate })); - if (!current || (candidate.length <= 20 && bytes <= 500_000)) { - if (!current) batches.push(candidate); - else batches[batches.length - 1] = candidate; + if ( + !current + || current.validate !== operation.validate + || candidate.length > 20 + || bytes > 500_000 + ) { + batches.push({ validate: operation.validate, writes: [operation.write] }); } else { - batches.push([write]); + current.writes = candidate; } } const receipts = []; for (let index = 0; index < batches.length; index++) { + const batch = batches[index]; const response = await fetch(`${pds}/xrpc/com.atproto.repo.applyWrites`, { method: "POST", headers: { Authorization: `Bearer ${accessJwt}`, "Content-Type": "application/json" }, - body: JSON.stringify({ repo: CAMERON_DID, writes: batches[index] }), + body: JSON.stringify({ repo: CAMERON_DID, writes: batch.writes, validate: batch.validate }), }); if (!response.ok) { throw new Error( `applyWrites batch ${index + 1}/${batches.length} failed: ${response.status} ${await response.text()}`, ); } - receipts.push(await response.json()); + receipts.push({ validate: batch.validate, response: await response.json() }); } return { batches: receipts }; } diff --git a/src/knowledge-atproto.ts b/src/knowledge-atproto.ts index 6fe2e9f..04f9a8d 100644 --- a/src/knowledge-atproto.ts +++ b/src/knowledge-atproto.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { TID } from "@atproto/common-web"; +import { isValidTid } from "@atproto/syntax"; export interface KnowledgeAtprotoManifestEntry { uri: string; @@ -31,6 +32,18 @@ export function nextStandardSiteRkey(): string { return TID.nextStr(); } +export function shouldValidateStandardSiteWrite(options: { + collection: string; + action: "create" | "update"; + rkey: string; +}): boolean { + return !( + options.collection === "site.standard.document" + && options.action === "update" + && !isValidTid(options.rkey) + ); +} + export function loadKnowledgeAtprotoManifest(): KnowledgeAtprotoManifest { const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as KnowledgeAtprotoManifest; if (manifest.version !== 2) { diff --git a/src/knowledge.test.ts b/src/knowledge.test.ts index ed42967..bac291c 100644 --- a/src/knowledge.test.ts +++ b/src/knowledge.test.ts @@ -7,7 +7,10 @@ import test from "node:test"; import { isValidTid } from "@atproto/syntax"; import { Hono } from "hono"; import { KnowledgeVideo } from "./components/knowledge-entry.tsx"; -import { nextStandardSiteRkey } from "./knowledge-atproto.ts"; +import { + nextStandardSiteRkey, + shouldValidateStandardSiteWrite, +} from "./knowledge-atproto.ts"; import { knowledgeContentDigest, knowledgeHref, @@ -49,6 +52,29 @@ test("allocates lexicon-valid TID keys for new Standard.site records", () => { assert.notEqual(first, second); }); +test("limits legacy record-key compatibility to existing document updates", () => { + assert.equal(shouldValidateStandardSiteWrite({ + collection: "site.standard.document", + action: "update", + rkey: "knowledge-now", + }), false); + assert.equal(shouldValidateStandardSiteWrite({ + collection: "site.standard.document", + action: "create", + rkey: "knowledge-new-entry", + }), true); + assert.equal(shouldValidateStandardSiteWrite({ + collection: "site.standard.document", + action: "update", + rkey: nextStandardSiteRkey(), + }), true); + assert.equal(shouldValidateStandardSiteWrite({ + collection: "site.standard.publication", + action: "update", + rkey: "legacy-publication-key", + }), true); +}); + test("uses a nested route without changing the stable slug", () => { const entry = parseKnowledgeDocument( draftSource("gemini-model-series", "language-models/gemini"),