@noy-db/as-xml 0.3.0-pre.1 → 0.3.0-pre.3

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/dist/index.d.ts CHANGED
@@ -39,6 +39,26 @@ interface AsXMLOptions {
39
39
  readonly namespace?: string;
40
40
  /** Optional namespace prefix. Used together with `namespace`. */
41
41
  readonly namespacePrefix?: string;
42
+ /**
43
+ * Apply the hub's `applyListProjection` read-projection before
44
+ * serialising records. `true` redacts only `classifiedFields` (mask /
45
+ * omit / rider, per the field's preset). The object form additionally
46
+ * redacts fields carrying a plain `fieldMeta` `sensitivity: 'pii' |
47
+ * 'secret'` tag, per `sensitivity: 'omit' | 'mask'`.
48
+ *
49
+ * Caveat: `describe()` reflects the declarations of *this session's*
50
+ * collection instance — redaction only takes effect when the
51
+ * collection was opened (this call or earlier in the session) with
52
+ * its `classifiedFields` / `fieldMeta` options. This is presentation-
53
+ * layer redaction; it never affects what's on disk. Sealed handles
54
+ * are unaffected either way — they always serialize as `'[sealed]'`,
55
+ * so ciphertext never leaks regardless of this option. Rider companion
56
+ * fields (e.g. `pan_last4`) remain visible as their own elements —
57
+ * they are safe write-time projections.
58
+ */
59
+ readonly redact?: boolean | {
60
+ readonly sensitivity: 'omit' | 'mask';
61
+ };
42
62
  }
