@nesso-how/schema 0.1.0-alpha.40 → 0.2.0-beta.0

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
@@ -27,6 +27,15 @@ const doc: GraphDocument = deserialize(json)
27
27
 
28
28
  Vocabulary-specific serialization (e.g. Nesso Learning Vocabulary) lives in [`@nesso-how/vocab-learning`](../vocab-learning/README.md).
29
29
 
30
+ ## Compatibility
31
+
32
+ `deserialize` requires an explicit envelope `version`. It replays sequential
33
+ migrations for released older formats and rejects files from newer formats
34
+ with `NewerGraphFormatError`.
35
+
36
+ Envelope format `1` is the first protected beta baseline. Unversioned alpha
37
+ documents are outside the compatibility contract.
38
+
30
39
  ## License
31
40
 
32
41
  MIT
package/dist/index.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  /** Current on-disk graph document schema version. Bump only when the envelope shape changes. */
2
2
  export declare const GRAPH_FORMAT_VERSION: 1;
3
+ export declare class NewerGraphFormatError extends Error {
4
+ readonly version: number;
5
+ constructor(version: number);
6
+ }
7
+ export declare class UnsupportedGraphFormatError extends Error {
8
+ readonly version: number | undefined;
9
+ constructor(version: number | undefined);
10
+ }
3
11
  export interface GraphConcept<D extends Record<string, unknown> = Record<string, unknown>> {
4
12
  id: string;
5
13
  label: string;
package/dist/index.js CHANGED
@@ -1,22 +1,81 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  /** Current on-disk graph document schema version. Bump only when the envelope shape changes. */
3
3
  export const GRAPH_FORMAT_VERSION = 1;
4
+ const ENVELOPE_MIGRATIONS = {};
5
+ export class NewerGraphFormatError extends Error {
6
+ version;
7
+ constructor(version) {
8
+ super(`This graph uses newer Nesso graph format version ${version}`);
9
+ this.name = 'NewerGraphFormatError';
10
+ this.version = version;
11
+ }
12
+ }
13
+ export class UnsupportedGraphFormatError extends Error {
14
+ version;
15
+ constructor(version) {
16
+ super(version === undefined
17
+ ? 'This graph does not declare a supported Nesso graph format version'
18
+ : `Unsupported Nesso graph format version: ${version}`);
19
+ this.name = 'UnsupportedGraphFormatError';
20
+ this.version = version;
21
+ }
22
+ }
23
+ function readEnvelopeVersion(document) {
24
+ const version = document.version;
25
+ // Stryker disable next-line ConditionalExpression: typeof guard subsumed by !Number.isInteger and version < 0 — equivalent mutant
26
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
27
+ throw new UnsupportedGraphFormatError(undefined);
28
+ }
29
+ return version;
30
+ }
31
+ function migrateEnvelope(document) {
32
+ let current = document;
33
+ let version = readEnvelopeVersion(current);
34
+ if (version > GRAPH_FORMAT_VERSION) {
35
+ throw new NewerGraphFormatError(version);
36
+ }
37
+ while (version < GRAPH_FORMAT_VERSION) {
38
+ const migrate = ENVELOPE_MIGRATIONS[version];
39
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: ENVELOPE_MIGRATIONS is empty at baseline; migrate is always undefined — equivalent mutant
40
+ if (migrate === undefined) {
41
+ throw new UnsupportedGraphFormatError(version);
42
+ }
43
+ /* c8 ignore start */ // migration-step-advance is unreachable while ENVELOPE_MIGRATIONS is empty
44
+ // Stryker disable next-line all: migration-step-advance is unreachable while ENVELOPE_MIGRATIONS is empty
45
+ current = migrate(current);
46
+ // Stryker disable next-line all: unreachable
47
+ const nextVersion = readEnvelopeVersion(current);
48
+ // Stryker disable next-line all: unreachable
49
+ if (nextVersion !== version + 1) {
50
+ // Stryker disable next-line all: unreachable
51
+ throw new Error(`Envelope migration ${version} must produce version ${version + 1}`);
52
+ }
53
+ // Stryker disable next-line all: unreachable
54
+ version = nextVersion;
55
+ /* c8 ignore stop */
56
+ }
57
+ return current;
58
+ }
4
59
  /** Serialize a graph document to JSON with keys in a stable, human-readable order. */
