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
10 changes: 10 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ To be released.

### @fedify/vocab

- Added `deepClone()` to vocabulary objects to copy nested objects before
resolving their properties, keeping the source graph unchanged. Replacement
values are copied too. Use `clone()` when a shallow copy is sufficient.
Large sparse property arrays retain their length and holes without creating
per-slot trust bookkeeping. Keep arrays stable while copying; newly added
entries may not be visited. Accessor and serialization traversal costs are
unchanged. [[#1243], [#1337]]

- Added vocabulary support for the [FEP-6757] draft, which marks
ActivityPub content with explicit copyright license URIs.
[[#1212], [#1241]]
Expand All @@ -233,6 +241,8 @@ To be released.
[FEP-6757]: https://w3id.org/fep/6757
[#1212]: https://github.com/fedify-dev/fedify/issues/1212
[#1241]: https://github.com/fedify-dev/fedify/pull/1241
[#1243]: https://github.com/fedify-dev/fedify/issues/1243
[#1337]: https://github.com/fedify-dev/fedify/pull/1337

### @fedify/vocab-runtime

Expand Down
12 changes: 12 additions & 0 deletions changes.d/vocab/deep-clone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
links:
'#1243': https://github.com/fedify-dev/fedify/issues/1243
'#1337': https://github.com/fedify-dev/fedify/pull/1337
---
- Added `deepClone()` to vocabulary objects to copy nested objects before
resolving their properties, keeping the source graph unchanged. Replacement
values are copied too. Use `clone()` when a shallow copy is sufficient.
Large sparse property arrays retain their length and holes without creating
per-slot trust bookkeeping. Keep arrays stable while copying; newly added
entries may not be visited. Accessor and serialization traversal costs are
unchanged. [[#1243], [#1337]]
12 changes: 12 additions & 0 deletions claude-plugin/skills/fedify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,18 @@ in-tree *src/webfinger*.

`fromJsonLd()` and `toJsonLd()` are asynchronous; always `await` them.

`clone()` is shallow: it copies property arrays but shares nested vocabulary
objects and URLs. In Fedify 2.5.0 and later, use synchronous `deepClone()` to
resolve nested properties without hydrating the source's objects. Replacement
values are copied too. Each copied node keeps its own default loaders and
verifier; second-argument options override only the root. The copy drops cached
JSON-LD and retained signed representations, so retain the received document
when its exact representation matters and sign copies again when needed.
Keep property arrays stable during copying, including inside custom `clone()`
overrides: `deepClone()` snapshots the existing indices of large sparse arrays.
Entries added during copying are not visited, so their nested objects may
remain shared. Copying preserves array length and holes.

> [!WARNING]
> `crossOrigin: "trust"` on vocabulary deserialization trusts embedded
> objects without re-fetching. Treat it as you would
Expand Down
82 changes: 62 additions & 20 deletions docs/manual/vocab.md
Original file line number Diff line number Diff line change
Expand Up @@ -951,31 +951,73 @@ if its cached JSON-LD contains an embedded object with its own `@context`, the
accessor re-parses that object on each call. Hydrating that returned object
does not update the nested instance stored in the source or clone.

If you need to preserve the received representation while resolving properties,
keep the received JSON-LD document. To resolve properties in a separate object
graph, serialize and re-parse the object before hydrating any properties rather
than using `clone()`:
### Deep copies

*This API is available since Fedify 2.5.0.*

Use `deepClone()` when you need to resolve properties without hydrating nested
objects held by the source. It copies the stored vocabulary graph, including
any objects you supply as replacement values. For example, you can inspect a
quote request through a copy while keeping the original to echo in a response:

~~~~ typescript twoslash
import { Create } from "@fedify/vocab";
import type { DocumentLoader } from "@fedify/vocab-runtime";
declare const original: Create;
declare const documentLoader: DocumentLoader;
declare const contextLoader: DocumentLoader;
import { Accept, QuoteRequest } from "@fedify/vocab";
declare const request: QuoteRequest;
// ---cut-before---
const options = { documentLoader, contextLoader };
const copy = await Create.fromJsonLd(await original.toJsonLd(), options);
// Resolve properties on copy while keeping original separate:
const note = await copy.getObject(options);
if (note != null) await note.getAttribution(options);
const resolving = request.deepClone();
const instrument = await resolving.getInstrument();
if (instrument != null) await instrument.getAttribution();

// The response embeds the original, whose nested objects were not hydrated:
const response = new Accept({ object: request });
~~~~

This round trip separates nested vocabulary objects; it does not preserve the
original JSON bytes. Pass any custom loaders again, as in the example above.
The copy also does not inherit trust in embedded objects. Under the
[origin-based security model](#origin-based-security-model), accessors may fetch
cross-origin objects again, even if the original already hydrated them. The
fetched data may have changed, or the fetch may fail.
Like `clone()`, `deepClone()` accepts constructor-compatible replacement values,
such as `request.deepClone({ summary: "Updated" })`. Nested vocabulary objects,
URLs and byte arrays are copied; immutable scalar values and opaque
cryptographic keys are shared. Repeated references to one object remain shared
within the copy. If the graph refers back to the source itself, including when
you pass the source as a replacement value, that reference becomes a separate
copy of the source's pre-update values. It does not point at the updated root.

Each copied object retains its own default document loader, context loader,
tracer, portable-object verifier and base URL. The optional second argument
accepts the same loader and verifier options as `clone()`, but it overrides
only the root's defaults. An option left `undefined` keeps the root's existing
default for that option. By contrast, `clone()` uses only the options passed
to it.

The copy retains embedded-object trust and the portable restrictions that
`clone()` preserves, but does not inherit positive verification evidence.
Historical portable referrers within the graph are copied without applying
replacement values; referrers outside the graph continue to point at the
original external objects.

`deepClone()` does not fetch remote objects or serialize and re-parse JSON-LD.
It walks the entire stored graph synchronously. Prefer `clone()` when a
shallow copy is sufficient. When copying a subclass, `deepClone()` calls its
`clone()` override. Such overrides must return fresh, compatible instances
and are responsible for their own side effects.

Property arrays keep their length and holes when copied. Large sparse
arrays with ordinary data entries use a snapshot of their existing indices.
Entries added after that snapshot are not visited, while deleted entries are
skipped. Keep property arrays stable during copying, including inside custom
`clone()` overrides, to preserve isolation of the copied graph. Indexed
getters, array subclasses and custom species use the existing array operations
when detected; Proxy traps can observe different reads and reflection
operations.

Trust for constructor-supplied objects follows the length of the stored
array. Building that trust does not read non-scalar plural input getters once
per slot. Sparse arrays can still be expensive to traverse through accessors
or to serialize.

Cached JSON-LD and retained signed representations are discarded at every
copied node. The copy therefore reflects modeled property values, not unknown
JSON-LD properties or the original document's formatting. Sign the copy again
if it needs a valid proof. To preserve the exact received representation,
keep the received JSON-LD document and use the copy only for inspection.


Looking up remote objects
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,49 @@ import type { DocumentLoader } from "../docloader.ts";
import { parseIri, withGatewayHints } from "../url.ts";
import { createScopedContextLoader } from "./jsonld-cache.ts";
import {
copyPortableProvenance,
createSnapshotContextLoader,
getPortableGatewayCandidates,
getReferrerGateways,
isInPortableChain,
isPortableIri,
recordPortableReferrer,
retargetPortableReferrer,
} from "./portable-dereference.ts";

const hrefs = (urls: URL[]) => urls.map((url) => url.href);

test("retargetPortableReferrer() only reconnects existing provenance", () => {
const id = parseIri("ap://did:key:z6Mkabc/actor");
const parent = { id, gateways: [new URL("https://original.example")] };
const copiedParent = { id, gateways: [new URL("https://copy.example")] };
const source = {};
const copy = {};
let calls = 0;
retargetPortableReferrer(copy, () => {
calls++;
return copiedParent;
});
deepStrictEqual(calls, 0);
ok(!isInPortableChain(copy));
recordPortableReferrer(parent, source, "https://example.com/property");
copyPortableProvenance(source, copy);
retargetPortableReferrer(copy, () => undefined);
deepStrictEqual(hrefs(getReferrerGateways(copy, id)!), [
"https://original.example/",
]);
retargetPortableReferrer(copy, (original) => {
ok(original === parent);
return copiedParent;
});
deepStrictEqual(hrefs(getReferrerGateways(copy, id)!), [
"https://copy.example/",
]);
deepStrictEqual(hrefs(getReferrerGateways(source, id)!), [
"https://original.example/",
]);
});

test("isPortableIri()", () => {
ok(isPortableIri(parseIri("ap://did:key:z6Mkabc/actor")));
ok(isPortableIri(parseIri("ap+ef61://did:key:z6Mkabc/actor")));
Expand Down
21 changes: 21 additions & 0 deletions packages/vocab-runtime/src/internal/portable-dereference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,27 @@ export function copyPortableProvenance(from: object, to: object): void {
});
}

/**
* Reconnects an existing historical referrer to its independent graph copy.
* Does not create provenance or add verification or collection-context evidence.
* An unresolved referrer remains external history. Like the other provenance
* helpers, this is for generated vocabulary code, not a public trust API.
* @internal
*/
export function retargetPortableReferrer(
copy: object,
resolve: (original: object) => object | undefined,
): void {
const provenance = provenances.get(copy);
if (provenance?.referrer == null) return;
const object = resolve(provenance.referrer.object);
if (object == null) return;
provenances.set(copy, {
...provenance,
referrer: { ...provenance.referrer, object },
});
}

/**
* Recognizes an FEP-ef61 compatible identifier to dereference as a portable
* object.
Expand Down
Loading
Loading