OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-protocol / schema-meta.js

221 lines · javascript ref:
import b4a from 'b4a'
import {
  arrayOf,
  canonicalAsciiBytes,
  canonicalUtf8Bytes,
  constant,
  decodeCanonical,
  encodeCanonical,
  optional,
  ranged,
  struct,
  u8
} from './codec.js'
import { protocolError } from './errors.js'
import {
  MASTER_SCHEMA_INVENTORY,
  SCHEMA_DEFINITION_KIND,
  assertMasterSchemaInventory
} from './master-schema-inventory.js'
import { SCHEMA_CATEGORY } from './registry.js'
import { schemaCatalogEntryV1 } from './schemas.js'

const version1 = constant(u8, 1, 'version')
const schemaName = canonicalAsciiBytes(1, 96, 'schemaName')

function fail (message) {
  protocolError('BAD_ENCODING', message)
}

function compareBytes (left, right) {
  return b4a.compare(left, right)
}

function assertSortedNames (values, name) {
  for (let index = 1; index < values.length; index++) {
    if (compareBytes(values[index - 1], values[index]) >= 0) {
      fail(`${name} must be strictly raw-ASCII sorted and duplicate-free`)
    }
  }
}

// This is the closed meta-grammar carried inside
// SchemaCatalogEntryV1.canonicalSchemaBytes. The source declaration is the exact
// fenced declaration from the stable master, not JavaScript source metadata.
export const canonicalSchemaDefinitionV1 = struct([
  ['version', version1],
  ['definitionKind', ranged(u8, 1, 4, 'definitionKind')],
  ['schemaName', schemaName],
  ['sourceDeclaration', canonicalUtf8Bytes(1, 0xffff, 'sourceDeclaration')],
  ['aliasOf', optional(schemaName, 'aliasOf')],
  ['compositionDependencies', arrayOf(schemaName, 0, 64, 'compositionDependencies')]
], {
  name: 'CanonicalSchemaDefinitionV1',
  validate (value) {
    assertSortedNames(value.compositionDependencies, 'compositionDependencies')
    if (value.aliasOf != null && value.definitionKind !== SCHEMA_DEFINITION_KIND.COMPOSITION) {
      fail('aliasOf is allowed only on a composition definition')
    }
    if (value.definitionKind === SCHEMA_DEFINITION_KIND.INLINE_UNION &&
        !b4a.toString(value.sourceDeclaration, 'utf8').includes('tagged union')) {
      fail('inline union source declaration must explicitly name its tagged union')
    }
  }
})

function extractDeclaration (canonicalMasterText, schemaName, definitionKind) {
  const escaped = schemaName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const header = definitionKind === SCHEMA_DEFINITION_KIND.INLINE_UNION
    ? new RegExp(`^${escaped}\\s*=\\s*tagged union\\s*\\{`, 'm')
    : new RegExp(`^${escaped}\\s*\\{`, 'm')
  const match = header.exec(canonicalMasterText)
  if (!match) throw new Error(`missing stable-master declaration for ${schemaName}`)
  let depth = 0
  let closing = -1
  for (let index = match.index; index < canonicalMasterText.length; index++) {
    const character = canonicalMasterText[index]
    if (character === '{') depth++
    if (character === '}') {
      depth--
      if (depth === 0) {
        closing = index + 1
        break
      }
    }
  }
  if (closing < 0) throw new Error(`unterminated stable-master declaration for ${schemaName}`)
  const declaration = canonicalMasterText.slice(match.index, closing)
  if (declaration.includes('\r') || declaration.endsWith('\n')) {
    throw new Error(`non-canonical source declaration extraction for ${schemaName}`)
  }
  return b4a.from(declaration, 'utf8')
}

function declarationCount (canonicalMasterText, schemaName, definitionKind) {
  const escaped = schemaName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const source = definitionKind === SCHEMA_DEFINITION_KIND.INLINE_UNION
    ? `^${escaped}\\s*=\\s*tagged union\\s*\\{`
    : `^${escaped}\\s*\\{`
  return (canonicalMasterText.match(new RegExp(source, 'gm')) || []).length
}