43
63
  interface AsXMLDownloadOptions extends AsXMLOptions {
44
64
  /** Filename offered to the browser. Default `'<collection>.xml'`. */
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/index.ts
2
+ import { applyListProjection } from "@noy-db/hub";
2
3
  import { diffVault } from "@noy-db/hub";
3
4
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
5
  async function toString(vault, options) {
@@ -10,6 +11,11 @@ async function toString(vault, options) {
10
11
  break;
11
12
  }
12
13
  }
14
+ if (options.redact !== void 0 && options.redact !== false) {
15
+ const desc = vault.collection(options.collection).describe();
16
+ const projectionOpts = options.redact === true ? void 0 : { sensitivity: options.redact.sensitivity };
17
+ records.splice(0, records.length, ...records.map((r) => applyListProjection(desc, r, projectionOpts)));
18
+ }
13
19
  const rootName = options.rootElement ?? "Records";
14
20
  const recordName = options.recordElement ?? pascalSingular(options.collection);
15
21
  const fields = options.fields ?? inferFields(records);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/as-xml** — XML plaintext export for noy-db.\n *\n * Hand-rolled XML emitter — zero dependencies, ~100 LoC. Escapes the\n * five predefined XML entities (`<`, `>`, `&`, `'`, `\"`) and numeric\n * control characters. Supports custom root/record element names,\n * namespaces, pretty-printing, and XML declarations.\n *\n * **Scope.** Single collection per call (mirroring as-csv's shape).\n * Multi-collection XML documents are best produced by the consumer\n * composing multiple `toString()` calls into a wrapping element.\n *\n * ### When to use\n *\n * - Legacy systems requiring XML input (accounting software, SOAP).\n * - Banking batch imports (ISO 20022, CAMT/PAIN files consumers wrap).\n * - Excel `.xml` SpreadsheetML 2003 legacy format (wrap in a\n * Workbook template).\n *\n * @packageDocumentation\n */\n\nimport type { Vault } from '@noy-db/hub'\n\nexport interface AsXMLOptions {\n /** Collection to export. */\n readonly collection: string\n /** Root element name. Default `'Records'`. */\n readonly rootElement?: string\n /** Per-record element name. Default pascal-cased singular of collection. */\n readonly recordElement?: string\n /** Explicit field list — defaults to inferred columns. */\n readonly fields?: readonly string[]\n /** Include `<?xml version=\"1.0\" encoding=\"UTF-8\"?>` declaration. Default `true`. */\n readonly xmlDeclaration?: boolean\n /** Pretty-print with 2-space indentation. Default `true`. */\n readonly pretty?: boolean\n /** Optional XML namespace on the root element. */\n readonly namespace?: string\n /** Optional namespace prefix. Used together with `namespace`. */\n readonly namespacePrefix?: string\n}\n\nexport interface AsXMLDownloadOptions extends AsXMLOptions {\n /** Filename offered to the browser. Default `'<collection>.xml'`. */\n readonly filename?: string\n}\n\nexport interface AsXMLWriteOptions extends AsXMLOptions {\n /** Required for Node file-write — Tier 3 risk gate. */\n readonly acknowledgeRisks: true\n}\n\nexport async function toString(vault: Vault, options: AsXMLOptions): Promise<string> {\n vault.assertCanExport('plaintext', 'xml')\n\n const records: unknown[] = []\n for await (const chunk of vault.exportStream({ granularity: 'collection' })) {\n if (chunk.collection === options.collection) {\n records.push(...chunk.records)\n break\n }\n }\n\n const rootName = options.rootElement ?? 'Records'\n const recordName = options.recordElement ?? pascalSingular(options.collection)\n const fields = options.fields ?? inferFields(records)\n const pretty = options.pretty !== false\n const declaration = options.xmlDeclaration !== false\n const indent = pretty ? ' ' : ''\n const nl = pretty ? '\\n' : ''\n\n const parts: string[] = []\n if (declaration) parts.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>' + nl)\n\n const nsAttr = options.namespace\n ? options.namespacePrefix\n ? ` xmlns:${options.namespacePrefix}=\"${escapeAttr(options.namespace)}\"`\n : ` xmlns=\"${escapeAttr(options.namespace)}\"`\n : ''\n const rootTag = options.namespacePrefix\n ? `${options.namespacePrefix}:${rootName}`\n : rootName\n const recordTag = options.namespacePrefix\n ? `${options.namespacePrefix}:${recordName}`\n : recordName\n\n parts.push(`<${rootTag}${nsAttr}>`)\n for (const record of records) {\n parts.push(nl + indent + `<${recordTag}>`)\n for (const field of fields) {\n const value = (record as Record<string, unknown>)[field]\n if (value === undefined) continue\n const tag = escapeElementName(field)\n parts.push(nl + indent + indent + `<${tag}>${escapeText(serializeValue(value))}</${tag}>`)\n }\n parts.push(nl + indent + `</${recordTag}>`)\n }\n parts.push(nl + `</${rootTag}>`)\n return parts.join('')\n}\n\nexport async function download(vault: Vault, options: AsXMLDownloadOptions): Promise<void> {\n const xml = await toString(vault, options)\n const filename = options.filename ?? `${options.collection}.xml`\n const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n a.click()\n URL.revokeObjectURL(url)\n}\n\nexport async function write(vault: Vault, path: string, options: AsXMLWriteOptions): Promise<void> {\n if (options.acknowledgeRisks !== true) {\n throw new Error(\n 'as-xml.write: acknowledgeRisks: true is required for on-disk plaintext output. ' +\n 'See docs/patterns/as-exports.md §\"The three tiers of \\\\\"plaintext out\\\\\"\"',\n )\n }\n const xml = await toString(vault, options)\n const { writeFile } = await import('node:fs/promises')\n await writeFile(path, xml, 'utf-8')\n}\n\n// ─── XML formatting internals ───────────────────────────────────────────\n\nconst XML_ENTITIES: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&apos;',\n}\n\nfunction escapeText(s: string): string {\n let out = s.replace(/[&<>]/g, ch => XML_ENTITIES[ch]!)\n // Strip XML-invalid control characters (U+0000–U+0008, U+000B, U+000C, U+000E–U+001F).\n // Done as a char-by-char scan to keep the regex free of control-character literals\n // (which eslint's no-control-regex flags).\n let cleaned = ''\n for (let i = 0; i < out.length; i++) {\n const code = out.charCodeAt(i)\n if (\n (code >= 0x00 && code <= 0x08) ||\n code === 0x0B ||\n code === 0x0C ||\n (code >= 0x0E && code <= 0x1F)\n ) continue\n cleaned += out[i]\n }\n out = cleaned\n return out\n}\n\nfunction escapeAttr(s: string): string {\n return s.replace(/[&<>\"']/g, ch => XML_ENTITIES[ch]!)\n}\n\nfunction escapeElementName(name: string): string {\n // XML element names must start with a letter/underscore and contain only\n // letters, digits, hyphens, underscores, dots. Replace everything else.\n const safe = name.replace(/[^A-Za-z0-9_.-]/g, '_')\n return /^[A-Za-z_]/.test(safe) ? safe : `_${safe}`\n}\n\nfunction serializeValue(value: unknown): string {\n if (value === null) return ''\n if (typeof value === 'string') return value\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (value instanceof Date) return value.toISOString()\n return JSON.stringify(value)\n}\n\nfunction inferFields(records: readonly unknown[]): string[] {\n const seen = new Set<string>()\n const fields: string[] = []\n for (const r of records) {\n if (r && typeof r === 'object') {\n for (const k of Object.keys(r)) {\n if (k === '_v' || k === '_ts' || k === '_by' || k === '_iv' || k === '_data' || k === '_noydb') continue\n if (!seen.has(k)) {\n seen.add(k)\n fields.push(k)\n }\n }\n }\n }\n return fields\n}\n\nfunction pascalSingular(collection: string): string {\n const singular = collection.endsWith('s') ? collection.slice(0, -1) : collection\n return singular.charAt(0).toUpperCase() + singular.slice(1)\n}\n\n// ─── Reader ──────────────────────────────────────\n\nimport { diffVault, type VaultDiff } from '@noy-db/hub'\nimport { XMLParser, XMLValidator } from 'fast-xml-parser'\n\nexport type ImportPolicy = 'merge' | 'replace' | 'insert-only'\n\nexport interface AsXMLImportOptions {\n /** Target collection. Required — XML has no native collection grouping. */\n readonly collection: string\n /**\n * Optional explicit record-element name. When omitted, the reader\n * picks the first repeated child of the root. (Writer's default is\n * `pascalSingular(collection)` — pass that here for symmetric\n * round-tripping if you wrote with a custom `recordElement`.)\n */\n readonly recordElement?: string\n /**\n * Optional field type hints. Cells are returned as strings unless\n * overridden. Same shape as `as-csv.fromString`'s `columnTypes`.\n */\n readonly fieldTypes?: Record<string, 'string' | 'number' | 'boolean'>\n /** Field carrying the record id. Default `'id'`. */\n readonly idKey?: string\n /** Reconciliation policy. Default `'merge'`. */\n readonly policy?: ImportPolicy\n}\n\nexport interface AsXMLImportPlan {\n readonly plan: VaultDiff\n readonly policy: ImportPolicy\n apply(): Promise<void>\n}\n\n/**\n * Parse XML into records and build an import plan. Inverts what\n * `toString()` writes: root → recordElement[] → field elements with\n * text content. Field values default to strings; pass `fieldTypes` to\n * coerce numbers / booleans on read.\n *\n * Throws on malformed XML — fast-xml-parser is invoked in strict mode\n * so unbalanced tags or invalid character sequences fail fast.\n *\n * Capability: `assertCanImport('plaintext', 'xml')`.\n * Atomicity: `apply()` runs inside `vault.noydb.transaction()`.\n */\nexport async function fromString(\n vault: Vault,\n xml: string,\n options: AsXMLImportOptions,\n): Promise<AsXMLImportPlan> {\n vault.assertCanImport('plaintext', 'xml')\n\n const policy: ImportPolicy = options.policy ?? 'merge'\n const idKey = options.idKey ?? 'id'\n const types = options.fieldTypes ?? {}\n\n const parser = new XMLParser({\n ignoreAttributes: true,\n parseTagValue: false, // keep raw strings — we coerce ourselves\n parseAttributeValue: false,\n trimValues: true,\n // Only force-as-array the record element so a single-record XML still\n // produces an array. We'll narrow this when we discover the element name.\n })\n\n // Strict validation first — fast-xml-parser is permissive by default\n // (silently accepts unbalanced tags). XMLValidator returns true on\n // success or an error object describing the position of the failure.\n const validation = XMLValidator.validate(xml)\n if (validation !== true) {\n const err = validation.err\n throw new Error(\n `as-xml.fromString: input is not valid XML (${err.code} at line ${err.line}: ${err.msg})`,\n )\n }\n\n let parsed: unknown\n try {\n parsed = parser.parse(xml)\n } catch (err) {\n throw new Error(`as-xml.fromString: input is not valid XML (${(err as Error).message})`)\n }\n\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('as-xml.fromString: parser produced a non-object root')\n }\n\n // Drill into the root. fast-xml-parser surfaces `<Root><Item/>...</Root>` as\n // `{ Root: { Item: [...] | {...} } }`. Strip namespace prefixes from element\n // names by splitting on `:` so the writer's `<ns:Records>` round-trips.\n const root = stripNamespacePrefixes(parsed as Record<string, unknown>)\n const rootKeys = Object.keys(root)\n if (rootKeys.length === 0) {\n return emptyPlan(vault, options.collection, policy, idKey)\n }\n // Pick first non-`?xml` root child — fast-xml-parser ignores the\n // declaration by default but be defensive.\n const rootName = rootKeys.find((k) => !k.startsWith('?')) ?? rootKeys[0]!\n const rootBody = stripIfObject(root[rootName])\n\n if (rootBody === null) {\n return emptyPlan(vault, options.collection, policy, idKey)\n }\n\n const recordElName = options.recordElement\n ? stripPrefix(options.recordElement)\n : pickRecordElementName(rootBody)\n\n if (recordElName === null) {\n return emptyPlan(vault, options.collection, policy, idKey)\n }\n\n const recordsRaw = rootBody[recordElName]\n const recordsArr = recordsRaw === undefined ? []\n : Array.isArray(recordsRaw) ? recordsRaw : [recordsRaw]\n\n const records: Record<string, unknown>[] = []\n for (const r of recordsArr) {\n if (r === null || typeof r !== 'object' || Array.isArray(r)) continue\n const flat = stripNamespacePrefixes(r as Record<string, unknown>)\n const record: Record<string, unknown> = {}\n for (const [field, raw] of Object.entries(flat)) {\n // The XML parser exposes text-only elements as primitives or\n // empty objects (when the element is `<tag/>`). Normalize.\n const text = textValue(raw)\n record[field] = coerce(text, types[field])\n }\n records.push(record)\n }\n\n const plan = await diffVault(vault, { [options.collection]: records }, {\n collections: [options.collection],\n idKey,\n })\n\n return {\n plan,\n policy,\n async apply(): Promise<void> {\n // Routes through txStrategy seam — throws clearly when\n // withTransactions() isn't opted in. Atomicity rolls back any\n // partial writes if a put fails mid-batch.\n await vault.noydb.transaction((tx) => {\n const txVault = tx.vault(vault.name)\n for (const entry of plan.added) {\n txVault.collection(entry.collection).put(entry.id, entry.record, { reason: 'import:xml' })\n }\n if (policy !== 'insert-only') {\n for (const entry of plan.modified) {\n txVault.collection(entry.collection).put(entry.id, entry.record, { reason: 'import:xml' })\n }\n }\n if (policy === 'replace') {\n for (const entry of plan.deleted) {\n txVault.collection(entry.collection).delete(entry.id)\n }\n }\n })\n },\n }\n}\n\nasync function emptyPlan(\n vault: Vault,\n collection: string,\n policy: ImportPolicy,\n idKey: string,\n): Promise<AsXMLImportPlan> {\n const plan = await diffVault(vault, { [collection]: [] }, { collections: [collection], idKey })\n return { plan, policy, async apply() { /* nothing to do */ } }\n}\n\nfunction stripPrefix(name: string): string {\n const idx = name.indexOf(':')\n return idx === -1 ? name : name.slice(idx + 1)\n}\n\nfunction stripNamespacePrefixes(obj: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(obj)) {\n out[stripPrefix(k)] = v\n }\n return out\n}\n\nfunction stripIfObject(v: unknown): Record<string, unknown> | null {\n if (v === null || typeof v !== 'object' || Array.isArray(v)) return null\n return stripNamespacePrefixes(v as Record<string, unknown>)\n}\n\n/**\n * Pick the first non-attribute key in `body` — that's the per-record\n * element. Skips namespace-attribute keys like `@_xmlns:foo` (defensive\n * even though `ignoreAttributes: true` should drop them).\n */\nfunction pickRecordElementName(body: Record<string, unknown>): string | null {\n for (const k of Object.keys(body)) {\n if (k.startsWith('@_') || k.startsWith('?')) continue\n return k\n }\n return null\n}\n\n/**\n * Extract the text content from a parsed-element value. The parser\n * surfaces `<a>x</a>` as the string `\"x\"`, `<a/>` as the empty string,\n * and `<a><b>...</b></a>` as an object — we treat the last case as\n * \"not a leaf\" and stringify whatever's there.\n */\nfunction textValue(raw: unknown): string {\n if (raw === null || raw === undefined) return ''\n if (typeof raw === 'string') return raw\n if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw)\n return ''\n}\n\nfunction coerce(cell: string, type?: 'string' | 'number' | 'boolean'): unknown {\n if (type === 'number') {\n if (cell === '') return undefined\n const n = Number(cell)\n return Number.isFinite(n) ? n : cell\n }\n if (type === 'boolean') {\n if (cell === 'true') return true\n if (cell === 'false') return false\n return cell\n }\n return cell\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/as-xml** — XML plaintext export for noy-db.\n *\n * Hand-rolled XML emitter — zero dependencies, ~100 LoC. Escapes the\n * five predefined XML entities (`<`, `>`, `&`, `'`, `\"`) and numeric\n * control characters. Supports custom root/record element names,\n * namespaces, pretty-printing, and XML declarations.\n *\n * **Scope.** Single collection per call (mirroring as-csv's shape).\n * Multi-collection XML documents are best produced by the consumer\n * composing multiple `toString()` calls into a wrapping element.\n *\n * ### When to use\n *\n * - Legacy systems requiring XML input (accounting software, SOAP).\n * - Banking batch imports (ISO 20022, CAMT/PAIN files consumers wrap).\n * - Excel `.xml` SpreadsheetML 2003 legacy format (wrap in a\n * Workbook template).\n *\n * @packageDocumentation\n */\n\nimport type { Vault, CollectionDescription } from '@noy-db/hub'\nimport { applyListProjection } from '@noy-db/hub'\n\nexport interface AsXMLOptions {\n /** Collection to export. */\n readonly collection: string\n /** Root element name. Default `'Records'`. */\n readonly rootElement?: string\n /** Per-record element name. Default pascal-cased singular of collection. */\n readonly recordElement?: string\n /** Explicit field list — defaults to inferred columns. */\n readonly fields?: readonly string[]\n /** Include `<?xml version=\"1.0\" encoding=\"UTF-8\"?>` declaration. Default `true`. */\n readonly xmlDeclaration?: boolean\n /** Pretty-print with 2-space indentation. Default `true`. */\n readonly pretty?: boolean\n /** Optional XML namespace on the root element. */\n readonly namespace?: string\n /** Optional namespace prefix. Used together with `namespace`. */\n readonly namespacePrefix?: string\n /**\n * Apply the hub's `applyListProjection` read-projection before\n * serialising records. `true` redacts only `classifiedFields` (mask /\n * omit / rider, per the field's preset). The object form additionally\n * redacts fields carrying a plain `fieldMeta` `sensitivity: 'pii' |\n * 'secret'` tag, per `sensitivity: 'omit' | 'mask'`.\n *\n * Caveat: `describe()` reflects the declarations of *this session's*\n * collection instance — redaction only takes effect when the\n * collection was opened (this call or earlier in the session) with\n * its `classifiedFields` / `fieldMeta` options. This is presentation-\n * layer redaction; it never affects what's on disk. Sealed handles\n * are unaffected either way — they always serialize as `'[sealed]'`,\n * so ciphertext never leaks regardless of this option. Rider companion\n * fields (e.g. `pan_last4`) remain visible as their own elements —\n * they are safe write-time projections.\n */\n readonly redact?: boolean | { readonly sensitivity: 'omit' | 'mask' }\n}\n\nexport interface AsXMLDownloadOptions extends AsXMLOptions {\n /** Filename offered to the browser. Default `'<collection>.xml'`. */\n readonly filename?: string\n}\n\nexport interface AsXMLWriteOptions extends AsXMLOptions {\n /** Required for Node file-write — Tier 3 risk gate. */\n readonly acknowledgeRisks: true\n}\n\nexport async function toString(vault: Vault, options: AsXMLOptions): Promise<string> {\n vault.assertCanExport('plaintext', 'xml')\n\n const records: unknown[] = []\n for await (const chunk of vault.exportStream({ granularity: 'collection' })) {\n if (chunk.collection === options.collection) {\n records.push(...chunk.records)\n break\n }\n }\n\n if (options.redact !== undefined && options.redact !== false) {\n const desc: CollectionDescription = vault.collection(options.collection).describe()\n const projectionOpts = options.redact === true ? undefined\n : { sensitivity: options.redact.sensitivity }\n records.splice(0, records.length, ...records.map((r) => applyListProjection(desc, r as Record<string, unknown>, projectionOpts)))\n }\n\n const rootName = options.rootElement ?? 'Records'\n const recordName = options.recordElement ?? pascalSingular(options.collection)\n const fields = options.fields ?? inferFields(records)\n const pretty = options.pretty !== false\n const declaration = options.xmlDeclaration !== false\n const indent = pretty ? ' ' : ''\n const nl = pretty ? '\\n' : ''\n\n const parts: string[] = []\n if (declaration) parts.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>' + nl)\n\n const nsAttr = options.namespace\n ? options.namespacePrefix\n ? ` xmlns:${options.namespacePrefix}=\"${escapeAttr(options.namespace)}\"`\n : ` xmlns=\"${escapeAttr(options.namespace)}\"`\n : ''\n const rootTag = options.namespacePrefix\n ? `${options.namespacePrefix}:${rootName}`\n : rootName\n const recordTag = options.namespacePrefix\n ? `${options.namespacePrefix}:${recordName}`\n : recordName\n\n parts.push(`<${rootTag}${nsAttr}>`)\n for (const record of records) {\n parts.push(nl + indent + `<${recordTag}>`)\n for (const field of fields) {\n const value = (record as Record<string, unknown>)[field]\n if (value === undefined) continue\n const tag = escapeElementName(field)\n parts.push(nl + indent + indent + `<${tag}>${escapeText(serializeValue(value))}</${tag}>`)\n }\n parts.push(nl + indent + `</${recordTag}>`)\n }\n parts.push(nl + `</${rootTag}>`)\n return parts.join('')\n}\n\nexport async function download(vault: Vault, options: AsXMLDownloadOptions): Promise<void> {\n const xml = await toString(vault, options)\n const filename = options.filename ?? `${options.collection}.xml`\n const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n a.click()\n URL.revokeObjectURL(url)\n}\n\nexport async function write(vault: Vault, path: string, options: AsXMLWriteOptions): Promise<void> {\n if (options.acknowledgeRisks !== true) {\n throw new Error(\n 'as-xml.write: acknowledgeRisks: true is required for on-disk plaintext output. ' +\n 'See docs/patterns/as-exports.md §\"The three tiers of \\\\\"plaintext out\\\\\"\"',\n )\n }\n const xml = await toString(vault, options)\n const { writeFile } = await import('node:fs/promises')\n await writeFile(path, xml, 'utf-8')\n}\n\n// ─── XML formatting internals ───────────────────────────────────────────\n\nconst XML_ENTITIES: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&apos;',\n}\n\nfunction escapeText(s: string): string {\n let out = s.replace(/[&<>]/g, ch => XML_ENTITIES[ch]!)\n // Strip XML-invalid control characters (U+0000–U+0008, U+000B, U+000C, U+000E–U+001F).\n // Done as a char-by-char scan to keep the regex free of control-character literals\n // (which eslint's no-control-regex flags).\n let cleaned = ''\n for (let i = 0; i < out.length; i++) {\n const code = out.charCodeAt(i)\n if (\n (code >= 0x00 && code <= 0x08) ||\n code === 0x0B ||\n code === 0x0C ||\n (code >= 0x0E && code <= 0x1F)\n ) continue\n cleaned += out[i]\n }\n out = cleaned\n return out\n}\n\nfunction escapeAttr(s: string): string {\n return s.replace(/[&<>\"']/g, ch => XML_ENTITIES[ch]!)\n}\n\nfunction escapeElementName(name: string): string {\n // XML element names must start with a letter/underscore and contain only\n // letters, digits, hyphens, underscores, dots. Replace everything else.\n const safe = name.replace(/[^A-Za-z0-9_.-]/g, '_')\n return /^[A-Za-z_]/.test(safe) ? safe : `_${safe}`\n}\n\nfunction serializeValue(value: unknown): string {\n if (value === null) return ''\n if (typeof value === 'string') return value\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (value instanceof Date) return value.toISOString()\n return JSON.stringify(value)\n}\n\nfunction inferFields(records: readonly unknown[]): string[] {\n const seen = new Set<string>()\n const fields: string[] = []\n for (const r of records) {\n if (r && typeof r === 'object') {\n for (const k of Object.keys(r)) {\n if (k === '_v' || k === '_ts' || k === '_by' || k === '_iv' || k === '_data' || k === '_noydb') continue\n if (!seen.has(k)) {\n seen.add(k)\n fields.push(k)\n }\n }\n }\n }\n return fields\n}\n\nfunction pascalSingular(collection: string): string {\n const singular = collection.endsWith('s') ? collection.slice(0, -1) : collection\n return singular.charAt(0).toUpperCase() + singular.slice(1)\n}\n\n// ─── Reader ──────────────────────────────────────\n\nimport { diffVault, type VaultDiff } from '@noy-db/hub'\nimport { XMLParser, XMLValidator } from 'fast-xml-parser'\n\nexport type ImportPolicy = 'merge' | 'replace' | 'insert-only'\n\nexport interface AsXMLImportOptions {\n /** Target collection. Required — XML has no native collection grouping. */\n readonly collection: string\n /**\n * Optional explicit record-element name. When omitted, the reader\n * picks the first repeated child of the root. (Writer's default is\n * `pascalSingular(collection)` — pass that here for symmetric\n * round-tripping if you wrote with a custom `recordElement`.)\n */\n readonly recordElement?: string\n /**\n * Optional field type hints. Cells are returned as strings unless\n * overridden. Same shape as `as-csv.fromString`'s `columnTypes`.\n */\n readonly fieldTypes?: Record<string, 'string' | 'number' | 'boolean'>\n /** Field carrying the record id. Default `'id'`. */\n readonly idKey?: string\n /** Reconciliation policy. Default `'merge'`. */\n readonly policy?: ImportPolicy\n}\n\nexport interface AsXMLImportPlan {\n readonly plan: VaultDiff\n readonly policy: ImportPolicy\n apply(): Promise<void>\n}\n\n/**\n * Parse XML into records and build an import plan. Inverts what\n * `toString()` writes: root → recordElement[] → field elements with\n * text content. Field values default to strings; pass `fieldTypes` to\n * coerce numbers / booleans on read.\n *\n * Throws on malformed XML — fast-xml-parser is invoked in strict mode\n * so unbalanced tags or invalid character sequences fail fast.\n *\n * Capability: `assertCanImport('plaintext', 'xml')`.\n * Atomicity: `apply()` runs inside `vault.noydb.transaction()`.\n */\nexport async function fromString(\n vault: Vault,\n xml: string,\n options: AsXMLImportOptions,\n): Promise<AsXMLImportPlan> {\n vault.assertCanImport('plaintext', 'xml')\n\n const policy: ImportPolicy = options.policy ?? 'merge'\n const idKey = options.idKey ?? 'id'\n const types = options.fieldTypes ?? {}\n\n const parser = new XMLParser({\n ignoreAttributes: true,\n parseTagValue: false, // keep raw strings — we coerce ourselves\n parseAttributeValue: false,\n trimValues: true,\n // Only force-as-array the record element so a single-record XML still\n // produces an array. We'll narrow this when we discover the element name.\n })\n\n // Strict validation first — fast-xml-parser is permissive by default\n // (silently accepts unbalanced tags). XMLValidator returns true on\n // success or an error object describing the position of the failure.\n const validation = XMLValidator.validate(xml)\n if (validation !== true) {\n const err = validation.err\n throw new Error(\n `as-xml.fromString: input is not valid XML (${err.code} at line ${err.line}: ${err.msg})`,\n )\n }\n\n let parsed: unknown\n try {\n parsed = parser.parse(xml)\n } catch (err) {\n throw new Error(`as-xml.fromString: input is not valid XML (${(err as Error).message})`)\n }\n\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('as-xml.fromString: parser produced a non-object root')\n }\n\n // Drill into the root. fast-xml-parser surfaces `<Root><Item/>...</Root>` as\n // `{ Root: { Item: [...] | {...} } }`. Strip namespace prefixes from element\n // names by splitting on `:` so the writer's `<ns:Records>` round-trips.\n const root = stripNamespacePrefixes(parsed as Record<string, unknown>)\n const rootKeys = Object.keys(root)\n if (rootKeys.length === 0) {\n return emptyPlan(vault, options.collection, policy, idKey)\n }\n // Pick first non-`?xml` root child — fast-xml-parser ignores the\n // declaration by default but be defensive.\n const rootName = rootKeys.find((k) => !k.startsWith('?')) ?? rootKeys[0]!\n const rootBody = stripIfObject(root[rootName])\n\n if (rootBody === null) {\n return emptyPlan(vault, options.collection, policy, idKey)\n }\n\n const recordElName = options.recordElement\n ? stripPrefix(options.recordElement)\n : pickRecordElementName(rootBody)\n\n if (recordElName === null) {\n return emptyPlan(vault, options.collection, policy, idKey)\n }\n\n const recordsRaw = rootBody[recordElName]\n const recordsArr = recordsRaw === undefined ? []\n : Array.isArray(recordsRaw) ? recordsRaw : [recordsRaw]\n\n const records: Record<string, unknown>[] = []\n for (const r of recordsArr) {\n if (r === null || typeof r !== 'object' || Array.isArray(r)) continue\n const flat = stripNamespacePrefixes(r as Record<string, unknown>)\n const record: Record<string, unknown> = {}\n for (const [field, raw] of Object.entries(flat)) {\n // The XML parser exposes text-only elements as primitives or\n // empty objects (when the element is `<tag/>`). Normalize.\n const text = textValue(raw)\n record[field] = coerce(text, types[field])\n }\n records.push(record)\n }\n\n const plan = await diffVault(vault, { [options.collection]: records }, {\n collections: [options.collection],\n idKey,\n })\n\n return {\n plan,\n policy,\n async apply(): Promise<void> {\n // Routes through txStrategy seam — throws clearly when\n // withTransactions() isn't opted in. Atomicity rolls back any\n // partial writes if a put fails mid-batch.\n await vault.noydb.transaction((tx) => {\n const txVault = tx.vault(vault.name)\n for (const entry of plan.added) {\n txVault.collection(entry.collection).put(entry.id, entry.record, { reason: 'import:xml' })\n }\n if (policy !== 'insert-only') {\n for (const entry of plan.modified) {\n txVault.collection(entry.collection).put(entry.id, entry.record, { reason: 'import:xml' })\n }\n }\n if (policy === 'replace') {\n for (const entry of plan.deleted) {\n txVault.collection(entry.collection).delete(entry.id)\n }\n }\n })\n },\n }\n}\n\nasync function emptyPlan(\n vault: Vault,\n collection: string,\n policy: ImportPolicy,\n idKey: string,\n): Promise<AsXMLImportPlan> {\n const plan = await diffVault(vault, { [collection]: [] }, { collections: [collection], idKey })\n return { plan, policy, async apply() { /* nothing to do */ } }\n}\n\nfunction stripPrefix(name: string): string {\n const idx = name.indexOf(':')\n return idx === -1 ? name : name.slice(idx + 1)\n}\n\nfunction stripNamespacePrefixes(obj: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(obj)) {\n out[stripPrefix(k)] = v\n }\n return out\n}\n\nfunction stripIfObject(v: unknown): Record<string, unknown> | null {\n if (v === null || typeof v !== 'object' || Array.isArray(v)) return null\n return stripNamespacePrefixes(v as Record<string, unknown>)\n}\n\n/**\n * Pick the first non-attribute key in `body` — that's the per-record\n * element. Skips namespace-attribute keys like `@_xmlns:foo` (defensive\n * even though `ignoreAttributes: true` should drop them).\n */\nfunction pickRecordElementName(body: Record<string, unknown>): string | null {\n for (const k of Object.keys(body)) {\n if (k.startsWith('@_') || k.startsWith('?')) continue\n return k\n }\n return null\n}\n\n/**\n * Extract the text content from a parsed-element value. The parser\n * surfaces `<a>x</a>` as the string `\"x\"`, `<a/>` as the empty string,\n * and `<a><b>...</b></a>` as an object — we treat the last case as\n * \"not a leaf\" and stringify whatever's there.\n */\nfunction textValue(raw: unknown): string {\n if (raw === null || raw === undefined) return ''\n if (typeof raw === 'string') return raw\n if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw)\n return ''\n}\n\nfunction coerce(cell: string, type?: 'string' | 'number' | 'boolean'): unknown {\n if (type === 'number') {\n if (cell === '') return undefined\n const n = Number(cell)\n return Number.isFinite(n) ? n : cell\n }\n if (type === 'boolean') {\n if (cell === 'true') return true\n if (cell === 'false') return false\n return cell\n }\n return cell\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/as-xml",
3
- "version": "0.3.0-pre.1",
3
+ "version": "0.3.0-pre.3",
4
4
  "description": "XML plaintext export for noy-db — decrypts records and formats as XML with RFC-compliant entity escaping. For legacy systems, banking batch imports, SOAP endpoints, and accounting software that requires XML. Gated by RFC #249 canExportPlaintext.",
5
5
  "license": "MIT",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -32,14 +32,14 @@
32
32
  "node": ">=22.0.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@noy-db/hub": "0.3.0-pre.1"
35
+ "@noy-db/hub": "0.3.0-pre.3"
36
36
  },
37
37
  "dependencies": {
38
38
  "fast-xml-parser": "^5.0.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^22.0.0",
42
- "@noy-db/hub": "0.3.0-pre.1"
42
+ "@noy-db/hub": "0.3.0-pre.3"
43
43
  },
44
44
  "keywords": [
45
45
  "noy-db",