Skip to content

Commit 16f8eae

Browse files
committed
feat: Update format version handling for content-addressed images and add tests for version compatibility
1 parent 2fff69f commit 16f8eae

4 files changed

Lines changed: 191 additions & 35 deletions

File tree

src/formats/migrations.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export const KNOWN_PROTOTYPE_KEYS = new Set([
9292
'redstring:abstractionChains', 'redstring:agentConfig', 'redstring:semanticMetadata',
9393
'redstring:isSpecificityChainNode', 'redstring:hasSpecificityChain', 'redstring:createdAt',
9494
'isSpecificityChainNode', 'hasSpecificityChain', 'createdAt',
95-
// Content-addressed full-resolution image (4.2.0). Deliberately top-level
95+
// Content-addressed full-resolution image. Deliberately top-level
9696
// rather than inside redstring:visualProperties — see the long note at the
9797
// emission site in redstringFormat.js. Being listed here is what stops a
9898
// CURRENT build from quarantining its own field; being top-level at all is
@@ -262,13 +262,15 @@ export const quarantineUnknownFields = (data, version) => {
262262
* So a 4.0.0 file takes the runMigrations fast path unchanged and re-stamps to 4.1.0
263263
* on the next save.
264264
*
265-
* Note on 4.1.0 → 4.2.0 (content-addressed images): also NO ledger step, for the
266-
* same reason. It adds two optional top-level prototype fields (redstring:imageRef,
267-
* redstring:imageRefExt) and never removes the inline redstring:imageSrc it can
268-
* stand in for — both forms stay readable indefinitely, so a 4.1.0 file with inline
269-
* base64 images is already a valid 4.2.0 file and takes the fast path untouched.
265+
* Note on content-addressed images (redstring:imageRef): no ledger step AND no
266+
* version bump. It adds two optional top-level prototype fields and never removes
267+
* the inline redstring:imageSrc they can stand in for — both forms stay readable
268+
* indefinitely, so a file carrying refs is still a valid 4.1.0 file and takes the
269+
* fast path untouched. The version stayed put on purpose: validateFormatVersion
270+
* hard-throws on a file newer than the reading app, so bumping would have locked
271+
* every already-deployed build out of the file entirely.
270272
*
271-
* The actual 4.2.0 work is a CONTENT migration, not a format one: existing inline
273+
* The real work there is a CONTENT migration, not a format one: existing inline
272274
* base64 has to be uploaded as blobs and swapped for refs. That cannot live here —
273275
* this module is pure, synchronous JSON→JSON, and the migration needs network
274276
* writes. It runs instead in GitSyncEngine._externalizeImages, lazily and

src/formats/redstringFormat.js

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,19 @@ import { runMigrations } from './migrations.js';
1414
import { safeJsonParse, stripDangerousKeys } from '../utils/safeJson.js';
1515
import { partitionLinksByState } from './linkState.js';
1616

17-
// Current format version
18-
export const CURRENT_FORMAT_VERSION = '4.2.0';
17+
// Current format version.
18+
//
19+
// Deliberately NOT bumped for content-addressed images (redstring:imageRef).
20+
// `validateFormatVersion` below hard-throws on any file stamped newer than the
21+
// reading app, so a 4.2.0 stamp would make every already-deployed build refuse
22+
// to OPEN the file — failing long before the prototype parsing where the
23+
// quarantine would have protected the unknown field. A device one release
24+
// behind would simply fail to load the universe.
25+
//
26+
// Since the new fields are additive and optional, a file carrying them IS a
27+
// valid 4.1.0 file, and nothing keys any behavior off the version number. So
28+
// not bumping costs nothing and keeps mixed-version devices working.
29+
export const CURRENT_FORMAT_VERSION = '4.1.0';
1930

2031
// v4 dataset structure gate (D10). All phases 3–6 complete; v4 is live.
2132
export const EMIT_V4 = true;
@@ -25,25 +36,19 @@ export const MIN_SUPPORTED_VERSION = '1.0.0';
2536

2637
// Version history and breaking changes
2738
export const VERSION_HISTORY = {
28-
'4.2.0': {
29-
date: '2026-08',
30-
changes: [
31-
'Content-addressed images: new optional top-level prototype fields redstring:imageRef ("sha256:<hex>") and redstring:imageRefExt point at a blob under universes/<folder>/images/, written by the git sync engine',
32-
'Only the full-resolution redstring:imageSrc is externalized; redstring:thumbnailSrc stays inline so the canvas renders with no network and older builds still draw a correct graph',
33-
'Inline base64 imageSrc remains fully valid on read — the two forms coexist and locally-saved files stay single-file',
34-
'Fields sit at the prototype top level so quarantineUnknownFields banks them into _preserved for builds that predate them, instead of being silently dropped from a rebuilt visualProperties block'
35-
],
36-
// Non-breaking: both fields are optional and additive, and a 4.1.0 file
37-
// (inline images, no refs) loads unchanged. No ledger step — see the note
38-
// above MIGRATIONS in migrations.js.
39-
breaking: false
40-
},
4139
'4.1.0': {
4240
date: '2026-07',
4341
changes: [
4442
'Per-instance node size: new optional instance field redstring:sizeMultiplier (in visualProperties) drives visual node dimensions and label size, layered on top of the global node-size scope',
4543
'Distinct from redstring:spatialScale, which remains the transient drag-lift register',
46-
'Same v4 dataset shape as 4.0.0 — additive, optional field only, no structural change'
44+
'Same v4 dataset shape as 4.0.0 — additive, optional field only, no structural change',
45+
// Added later within 4.1.0, deliberately without a version bump — see the
46+
// note on CURRENT_FORMAT_VERSION for why stamping a new minor would lock
47+
// already-deployed builds out of the file entirely.
48+
'Content-addressed images (additive, no version bump): optional top-level prototype fields redstring:imageRef ("sha256:<hex>") and redstring:imageRefExt point at a blob under universes/<folder>/images/, written by the git sync engine',
49+
'Only the full-resolution redstring:imageSrc is externalized; redstring:thumbnailSrc stays inline so the canvas renders with no network and builds that ignore the ref still draw a correct graph',
50+
'Inline base64 imageSrc remains fully valid on read — the two forms coexist indefinitely and locally-saved files stay single-file',
51+
'The image fields sit at the prototype top level so quarantineUnknownFields banks them into _preserved for builds that predate them, instead of being silently dropped from a rebuilt visualProperties block'
4752
],
4853
// Non-breaking: the field is optional and defaults to 1.0 (Medium), so 4.0.0 files
4954
// (which lack it) load unchanged and render at Medium.
@@ -172,18 +177,46 @@ export const validateFormatVersion = (redstringData) => {
172177
};
173178
}
174179

175-
// Check if version is from the future
180+
// Check if version is from the future.
181+
//
182+
// Only a newer MAJOR is genuinely unreadable. Within a major, this format's
183+
// contract is additive-and-optional — minor bumps add fields that an older
184+
// reader either recognizes or quarantines into `_preserved`, and `stageOf`
185+
// in the migration ledger already keys on major alone for exactly this
186+
// reason. Refusing a newer minor was stricter than the format itself, and it
187+
// is a one-way trap: any build that ever writes a higher minor produces
188+
// files that a rolled-back or slightly older build cannot open at all, even
189+
// though every byte in them is understood.
190+
//
191+
// A newer major still hard-fails. That is where structural change lives, and
192+
// guessing at it would be worse than refusing.
176193
const compareToCurrent = compareVersions(fileVersion, CURRENT_FORMAT_VERSION);
177194
if (compareToCurrent === 1) {
195+
if (fileParsed.major > currentParsed.major) {
196+
return {
197+
valid: false,
198+
version: fileVersion,
199+
error: `File version ${fileVersion} is newer than the current app version ${CURRENT_FORMAT_VERSION}. Please update Redstring.`,
200+
needsMigration: false,
201+
tooNew: true
202+
};
203+
}
204+
205+
console.warn(
206+
`[Format] File version ${fileVersion} is newer than this build's ${CURRENT_FORMAT_VERSION}, ` +
207+
`but shares major version ${fileParsed.major} — reading it. Fields this build does not ` +
208+
`recognize are preserved rather than dropped.`
209+
);
178210
return {
179-
valid: false,
211+
valid: true,
180212
version: fileVersion,
181-
error: `File version ${fileVersion} is newer than the current app version ${CURRENT_FORMAT_VERSION}. Please update Redstring.`,
213+
currentVersion: CURRENT_FORMAT_VERSION,
182214
needsMigration: false,
183-
tooNew: true
215+
canAutoMigrate: false,
216+
newerMinor: true
184217
};
185218
}
186-
219+
187220
// Check if migration is needed
188221
const needsMigration = compareToCurrent === -1;
189222

@@ -963,8 +996,9 @@ export const exportToRedstring = (storeState, userDomain = null, { emitV4 = EMIT
963996
// Critical for image re-fetching on reload and OOM prevention
964997
"redstring:semanticMetadata": prototype.semanticMetadata || null,
965998

966-
// Content-addressed reference to the full-resolution image (4.2.0), when
967-
// the git sync engine has externalized it to a blob beside this file.
999+
// Content-addressed reference to the full-resolution image, when the git
1000+
// sync engine has externalized it to a blob beside this file. Additive
1001+
// and optional — no version bump, see CURRENT_FORMAT_VERSION.
9681002
// See the note below on why this sits at the TOP LEVEL of the prototype
9691003
// rather than inside redstring:visualProperties with its siblings.
9701004
...(prototype.imageRef ? { "redstring:imageRef": prototype.imageRef } : {}),
@@ -976,9 +1010,9 @@ export const exportToRedstring = (storeState, userDomain = null, { emitV4 = EMIT
9761010
// It belongs there by kinship — imageSrc/thumbnailSrc/imageAspectRatio all
9771011
// live in that block. But `visualProperties` is rebuilt from scratch on
9781012
// every export out of explicitly named store fields, so an older build that
979-
// reads a 4.2.0 file, then saves, would reconstruct that block WITHOUT the
980-
// ref it never knew to read: the blob is orphaned in the repo and the node
981-
// loses its image for good.
1013+
// reads a file carrying a ref, then saves, would reconstruct that block
1014+
// WITHOUT the ref it never knew to read: the blob is orphaned in the repo
1015+
// and the node loses its image for good.
9821016
//
9831017
// At the top level, `quarantineUnknownFields` catches it instead — unknown
9841018
// top-level prototype keys are banked into `_preserved[version]`, and
@@ -1734,7 +1768,7 @@ export const importFromRedstring = (redstringData, storeActions) => {
17341768
convertedPrototype.imageAspectRatio = coalesce(prototype.imageAspectRatio, visual['redstring:imageAspectRatio']);
17351769
}
17361770

1737-
// Content-addressed full-resolution image (4.2.0). Coexists with an
1771+
// Content-addressed full-resolution image. Coexists with an
17381772
// inline imageSrc rather than replacing it: a file may hold either
17391773
// form (or, mid-migration, both), and the reader must keep accepting
17401774
// inline data URLs forever — .redstring is a format other people's

test/formats/imageRefs.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe('imageRef round-trip through the .redstring format', () => {
172172
});
173173

174174
it('recovers a ref that an older build quarantined into _preserved', () => {
175-
// What a build predating 4.2.0 leaves behind: it did not recognize the
175+
// What a build predating the imageRef fields leaves behind: it did not recognize the
176176
// top-level field, so quarantineUnknownFields banked it. Recovering it on
177177
// the way back in is the other half of that protection — without this the
178178
// ref is lost on the first round trip through an out-of-date client, the

test/formats/versionGate.test.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import {
3+
validateFormatVersion,
4+
importFromRedstring,
5+
exportToRedstring,
6+
CURRENT_FORMAT_VERSION
7+
} from '../../src/formats/redstringFormat.js';
8+
9+
/**
10+
* The compatibility gate in validateFormatVersion.
11+
*
12+
* It had no coverage, and that cost real data access: a build that briefly
13+
* stamped a higher minor wrote files its own successor then refused to open,
14+
* with every byte in them perfectly understood. These tests pin the contract —
15+
* newer MAJOR is fatal, newer minor is readable — so that trap cannot come back.
16+
*/
17+
18+
const docAt = (version) => ({
19+
format: `redstring-v${version}`,
20+
metadata: { version }
21+
});
22+
23+
afterEach(() => {
24+
vi.restoreAllMocks();
25+
});
26+
27+
describe('validateFormatVersion — compatibility gate', () => {
28+
it('accepts the current version', () => {
29+
const result = validateFormatVersion(docAt(CURRENT_FORMAT_VERSION));
30+
expect(result.valid).toBe(true);
31+
expect(result.needsMigration).toBe(false);
32+
});
33+
34+
it('accepts an older version and flags it for migration', () => {
35+
const result = validateFormatVersion(docAt('3.0.0'));
36+
expect(result.valid).toBe(true);
37+
expect(result.needsMigration).toBe(true);
38+
expect(result.canAutoMigrate).toBe(true);
39+
});
40+
41+
it('reads a newer MINOR within the same major', () => {
42+
// The case that locked a real universe out of the app. Within a major the
43+
// format is additive-and-optional, so a higher minor is always readable.
44+
vi.spyOn(console, 'warn').mockImplementation(() => {});
45+
const result = validateFormatVersion(docAt('4.2.0'));
46+
expect(result.valid).toBe(true);
47+
expect(result.newerMinor).toBe(true);
48+
expect(result.tooNew).toBeUndefined();
49+
// Nothing to migrate — the ledger keys on major, which is unchanged.
50+
expect(result.needsMigration).toBe(false);
51+
});
52+
53+
it('reads a newer PATCH within the same major', () => {
54+
vi.spyOn(console, 'warn').mockImplementation(() => {});
55+
expect(validateFormatVersion(docAt('4.1.9')).valid).toBe(true);
56+
});
57+
58+
it('says so on the console when it reads a newer minor', () => {
59+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
60+
validateFormatVersion(docAt('4.5.0'));
61+
expect(warn).toHaveBeenCalledTimes(1);
62+
expect(warn.mock.calls[0][0]).toContain('4.5.0');
63+
});
64+
65+
it('still refuses a newer MAJOR', () => {
66+
// Structural change lives at the major boundary; guessing there would be
67+
// worse than refusing.
68+
const result = validateFormatVersion(docAt('5.0.0'));
69+
expect(result.valid).toBe(false);
70+
expect(result.tooNew).toBe(true);
71+
});
72+
73+
it('still refuses a version below the supported floor', () => {
74+
const result = validateFormatVersion(docAt('0.9.0'));
75+
expect(result.valid).toBe(false);
76+
expect(result.tooOld).toBe(true);
77+
});
78+
79+
it('rejects an unparseable version rather than guessing', () => {
80+
const result = validateFormatVersion({ format: 'redstring-vbanana' });
81+
expect(result.valid).toBe(false);
82+
});
83+
});
84+
85+
describe('importFromRedstring — newer-minor documents', () => {
86+
const buildState = () => ({
87+
graphs: new Map(),
88+
nodePrototypes: new Map([
89+
['p1', { id: 'p1', name: 'Thing', description: '', definitionGraphIds: [], abstractionChains: {} }]
90+
]),
91+
edges: new Map(),
92+
openGraphIds: [],
93+
activeGraphId: null,
94+
activeDefinitionNodeId: null,
95+
expandedGraphIds: new Set(),
96+
rightPanelTabs: [],
97+
savedNodeIds: new Set(),
98+
savedGraphIds: new Set(),
99+
showConnectionNames: false
100+
});
101+
102+
it('imports a document stamped a minor ahead without throwing', () => {
103+
vi.spyOn(console, 'warn').mockImplementation(() => {});
104+
const doc = exportToRedstring(buildState());
105+
// Restamp as though written by a build one minor ahead.
106+
doc.format = 'redstring-v4.2.0';
107+
doc.metadata = { ...doc.metadata, version: '4.2.0' };
108+
109+
const { storeState } = importFromRedstring(doc);
110+
expect(storeState.nodePrototypes.get('p1').name).toBe('Thing');
111+
});
112+
113+
it('throws on a document stamped a major ahead', () => {
114+
const doc = exportToRedstring(buildState());
115+
doc.format = 'redstring-v5.0.0';
116+
doc.metadata = { ...doc.metadata, version: '5.0.0' };
117+
118+
expect(() => importFromRedstring(doc)).toThrow(/newer than/);
119+
});
120+
});

0 commit comments

Comments
 (0)