function compileInventoryCatalog (canonicalMasterText, inventoryEntries, inventoryAudit) {
  const knownSchemaNames = new Set(MASTER_SCHEMA_INVENTORY.map(entry => entry.schemaName))
  const definitions = []
  const entries = []
  const canonicalSchemaBytesByName = new Map()

  for (const inventory of inventoryEntries) {
    const sourceDeclaration = extractDeclaration(canonicalMasterText, inventory.schemaName, inventory.definitionKind)
    const declaredDependencies = [...new Set((b4a.toString(sourceDeclaration, 'utf8')
      .match(/\b[A-Za-z][A-Za-z0-9]*V1\b/g) || [])
      .filter(name => name !== inventory.schemaName && knownSchemaNames.has(name)))].sort()
    if (declaredDependencies.join('\0') !== [...inventory.compositionDependencies].sort().join('\0')) {
      throw new Error(`composition dependency rules drifted for ${inventory.schemaName}: declared=${declaredDependencies.join(',')} catalog=${inventory.compositionDependencies.join(',')}`)
    }
    const value = {
      version: 1,
      definitionKind: inventory.definitionKind,
      schemaName: b4a.from(inventory.schemaName, 'ascii'),
      sourceDeclaration,
      aliasOf: inventory.aliasOf == null ? null : b4a.from(inventory.aliasOf, 'ascii'),
      compositionDependencies: inventory.compositionDependencies
        .map(name => b4a.from(name, 'ascii'))
        .sort(compareBytes)
    }
    const canonicalSchemaBytes = encodeCanonical(canonicalSchemaDefinitionV1, value)
    if (canonicalSchemaBytes.byteLength > 0xffff) throw new Error(`${inventory.schemaName} meta definition exceeds 65535 bytes`)
    const decoded = decodeCanonical(canonicalSchemaDefinitionV1, canonicalSchemaBytes)
    if (b4a.toString(decoded.schemaName, 'ascii') !== inventory.schemaName) {
      throw new Error(`schema meta round trip changed ${inventory.schemaName}`)
    }
    const entry = {
      category: inventory.category,
      categoryLocalSchemaId: inventory.categoryLocalSchemaId,
      schemaName: b4a.from(inventory.schemaName, 'ascii'),
      canonicalSchemaBytes
    }
    // Exercise the public catalog codec while compiling, including PRIVATE_IPC
    // rows. This validates category-local IDs without taking codec ownership.
    decodeCanonical(schemaCatalogEntryV1, encodeCanonical(schemaCatalogEntryV1, entry))
    definitions.push(value)
    entries.push(entry)
    canonicalSchemaBytesByName.set(inventory.schemaName, canonicalSchemaBytes)
  }

  const duplicateDefinitionBytes = []
  const hashes = new Map()
  for (const entry of entries) {
    const key = b4a.toString(entry.canonicalSchemaBytes, 'hex')
    if (hashes.has(key)) duplicateDefinitionBytes.push(`${hashes.get(key)}=${b4a.toString(entry.schemaName, 'ascii')}`)
    hashes.set(key, b4a.toString(entry.schemaName, 'ascii'))
  }
  if (duplicateDefinitionBytes.length > 0) throw new Error(`duplicate canonical schema definitions: ${duplicateDefinitionBytes.join(', ')}`)

  return Object.freeze({
    inventoryAudit,
    definitions: Object.freeze(definitions),
    entries: Object.freeze(entries),
    canonicalSchemaBytesByName
  })
}

export function compileMasterSchemaCatalog (canonicalMasterText) {
  const inventoryAudit = assertMasterSchemaInventory(canonicalMasterText)
  return compileInventoryCatalog(canonicalMasterText, MASTER_SCHEMA_INVENTORY, inventoryAudit)
}

export function compileMasterSchemaCatalogForCategory (canonicalMasterText, category) {
  if (!Object.values(SCHEMA_CATEGORY).includes(category)) throw new TypeError('unknown schema category')
  const inventoryEntries = MASTER_SCHEMA_INVENTORY.filter(entry => entry.category === category)
  const missingSchemaNames = []
  const duplicateSchemaNames = []
  for (const inventory of inventoryEntries) {
    const count = declarationCount(canonicalMasterText, inventory.schemaName, inventory.definitionKind)
    if (count === 0) missingSchemaNames.push(inventory.schemaName)
    if (count > 1) duplicateSchemaNames.push(inventory.schemaName)
  }
  if (missingSchemaNames.length > 0 || duplicateSchemaNames.length > 0) {
    const error = new Error('master schema category does not match its executable inventory')
    error.code = 'BLIND_MASTER_SCHEMA_CATEGORY_MISMATCH'
    error.category = category
    error.missingSchemaNames = missingSchemaNames
    error.duplicateSchemaNames = duplicateSchemaNames
    throw error
  }
  const inventoryAudit = Object.freeze({
    ok: true,
    category,
    catalogSchemaCount: inventoryEntries.length,
    missingSchemaNames: Object.freeze([]),
    duplicateSchemaNames: Object.freeze([])
  })
  return compileInventoryCatalog(canonicalMasterText, inventoryEntries, inventoryAudit)
}

export function schemaCatalogEncoding (minimum, maximum, name = 'schema catalog') {
  return arrayOf(schemaCatalogEntryV1, minimum, maximum, name)
}

export function encodeSchemaCatalog (entries, options = {}) {
  const minimum = options.minimum == null ? entries.length : options.minimum
  const maximum = options.maximum == null ? entries.length : options.maximum
  return encodeCanonical(schemaCatalogEncoding(minimum, maximum, options.name), entries)
}

export function decodeSchemaCatalog (bytes, options = {}) {
  const minimum = options.minimum == null ? 0 : options.minimum
  const maximum = options.maximum == null ? 0xffff : options.maximum
  const entries = decodeCanonical(schemaCatalogEncoding(minimum, maximum, options.name), bytes)
  const names = new Set()
  const ids = new Set()
  for (const entry of entries) {
    const name = b4a.toString(entry.schemaName, 'ascii')
    const id = `${entry.category}:${entry.categoryLocalSchemaId}`
    if (names.has(name) || ids.has(id)) fail('schema catalog contains a duplicate name or category-local ID')
    names.add(name)
    ids.add(id)
  }
  return entries
}