diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 6fd6e454..0125d669 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -7,6 +7,7 @@ import { type CultCacheDocumentValue, type CultCacheEnvelope, SchemaConflictError, + schemaIdentityOf, } from "@gamecult/cultcache-ts"; import { @@ -39,7 +40,9 @@ export function defineCultNetDocumentBinding< export class CultNetDocumentRegistry { readonly #bindings = new Map(); - readonly #schemaBindings = new Map(); + // The binding that owns each schema id, and every binding listing an id as compatible, in registration order. + readonly #owners = new Map(); + readonly #listers = new Map(); constructor(bindings: Iterable = []) { for (const binding of bindings) { @@ -48,26 +51,32 @@ export class CultNetDocumentRegistry { } register(binding: CultNetDocumentBinding): this { - // One definition owns a type, a schema id and a schema name. Another definition claiming any of them is - // refused, so a schema never resolves to two types. - const schemaId = schemaIdForBinding(binding); - const schemaName = schemaNameForBinding(binding); - for (const holder of this.#uniqueBindings()) { + // One definition owns a type, a schema id and a schema name, derived as CultCache derives them. Another definition + // claiming any of them is refused, so a schema never resolves to two types. + const identity = schemaIdentityOf(binding.definition); + for (const holder of this.#bindings.values()) { if (holder.definition === binding.definition) { continue; } + const held = schemaIdentityOf(holder.definition); const claimed = holder.definition.type === binding.definition.type ? `type "${binding.definition.type}"` - : schemaIdForBinding(holder) === schemaId ? `schema id "${schemaId}"` - : schemaNameForBinding(holder) === schemaName ? `schema name "${schemaName}"` + : held.schemaId === identity.schemaId ? `schema id "${identity.schemaId}"` + : held.schemaName === identity.schemaName ? `schema name "${identity.schemaName}"` : undefined; if (claimed) { - throw new SchemaConflictError(schemaId, [schemaNameForBinding(holder), schemaName], "", - `CultNet ${claimed} is already bound to type "${holder.definition.type}"; type "${binding.definition.type}" (schema id "${schemaId}") cannot also be bound.`); + throw new SchemaConflictError(identity.schemaId, [held.schemaName, identity.schemaName], "", + `CultNet ${claimed} is already bound to type "${holder.definition.type}"; type "${binding.definition.type}" (schema id "${identity.schemaId}") cannot also be bound.`); } } this.#bindings.set(binding.definition.type, binding); - this.#schemaBindings.set(schemaIdForBinding(binding), binding); + this.#owners.set(identity.schemaId, binding); + for (const schemaId of identity.compatibleSchemaIds) { + const listers = this.#listers.get(schemaId) ?? []; + if (schemaId !== identity.schemaId && !listers.some(lister => lister.definition === binding.definition)) { + this.#listers.set(schemaId, [...listers, binding]); + } + } return this; } @@ -75,16 +84,22 @@ export class CultNetDocumentRegistry { return this.#bindings.get(documentType); } + // The binding a record under this schema id belongs to: the id's owner, else the one binding that lists it as + // compatible. An id nothing declares resolves to nothing. An id that several bindings list and none owns is refused, + // as the cache refuses it, whatever order the bindings were registered in. getBySchemaId(schemaId: string): CultNetDocumentBinding | undefined { - const exact = this.#schemaBindings.get(schemaId); - if (exact) { - return exact; + const owner = this.#owners.get(schemaId); + if (owner) { + return owner; } - - const requestedSchemaIds = new Set([schemaId]); - return this.#uniqueBindings().find(binding => - schemaMatchesBinding(schemaIdForBinding(binding), binding, requestedSchemaIds), - ); + const listers = this.#listers.get(schemaId) ?? []; + if (listers.length > 1) { + const types = listers.map(lister => lister.definition.type).sort(); + throw new SchemaConflictError(schemaId, listers.map(lister => schemaIdentityOf(lister.definition).schemaName).sort(), "", + `CultNet schema id "${schemaId}" is owned by no bound type and declared compatible by several (${types.join(", ")}), ` + + "so it resolves to none. Bind the type that owns the id, or declare it on one type."); + } + return listers[0]; } createDocumentPutMessage( @@ -186,7 +201,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { + if (requestedSchemaIds && !answersTo(schemaId, binding, requestedSchemaIds)) { continue; } @@ -223,7 +238,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { + if (requestedSchemaIds && !answersTo(schemaId, binding, requestedSchemaIds)) { continue; } @@ -265,7 +280,7 @@ export class CultNetDocumentRegistry { cache: CultCache, message: CultNetDocumentPutRawMessage, ): Promise { - const binding = this.#resolveRawDocumentBinding(message.document); + const binding = this.#requireSchemaBinding(message.document.schemaId); return cache.putEnvelope(binding.definition, { key: message.document.recordKey, type: binding.definition.type, @@ -319,33 +334,6 @@ export class CultNetDocumentRegistry { return binding; } - // routes a replicated raw record to a local binding; the store always persists the local schema id; this is not store-reader recovery. - #resolveRawDocumentBinding(document: CultNetRawDocumentRecord): CultNetDocumentBinding { - const exact = this.getBySchemaId(document.schemaId); - if (exact) { - return exact; - } - - const payload = new Uint8Array(document.payload); - for (const binding of this.#uniqueBindings()) { - try { - decodeDocumentValue(binding.definition, payload); - return binding; - } catch { - // Keep looking; a foreign schema id is acceptable only when the payload validates locally. - } - } - - throw new Error(`No CultNet document binding is registered for schema "${document.schemaId}".`); - } - - #uniqueBindings(): CultNetDocumentBinding[] { - return [...new Set([ - ...this.#schemaBindings.values(), - ...this.#bindings.values(), - ])]; - } - #createRawDocumentRecord(envelope: CultCacheEnvelope): CultNetRawDocumentRecord { const binding = this.#requireBinding(envelope.type); return { @@ -359,47 +347,17 @@ export class CultNetDocumentRegistry { } function schemaIdForBinding(binding: CultNetDocumentBinding): string { - return binding.definition.schemaId ?? binding.definition.type; + return schemaIdentityOf(binding.definition).schemaId; } -function schemaNameForBinding(binding: CultNetDocumentBinding): string { - return binding.definition.schemaName ?? binding.definition.type; -} - -function schemaMatchesBinding( +// A held record matches a snapshot filter when the filter names the id it carries or any id its binding answers to. +function answersTo( schemaId: string, binding: CultNetDocumentBinding, requestedSchemaIds: Set, ): boolean { - if (requestedSchemaIds.has(schemaId)) { - return true; - } - - const definition = binding.definition; - if (definition.schemaId && requestedSchemaIds.has(definition.schemaId)) { - return true; - } - if (definition.schemaName && requestedSchemaIds.has(definition.schemaName)) { - return true; - } - if (definition.schemaVersion && requestedSchemaIds.has(definition.schemaVersion)) { - return true; - } - if (definition.compatibleSchemaIds?.some(candidate => requestedSchemaIds.has(candidate))) { - return true; - } - - return Array.from(requestedSchemaIds) - .some(candidate => inferSchemaName(candidate) === definition.schemaName); -} - -function inferSchemaName(schemaId: string): string | undefined { - const marker = schemaId.lastIndexOf(".v"); - if (marker <= 0 || marker + 2 >= schemaId.length) { - return undefined; - } - const version = schemaId.slice(marker + 2); - return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined; + return requestedSchemaIds.has(schemaId) + || schemaIdentityOf(binding.definition).compatibleSchemaIds.some(candidate => requestedSchemaIds.has(candidate)); } function schemaIdForEnvelope( @@ -409,17 +367,6 @@ function schemaIdForEnvelope( return envelope.schemaId ?? schemaIdForBinding(binding); } -function resolvePayloadSchemaVersion( - binding: CultNetDocumentBinding, - value: CultCacheDocumentValue, -): string | undefined { - if (typeof binding.payloadSchemaVersion === "function") { - return binding.payloadSchemaVersion(value); - } - - return binding.payloadSchemaVersion; -} - function decodeDocumentValue( definition: TDefinition, payload: Uint8Array, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 26c3072a..3c73bec0 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1589,57 +1589,145 @@ test("rudp sequence-neutral acknowledgements interoperate with ordered receivers assert.deepEqual(receiver.receive(request).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["snapshot"]); }); -test("CultNet raw replication applies compatible foreign-schema snapshots", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-foreign-schema-raw-")); +// A replicated record whose schema id no binding declares is refused, whatever its payload parses as: the registry never +// guesses a type from a payload's shape and never relabels a record it does not understand. +test("CultNet raw replication refuses a record under a schema id no binding declares, whatever its shape", async () => { + const note = defineDocumentType({ + type: "p5.note", schemaId: "p5.note.v1", schemaName: "p5.note", schema: z.object({ body: z.string() }), + }); + // Registered first, and accepts every payload: shape routing would store the record under it. + const anything = defineDocumentType({ type: "p5.anything", schema: z.any() }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: anything }), + defineCultNetDocumentBinding({ definition: note }), + ]); + const origin = CultCache.builder().withDocumentType(note).build(); + await origin.put(note, "k", { body: "from a peer" }); + const snapshot = registry.createRawSnapshotResponse(origin, "p5"); + assert.equal(snapshot.documents[0]?.schemaId, "p5.note.v1"); - try { - const documentDefinition = defineDocumentType({ - type: "cultnet.note", - schemaId: "cultnet.note.v1", - schemaName: "cultnet.note", - schemaVersion: "cultnet.note.v1", - schema: z.object({ - schema_version: z.string(), - noteId: z.string(), - body: z.string(), + for (const schemaId of ["runtime.generated.p5.note.ui.99", "p5.note.v7", "p5.note"]) { + const target = CultCache.builder().withDocumentType(note).withDocumentType(anything).build(); + await assert.rejects( + registry.applyRawSnapshotResponse(target, { + ...snapshot, + documents: snapshot.documents.map(document => ({ ...document, schemaId })), }), - name: "noteId", - }); - const registry = new CultNetDocumentRegistry([ - defineCultNetDocumentBinding({ definition: documentDefinition }), - ]); - const originCache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack"))) - .build(); - const targetCache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack"))) - .build(); - - await originCache.put(documentDefinition, "note:foreign-schema", { - schema_version: "cultnet.note.v1", - noteId: "note:foreign-schema", - body: "accepted by payload shape", - }); - const rawSnapshot = registry.createRawSnapshotResponse(originCache, "foreign-schema-raw"); - rawSnapshot.documents[0]!.schemaId = "runtime.generated.cultnet.note.ui.99"; - - await registry.applyRawSnapshotResponse(targetCache, rawSnapshot); - - assert.equal( - targetCache.getRequired(documentDefinition, "note:foreign-schema").body, - "accepted by payload shape", + (error: unknown) => error instanceof Error + && error.message === `No CultNet document binding is registered for schema "${schemaId}".`, ); - assert.equal( - targetCache.getRequiredEnvelope(documentDefinition, "note:foreign-schema").schemaId, - "cultnet.note.v1", - ); - } finally { - rmSync(tempDir, { recursive: true, force: true }); + assert.deepEqual(target.snapshot(), [], `nothing was stored for ${schemaId}`); } }); +// The registry stamps and resolves a definition's schema id exactly as CultCache does, so a record a cache wrote is read +// back as the type that wrote it on every peer. +test("CultNet registry derives a definition's schema id as the cache does, and refuses what the cache refuses", async () => { + const shape = z.object({ body: z.string() }); + const a = defineDocumentType({ type: "p1.a", schemaName: "p1.shared", schema: shape }); + const b = defineDocumentType({ type: "p1.b", schemaId: "p1.shared", schemaName: "p1.b", schema: shape }); + const fillers = [0, 1].map(index => defineCultNetDocumentBinding({ + definition: defineDocumentType({ type: `p1.filler${index}`, schema: shape }), + })); + + // The cache refuses B beside A: A's records carry "p1.shared". + await assert.rejects(CultCache.builder().withDocumentType(a).build().registerDocumentType(b), SchemaConflictError); + for (const [first, second] of [[a, b], [b, a]]) { + // Two bindings stand ahead of the holder, so a check that looks only at the first holder misses it. + const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: first })]); + assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: second })), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + assert.equal(error.schemaId, "p1.shared"); + return true; + }); + assert.equal(registry.get(second.type), undefined); + } + + const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: a })]); + const origin = CultCache.builder().withDocumentType(a).build(); + await origin.put(a, "k", { body: "written by a" }); + const stamped = origin.getRequiredEnvelope(a, "k").schemaId; + assert.equal(stamped, "p1.shared"); + assert.equal(registry.createDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped); + assert.equal(registry.createRawDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped); + assert.equal(registry.getBySchemaId(stamped!)?.definition, a); + + const peer = CultCache.builder().withDocumentType(a).build(); + await registry.applyRawSnapshotResponse(peer, registry.createRawSnapshotResponse(origin, "p1")); + assert.deepEqual(peer.getRequired(a, "k"), { body: "written by a" }); + assert.equal(peer.getRequiredEnvelope(a, "k").schemaId, stamped); +}); + +// An id nobody owns resolves to the one binding that lists it as compatible. Several listers and no owner resolve to +// none, and the refusal does not depend on the order they were registered in. An owner beats every lister. +test("CultNet registry refuses an unowned schema id that several bindings list, in any order", async () => { + const shape = z.object({ body: z.string() }); + const first = defineDocumentType({ type: "p2.first", schemaId: "p2.first", compatibleSchemaIds: ["p2.old"], schema: shape }); + const second = defineDocumentType({ type: "p2.second", schemaId: "p2.second", compatibleSchemaIds: ["p2.old"], schema: shape }); + const owner = defineDocumentType({ type: "p2.owner", schemaId: "p2.old", schema: shape }); + const bind = (definition: typeof first) => defineCultNetDocumentBinding({ definition }); + + for (const order of [[first, second], [second, first]]) { + const registry = new CultNetDocumentRegistry(order.map(bind)); + assert.throws(() => registry.getBySchemaId("p2.old"), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + assert.equal(error.schemaId, "p2.old"); + assert.deepEqual(error.schemaNames, ["p2.first", "p2.second"]); + assert.ok(error.message.includes("(p2.first, p2.second)"), error.message); + return true; + }); + const cache = CultCache.builder().withDocumentType(first).withDocumentType(second).build(); + const origin = CultCache.builder().withDocumentType(first).build(); + await origin.put(first, "k", { body: "old" }); + const document = { ...registry.createRawSnapshotResponse(origin, "p2").documents[0]!, schemaId: "p2.old" }; + await assert.rejects( + registry.applyRawDocumentPutMessage(cache, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "p2", document }), + SchemaConflictError, + ); + assert.deepEqual(cache.snapshot(), []); + + for (const withOwner of [[...order, owner], [owner, ...order]]) { + assert.equal(new CultNetDocumentRegistry(withOwner.map(bind)).getBySchemaId("p2.old")?.definition, owner); + } + } + assert.equal(new CultNetDocumentRegistry([bind(first)]).getBySchemaId("p2.old")?.definition, first); +}); + +// A schema id is only ever an id a definition declares. A version string, a schema name, or an id that differs from a +// declared one by its version suffix names nothing. +test("CultNet registry resolves only declared schema ids, never a version, a name or an inferred version", async () => { + const note = defineDocumentType({ + type: "p3.note", + schemaId: "p3.note.v1", + schemaName: "p3.note", + schemaVersion: "p4.note.v3", + schema: z.object({ body: z.string() }), + }); + const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]); + const cache = CultCache.builder().withDocumentType(note).build(); + await cache.put(note, "k", { body: "held" }); + + assert.equal(registry.getBySchemaId("p3.note.v1")?.definition, note); + for (const undeclared of ["p3.note.v7", "p4.note.v3", "p3.note"]) { + assert.equal(registry.getBySchemaId(undeclared), undefined, undeclared); + const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: [undeclared] }; + assert.deepEqual(registry.createSnapshotResponse(cache, "f", filter).documents, [], undeclared); + assert.deepEqual(registry.createRawSnapshotResponse(cache, "f", filter).documents, [], undeclared); + await assert.rejects(registry.applyDocumentPutMessage(cache, { + schemaVersion: "cultnet.document_put.v0", + messageId: "put", + document: { schemaId: undeclared, recordKey: "k", storedAt: "2026-09-30T00:00:00.000Z", payload: { body: "relabelled" } }, + }), /No CultNet document binding/); + await assert.rejects(registry.applyDocumentDeleteMessage(cache, { + schemaVersion: "cultnet.document_delete.v0", messageId: "delete", schemaId: undeclared, recordKey: "k", + }), /No CultNet document binding/); + assert.deepEqual(cache.getRequired(note, "k"), { body: "held" }); + } + const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: ["p3.note.v1"] }; + assert.equal(registry.createSnapshotResponse(cache, "f", filter).documents.length, 1); + assert.equal(registry.createRawSnapshotResponse(cache, "f", filter).documents.length, 1); +}); + test("CultNet document registry binds one type per schema and refuses a second, typed", () => { const noteShape = z.object({ noteId: z.string() }); const owner = defineDocumentType({ type: "tests.note", schemaId: "id.note", schemaName: "tests.note", schema: noteShape }); @@ -1656,7 +1744,13 @@ test("CultNet document registry binds one type per schema and refuses a second, [sameType, "id.third", ["tests.note", "tests.third"]], ] as const) { for (const order of [[owner, claimant], [claimant, owner]]) { - const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: order[0] })]); + // Two bindings are registered ahead of the holder, so a conflict loop that checks only the first holder fails. + const registry = new CultNetDocumentRegistry([ + ...["tests.filler-a", "tests.filler-b"].map(type => defineCultNetDocumentBinding({ + definition: defineDocumentType({ type, schema: noteShape }), + })), + defineCultNetDocumentBinding({ definition: order[0] }), + ]); assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: order[1] })), (error: unknown) => { assert.ok(error instanceof SchemaConflictError, String(error)); const ownerFirst = order[0] === owner; @@ -1681,113 +1775,6 @@ test("CultNet document registry binds one type per schema and refuses a second, assert.equal(registry.getBySchemaId("id.lister")?.definition, lister); }); -test("CultNet document registry filters snapshots by schema aliases", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-")); - - try { - const documentDefinition = defineDocumentType({ - type: "cultnet.alias_note", - schemaId: "sha256:cultnet-alias-note", - schemaName: "cultnet.alias_note", - schemaVersion: "cultnet.alias_note.v1", - schema: z.object({ - schema_version: z.string(), - noteId: z.string(), - body: z.string(), - }), - name: "noteId", - }); - const registry = new CultNetDocumentRegistry([ - defineCultNetDocumentBinding({ definition: documentDefinition }), - ]); - const cache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "alias.msgpack"))) - .build(); - - await cache.put(documentDefinition, "note:alias", { - schema_version: "cultnet.alias_note.v1", - noteId: "note:alias", - body: "filtered by alias", - }); - - const typedSnapshot = registry.createSnapshotResponse(cache, "typed-alias", { - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "typed-alias", - schemaIds: ["cultnet.alias_note.v1"], - }); - const rawSnapshot = registry.createRawSnapshotResponse(cache, "raw-alias", { - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "raw-alias", - schemaIds: ["cultnet.alias_note.v1"], - }); - - assert.equal(typedSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); - assert.equal(typedSnapshot.documents[0]?.recordKey, "note:alias"); - assert.equal(rawSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); - assert.equal(rawSnapshot.documents[0]?.recordKey, "note:alias"); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -}); - -test("CultNet document registry applies typed put and delete messages by schema alias", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-typed-")); - - try { - const documentDefinition = defineDocumentType({ - type: "cultnet.typed_alias_note", - schemaId: "sha256:cultnet-typed-alias-note", - schemaName: "cultnet.typed_alias_note", - schemaVersion: "cultnet.typed_alias_note.v1", - schema: z.object({ - schema_version: z.string(), - noteId: z.string(), - body: z.string(), - }), - name: "noteId", - }); - const registry = new CultNetDocumentRegistry([ - defineCultNetDocumentBinding({ definition: documentDefinition }), - ]); - const cache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "typed-alias.msgpack"))) - .build(); - - await registry.applyDocumentPutMessage(cache, { - schemaVersion: "cultnet.document_put.v0", - messageId: "typed-alias-put", - document: { - schemaId: "cultnet.typed_alias_note.v1", - recordKey: "note:typed-alias", - storedAt: "2026-06-27T00:00:00.000Z", - payload: { - schema_version: "cultnet.typed_alias_note.v1", - noteId: "note:typed-alias", - body: "typed alias applied", - }, - }, - }); - - assert.equal( - cache.getRequired(documentDefinition, "note:typed-alias").body, - "typed alias applied", - ); - assert.equal(registry.getBySchemaId("cultnet.typed_alias_note.v1")?.definition.type, documentDefinition.type); - - assert.equal(await registry.applyDocumentDeleteMessage(cache, { - schemaVersion: "cultnet.document_delete.v0", - messageId: "typed-alias-delete", - schemaId: "cultnet.typed_alias_note.v1", - recordKey: "note:typed-alias", - }), true); - assert.equal(cache.get(documentDefinition, "note:typed-alias"), undefined); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -}); - test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => { const note: InteropNote = { schemaVersion: INTEROP_SCHEMA_VERSION,