@fedify/vocab-tools 2.5.0-dev.2289 → 2.5.0-dev.2304

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,3 +64,43 @@ of trusting a matching origin. Locally constructed values, previously fetched
64
64
  values, and the explicit `crossOrigin: "trust"` option retain their usual
65
65
  behavior. This does not verify id-less embedded objects or establish publishing
66
66
  authority.
67
+
68
+
69
+ Redundant properties
70
+ --------------------
71
+
72
+ Both functional and non-functional property schemas can declare synonyms in
73
+ `redundantProperties`. On reading, the canonical property takes precedence,
74
+ followed by synonyms in declaration order. The first non-empty raw value set
75
+ is decoded in full, preserving its order and multiplicity. Values from other
76
+ synonyms are never merged, even when the selected values fail range validation.
77
+ List and graph containers are decoded only after choosing the property.
78
+
79
+ `redundantPropertiesWrite` defaults to `all`, which writes the canonical
80
+ property and every synonym with the same complete container representation.
81
+ Set it to `canonical` to write only the canonical property while still accepting
82
+ synonyms on reading:
83
+
84
+ ~~~~ yaml
85
+ - singularName: license
86
+ pluralName: licenses
87
+ uri: "https://example.com/license"
88
+ description: The licenses of the object.
89
+ range:
90
+ - "http://www.w3.org/2001/XMLSchema#anyURI"
91
+ redundantProperties:
92
+ - uri: "https://schema.org/license"
93
+ - uri: "http://creativecommons.org/ns#license"
94
+ redundantPropertiesWrite: canonical
95
+ ~~~~
96
+
97
+ A synonym's `compactName` is optional. Without it, serialization uses the
98
+ JSON-LD processor to select a context term or retain the full URI. For IRI
99
+ values, context terms must declare `"@type": "@id"`; container terms must
100
+ carry the appropriate `@container`. Writable container synonyms also use the
101
+ processor to preserve their representation. Explicit caller contexts take
102
+ precedence over the default context.
103
+
104
+ As with other properties, an object parsed with `fromJsonLd()` retains its
105
+ original JSON-LD document for default serialization. Pass `format: "compact"`
106
+ or `format: "expand"` to serialize its decoded values under the write policy.
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-tools",
3
- "version": "2.5.0-dev.2289+111f0945",
3
+ "version": "2.5.0-dev.2304+3a599380",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
package/dist/mod.cjs CHANGED
@@ -8,7 +8,7 @@ let yaml = require("yaml");
8
8
  let es_toolkit = require("es-toolkit");
9
9
  //#region deno.json
10
10
  var name = "@fedify/vocab-tools";
11
- var version = "2.5.0-dev.2289+111f0945";
11
+ var version = "2.5.0-dev.2304+3a599380";
12
12
  //#endregion
13
13
  //#region src/type.ts
