Barazo lexicon schemas and TypeScript types barazo.forum

Merge pull request #11 from barazo-forum/test/backward-compatibility

test(compat): add backward compatibility test suite

authored by

Guido X Jansen and committed by
GitHub
952e57a8 d652db8b

+565 -1
+1 -1
eslint.config.js
··· 23 23 languageOptions: { 24 24 parserOptions: { 25 25 projectService: { 26 - allowDefaultProject: ["tests/*.ts"], 26 + allowDefaultProject: ["tests/*.ts", "tests/fixtures/*.ts"], 27 27 }, 28 28 tsconfigRootDir: import.meta.dirname, 29 29 },
+1
package.json
··· 30 30 "lint:fix": "eslint --fix src/ tests/", 31 31 "test": "vitest run", 32 32 "test:watch": "vitest", 33 + "test:compat": "vitest run tests/backward-compatibility.test.ts", 33 34 "test:coverage": "vitest run --coverage", 34 35 "generate": "lex gen-server ./src/generated ./lexicons/**/*.json && node scripts/fixup-generated.js", 35 36 "clean": "rm -rf dist"
+461
tests/backward-compatibility.test.ts
··· 1 + /** 2 + * Backward Compatibility Test Suite 3 + * 4 + * Ensures that schema changes never break records already stored on user PDSes. 5 + * This test suite enforces the rules from PRD section 9 (Backwards Compatibility Rules): 6 + * 7 + * - Fields can be added (optional only) 8 + * - Fields cannot be removed 9 + * - Field types cannot change 10 + * - Required fields cannot become optional (or vice versa) 11 + * - Breaking changes require a new lexicon ID 12 + * 13 + * How it works: 14 + * 1. Baseline records (fixtures) represent data already on PDSes 15 + * 2. Schema snapshots capture structural invariants (required fields, property names) 16 + * 3. Tests validate baseline records against BOTH Zod schemas and lexicon validators 17 + * 4. If any test fails after a schema change, the change is backward-incompatible 18 + */ 19 + 20 + import { describe, it, expect } from "vitest"; 21 + import * as fs from "node:fs"; 22 + import * as path from "node:path"; 23 + import { 24 + topicPostSchema, 25 + topicReplySchema, 26 + reactionSchema, 27 + actorPreferencesSchema, 28 + } from "../src/validation/index.js"; 29 + import { 30 + ForumBarazoTopicPost, 31 + ForumBarazoTopicReply, 32 + ForumBarazoInteractionReaction, 33 + ForumBarazoActorPreferences, 34 + } from "../src/generated/index.js"; 35 + import { 36 + topicPostMinimal, 37 + topicPostFull, 38 + topicReplyMinimal, 39 + topicReplyFull, 40 + reactionMinimal, 41 + reactionFull, 42 + actorPreferencesMinimal, 43 + actorPreferencesFull, 44 + } from "./fixtures/baseline-records.js"; 45 + 46 + // ── Helpers ───────────────────────────────────────────────────────── 47 + 48 + function loadLexiconJson(lexiconPath: string): Record<string, unknown> { 49 + const fullPath = path.resolve( 50 + import.meta.dirname, 51 + "..", 52 + "lexicons", 53 + lexiconPath, 54 + ); 55 + return JSON.parse(fs.readFileSync(fullPath, "utf-8")) as Record< 56 + string, 57 + unknown 58 + >; 59 + } 60 + 61 + function getRecordProperties( 62 + lexicon: Record<string, unknown>, 63 + ): Record<string, unknown> { 64 + const defs = lexicon["defs"] as Record<string, unknown>; 65 + const main = defs["main"] as Record<string, unknown>; 66 + const record = main["record"] as Record<string, unknown>; 67 + return record["properties"] as Record<string, unknown>; 68 + } 69 + 70 + function getRequiredFields(lexicon: Record<string, unknown>): string[] { 71 + const defs = lexicon["defs"] as Record<string, unknown>; 72 + const main = defs["main"] as Record<string, unknown>; 73 + const record = main["record"] as Record<string, unknown>; 74 + return record["required"] as string[]; 75 + } 76 + 77 + // Adds $type to a record for lexicon validator (which requires it) 78 + function withType<T extends Record<string, unknown>>( 79 + record: T, 80 + type: string, 81 + ): T & { $type: string } { 82 + return { ...record, $type: type }; 83 + } 84 + 85 + // ── Schema Structural Snapshots ───────────────────────────────────── 86 + // These capture the contract. If any of these change, a test will fail, 87 + // forcing the developer to consider backward compatibility implications. 88 + 89 + const SCHEMA_SNAPSHOTS = { 90 + "forum.barazo.topic.post": { 91 + requiredFields: ["title", "content", "community", "category", "createdAt"], 92 + allProperties: [ 93 + "title", 94 + "content", 95 + "contentFormat", 96 + "community", 97 + "category", 98 + "tags", 99 + "labels", 100 + "createdAt", 101 + ], 102 + }, 103 + "forum.barazo.topic.reply": { 104 + requiredFields: ["content", "root", "parent", "community", "createdAt"], 105 + allProperties: [ 106 + "content", 107 + "contentFormat", 108 + "root", 109 + "parent", 110 + "community", 111 + "labels", 112 + "createdAt", 113 + ], 114 + }, 115 + "forum.barazo.interaction.reaction": { 116 + requiredFields: ["subject", "type", "community", "createdAt"], 117 + allProperties: ["subject", "type", "community", "createdAt"], 118 + }, 119 + "forum.barazo.actor.preferences": { 120 + requiredFields: ["maturityLevel", "updatedAt"], 121 + allProperties: [ 122 + "maturityLevel", 123 + "mutedWords", 124 + "blockedDids", 125 + "mutedDids", 126 + "crossPostDefaults", 127 + "updatedAt", 128 + ], 129 + }, 130 + } as const; 131 + 132 + // ── Baseline Record Validation (Zod) ──────────────────────────────── 133 + 134 + describe("backward compatibility: Zod validation of baseline records", () => { 135 + describe("forum.barazo.topic.post", () => { 136 + it("validates minimal baseline record", () => { 137 + const result = topicPostSchema.safeParse(topicPostMinimal); 138 + expect(result.success).toBe(true); 139 + }); 140 + 141 + it("validates full baseline record", () => { 142 + const result = topicPostSchema.safeParse(topicPostFull); 143 + expect(result.success).toBe(true); 144 + }); 145 + }); 146 + 147 + describe("forum.barazo.topic.reply", () => { 148 + it("validates minimal baseline record", () => { 149 + const result = topicReplySchema.safeParse(topicReplyMinimal); 150 + expect(result.success).toBe(true); 151 + }); 152 + 153 + it("validates full baseline record", () => { 154 + const result = topicReplySchema.safeParse(topicReplyFull); 155 + expect(result.success).toBe(true); 156 + }); 157 + }); 158 + 159 + describe("forum.barazo.interaction.reaction", () => { 160 + it("validates minimal baseline record", () => { 161 + const result = reactionSchema.safeParse(reactionMinimal); 162 + expect(result.success).toBe(true); 163 + }); 164 + 165 + it("validates full baseline record", () => { 166 + const result = reactionSchema.safeParse(reactionFull); 167 + expect(result.success).toBe(true); 168 + }); 169 + }); 170 + 171 + describe("forum.barazo.actor.preferences", () => { 172 + it("validates minimal baseline record", () => { 173 + const result = actorPreferencesSchema.safeParse(actorPreferencesMinimal); 174 + expect(result.success).toBe(true); 175 + }); 176 + 177 + it("validates full baseline record", () => { 178 + const result = actorPreferencesSchema.safeParse(actorPreferencesFull); 179 + expect(result.success).toBe(true); 180 + }); 181 + }); 182 + }); 183 + 184 + // ── Baseline Record Validation (Lexicon/AT Protocol) ──────────────── 185 + 186 + describe("backward compatibility: lexicon validation of baseline records", () => { 187 + describe("forum.barazo.topic.post", () => { 188 + it("validates minimal baseline record", () => { 189 + const result = ForumBarazoTopicPost.validateRecord( 190 + withType(topicPostMinimal, "forum.barazo.topic.post"), 191 + ); 192 + expect(result.success).toBe(true); 193 + }); 194 + 195 + it("validates full baseline record", () => { 196 + const result = ForumBarazoTopicPost.validateRecord( 197 + withType(topicPostFull, "forum.barazo.topic.post"), 198 + ); 199 + expect(result.success).toBe(true); 200 + }); 201 + }); 202 + 203 + describe("forum.barazo.topic.reply", () => { 204 + it("validates minimal baseline record", () => { 205 + const result = ForumBarazoTopicReply.validateRecord( 206 + withType(topicReplyMinimal, "forum.barazo.topic.reply"), 207 + ); 208 + expect(result.success).toBe(true); 209 + }); 210 + 211 + it("validates full baseline record", () => { 212 + const result = ForumBarazoTopicReply.validateRecord( 213 + withType(topicReplyFull, "forum.barazo.topic.reply"), 214 + ); 215 + expect(result.success).toBe(true); 216 + }); 217 + }); 218 + 219 + describe("forum.barazo.interaction.reaction", () => { 220 + it("validates minimal baseline record", () => { 221 + const result = ForumBarazoInteractionReaction.validateRecord( 222 + withType(reactionMinimal, "forum.barazo.interaction.reaction"), 223 + ); 224 + expect(result.success).toBe(true); 225 + }); 226 + 227 + it("validates full baseline record", () => { 228 + const result = ForumBarazoInteractionReaction.validateRecord( 229 + withType(reactionFull, "forum.barazo.interaction.reaction"), 230 + ); 231 + expect(result.success).toBe(true); 232 + }); 233 + }); 234 + 235 + describe("forum.barazo.actor.preferences", () => { 236 + it("validates minimal baseline record", () => { 237 + const result = ForumBarazoActorPreferences.validateRecord( 238 + withType(actorPreferencesMinimal, "forum.barazo.actor.preferences"), 239 + ); 240 + expect(result.success).toBe(true); 241 + }); 242 + 243 + it("validates full baseline record", () => { 244 + const result = ForumBarazoActorPreferences.validateRecord( 245 + withType(actorPreferencesFull, "forum.barazo.actor.preferences"), 246 + ); 247 + expect(result.success).toBe(true); 248 + }); 249 + }); 250 + }); 251 + 252 + // ── Schema Structural Invariants ──────────────────────────────────── 253 + // These tests catch accidental changes to required fields, removed 254 + // properties, or changed field types. 255 + 256 + describe("backward compatibility: schema structural invariants", () => { 257 + const lexiconFiles: Array<{ 258 + name: string; 259 + path: string; 260 + snapshot: (typeof SCHEMA_SNAPSHOTS)[keyof typeof SCHEMA_SNAPSHOTS]; 261 + }> = [ 262 + { 263 + name: "forum.barazo.topic.post", 264 + path: "forum/barazo/topic/post.json", 265 + snapshot: SCHEMA_SNAPSHOTS["forum.barazo.topic.post"], 266 + }, 267 + { 268 + name: "forum.barazo.topic.reply", 269 + path: "forum/barazo/topic/reply.json", 270 + snapshot: SCHEMA_SNAPSHOTS["forum.barazo.topic.reply"], 271 + }, 272 + { 273 + name: "forum.barazo.interaction.reaction", 274 + path: "forum/barazo/interaction/reaction.json", 275 + snapshot: SCHEMA_SNAPSHOTS["forum.barazo.interaction.reaction"], 276 + }, 277 + { 278 + name: "forum.barazo.actor.preferences", 279 + path: "forum/barazo/actor/preferences.json", 280 + snapshot: SCHEMA_SNAPSHOTS["forum.barazo.actor.preferences"], 281 + }, 282 + ]; 283 + 284 + for (const { name, path: lexPath, snapshot } of lexiconFiles) { 285 + describe(name, () => { 286 + const lexicon = loadLexiconJson(lexPath); 287 + 288 + it("has not removed any required fields", () => { 289 + const currentRequired = getRequiredFields(lexicon); 290 + for (const field of snapshot.requiredFields) { 291 + expect( 292 + currentRequired, 293 + `Required field "${field}" was removed from ${name}. ` + 294 + "This breaks existing records that rely on this field being required. " + 295 + "Use a new lexicon ID for breaking changes.", 296 + ).toContain(field); 297 + } 298 + }); 299 + 300 + it("has not added new required fields", () => { 301 + const currentRequired = getRequiredFields(lexicon); 302 + for (const field of currentRequired) { 303 + expect( 304 + snapshot.requiredFields, 305 + `New required field "${field}" was added to ${name}. ` + 306 + "Existing records on PDSes don't have this field. " + 307 + "New fields must be optional, or use a new lexicon ID.", 308 + ).toContain(field); 309 + } 310 + }); 311 + 312 + it("has not removed any properties", () => { 313 + const currentProperties = Object.keys(getRecordProperties(lexicon)); 314 + for (const prop of snapshot.allProperties) { 315 + expect( 316 + currentProperties, 317 + `Property "${prop}" was removed from ${name}. ` + 318 + "Existing records on PDSes may contain this field. " + 319 + "Fields cannot be removed from published schemas.", 320 + ).toContain(prop); 321 + } 322 + }); 323 + 324 + it("may have added new optional properties (allowed)", () => { 325 + // This test documents that new properties were added. 326 + // New properties are allowed as long as they are optional (not in required[]). 327 + const currentProperties = Object.keys(getRecordProperties(lexicon)); 328 + const currentRequired = getRequiredFields(lexicon); 329 + const newProperties = currentProperties.filter( 330 + (p) => !snapshot.allProperties.includes(p), 331 + ); 332 + 333 + for (const prop of newProperties) { 334 + expect( 335 + currentRequired, 336 + `New property "${prop}" in ${name} is required. ` + 337 + "New properties must be optional to maintain backward compatibility.", 338 + ).not.toContain(prop); 339 + } 340 + }); 341 + }); 342 + } 343 + }); 344 + 345 + // ── Forward Compatibility (extra fields) ──────────────────────────── 346 + // AT Protocol records may contain extra fields (from future schema versions). 347 + // The lexicon validator must not reject records with unknown properties. 348 + 349 + describe("backward compatibility: records with extra unknown fields", () => { 350 + it("lexicon validator accepts topic.post with extra fields", () => { 351 + const record = withType( 352 + { ...topicPostMinimal, futureField: "some-value", anotherNew: 42 }, 353 + "forum.barazo.topic.post", 354 + ); 355 + const result = ForumBarazoTopicPost.validateRecord(record); 356 + expect(result.success).toBe(true); 357 + }); 358 + 359 + it("lexicon validator accepts topic.reply with extra fields", () => { 360 + const record = withType( 361 + { ...topicReplyMinimal, futureField: true }, 362 + "forum.barazo.topic.reply", 363 + ); 364 + const result = ForumBarazoTopicReply.validateRecord(record); 365 + expect(result.success).toBe(true); 366 + }); 367 + 368 + it("lexicon validator accepts reaction with extra fields", () => { 369 + const record = withType( 370 + { ...reactionMinimal, futureField: ["a", "b"] }, 371 + "forum.barazo.interaction.reaction", 372 + ); 373 + const result = ForumBarazoInteractionReaction.validateRecord(record); 374 + expect(result.success).toBe(true); 375 + }); 376 + 377 + it("lexicon validator accepts actor.preferences with extra fields", () => { 378 + const record = withType( 379 + { ...actorPreferencesMinimal, futureField: { nested: true } }, 380 + "forum.barazo.actor.preferences", 381 + ); 382 + const result = ForumBarazoActorPreferences.validateRecord(record); 383 + expect(result.success).toBe(true); 384 + }); 385 + }); 386 + 387 + // ── Field Type Stability ──────────────────────────────────────────── 388 + // Ensures field types haven't changed in the lexicon JSON schemas. 389 + 390 + describe("backward compatibility: field type stability", () => { 391 + const FIELD_TYPE_SNAPSHOTS: Record< 392 + string, 393 + { path: string; types: Record<string, string> } 394 + > = { 395 + "forum.barazo.topic.post": { 396 + path: "forum/barazo/topic/post.json", 397 + types: { 398 + title: "string", 399 + content: "string", 400 + contentFormat: "string", 401 + community: "string", 402 + category: "string", 403 + tags: "array", 404 + labels: "union", 405 + createdAt: "string", 406 + }, 407 + }, 408 + "forum.barazo.topic.reply": { 409 + path: "forum/barazo/topic/reply.json", 410 + types: { 411 + content: "string", 412 + contentFormat: "string", 413 + root: "ref", 414 + parent: "ref", 415 + community: "string", 416 + labels: "union", 417 + createdAt: "string", 418 + }, 419 + }, 420 + "forum.barazo.interaction.reaction": { 421 + path: "forum/barazo/interaction/reaction.json", 422 + types: { 423 + subject: "ref", 424 + type: "string", 425 + community: "string", 426 + createdAt: "string", 427 + }, 428 + }, 429 + "forum.barazo.actor.preferences": { 430 + path: "forum/barazo/actor/preferences.json", 431 + types: { 432 + maturityLevel: "string", 433 + mutedWords: "array", 434 + blockedDids: "array", 435 + mutedDids: "array", 436 + crossPostDefaults: "ref", 437 + updatedAt: "string", 438 + }, 439 + }, 440 + }; 441 + 442 + for (const [name, { path: lexPath, types }] of Object.entries( 443 + FIELD_TYPE_SNAPSHOTS, 444 + )) { 445 + describe(name, () => { 446 + const lexicon = loadLexiconJson(lexPath); 447 + const properties = getRecordProperties(lexicon); 448 + 449 + for (const [field, expectedType] of Object.entries(types)) { 450 + it(`field "${field}" remains type "${expectedType}"`, () => { 451 + const prop = properties[field] as Record<string, unknown>; 452 + expect( 453 + prop["type"], 454 + `Field "${field}" in ${name} changed type from "${expectedType}" to "${String(prop["type"])}". ` + 455 + "Field types cannot change in published schemas.", 456 + ).toBe(expectedType); 457 + }); 458 + } 459 + }); 460 + } 461 + });
+102
tests/fixtures/baseline-records.ts
··· 1 + /** 2 + * Baseline records representing data already stored on user PDSes. 3 + * 4 + * These fixtures simulate records created with the current schema version. 5 + * When schemas evolve (new optional fields, description changes, etc.), 6 + * these records MUST continue to validate. If any baseline record fails 7 + * validation after a schema change, the change is backward-incompatible 8 + * and must be reverted or handled via a new lexicon ID. 9 + * 10 + * Each record type has: 11 + * - A minimal record (only required fields) 12 + * - A full record (all optional fields populated) 13 + * 14 + * DO NOT modify existing records in this file when adding new optional fields 15 + * to schemas. Instead, create new fixture variants that include the new fields. 16 + * The existing fixtures must remain unchanged to prove backward compatibility. 17 + */ 18 + 19 + const VALID_DID = "did:plc:abc123def456"; 20 + const VALID_DATETIME = "2026-02-12T10:00:00.000Z"; 21 + const VALID_STRONG_REF = { 22 + uri: "at://did:plc:abc123/forum.barazo.topic.post/3jzfcijpj2z2a", 23 + cid: "bafyreibouvacvqhc2vkwwtdkfynpcaoatmkde7uhrw47ne4gu63cnzc7yq", 24 + }; 25 + 26 + // ── topic.post ────────────────────────────────────────────────────── 27 + 28 + export const topicPostMinimal = { 29 + title: "My First Topic", 30 + content: "Hello, this is the body of my first topic post.", 31 + community: VALID_DID, 32 + category: "general", 33 + createdAt: VALID_DATETIME, 34 + }; 35 + 36 + export const topicPostFull = { 37 + title: "Full Featured Topic", 38 + content: "This topic includes every optional field available at v0.1.0.", 39 + contentFormat: "markdown" as const, 40 + community: VALID_DID, 41 + category: "announcements", 42 + tags: ["release", "v1"], 43 + labels: { 44 + $type: "com.atproto.label.defs#selfLabels", 45 + values: [{ val: "nudity" }], 46 + }, 47 + createdAt: VALID_DATETIME, 48 + }; 49 + 50 + // ── topic.reply ───────────────────────────────────────────────────── 51 + 52 + export const topicReplyMinimal = { 53 + content: "Great post, thanks for sharing!", 54 + root: VALID_STRONG_REF, 55 + parent: VALID_STRONG_REF, 56 + community: VALID_DID, 57 + createdAt: VALID_DATETIME, 58 + }; 59 + 60 + export const topicReplyFull = { 61 + content: "This reply uses all optional fields available at v0.1.0.", 62 + contentFormat: "markdown" as const, 63 + root: VALID_STRONG_REF, 64 + parent: VALID_STRONG_REF, 65 + community: VALID_DID, 66 + labels: { 67 + $type: "com.atproto.label.defs#selfLabels", 68 + values: [{ val: "graphic-media" }], 69 + }, 70 + createdAt: VALID_DATETIME, 71 + }; 72 + 73 + // ── interaction.reaction ──────────────────────────────────────────── 74 + 75 + export const reactionMinimal = { 76 + subject: VALID_STRONG_REF, 77 + type: "like", 78 + community: VALID_DID, 79 + createdAt: VALID_DATETIME, 80 + }; 81 + 82 + // Reaction has no optional fields beyond the required ones 83 + export const reactionFull = { ...reactionMinimal }; 84 + 85 + // ── actor.preferences ─────────────────────────────────────────────── 86 + 87 + export const actorPreferencesMinimal = { 88 + maturityLevel: "safe" as const, 89 + updatedAt: VALID_DATETIME, 90 + }; 91 + 92 + export const actorPreferencesFull = { 93 + maturityLevel: "all" as const, 94 + mutedWords: ["spam", "crypto-scam"], 95 + blockedDids: ["did:plc:blocked1", "did:plc:blocked2"], 96 + mutedDids: ["did:plc:muted1"], 97 + crossPostDefaults: { 98 + bluesky: true, 99 + frontpage: false, 100 + }, 101 + updatedAt: VALID_DATETIME, 102 + };