5
60
  export function serialize(doc) {
6
61
  const { vocabulary, id, updatedAt, name, concepts, relations, meta } = doc;
7
62
  return JSON.stringify({
8
63
  version: GRAPH_FORMAT_VERSION,
64
+ // Stryker disable next-line ConditionalExpression: JSON.stringify drops undefined values, so always spreading {vocabulary:undefined} is equivalent
9
65
  ...(vocabulary !== undefined && { vocabulary }),
66
+ // Stryker disable next-line ConditionalExpression: JSON.stringify drops undefined values, so always spreading {id:undefined} is equivalent
10
67
  ...(id !== undefined && { id }),
68
+ // Stryker disable next-line ConditionalExpression: JSON.stringify drops undefined values, so always spreading {updatedAt:undefined} is equivalent
11
69
  ...(updatedAt !== undefined && { updatedAt }),
12
70
  name,
13
71
  concepts,
14
72
  relations,
73
+ // Stryker disable next-line ConditionalExpression: JSON.stringify drops undefined values, so always spreading {meta:undefined} is equivalent
15
74
  ...(meta !== undefined && { meta }),
16
75
  }, null, 2);
17
76
  }
18
77
  function isRecord(value) {
19
- return typeof value === 'object' && value !== null;
78
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
20
79
  }
21
80
  /**
22
81
  * Parse and structurally validate a graph document. Files are user-editable (and
@@ -25,12 +84,13 @@ function isRecord(value) {
25
84
  * then be re-persisted. Vocabulary-specific normalization is the caller's responsibility.
26
85
  */
27
86
  export function deserialize(json) {
28
- const data = JSON.parse(json);
29
- if (!isRecord(data) || !Array.isArray(data.concepts) || !Array.isArray(data.relations)) {
30
- throw new Error('Invalid Nesso graph document: missing concepts or relations array');
87
+ const parsed = JSON.parse(json);
88
+ if (!isRecord(parsed)) {
89
+ throw new Error('Invalid Nesso graph document');
31
90
  }
32
- if (typeof data.version === 'number' && data.version !== GRAPH_FORMAT_VERSION) {
33
- throw new Error(`Unsupported Nesso graph document version: ${data.version}`);
91
+ const data = migrateEnvelope(parsed);
92
+ if (!Array.isArray(data.concepts) || !Array.isArray(data.relations)) {
93
+ throw new Error('Invalid Nesso graph document: missing concepts or relations array');
34
94
  }
35
95
  const rawConcepts = data.concepts;
36
96
  const concepts = rawConcepts.map((value, i) => {
@@ -38,8 +98,10 @@ export function deserialize(json) {
38
98
  typeof value.id !== 'string' ||
39
99
  value.id === '' ||
40
100
  typeof value.label !== 'string' ||
101
+ // Stryker disable next-line ConditionalExpression: typeof x guard subsumed by !Number.isFinite — equivalent mutant
41
102
  typeof value.x !== 'number' ||
42
103
  !Number.isFinite(value.x) ||
104
+ // Stryker disable next-line ConditionalExpression: typeof y guard subsumed by !Number.isFinite — equivalent mutant
43
105
  typeof value.y !== 'number' ||
44
106
  !Number.isFinite(value.y)) {
45
107
  throw new Error(`Invalid Nesso graph document: concept ${i} is missing a valid id, label, or position`);
@@ -82,9 +144,8 @@ export function deserialize(json) {
82
144
  vocabulary = { id: vocabValue.id, version: vocabValue.version };
83
145
  }
84
146
  let meta;
85
- const metaValue = data.meta;
86
- if (metaValue !== undefined) {
87
- meta = metaValue;
147
+ if (isRecord(data.meta)) {
148
+ meta = data.meta;
88
149
  }
89
150
  return {
90
151
  version: GRAPH_FORMAT_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nesso-how/schema",
3
- "version": "0.1.0-alpha.40",
3
+ "version": "0.2.0-beta.0",
4
4
  "description": "Nesso knowledge graph document schema — JSON serialize/deserialize",
5
5
  "license": "MIT",
6
6
  "repository": {