14
14
  const HEURISTICS_CONTEXTS = [
@@ -844,12 +844,15 @@ async function* generateEncoder(typeUri, types) {
844
844
  ? compactItems
845
845
  : compactItems[0];
846
846
  `;
847
- if (property.functional && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
847
+ if (property.redundantPropertiesWrite !== "canonical" && property.redundantProperties != null && !(isNonFunctionalProperty(property) && property.container != null)) for (const prop of property.redundantProperties) {
848
+ if (prop.compactName == null) continue;
849
+ yield `
848
850
  result[${JSON.stringify(prop.compactName)}]
849
851
  = compactItems.length > 1
850
852
  ? compactItems
851
853
  : compactItems[0];
852
854
  `;
855
+ }
853
856
  } else yield `
854
857
  result[${JSON.stringify(property.compactName)}] = compactItems;
855
858
  `;
@@ -916,7 +919,7 @@ async function* generateEncoder(typeUri, types) {
916
919
  );
917
920
  values[${JSON.stringify(property.uri)}] = propValue;
918
921
  `;
919
- if (property.functional && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
922
+ if (property.redundantPropertiesWrite !== "canonical" && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
920
923
  values[${JSON.stringify(prop.uri)}] = propValue;
921
924
  `;
922
925
  yield `
@@ -988,7 +991,7 @@ async function* generateEncoder(typeUri, types) {
988
991
 
989
992
  protected ${emitOverride$1(typeUri, types)} isCompactable(): boolean {
990
993
  `;
991
- for (const property of type.properties) if (property.extraContext != null || !property.range.every((r) => isCompactableType(r, types))) yield `
994
+ for (const property of type.properties) if (property.extraContext != null || property.redundantPropertiesWrite !== "canonical" && property.redundantProperties != null && property.redundantProperties.length > 0 && (property.redundantProperties.some((p) => p.compactName == null) || isNonFunctionalProperty(property) && property.container != null) || !property.range.every((r) => isCompactableType(r, types))) yield `
992
995
  if (
993
996
  this.${await getFieldName(property.uri)} != null &&
994
997
  this.${await getFieldName(property.uri)}.length > 0
@@ -1167,7 +1170,7 @@ async function* generateDecoder(typeUri, types, moduleVarNames) {
1167
1170
  yield `
1168
1171
  let ${arrayVariable} = ${property.uri === "https://w3id.org/fep/ef61/gateways" ? "getPortableActorGateways(values) as typeof values[string]" : `values[${JSON.stringify(property.uri)}]`};
1169
1172
  `;
1170
- if (property.functional && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
1173
+ if (property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
1171
1174
  if (${arrayVariable} == null || ${arrayVariable}.length < 1) {
1172
1175
  ${arrayVariable} = values[${JSON.stringify(prop.uri)}];
1173
1176
  }
package/dist/mod.d.cts CHANGED
@@ -68,6 +68,22 @@ interface TypeSchema {
68
68
  defaultContext: Context;
69
69
  }
70
70
  interface PropertySchemaBase {
71
+ /**
72
+ * Synonyms read after the canonical property, in declaration order.
73
+ * Deserialization selects the first non-empty raw value set before decoding
74
+ * its values and containers; it never merges synonyms or retries invalid
75
+ * values. Serialization writes all synonyms by default.
76
+ */
77
+ redundantProperties?: {
78
+ /** The qualified URI of the synonym. */uri: string; /** The synonym's key in the default compact JSON-LD context. */
79
+ compactName?: string;
80
+ }[];
81
+ /**
82
+ * Whether serialization writes every synonym or only the canonical property.
83
+ * Defaults to `"all"`. This does not change deserialization precedence or
84
+ * the existing cache of parsed JSON-LD documents.
85
+ */
86
+ redundantPropertiesWrite?: "all" | "canonical";
71
87
  /**
72
88
  * A context URL added to the default serialization context when this
73
89
  * property's expanded IRI appears in the compacted document. Explicit
@@ -193,23 +209,6 @@ type PropertySchema = PropertySchemaBase & PropertySchemaTyping & {
193
209
  * and `singularAccessor` should not be specified.
194
210
  */
195
211
  functional: true;
196
- /**
197
- * If it's present, those redundant properties are also filled with
198
- * the same value altogether when the object is serialized into
199
- * JSON-LD. When it's deserialized from JSON-LD, it tries to
200
- * parse the values of the specified properties in order.
201
- */
202
- redundantProperties?: {
203
- /**
204
- * The qualified URI of the property.
205
- */
206
- uri: string;
207
- /**
208
- * The property name used in the compacted JSON-LD document. It is used
209
- * as the key of the property.
210
- */
211
- compactName?: string;
212
- }[];
213
212
  };
214
213
  /**
215
214
  * A JSON-LD context, which is placed in the `@context` property of a JSON-LD
package/dist/mod.d.ts CHANGED
@@ -68,6 +68,22 @@ interface TypeSchema {
68
68
  defaultContext: Context;
69
69
  }
70
70
  interface PropertySchemaBase {
71
+ /**
72
+ * Synonyms read after the canonical property, in declaration order.
73
+ * Deserialization selects the first non-empty raw value set before decoding
74
+ * its values and containers; it never merges synonyms or retries invalid
75
+ * values. Serialization writes all synonyms by default.
76
+ */
77
+ redundantProperties?: {
78
+ /** The qualified URI of the synonym. */uri: string; /** The synonym's key in the default compact JSON-LD context. */
79
+ compactName?: string;
80
+ }[];
81
+ /**
82
+ * Whether serialization writes every synonym or only the canonical property.
83
+ * Defaults to `"all"`. This does not change deserialization precedence or
84
+ * the existing cache of parsed JSON-LD documents.
85
+ */
86
+ redundantPropertiesWrite?: "all" | "canonical";
71
87
  /**
72
88
  * A context URL added to the default serialization context when this
73
89
  * property's expanded IRI appears in the compacted document. Explicit
@@ -193,23 +209,6 @@ type PropertySchema = PropertySchemaBase & PropertySchemaTyping & {
193
209
  * and `singularAccessor` should not be specified.
194
210
  */
195
211
  functional: true;
196
- /**
197
- * If it's present, those redundant properties are also filled with
198
- * the same value altogether when the object is serialized into
199
- * JSON-LD. When it's deserialized from JSON-LD, it tries to
200
- * parse the values of the specified properties in order.
201
- */
202
- redundantProperties?: {
203
- /**
204
- * The qualified URI of the property.
205
- */
206
- uri: string;
207
- /**
208
- * The property name used in the compacted JSON-LD document. It is used
209
- * as the key of the property.
210
- */
211
- compactName?: string;
212
- }[];
213
212
  };
214
213
  /**
215
214
  * A JSON-LD context, which is placed in the `@context` property of a JSON-LD
package/dist/mod.js CHANGED
@@ -7,7 +7,7 @@ import { parse } from "yaml";
7
7
  import { pascalCase } from "es-toolkit";
8
8
  //#region deno.json
9
9
  var name = "@fedify/vocab-tools";
10
- var version = "2.5.0-dev.2289+111f0945";
10
+ var version = "2.5.0-dev.2304+3a599380";
11
11
  //#endregion
12
12
  //#region src/type.ts
13
13
  const HEURISTICS_CONTEXTS = [
@@ -843,12 +843,15 @@ async function* generateEncoder(typeUri, types) {
843
843
  ? compactItems
844
844
  : compactItems[0];
845
845
  `;
846
- if (property.functional && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
846
+ if (property.redundantPropertiesWrite !== "canonical" && property.redundantProperties != null && !(isNonFunctionalProperty(property) && property.container != null)) for (const prop of property.redundantProperties) {
847
+ if (prop.compactName == null) continue;
848
+ yield `
847
849
  result[${JSON.stringify(prop.compactName)}]
848
850
  = compactItems.length > 1
849
851
  ? compactItems
850
852
  : compactItems[0];
851
853
  `;
854
+ }
852
855
  } else yield `
853
856
  result[${JSON.stringify(property.compactName)}] = compactItems;
854
857
  `;
@@ -915,7 +918,7 @@ async function* generateEncoder(typeUri, types) {
915
918
  );
916
919
  values[${JSON.stringify(property.uri)}] = propValue;
917
920
  `;
918
- if (property.functional && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
921
+ if (property.redundantPropertiesWrite !== "canonical" && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
919
922
  values[${JSON.stringify(prop.uri)}] = propValue;
920
923
  `;
921
924
  yield `
@@ -987,7 +990,7 @@ async function* generateEncoder(typeUri, types) {
987
990
 
988
991
  protected ${emitOverride$1(typeUri, types)} isCompactable(): boolean {
989
992
  `;
990
- for (const property of type.properties) if (property.extraContext != null || !property.range.every((r) => isCompactableType(r, types))) yield `
993
+ for (const property of type.properties) if (property.extraContext != null || property.redundantPropertiesWrite !== "canonical" && property.redundantProperties != null && property.redundantProperties.length > 0 && (property.redundantProperties.some((p) => p.compactName == null) || isNonFunctionalProperty(property) && property.container != null) || !property.range.every((r) => isCompactableType(r, types))) yield `
991
994
  if (
992
995
  this.${await getFieldName(property.uri)} != null &&
993
996
  this.${await getFieldName(property.uri)}.length > 0
@@ -1166,7 +1169,7 @@ async function* generateDecoder(typeUri, types, moduleVarNames) {
1166
1169
  yield `
1167
1170
  let ${arrayVariable} = ${property.uri === "https://w3id.org/fep/ef61/gateways" ? "getPortableActorGateways(values) as typeof values[string]" : `values[${JSON.stringify(property.uri)}]`};
1168
1171
  `;
1169
- if (property.functional && property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
1172
+ if (property.redundantProperties != null) for (const prop of property.redundantProperties) yield `
1170
1173
  if (${arrayVariable} == null || ${arrayVariable}.length < 1) {
1171
1174
  ${arrayVariable} = values[${JSON.stringify(prop.uri)}];
1172
1175
  }
package/dist/schema.yaml CHANGED
@@ -45,33 +45,41 @@ $defs:
45
45
  functional:
46
46
  description: *functional-description
47
47
  const: true
48
- redundantProperties::
49
- description: >-
50
- If it's present, those redundant properties are also filled with
51
- the same value altogether when the object is serialized into
52
- JSON-LD. When it's deserialized from JSON-LD, it tries to
53
- parse the values of the specified properties in order.
54
- type: array
55
- items:
56
- type: object
57
- properties:
58
- uri:
59
- description: The qualified URI of the property.
60
- type: string
61
- format: uri
62
- compactName:
63
- description: >-
64
- The property name used in the compacted JSON-LD document.
65
- It is used as the key of the property.
66
- type: string
67
- required:
68
- - uri
69
48
  required:
70
49
  - functional
71
50
 
72
51
  property-base:
73
52
  type: object
74
53
  properties:
54
+ redundantProperties:
55
+ description: >-
56
+ Synonyms read after the canonical property in declaration order.
57
+ The first non-empty raw value set is selected before decoding values
58
+ and containers. Synonyms are never merged or retried on invalid values.
59
+ Serialization writes all synonyms by default.
60
+ type: array
61
+ items:
62
+ type: object
63
+ properties:
64
+ uri:
65
+ description: The qualified URI of the property.
66
+ type: string
67
+ format: uri
68
+ compactName:
69
+ description: >-
70
+ The property name used in the compacted JSON-LD document.
71
+ It is used as the key of the property.
72
+ type: string
73
+ required:
74
+ - uri
75
+ redundantPropertiesWrite:
76
+ description: >-
77
+ Whether serialization writes all synonyms or only the canonical
78
+ property. Does not change deserialization precedence.
79
+ enum:
80
+ - all
81
+ - canonical
82
+ default: all
75
83
  extraContext:
76
84
  description: >-
77
85
  A context URL added to default serialization when this property's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-tools",
3
- "version": "2.5.0-dev.2289+111f0945",
3
+ "version": "2.5.0-dev.2304+3a599380",
4
4
  "description": "Code generator for Activity Vocabulary APIs",
5
5
  "homepage": "https://fedify.dev/",
6
6
  "repository": {
@@ -46,9 +46,12 @@
46
46
  },
47
47
  "license": "MIT",
48
48
  "devDependencies": {
49
+ "@logtape/logtape": "^2.2.0",
50
+ "@opentelemetry/api": "^1.9.1",
49
51
  "@types/node": "^24.12.2",
50
52
  "tsdown": "^0.22.0",
51
- "typescript": "^6.0.0"
53
+ "typescript": "^6.0.0",
54
+ "@fedify/vocab-runtime": "^2.5.0-dev.2304+3a599380"
52
55
  },
53
56
  "dependencies": {
54
57
  "@cfworker/json-schema": "^4.1.1",
@@ -0,0 +1,409 @@
1
+ import { deepStrictEqual, match, ok, rejects } from "node:assert/strict";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { pathToFileURL } from "node:url";
7
+ import { generateClasses } from "./class.ts";
8
+ import type { PropertySchema, TypeSchema } from "./schema.ts";
9
+
10
+ const NS = "https://example.com/";
11
+ const AS = "https://www.w3.org/ns/activitystreams";
12
+ const CONTEXT = `${NS}context`;
13
+ const XSD = "http://www.w3.org/2001/XMLSchema#";
14
+ const context = {
15
+ type: "@type",
16
+ id: "@id",
17
+ Fixture: `${NS}Fixture`,
18
+ Value: `${NS}Value`,
19
+ label: `${NS}label`,
20
+ value: `${NS}value`,
21
+ first: `${NS}first`,
22
+ second: `${NS}second`,
23
+ };
24
+
25
+ interface Instance {
26
+ values: unknown[];
27
+ value: unknown;
28
+ toJsonLd(options?: Record<string, unknown>): Promise<Record<string, unknown>>;
29
+ }
30
+ interface FixtureClass {
31
+ new (values?: Record<string, unknown>): Instance;
32
+ fromJsonLd(
33
+ document: unknown,
34
+ options?: Record<string, unknown>,
35
+ ): Promise<Instance>;
36
+ }
37
+
38
+ async function withFixture(
39
+ options: Partial<PropertySchema>,
40
+ run: (
41
+ Fixture: FixtureClass,
42
+ loader: (url: string) => Promise<unknown>,
43
+ source: string,
44
+ ) => Promise<void>,
45
+ ) {
46
+ const property = {
47
+ singularName: "value",
48
+ pluralName: "values",
49
+ uri: `${NS}value`,
50
+ compactName: "value",
51
+ description: "A value.",
52
+ range: [`${XSD}string`],
53
+ redundantProperties: [
54
+ { uri: `${NS}first`, compactName: "first" },
55
+ { uri: `${NS}second`, compactName: "second" },
56
+ ],
57
+ ...options,
58
+ } as PropertySchema;
59
+ const terms: Record<string, unknown> = { ...context };
60
+ for (const name of ["value", "first", "second"]) {
61
+ const term: Record<string, unknown> = { "@id": `${NS}${name}` };
62
+ if (property.range[0] === `${XSD}anyURI`) term["@type"] = "@id";
63
+ if ("container" in property && property.container != null) {
64
+ term["@container"] = `@${property.container}`;
65
+ }
66
+ terms[name] = term;
67
+ }
68
+ const loader = (url: string) => {
69
+ if (url !== AS && url !== CONTEXT) {
70
+ throw new Error(`Unexpected context: ${url}`);
71
+ }
72
+ return Promise.resolve({
73
+ contextUrl: null,
74
+ documentUrl: url,
75
+ document: {
76
+ "@context": url === AS ? { type: "@type", id: "@id" } : terms,
77
+ },
78
+ });
79
+ };
80
+ const types: Record<string, TypeSchema> = {
81
+ [`${NS}Fixture`]: {
82
+ name: "Fixture",
83
+ uri: `${NS}Fixture`,
84
+ compactName: "Fixture",
85
+ entity: false,
86
+ description: "A fixture.",
87
+ defaultContext: [AS, CONTEXT] as unknown as TypeSchema["defaultContext"],
88
+ properties: [property],
89
+ },
90
+ [`${NS}Value`]: {
91
+ name: "Value",
92
+ uri: `${NS}Value`,
93
+ compactName: "Value",
94
+ entity: false,
95
+ description: "An embedded value.",
96
+ defaultContext: [AS, CONTEXT] as unknown as TypeSchema["defaultContext"],
97
+ properties: [{
98
+ singularName: "label",
99
+ functional: true,
100
+ uri: `${NS}label`,
101
+ compactName: "label",
102
+ description: "A label.",
103
+ range: [`${XSD}string`],
104
+ }],
105
+ },
106
+ };
107
+ const source = (await Array.fromAsync(generateClasses(types))).join("");
108
+ const resolved = source.replace(
109
+ /from\s+"([^"]+)"/g,
110
+ (_, specifier: string) =>
111
+ `from ${JSON.stringify(import.meta.resolve(specifier))}`,
112
+ );
113
+ const dir = await mkdtemp(join(tmpdir(), "fedify-codec-"));
114
+ try {
115
+ const file = join(dir, "fixture.mts");
116
+ await writeFile(file, resolved);
117
+ const { Fixture } = await import(pathToFileURL(file).href);
118
+ await run(Fixture, loader, source);
119
+ } finally {
120
+ await rm(dir, { recursive: true, force: true });
121
+ }
122
+ }
123
+
124
+ const literal = (value: unknown) => ({ "@value": value });
125
+ const document = (properties: Record<string, unknown>) => ({
126
+ "@type": [`${NS}Fixture`],
127
+ ...properties,
128
+ });
129
+
130
+ test("redundant properties select the first non-empty entire set", async () => {
131
+ await withFixture({}, async (Fixture, contextLoader) => {
132
+ for (
133
+ const [properties, expected] of [
134
+ [{ [`${NS}first`]: [literal("a"), literal("b"), literal("a")] }, [
135
+ "a",
136
+ "b",
137
+ "a",
138
+ ]],
139
+ [{ [`${NS}value`]: [], [`${NS}first`]: [literal("a")] }, ["a"]],
140
+ [{ [`${NS}first`]: [], [`${NS}second`]: [literal("b")] }, ["b"]],
141
+ [{ [`${NS}value`]: [literal("c")], [`${NS}first`]: [literal("a")] }, [
142
+ "c",
143
+ ]],
144
+ [{ [`${NS}first`]: [literal("a")], [`${NS}second`]: [literal("b")] }, [
145
+ "a",
146
+ ]],
147
+ [{}, []],
148
+ ] as const
149
+ ) {
150
+ const parsed = await Fixture.fromJsonLd(document(properties), {
151
+ contextLoader,
152
+ });
153
+ deepStrictEqual(parsed.values, expected);
154
+ }
155
+ const parsed = await Fixture.fromJsonLd({
156
+ "@context": termsForRead(),
157
+ type: "Fixture",
158
+ alternate: ["a", "b", "a"],
159
+ }, { contextLoader });
160
+ deepStrictEqual(parsed.values, ["a", "b", "a"]);
161
+ });
162
+ });
163
+
164
+ function termsForRead() {
165
+ return { ...context, alternate: `${NS}first` };
166
+ }
167
+
168
+ test("redundant properties do not retry invalid canonical values", async () => {
169
+ await withFixture({
170
+ range: [
171
+ `${XSD}string`,
172
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString",
173
+ ],
174
+ }, async (Fixture, contextLoader) => {
175
+ const parsed = await Fixture.fromJsonLd(
176
+ document({
177
+ [`${NS}value`]: [literal(42)],
178
+ [`${NS}first`]: [literal("fallback")],
179
+ }),
180
+ { contextLoader },
181
+ );
182
+ deepStrictEqual(parsed.values, []);
183
+ });
184
+ await withFixture(
185
+ { range: ["fedify:url"] },
186
+ async (Fixture, contextLoader) => {
187
+ const parsed = await Fixture.fromJsonLd(
188
+ document({
189
+ [`${NS}value`]: [literal("http://[")],
190
+ [`${NS}first`]: [literal("https://example.com/fallback")],
191
+ }),
192
+ { contextLoader },
193
+ );
194
+ deepStrictEqual(parsed.values, []);
195
+ },
196
+ );
197
+ await withFixture(
198
+ { range: [`${XSD}anyURI`] },
199
+ async (Fixture, contextLoader) => {
200
+ await rejects(Fixture.fromJsonLd(
201
+ document({
202
+ [`${NS}value`]: [{ "@id": "http://[" }],
203
+ [`${NS}first`]: [{ "@id": "https://example.com/fallback" }],
204
+ }),
205
+ { contextLoader },
206
+ ));
207
+ },
208
+ );
209
+ });
210
+
211
+ for (const policy of [undefined, "all", "canonical"] as const) {
212
+ test(`redundant properties write policy ${policy ?? "default"}`, async () => {
213
+ await withFixture(
214
+ { redundantPropertiesWrite: policy },
215
+ async (Fixture, contextLoader) => {
216
+ for (const values of [[], ["a"], ["a", "b", "a"]]) {
217
+ const instance = new Fixture({ values });
218
+ const compact = await instance.toJsonLd({
219
+ format: "compact",
220
+ contextLoader,
221
+ });
222
+ deepStrictEqual(await instance.toJsonLd({ contextLoader }), compact);
223
+ const expanded = await instance.toJsonLd({
224
+ format: "expand",
225
+ contextLoader,
226
+ }) as unknown as Record<string, unknown>[];
227
+ for (const alias of ["first", "second"]) {
228
+ if (policy === "canonical" || values.length === 0) {
229
+ ok(!(alias in compact));
230
+ ok(!(`${NS}${alias}` in expanded[0]));
231
+ } else {
232
+ deepStrictEqual(compact[alias], compact.value);
233
+ deepStrictEqual(
234
+ expanded[0][`${NS}${alias}`],
235
+ expanded[0][`${NS}value`],
236
+ );
237
+ }
238
+ }
239
+ const custom = await instance.toJsonLd({
240
+ format: "compact",
241
+ contextLoader,
242
+ context: { ...context, renamed: `${NS}value` },
243
+ });
244
+ deepStrictEqual(
245
+ (await Fixture.fromJsonLd(custom, { contextLoader })).values,
246
+ values,
247
+ );
248
+ }
249
+ const aliasOnly = await Fixture.fromJsonLd(
250
+ document({ [`${NS}first`]: [literal("read")] }),
251
+ { contextLoader },
252
+ );
253
+ deepStrictEqual(aliasOnly.values, ["read"]);
254
+ },
255
+ );
256
+ });
257
+ }
258
+
259
+ for (const container of ["list", "graph"] as const) {
260
+ for (const policy of ["all", "canonical"] as const) {
261
+ test(`redundant ${container} containers with ${policy} writes`, async () => {
262
+ await withFixture({
263
+ container,
264
+ redundantPropertiesWrite: policy,
265
+ range: container === "graph" ? [`${NS}Value`] : [`${XSD}string`],
266
+ }, async (Fixture, contextLoader) => {
267
+ const items = container === "list"
268
+ ? [literal("a"), literal("b"), literal("a")]
269
+ : [
270
+ { "@type": [`${NS}Value`], [`${NS}label`]: [literal("a")] },
271
+ { "@type": [`${NS}Value`], [`${NS}label`]: [literal("b")] },
272
+ ];
273
+ const set = container === "list"
274
+ ? [{ "@list": items }]
275
+ : items.map((item) => ({ "@graph": [item] }));
276
+ const parsed = await Fixture.fromJsonLd(
277
+ document({
278
+ [`${NS}first`]: set,
279
+ [`${NS}second`]: [{ "@list": [literal("other")] }],
280
+ }),
281
+ { contextLoader },
282
+ );
283
+ deepStrictEqual(
284
+ container === "list"
285
+ ? parsed.values
286
+ : parsed.values.map((v) => (v as { label: string }).label),
287
+ container === "list" ? ["a", "b", "a"] : ["a", "b"],
288
+ );
289
+ const instance = new Fixture({ values: parsed.values });
290
+ const compact = await instance.toJsonLd({
291
+ format: "compact",
292
+ contextLoader,
293
+ });
294
+ if (container === "list" || policy === "all") {
295
+ deepStrictEqual(await instance.toJsonLd({ contextLoader }), compact);
296
+ } else {
297
+ // Parsed children retain expanded caches in the heuristic path.
298
+ const defaultOutput = await instance.toJsonLd({ contextLoader });
299
+ const decoded = await Fixture.fromJsonLd(defaultOutput, {
300
+ contextLoader,
301
+ });
302
+ deepStrictEqual(
303
+ decoded.values.map((v) => (v as { label: string }).label),
304
+ ["a", "b"],
305
+ );
306
+ }
307
+ const expanded = await instance.toJsonLd({
308
+ format: "expand",
309
+ contextLoader,
310
+ }) as unknown as Record<string, unknown>[];
311
+ for (const alias of ["first", "second"]) {
312
+ if (policy === "canonical") {
313
+ ok(!(`${NS}${alias}` in expanded[0]));
314
+ ok(!(alias in compact));
315
+ } else {
316
+ deepStrictEqual(
317
+ expanded[0][`${NS}${alias}`],
318
+ expanded[0][`${NS}value`],
319
+ );
320
+ deepStrictEqual(compact[alias], compact.value);
321
+ }
322
+ }
323
+ const roundtrip = await Fixture.fromJsonLd(compact, { contextLoader });
324
+ deepStrictEqual(
325
+ container === "list"
326
+ ? roundtrip.values
327
+ : roundtrip.values.map((v) => (v as { label: string }).label),
328
+ container === "list" ? ["a", "b", "a"] : ["a", "b"],
329
+ );
330
+ const empty = await Fixture.fromJsonLd(
331
+ document({
332
+ [`${NS}value`]: [{ [`@${container}`]: [] }],
333
+ [`${NS}first`]: set,
334
+ }),
335
+ { contextLoader },
336
+ );
337
+ deepStrictEqual(empty.values, []);
338
+ });
339
+ });
340
+ }
341
+ }
342
+
343
+ for (const functional of [false, true]) {
344
+ test(`URI-only aliases and portable read keys (functional=${functional})`, async () => {
345
+ for (const policy of ["all", "canonical"] as const) {
346
+ await withFixture({
347
+ ...(functional ? { functional: true } : {}),
348
+ range: [`${XSD}anyURI`],
349
+ redundantPropertiesWrite: policy,
350
+ redundantProperties: [{ uri: `${NS}first` }],
351
+ }, async (Fixture, contextLoader, source) => {
352
+ ok(!source.includes("result[undefined]"));
353
+ match(source, /PORTABLE_IRI_KEYS[^\n]+https:\/\/example.com\/first/);
354
+ const url = new URL("https://example.com/object");
355
+ const instance = new Fixture(
356
+ functional ? { value: url } : { values: [url] },
357
+ );
358
+ const compact = await instance.toJsonLd({
359
+ format: "compact",
360
+ contextLoader,
361
+ });
362
+ deepStrictEqual(await instance.toJsonLd({ contextLoader }), compact);
363
+ const expanded = await instance.toJsonLd({
364
+ format: "expand",
365
+ contextLoader,
366
+ }) as unknown as Record<string, unknown>[];
367
+ if (policy === "all") {
368
+ deepStrictEqual(expanded[0][`${NS}first`], [{ "@id": url.href }]);
369
+ } else ok(!(`${NS}first` in expanded[0]));
370
+ const custom = await instance.toJsonLd({
371
+ format: "compact",
372
+ contextLoader,
373
+ context: {
374
+ type: "@type",
375
+ Fixture: `${NS}Fixture`,
376
+ value: { "@id": `${NS}value`, "@type": "@id" },
377
+ },
378
+ });
379
+ if (policy === "all") {
380
+ deepStrictEqual(custom[`${NS}first`], { "@id": url.href });
381
+ } else {
382
+ ok(!(`${NS}first` in custom));
383
+ }
384
+ const parsed = await Fixture.fromJsonLd(
385
+ document({
386
+ [`${NS}first`]: [{ "@id": "ap://did:key:z6Mkabc/objects/1" }],
387
+ }),
388
+ { contextLoader },
389
+ );
390
+ const value = functional ? parsed.value : parsed.values[0];
391
+ ok(value instanceof URL);
392
+ const cached = await parsed.toJsonLd({ contextLoader });
393
+ deepStrictEqual(cached[`${NS}first`], [{
394
+ "@id": "ap+ef61://did:key:z6Mkabc/objects/1",
395
+ }]);
396
+ const compactInput = await Fixture.fromJsonLd({
397
+ "@context": [AS, CONTEXT],
398
+ type: "Fixture",
399
+ first: "ap://did:key:z6Mkabc/objects/1",
400
+ }, { contextLoader });
401
+ const compactCache = await compactInput.toJsonLd({ contextLoader });
402
+ deepStrictEqual(
403
+ compactCache.first,
404
+ "ap+ef61://did:key:z6Mkabc/objects/1",
405
+ );
406
+ });
407
+ }
408
+ });
409
+ }
package/src/codec.ts CHANGED
@@ -162,8 +162,13 @@ export async function* generateEncoder(
162
162
  ? compactItems
163
163
  : compactItems[0];
164
164
  `;
165
- if (property.functional && property.redundantProperties != null) {
165
+ if (
166
+ property.redundantPropertiesWrite !== "canonical" &&
167
+ property.redundantProperties != null &&
168
+ !(isNonFunctionalProperty(property) && property.container != null)
169
+ ) {
166
170
  for (const prop of property.redundantProperties) {
171
+ if (prop.compactName == null) continue;
167
172
  yield `
168
173
  result[${JSON.stringify(prop.compactName)}]
169
174
  = compactItems.length > 1
@@ -255,7 +260,10 @@ export async function* generateEncoder(
255
260
  );
256
261
  values[${JSON.stringify(property.uri)}] = propValue;
257
262
  `;
258
- if (property.functional && property.redundantProperties != null) {
263
+ if (
264
+ property.redundantPropertiesWrite !== "canonical" &&
265
+ property.redundantProperties != null
266
+ ) {
259
267
  for (const prop of property.redundantProperties) {
260
268
  yield `
261
269
  values[${JSON.stringify(prop.uri)}] = propValue;
@@ -348,6 +356,11 @@ export async function* generateEncoder(
348
356
  for (const property of type.properties) {
349
357
  if (
350
358
  property.extraContext != null ||
359
+ (property.redundantPropertiesWrite !== "canonical" &&
360
+ property.redundantProperties != null &&
361
+ property.redundantProperties.length > 0 &&
362
+ (property.redundantProperties.some((p) => p.compactName == null) ||
363
+ (isNonFunctionalProperty(property) && property.container != null))) ||
351
364
  !property.range.every((r) => isCompactableType(r, types))
352
365
  ) {
353
366
  yield `
@@ -544,7 +557,7 @@ export async function* generateDecoder(
544
557
  yield `
545
558
  let ${arrayVariable} = ${propertyValues};
546
559
  `;
547
- if (property.functional && property.redundantProperties != null) {
560
+ if (property.redundantProperties != null) {
548
561
  for (const prop of property.redundantProperties) {
549
562
  yield `
550
563
  if (${arrayVariable} == null || ${arrayVariable}.length < 1) {
@@ -1,8 +1,13 @@
1
- import { deepStrictEqual, ok, throws } from "node:assert";
1
+ import { deepStrictEqual, ok, rejects, throws } from "node:assert";
2
2
  import { test } from "node:test";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { stringify } from "yaml";
3
7
  import {
4
8
  hasSingularAccessor,
5
9
  isNonFunctionalProperty,
10
+ loadSchemaFiles,
6
11
  type PropertySchema,
7
12
  type TypeSchema,
8
13
  type TypeUri,
@@ -244,3 +249,71 @@ test("validateTypeSchemas() rejects mixed fedify:vocabEntityType ranges", () =>
244
249
  },
245
250
  );
246
251
  });
252
+
253
+ test("loadSchemaFiles validates redundant properties on both property kinds", async () => {
254
+ const dir = await mkdtemp(join(tmpdir(), "fedify-schema-"));
255
+ try {
256
+ for (const functional of [false, true]) {
257
+ const property = {
258
+ singularName: "value",
259
+ ...(functional ? { functional: true } : { pluralName: "values" }),
260
+ uri: "https://example.com/value",
261
+ description: "A value.",
262
+ range: ["http://www.w3.org/2001/XMLSchema#string"],
263
+ redundantProperties: [{ uri: "https://example.com/alias" }],
264
+ };
265
+ const type = {
266
+ name: "Fixture",
267
+ uri: "https://example.com/Fixture",
268
+ entity: false,
269
+ description: "A fixture.",
270
+ defaultContext: {},
271
+ properties: [property],
272
+ };
273
+ for (const policy of [undefined, "all", "canonical"]) {
274
+ await writeFile(
275
+ join(dir, "fixture.yaml"),
276
+ stringify({
277
+ ...type,
278
+ properties: [{ ...property, redundantPropertiesWrite: policy }],
279
+ }),
280
+ );
281
+ const types = await loadSchemaFiles(dir);
282
+ deepStrictEqual(
283
+ types[type.uri].properties[0].redundantProperties,
284
+ property.redundantProperties,
285
+ );
286
+ deepStrictEqual(
287
+ types[type.uri].properties[0].redundantPropertiesWrite,
288
+ policy,
289
+ );
290
+ }
291
+ for (
292
+ const invalid of [
293
+ { redundantPropertiesWrite: "merge" },
294
+ { redundantPropertiesWrite: 42 },
295
+ { redundantProperties: "alias" },
296
+ { redundantProperties: [{}] },
297
+ { redundantProperties: [{ uri: 42 }] },
298
+ {
299
+ redundantProperties: [{
300
+ uri: "https://example.com/alias",
301
+ compactName: 42,
302
+ }],
303
+ },
304
+ ]
305
+ ) {
306
+ await writeFile(
307
+ join(dir, "fixture.yaml"),
308
+ stringify({
309
+ ...type,
310
+ properties: [{ ...property, ...invalid }],
311
+ }),
312
+ );
313
+ await rejects(loadSchemaFiles(dir), AggregateError);
314
+ }
315
+ }
316
+ } finally {
317
+ await rm(dir, { recursive: true, force: true });
318
+ }
319
+ });
package/src/schema.ts CHANGED
@@ -85,6 +85,27 @@ export interface TypeSchema {
85
85
  }
86
86
 
87
87
  export interface PropertySchemaBase {
88
+ /**
89
+ * Synonyms read after the canonical property, in declaration order.
90
+ * Deserialization selects the first non-empty raw value set before decoding
91
+ * its values and containers; it never merges synonyms or retries invalid
92
+ * values. Serialization writes all synonyms by default.
93
+ */
94
+ redundantProperties?: {
95
+ /** The qualified URI of the synonym. */
96
+ uri: string;
97
+
98
+ /** The synonym's key in the default compact JSON-LD context. */
99
+ compactName?: string;
100
+ }[];
101
+
102
+ /**
103
+ * Whether serialization writes every synonym or only the canonical property.
104
+ * Defaults to `"all"`. This does not change deserialization precedence or
105
+ * the existing cache of parsed JSON-LD documents.
106
+ */
107
+ redundantPropertiesWrite?: "all" | "canonical";
108
+
88
109
  /**
89
110
  * A context URL added to the default serialization context when this
90
111
  * property's expanded IRI appears in the compacted document. Explicit
@@ -229,25 +250,6 @@ export type PropertySchema =
229
250
  * and `singularAccessor` should not be specified.
230
251
  */
231
252
  functional: true;
232
-
233
- /**
234
- * If it's present, those redundant properties are also filled with
235
- * the same value altogether when the object is serialized into
236
- * JSON-LD. When it's deserialized from JSON-LD, it tries to
237
- * parse the values of the specified properties in order.
238
- */
239
- redundantProperties?: {
240
- /**
241
- * The qualified URI of the property.
242
- */
243
- uri: string;
244
-
245
- /**
246
- * The property name used in the compacted JSON-LD document. It is used
247
- * as the key of the property.
248
- */
249
- compactName?: string;
250
- }[];
251
253
  };
252
254
 
253
255
  /**
package/src/schema.yaml CHANGED
@@ -45,33 +45,41 @@ $defs:
45
45
  functional:
46
46
  description: *functional-description
47
47
  const: true
48
- redundantProperties::
49
- description: >-
50
- If it's present, those redundant properties are also filled with
51
- the same value altogether when the object is serialized into
52
- JSON-LD. When it's deserialized from JSON-LD, it tries to
53
- parse the values of the specified properties in order.
54
- type: array
55
- items:
56
- type: object
57
- properties:
58
- uri:
59
- description: The qualified URI of the property.
60
- type: string
61
- format: uri
62
- compactName:
63
- description: >-
64
- The property name used in the compacted JSON-LD document.
65
- It is used as the key of the property.
66
- type: string
67
- required:
68
- - uri
69
48
  required:
70
49
  - functional
71
50
 
72
51
  property-base:
73
52
  type: object
74
53
  properties:
54
+ redundantProperties:
55
+ description: >-
56
+ Synonyms read after the canonical property in declaration order.
57
+ The first non-empty raw value set is selected before decoding values
58
+ and containers. Synonyms are never merged or retried on invalid values.
59
+ Serialization writes all synonyms by default.
60
+ type: array
61
+ items:
62
+ type: object
63
+ properties:
64
+ uri:
65
+ description: The qualified URI of the property.
66
+ type: string
67
+ format: uri
68
+ compactName:
69
+ description: >-
70
+ The property name used in the compacted JSON-LD document.
71
+ It is used as the key of the property.
72
+ type: string
73
+ required:
74
+ - uri
75
+ redundantPropertiesWrite:
76
+ description: >-
77
+ Whether serialization writes all synonyms or only the canonical
78
+ property. Does not change deserialization precedence.
79
+ enum:
80
+ - all
81
+ - canonical
82
+ default: all
75
83
  extraContext:
76
84
  description: >-
77
85
  A context URL added to default serialization when this property's