Skip to content

Commit 6f9581b

Browse files
committed
Narrow portable IRI cache detection
Generated codecs now look for portable ActivityPub IRIs only in ID-valued JSON-LD positions before deciding whether to bypass the raw JSON-LD cache. This keeps ordinary text such as post content from causing cache misses and dropping extension properties on default serialization. #850 (comment) #850 (comment) Assisted-by: Codex:gpt-5.5
1 parent 5c67df6 commit 6f9581b

6 files changed

Lines changed: 373 additions & 493 deletions

File tree

packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap

Lines changed: 98 additions & 163 deletions
Large diffs are not rendered by default.

packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap

Lines changed: 98 additions & 163 deletions
Large diffs are not rendered by default.

packages/vocab-tools/src/__snapshots__/class.test.ts.snap

Lines changed: 98 additions & 163 deletions
Large diffs are not rendered by default.

packages/vocab-tools/src/class.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import { generateCloner, generateConstructor } from "./constructor.ts";
33
import { generateFields } from "./field.ts";
44
import { generateInspector, generateInspectorPostClass } from "./inspector.ts";
55
import { generateProperties } from "./property.ts";
6-
import { type TypeSchema, validateTypeSchemas } from "./schema.ts";
6+
import {
7+
type PropertySchema,
8+
type TypeSchema,
9+
validateTypeSchemas,
10+
} from "./schema.ts";
711
import { emitOverride } from "./type.ts";
812

13+
const XSD_ANY_URI = "http://www.w3.org/2001/XMLSchema#anyURI";
14+
915
/**
1016
* Sorts the given types topologically so that the base types come before the
1117
* extended types.
@@ -169,6 +175,33 @@ export function getEntityTypeById(id: string | URL): $EntityType | undefined {
169175
`;
170176
}
171177

178+
function canContainIriValue(
179+
property: PropertySchema,
180+
types: Record<string, TypeSchema>,
181+
): boolean {
182+
return property.range.some((typeUri) =>
183+
typeUri === XSD_ANY_URI || typeUri in types && types[typeUri].entity
184+
);
185+
}
186+
187+
function addPortableIriKeys(
188+
keys: Set<string>,
189+
property: PropertySchema,
190+
types: Record<string, TypeSchema>,
191+
): void {
192+
if (!canContainIriValue(property, types)) return;
193+
keys.add(property.uri);
194+
if (property.compactName != null) keys.add(property.compactName);
195+
if (property.functional && property.redundantProperties != null) {
196+
for (const redundantProperty of property.redundantProperties) {
197+
keys.add(redundantProperty.uri);
198+
if (redundantProperty.compactName != null) {
199+
keys.add(redundantProperty.compactName);
200+
}
201+
}
202+
}
203+
}
204+
172205
/**
173206
* Generates the TypeScript classes from the given types.
174207
* @param types The types to generate classes from.
@@ -210,7 +243,30 @@ export async function* generateClasses(
210243
isTemporalInstant,
211244
} from "@fedify/vocab-runtime/temporal";\n`;
212245
yield "\n\n";
213-
yield "const PORTABLE_IRI_PATTERN = /ap(?:\\+ef61)?:\\/\\//i;\n\n";
246+
const portableIriKeys = new Set(["@id", "id"]);
247+
for (const type of Object.values(types)) {
248+
for (const property of type.properties) {
249+
addPortableIriKeys(portableIriKeys, property, types);
250+
}
251+
}
252+
yield "const PORTABLE_IRI_PATTERN = /^ap(?:\\+ef61)?:\\/\\//i;\n";
253+
yield `const PORTABLE_IRI_KEYS: ReadonlySet<string> = new Set(${
254+
JSON.stringify([...portableIriKeys].sort())
255+
});\n\n`;
256+
yield `function hasPortableIri(value: unknown, key?: string): boolean {
257+
if (typeof value === "string") {
258+
return key != null && PORTABLE_IRI_KEYS.has(key) &&
259+
PORTABLE_IRI_PATTERN.test(value);
260+
}
261+
if (Array.isArray(value)) {
262+
return value.some((item) => hasPortableIri(item, key));
263+
}
264+
if (value == null || typeof value !== "object") return false;
265+
const object = value as Record<string, unknown>;
266+
return globalThis.Object.keys(object).some((entryKey) =>
267+
hasPortableIri(object[entryKey], entryKey)
268+
);
269+
}\n\n`;
214270
const moduleVarNames = new Map<string, string>();
215271
const sorted = sortTopologically(types);
216272
for (const typeUri of sorted) {

packages/vocab-tools/src/codec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,7 @@ export async function* generateDecoder(
535535
yield `
536536
if (!("_fromSubclass" in options) || !options._fromSubclass) {
537537
try {
538-
const jsonText = JSON.stringify(json);
539-
if (jsonText == null || !PORTABLE_IRI_PATTERN.test(jsonText)) {
538+
if (!hasPortableIri(json)) {
540539
instance._cachedJsonLd = structuredClone(json);
541540
}
542541
} catch {

packages/vocab/src/vocab.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,26 @@ test("fromJsonLd() handles portable ActivityPub IRIs", async () => {
599599
);
600600
});
601601

602+
test("fromJsonLd() caches text that mentions portable ActivityPub IRIs", async () => {
603+
const noteJson = {
604+
"@context": [
605+
"https://www.w3.org/ns/activitystreams",
606+
{ extra: "https://example.com/ns#extra" },
607+
],
608+
type: "Note",
609+
id: "https://example.com/notes/1",
610+
content: "This is text about ap://did:key:z6Mkabc/actor.",
611+
extra: "This extension property should stay cached.",
612+
};
613+
614+
const note = await Note.fromJsonLd(noteJson, {
615+
documentLoader: mockDocumentLoader,
616+
contextLoader: mockDocumentLoader,
617+
});
618+
619+
deepStrictEqual(await note.toJsonLd(), noteJson);
620+
});
621+
602622
test({
603623
name: "Activity.getObject()",
604624
permissions: { env: true, read: true },

0 commit comments

Comments
 (0)