@airalogy/aira-core 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  Core TypeScript parser and validator for Airalogy `.aira` archives.
4
4
 
5
- It opens `.aira` files in browser-compatible JavaScript, reads `_airalogy_archive/manifest.json`, lists archive members, loads JSON/text payloads, and validates manifest references, Record hashes, Protocol file hashes, and offline blob hashes.
5
+ It opens `.aira` files in browser-compatible JavaScript, reads `_airalogy_archive/manifest.json`, lists archive members, loads JSON/text payloads, and validates manifest references, Record payload structure, Record hashes, Protocol file hashes, and offline blob hashes.
6
6
 
7
7
  Supported archive kinds are `protocol`, `protocols`, and `records`.
8
8
 
9
9
  Example archives covering these kinds are available in `examples/aira/`.
10
10
 
11
+ The public manifest schema is available at `schemas/aira/manifest.v1.schema.json`; the public Record schema is available at `schemas/aira/record.v1.schema.json`.
12
+
11
13
  ```ts
12
14
  import { openAiraArchive } from '@airalogy/aira-core'
13
15
 
@@ -0,0 +1,117 @@
1
+ export declare const AIRA_MANIFEST_PATH = "_airalogy_archive/manifest.json";
2
+ export declare const AIRA_ARCHIVE_FORMAT = "airalogy.archive";
3
+ export declare const AIRALOGY_RECORD_FORMAT = "airalogy.record";
4
+ export declare const AIRALOGY_RECORD_SCHEMA_VERSION = 1;
5
+ export type AiraArchiveKind = 'protocol' | 'protocols' | 'records';
6
+ export interface AiraEntry {
7
+ name: string;
8
+ compressedSize: number;
9
+ uncompressedSize: number;
10
+ compressionMethod: number;
11
+ localHeaderOffset: number;
12
+ }
13
+ export interface AiraProtocolManifest {
14
+ protocol_id?: string | null;
15
+ protocol_version?: string | null;
16
+ protocol_name?: string | null;
17
+ entrypoint?: string;
18
+ archive_root?: string;
19
+ files?: string[];
20
+ file_hashes?: Record<string, string>;
21
+ }
22
+ export interface AiraRecordManifest {
23
+ path: string;
24
+ record_id?: string | null;
25
+ record_version?: string | number | null;
26
+ protocol_id?: string | null;
27
+ protocol_version?: string | null;
28
+ sha1?: string | null;
29
+ sha256?: string | null;
30
+ source_path?: string;
31
+ source_index?: number;
32
+ embedded_protocol_root?: string | null;
33
+ }
34
+ export interface AiralogyRecordPayload {
35
+ format?: string;
36
+ schema_version?: number;
37
+ airalogy_record_id?: string | null;
38
+ record_id?: string | null;
39
+ record_version?: number | null;
40
+ metadata?: Record<string, unknown> | null;
41
+ data?: {
42
+ var?: Record<string, unknown>;
43
+ step?: Record<string, unknown>;
44
+ check?: Record<string, unknown>;
45
+ quiz?: Record<string, unknown>;
46
+ [key: string]: unknown;
47
+ };
48
+ files?: Array<Record<string, unknown>>;
49
+ [key: string]: unknown;
50
+ }
51
+ export interface AiraBlobManifest {
52
+ blob_id: string;
53
+ archive_path: string;
54
+ sha256: string;
55
+ size?: number;
56
+ }
57
+ export interface AiraFileManifest {
58
+ file_id?: string | null;
59
+ source_uri?: string | null;
60
+ blob_id?: string | null;
61
+ filename?: string | null;
62
+ mime_type?: string | null;
63
+ size?: number | null;
64
+ record_path?: string | null;
65
+ field_path?: string | null;
66
+ }
67
+ export interface AiraManifest {
68
+ format: string;
69
+ version: number;
70
+ kind: AiraArchiveKind;
71
+ created_at?: string;
72
+ protocol?: AiraProtocolManifest;
73
+ records?: AiraRecordManifest[];
74
+ protocols?: AiraProtocolManifest[];
75
+ blobs?: AiraBlobManifest[];
76
+ files?: AiraFileManifest[];
77
+ [key: string]: unknown;
78
+ }
79
+ export interface AiraSummary {
80
+ format: string;
81
+ version: number;
82
+ kind: AiraArchiveKind;
83
+ createdAt?: string;
84
+ memberCount: number;
85
+ recordCount: number;
86
+ protocolCount: number;
87
+ blobCount: number;
88
+ fileCount: number;
89
+ }
90
+ export interface AiraValidationResult {
91
+ ok: boolean;
92
+ issues: string[];
93
+ }
94
+ export declare class AiraArchive {
95
+ readonly bytes: Uint8Array;
96
+ readonly entries: AiraEntry[];
97
+ readonly manifest: AiraManifest;
98
+ private readonly entryMap;
99
+ private constructor();
100
+ static open(input: ArrayBuffer | Blob | Uint8Array): Promise<AiraArchive>;
101
+ has(path: string): boolean;
102
+ summary(): AiraSummary;
103
+ readBytes(path: string): Promise<Uint8Array>;
104
+ readText(path: string): Promise<string>;
105
+ readJson<T = unknown>(path: string): Promise<T>;
106
+ validate(): Promise<AiraValidationResult>;
107
+ private validateProtocol;
108
+ private validateProtocolList;
109
+ private validateRecords;
110
+ private validateBlobs;
111
+ private validateFileReferences;
112
+ }
113
+ export declare function openAiraArchive(input: ArrayBuffer | Blob | Uint8Array): Promise<AiraArchive>;
114
+ export declare function readAiraArchiveSummary(input: ArrayBuffer | Blob | Uint8Array): Promise<AiraSummary>;
115
+ export declare function prettyPrintJson(value: unknown): string;
116
+ export declare function encodeUtf8(value: string): Uint8Array;
117
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,83 +1,112 @@
1
- const b = "_airalogy_archive/manifest.json", g = "airalogy.archive", A = new TextDecoder("utf-8"), E = new TextEncoder(), R = /^[0-9a-f]{64}$/;
1
+ const y = "_airalogy_archive/manifest.json", g = "airalogy.archive", E = "airalogy.record", k = 1, R = new TextDecoder("utf-8"), x = new TextEncoder(), j = /^[0-9a-f]{64}$/;
2
2
  function m(i, t) {
3
3
  return i.getUint16(t, !0);
4
4
  }
5
- function p(i, t) {
5
+ function u(i, t) {
6
6
  return i.getUint32(t, !0);
7
7
  }
8
- function U(i) {
9
- return A.decode(i);
8
+ function I(i) {
9
+ return R.decode(i);
10
10
  }
11
11
  function v(i) {
12
12
  return !i || i.startsWith("/") ? `Archive member '${i}' uses an absolute or empty path.` : i.split("/").some((t) => t === "..") ? `Archive member '${i}' escapes the archive root.` : null;
13
13
  }
14
- function k(i) {
14
+ function p(i) {
15
+ return !!i && typeof i == "object" && !Array.isArray(i);
16
+ }
17
+ function D(i, t) {
18
+ const e = [];
19
+ if (!p(i))
20
+ return [`${t} must be a JSON object.`];
21
+ i.format !== void 0 && i.format !== E && e.push(`${t} format must be '${E}' when present.`), i.schema_version !== void 0 && i.schema_version !== 1 && e.push(`${t} schema_version must be 1 when present.`), i.record_id !== void 0 && i.record_id !== null && (typeof i.record_id != "string" || i.record_id.length === 0) && e.push(`${t} record_id must be a non-empty string when present.`), i.airalogy_record_id !== void 0 && i.airalogy_record_id !== null && (typeof i.airalogy_record_id != "string" || i.airalogy_record_id.length === 0) && e.push(`${t} airalogy_record_id must be a non-empty string when present.`);
22
+ const o = i.record_version;
23
+ if (o != null && (typeof o != "number" || !Number.isInteger(o) || o < 1) && e.push(`${t} record_version must be a positive integer when present.`), i.metadata !== void 0 && i.metadata !== null && !p(i.metadata) && e.push(`${t} metadata must be an object when present.`), !p(i.data))
24
+ return e.push(`${t} data must be an object.`), e;
25
+ p(i.data.var) || e.push(`${t} data.var must be an object.`);
26
+ for (const s of ["step", "check", "quiz"]) {
27
+ const r = i.data[s];
28
+ r != null && !p(r) && e.push(`${t} data.${s} must be an object when present.`);
29
+ }
30
+ if (i.files !== void 0)
31
+ if (!Array.isArray(i.files))
32
+ e.push(`${t} files must be a list when present.`);
33
+ else
34
+ for (const [s, r] of i.files.entries()) {
35
+ if (!p(r)) {
36
+ e.push(`${t} files[${s + 1}] must be an object.`);
37
+ continue;
38
+ }
39
+ ["file_id", "source_uri", "blob_id"].some((n) => typeof r[n] == "string" && r[n]) || e.push(`${t} files[${s + 1}] must include file_id, source_uri, or blob_id.`);
40
+ }
41
+ return e;
42
+ }
43
+ function C(i) {
15
44
  const t = Math.max(0, i.length - 65535 - 22);
16
- for (let o = i.length - 22; o >= t; o -= 1)
17
- if (i[o] === 80 && i[o + 1] === 75 && i[o + 2] === 5 && i[o + 3] === 6)
18
- return o;
45
+ for (let e = i.length - 22; e >= t; e -= 1)
46
+ if (i[e] === 80 && i[e + 1] === 75 && i[e + 2] === 5 && i[e + 3] === 6)
47
+ return e;
19
48
  throw new Error("Archive is not a valid zip file: EOCD not found.");
20
49
  }
21
- function D(i) {
22
- const t = new DataView(i.buffer, i.byteOffset, i.byteLength), o = k(i), n = m(t, o + 10), s = p(t, o + 16), c = [];
23
- let e = s;
24
- for (let r = 0; r < n; r += 1) {
25
- if (p(t, e) !== 33639248)
26
- throw new Error(`Archive central directory is invalid at offset ${e}.`);
27
- const a = m(t, e + 10), h = p(t, e + 20), l = p(t, e + 24), f = m(t, e + 28), d = m(t, e + 30), $ = m(t, e + 32), u = p(t, e + 42), w = e + 46, S = U(i.slice(w, w + f));
28
- if (p(t, u) !== 67324752)
50
+ function M(i) {
51
+ const t = new DataView(i.buffer, i.byteOffset, i.byteLength), e = C(i), o = m(t, e + 10), s = u(t, e + 16), r = [];
52
+ let n = s;
53
+ for (let a = 0; a < o; a += 1) {
54
+ if (u(t, n) !== 33639248)
55
+ throw new Error(`Archive central directory is invalid at offset ${n}.`);
56
+ const c = m(t, n + 10), f = u(t, n + 20), d = u(t, n + 24), h = m(t, n + 28), l = m(t, n + 30), _ = m(t, n + 32), b = u(t, n + 42), w = n + 46, S = I(i.slice(w, w + h));
57
+ if (u(t, b) !== 67324752)
29
58
  throw new Error(`Archive local header is invalid for '${S}'.`);
30
- const B = m(t, u + 26), P = m(t, u + 28), x = u + 30 + B + P;
31
- c.push({
59
+ const B = m(t, b + 26), O = m(t, b + 28), P = b + 30 + B + O;
60
+ r.push({
32
61
  name: S,
33
- compressionMethod: a,
34
- compressedSize: h,
35
- uncompressedSize: l,
36
- localHeaderOffset: u,
37
- compressedDataStart: x
38
- }), e = w + f + d + $;
62
+ compressionMethod: c,
63
+ compressedSize: f,
64
+ uncompressedSize: d,
65
+ localHeaderOffset: b,
66
+ compressedDataStart: P
67
+ }), n = w + h + l + _;
39
68
  }
40
- return c.filter((r) => !r.name.endsWith("/"));
69
+ return r.filter((a) => !a.name.endsWith("/"));
41
70
  }
42
- async function j(i) {
71
+ async function U(i) {
43
72
  const t = globalThis.DecompressionStream;
44
73
  if (!t)
45
74
  throw new Error("This browser does not support DecompressionStream.");
46
- const o = new Blob([i]).stream().pipeThrough(new t("deflate-raw")), n = await new Response(o).arrayBuffer();
47
- return new Uint8Array(n);
75
+ const e = new Blob([i]).stream().pipeThrough(new t("deflate-raw")), o = await new Response(e).arrayBuffer();
76
+ return new Uint8Array(o);
48
77
  }
49
- async function _(i) {
78
+ async function A(i) {
50
79
  const t = await crypto.subtle.digest("SHA-256", i);
51
- return Array.from(new Uint8Array(t)).map((o) => o.toString(16).padStart(2, "0")).join("");
80
+ return Array.from(new Uint8Array(t)).map((e) => e.toString(16).padStart(2, "0")).join("");
52
81
  }
53
- class y {
82
+ class $ {
54
83
  bytes;
55
84
  entries;
56
85
  manifest;
57
86
  entryMap;
58
- constructor(t, o, n) {
59
- this.bytes = t, this.entries = o.map(({ compressedDataStart: s, ...c }) => c), this.entryMap = new Map(o.map((s) => [s.name, s])), this.manifest = n;
87
+ constructor(t, e, o) {
88
+ this.bytes = t, this.entries = e.map(({ compressedDataStart: s, ...r }) => r), this.entryMap = new Map(e.map((s) => [s.name, s])), this.manifest = o;
60
89
  }
61
90
  static async open(t) {
62
- const o = t instanceof Uint8Array ? t : t instanceof Blob ? new Uint8Array(await t.arrayBuffer()) : new Uint8Array(t), n = D(o);
63
- if (!n.find((r) => r.name === b))
64
- throw new Error(`Archive does not contain '${b}'.`);
65
- const e = await new y(o, n, {
91
+ const e = t instanceof Uint8Array ? t : t instanceof Blob ? new Uint8Array(await t.arrayBuffer()) : new Uint8Array(t), o = M(e);
92
+ if (!o.find((a) => a.name === y))
93
+ throw new Error(`Archive does not contain '${y}'.`);
94
+ const n = await new $(e, o, {
66
95
  format: g,
67
96
  version: 0,
68
97
  kind: "records"
69
- }).readJson(b);
70
- if (e.format !== g)
71
- throw new Error(`Unsupported archive format '${String(e.format)}'.`);
72
- if (e.kind !== "protocol" && e.kind !== "protocols" && e.kind !== "records")
73
- throw new Error(`Unsupported archive kind '${String(e.kind)}'.`);
74
- return new y(o, n, e);
98
+ }).readJson(y);
99
+ if (n.format !== g)
100
+ throw new Error(`Unsupported archive format '${String(n.format)}'.`);
101
+ if (n.kind !== "protocol" && n.kind !== "protocols" && n.kind !== "records")
102
+ throw new Error(`Unsupported archive kind '${String(n.kind)}'.`);
103
+ return new $(e, o, n);
75
104
  }
76
105
  has(t) {
77
106
  return this.entryMap.has(t);
78
107
  }
79
108
  summary() {
80
- const t = Array.isArray(this.manifest.records) ? this.manifest.records.length : 0, o = this.manifest.kind === "protocol" ? 1 : Array.isArray(this.manifest.protocols) ? this.manifest.protocols.length : 0, n = Array.isArray(this.manifest.blobs) ? this.manifest.blobs.length : 0, s = Array.isArray(this.manifest.files) ? this.manifest.files.length : 0;
109
+ const t = Array.isArray(this.manifest.records) ? this.manifest.records.length : 0, e = this.manifest.kind === "protocol" ? 1 : Array.isArray(this.manifest.protocols) ? this.manifest.protocols.length : 0, o = Array.isArray(this.manifest.blobs) ? this.manifest.blobs.length : 0, s = Array.isArray(this.manifest.files) ? this.manifest.files.length : 0;
81
110
  return {
82
111
  format: this.manifest.format,
83
112
  version: this.manifest.version,
@@ -85,207 +114,209 @@ class y {
85
114
  createdAt: this.manifest.created_at,
86
115
  memberCount: this.entries.length,
87
116
  recordCount: t,
88
- protocolCount: o,
89
- blobCount: n,
117
+ protocolCount: e,
118
+ blobCount: o,
90
119
  fileCount: s
91
120
  };
92
121
  }
93
122
  async readBytes(t) {
94
- const o = this.entryMap.get(t);
95
- if (!o)
123
+ const e = this.entryMap.get(t);
124
+ if (!e)
96
125
  throw new Error(`Archive member '${t}' not found.`);
97
- const n = this.bytes.slice(
98
- o.compressedDataStart,
99
- o.compressedDataStart + o.compressedSize
126
+ const o = this.bytes.slice(
127
+ e.compressedDataStart,
128
+ e.compressedDataStart + e.compressedSize
100
129
  );
101
- if (o.compressionMethod === 0)
102
- return n;
103
- if (o.compressionMethod === 8)
104
- return j(n);
105
- throw new Error(`Archive member '${t}' uses unsupported compression method ${o.compressionMethod}.`);
130
+ if (e.compressionMethod === 0)
131
+ return o;
132
+ if (e.compressionMethod === 8)
133
+ return U(o);
134
+ throw new Error(`Archive member '${t}' uses unsupported compression method ${e.compressionMethod}.`);
106
135
  }
107
136
  async readText(t) {
108
- return A.decode(await this.readBytes(t));
137
+ return R.decode(await this.readBytes(t));
109
138
  }
110
139
  async readJson(t) {
111
140
  return JSON.parse(await this.readText(t));
112
141
  }
113
142
  async validate() {
114
- const t = [], o = new Set(this.entries.map((n) => n.name));
115
- for (const n of this.entries) {
116
- const s = v(n.name);
143
+ const t = [], e = new Set(this.entries.map((o) => o.name));
144
+ for (const o of this.entries) {
145
+ const s = v(o.name);
117
146
  s && t.push(s);
118
147
  }
119
- return o.has(b) || t.push(`Archive is missing '${b}'.`), this.manifest.format !== g && t.push(`Unsupported archive format '${String(this.manifest.format)}'.`), this.manifest.version !== 1 && t.push(`Unsupported archive version '${String(this.manifest.version)}'.`), this.manifest.kind === "protocol" ? await this.validateProtocol(this.manifest.protocol, "", t) : this.manifest.kind === "protocols" ? await this.validateProtocolList(this.manifest.protocols, t, !0) : this.manifest.kind === "records" ? await this.validateRecords(t) : t.push(`Unsupported archive kind '${String(this.manifest.kind)}'.`), { ok: t.length === 0, issues: t };
148
+ return e.has(y) || t.push(`Archive is missing '${y}'.`), this.manifest.format !== g && t.push(`Unsupported archive format '${String(this.manifest.format)}'.`), this.manifest.version !== 1 && t.push(`Unsupported archive version '${String(this.manifest.version)}'.`), this.manifest.kind === "protocol" ? await this.validateProtocol(this.manifest.protocol, "", t) : this.manifest.kind === "protocols" ? await this.validateProtocolList(this.manifest.protocols, t, !0) : this.manifest.kind === "records" ? await this.validateRecords(t) : t.push(`Unsupported archive kind '${String(this.manifest.kind)}'.`), { ok: t.length === 0, issues: t };
120
149
  }
121
- async validateProtocol(t, o, n) {
150
+ async validateProtocol(t, e, o) {
122
151
  if (!t || typeof t != "object") {
123
- n.push("Protocol manifest entry must be an object.");
152
+ o.push("Protocol manifest entry must be an object.");
124
153
  return;
125
154
  }
126
- const s = t.entrypoint || "protocol.aimd", c = `${o}${s}`;
127
- this.has(c) || n.push(`Protocol entrypoint '${c}' is missing.`);
128
- const e = Array.isArray(t.files) ? t.files : [], r = t.file_hashes && typeof t.file_hashes == "object" ? t.file_hashes : {};
129
- for (const a of e) {
130
- const h = `${o}${a}`, l = v(h);
131
- if (l) {
132
- n.push(l);
155
+ const s = t.entrypoint || "protocol.aimd", r = `${e}${s}`;
156
+ this.has(r) || o.push(`Protocol entrypoint '${r}' is missing.`);
157
+ const n = Array.isArray(t.files) ? t.files : [], a = t.file_hashes && typeof t.file_hashes == "object" ? t.file_hashes : {};
158
+ for (const c of n) {
159
+ const f = `${e}${c}`, d = v(f);
160
+ if (d) {
161
+ o.push(d);
133
162
  continue;
134
163
  }
135
- if (!this.has(h)) {
136
- n.push(`Protocol file '${h}' is missing.`);
164
+ if (!this.has(f)) {
165
+ o.push(`Protocol file '${f}' is missing.`);
137
166
  continue;
138
167
  }
139
- const f = r[a];
140
- if (f) {
141
- const d = await _(await this.readBytes(h));
142
- d !== f && n.push(`Protocol file '${h}' sha256 mismatch: expected ${f}, got ${d}.`);
168
+ const h = a[c];
169
+ if (h) {
170
+ const l = await A(await this.readBytes(f));
171
+ l !== h && o.push(`Protocol file '${f}' sha256 mismatch: expected ${h}, got ${l}.`);
143
172
  }
144
173
  }
145
174
  }
146
- async validateProtocolList(t, o, n = !1) {
175
+ async validateProtocolList(t, e, o = !1) {
147
176
  const s = /* @__PURE__ */ new Set();
148
177
  if (!Array.isArray(t))
149
- return o.push("Protocols manifest field must be a list."), s;
150
- n && t.length === 0 && o.push("Protocols manifest field must include at least one protocol.");
151
- for (const [c, e] of t.entries()) {
152
- if (!e || typeof e != "object") {
153
- o.push(`Protocol manifest entry #${c + 1} must be an object.`);
178
+ return e.push("Protocols manifest field must be a list."), s;
179
+ o && t.length === 0 && e.push("Protocols manifest field must include at least one protocol.");
180
+ for (const [r, n] of t.entries()) {
181
+ if (!n || typeof n != "object") {
182
+ e.push(`Protocol manifest entry #${r + 1} must be an object.`);
154
183
  continue;
155
184
  }
156
- if (!e.archive_root) {
157
- o.push(`Protocol manifest entry #${c + 1} is missing archive_root.`);
185
+ if (!n.archive_root) {
186
+ e.push(`Protocol manifest entry #${r + 1} is missing archive_root.`);
158
187
  continue;
159
188
  }
160
- if (s.has(e.archive_root)) {
161
- o.push(`Protocol manifest entry #${c + 1} uses duplicate archive_root '${e.archive_root}'.`);
189
+ if (s.has(n.archive_root)) {
190
+ e.push(`Protocol manifest entry #${r + 1} uses duplicate archive_root '${n.archive_root}'.`);
162
191
  continue;
163
192
  }
164
- s.add(e.archive_root), await this.validateProtocol(e, `${e.archive_root.replace(/\/+$/, "")}/`, o);
193
+ s.add(n.archive_root), await this.validateProtocol(n, `${n.archive_root.replace(/\/+$/, "")}/`, e);
165
194
  }
166
195
  return s;
167
196
  }
168
197
  async validateRecords(t) {
169
- const o = Array.isArray(this.manifest.records) ? this.manifest.records : [], n = await this.validateProtocolList(this.manifest.protocols, t), s = /* @__PURE__ */ new Set();
170
- for (const [e, r] of o.entries()) {
171
- if (!r || typeof r != "object") {
172
- t.push(`Record manifest entry #${e + 1} must be an object.`);
198
+ const e = Array.isArray(this.manifest.records) ? this.manifest.records : [], o = await this.validateProtocolList(this.manifest.protocols, t), s = /* @__PURE__ */ new Set();
199
+ for (const [n, a] of e.entries()) {
200
+ if (!a || typeof a != "object") {
201
+ t.push(`Record manifest entry #${n + 1} must be an object.`);
173
202
  continue;
174
203
  }
175
- const a = r.path;
176
- if (!a) {
177
- t.push(`Record manifest entry #${e + 1} is missing a path.`);
204
+ const c = a.path;
205
+ if (!c) {
206
+ t.push(`Record manifest entry #${n + 1} is missing a path.`);
178
207
  continue;
179
208
  }
180
- const h = v(a);
181
- if (h) {
182
- t.push(h);
209
+ const f = v(c);
210
+ if (f) {
211
+ t.push(f);
183
212
  continue;
184
213
  }
185
- if (!this.has(a)) {
186
- t.push(`Record file '${a}' is missing.`);
214
+ if (!this.has(c)) {
215
+ t.push(`Record file '${c}' is missing.`);
187
216
  continue;
188
217
  }
189
- s.add(a);
190
- let l;
218
+ s.add(c);
219
+ let d, h;
191
220
  try {
192
- l = await this.readBytes(a), JSON.parse(A.decode(l));
221
+ d = await this.readBytes(c), h = JSON.parse(R.decode(d));
193
222
  } catch {
194
- t.push(`Record file '${a}' is not valid UTF-8 JSON.`);
223
+ t.push(`Record file '${c}' is not valid UTF-8 JSON.`);
195
224
  continue;
196
225
  }
197
- if (r.sha256) {
198
- const f = await _(l);
199
- f !== r.sha256 && t.push(`Record file '${a}' sha256 mismatch: expected ${r.sha256}, got ${f}.`);
226
+ if (t.push(...D(h, `Record file '${c}'`)), a.sha256) {
227
+ const l = await A(d);
228
+ l !== a.sha256 && t.push(`Record file '${c}' sha256 mismatch: expected ${a.sha256}, got ${l}.`);
200
229
  }
201
- r.embedded_protocol_root && !n.has(r.embedded_protocol_root) && t.push(`Record file '${a}' references missing embedded protocol root '${r.embedded_protocol_root}'.`);
230
+ a.embedded_protocol_root && !o.has(a.embedded_protocol_root) && t.push(`Record file '${c}' references missing embedded protocol root '${a.embedded_protocol_root}'.`);
202
231
  }
203
- const c = await this.validateBlobs(t);
204
- this.validateFileReferences(t, c, s);
232
+ const r = await this.validateBlobs(t);
233
+ this.validateFileReferences(t, r, s);
205
234
  }
206
235
  async validateBlobs(t) {
207
- const o = /* @__PURE__ */ new Set(), n = this.manifest.blobs;
208
- if (n === void 0)
209
- return o;
210
- if (!Array.isArray(n))
211
- return t.push("Blobs manifest field must be a list."), o;
236
+ const e = /* @__PURE__ */ new Set(), o = this.manifest.blobs;
237
+ if (o === void 0)
238
+ return e;
239
+ if (!Array.isArray(o))
240
+ return t.push("Blobs manifest field must be a list."), e;
212
241
  const s = /* @__PURE__ */ new Set();
213
- for (const [c, e] of n.entries()) {
214
- if (!e || typeof e != "object") {
215
- t.push(`Blob manifest entry #${c + 1} must be an object.`);
242
+ for (const [r, n] of o.entries()) {
243
+ if (!n || typeof n != "object") {
244
+ t.push(`Blob manifest entry #${r + 1} must be an object.`);
216
245
  continue;
217
246
  }
218
- const r = e.blob_id, a = e.archive_path, h = e.sha256;
219
- if (!r) {
220
- t.push(`Blob manifest entry #${c + 1} is missing blob_id.`);
247
+ const a = n.blob_id, c = n.archive_path, f = n.sha256;
248
+ if (!a) {
249
+ t.push(`Blob manifest entry #${r + 1} is missing blob_id.`);
221
250
  continue;
222
251
  }
223
- if (o.has(r)) {
224
- t.push(`Blob manifest entry #${c + 1} uses duplicate blob_id '${r}'.`);
252
+ if (e.has(a)) {
253
+ t.push(`Blob manifest entry #${r + 1} uses duplicate blob_id '${a}'.`);
225
254
  continue;
226
255
  }
227
- if (o.add(r), !h || !R.test(h)) {
228
- t.push(`Blob '${r}' must include a valid sha256 hash.`);
256
+ if (e.add(a), !f || !j.test(f)) {
257
+ t.push(`Blob '${a}' must include a valid sha256 hash.`);
229
258
  continue;
230
259
  }
231
- const l = `sha256:${h}`;
232
- if (r !== l && t.push(`Blob '${r}' does not match sha256-derived id '${l}'.`), !a) {
233
- t.push(`Blob '${r}' is missing archive_path.`);
260
+ const d = `sha256:${f}`;
261
+ if (a !== d && t.push(`Blob '${a}' does not match sha256-derived id '${d}'.`), !c) {
262
+ t.push(`Blob '${a}' is missing archive_path.`);
234
263
  continue;
235
264
  }
236
- const f = v(a);
237
- if (f) {
238
- t.push(f);
265
+ const h = v(c);
266
+ if (h) {
267
+ t.push(h);
239
268
  continue;
240
269
  }
241
- if (a.startsWith("blobs/sha256/") || t.push(`Blob '${r}' archive_path must be under 'blobs/sha256/'.`), s.has(a)) {
242
- t.push(`Blob '${r}' uses duplicate archive_path '${a}'.`);
270
+ if (c.startsWith("blobs/sha256/") || t.push(`Blob '${a}' archive_path must be under 'blobs/sha256/'.`), s.has(c)) {
271
+ t.push(`Blob '${a}' uses duplicate archive_path '${c}'.`);
243
272
  continue;
244
273
  }
245
- if (s.add(a), !this.has(a)) {
246
- t.push(`Blob file '${a}' is missing.`);
274
+ if (s.add(c), !this.has(c)) {
275
+ t.push(`Blob file '${c}' is missing.`);
247
276
  continue;
248
277
  }
249
- const d = await this.readBytes(a), $ = await _(d);
250
- $ !== h && t.push(`Blob file '${a}' sha256 mismatch: expected ${h}, got ${$}.`), typeof e.size == "number" && e.size !== d.byteLength ? t.push(`Blob file '${a}' size mismatch: expected ${e.size}, got ${d.byteLength}.`) : e.size !== void 0 && typeof e.size != "number" && t.push(`Blob '${r}' size must be a number when present.`);
278
+ const l = await this.readBytes(c), _ = await A(l);
279
+ _ !== f && t.push(`Blob file '${c}' sha256 mismatch: expected ${f}, got ${_}.`), typeof n.size == "number" && n.size !== l.byteLength ? t.push(`Blob file '${c}' size mismatch: expected ${n.size}, got ${l.byteLength}.`) : n.size !== void 0 && typeof n.size != "number" && t.push(`Blob '${a}' size must be a number when present.`);
251
280
  }
252
- return o;
281
+ return e;
253
282
  }
254
- validateFileReferences(t, o, n) {
283
+ validateFileReferences(t, e, o) {
255
284
  const s = this.manifest.files;
256
285
  if (s !== void 0) {
257
286
  if (!Array.isArray(s)) {
258
287
  t.push("Files manifest field must be a list.");
259
288
  return;
260
289
  }
261
- for (const [c, e] of s.entries()) {
262
- if (!e || typeof e != "object") {
263
- t.push(`File manifest entry #${c + 1} must be an object.`);
290
+ for (const [r, n] of s.entries()) {
291
+ if (!n || typeof n != "object") {
292
+ t.push(`File manifest entry #${r + 1} must be an object.`);
264
293
  continue;
265
294
  }
266
- !e.file_id && !e.source_uri && !e.blob_id && t.push(`File manifest entry #${c + 1} must include file_id, source_uri, or blob_id.`), e.blob_id && !o.has(e.blob_id) && t.push(`File manifest entry #${c + 1} references missing blob_id '${e.blob_id}'.`), e.record_path && !n.has(e.record_path) && t.push(`File manifest entry #${c + 1} references missing record_path '${e.record_path}'.`), e.field_path !== void 0 && typeof e.field_path != "string" && t.push(`File manifest entry #${c + 1} field_path must be a string.`);
295
+ !n.file_id && !n.source_uri && !n.blob_id && t.push(`File manifest entry #${r + 1} must include file_id, source_uri, or blob_id.`), n.blob_id && !e.has(n.blob_id) && t.push(`File manifest entry #${r + 1} references missing blob_id '${n.blob_id}'.`), n.record_path && !o.has(n.record_path) && t.push(`File manifest entry #${r + 1} references missing record_path '${n.record_path}'.`), n.field_path !== void 0 && typeof n.field_path != "string" && t.push(`File manifest entry #${r + 1} field_path must be a string.`);
267
296
  }
268
297
  }
269
298
  }
270
299
  }
271
- async function M(i) {
272
- return y.open(i);
300
+ async function L(i) {
301
+ return $.open(i);
273
302
  }
274
- async function O(i) {
275
- return (await y.open(i)).summary();
303
+ async function N(i) {
304
+ return (await $.open(i)).summary();
276
305
  }
277
- function z(i) {
306
+ function H(i) {
278
307
  return JSON.stringify(i, null, 2);
279
308
  }
280
- function F(i) {
281
- return E.encode(i);
309
+ function z(i) {
310
+ return x.encode(i);
282
311
  }
283
312
  export {
313
+ E as AIRALOGY_RECORD_FORMAT,
314
+ k as AIRALOGY_RECORD_SCHEMA_VERSION,
284
315
  g as AIRA_ARCHIVE_FORMAT,
285
- b as AIRA_MANIFEST_PATH,
286
- y as AiraArchive,
287
- F as encodeUtf8,
288
- M as openAiraArchive,
289
- z as prettyPrintJson,
290
- O as readAiraArchiveSummary
316
+ y as AIRA_MANIFEST_PATH,
317
+ $ as AiraArchive,
318
+ z as encodeUtf8,
319
+ L as openAiraArchive,
320
+ H as prettyPrintJson,
321
+ N as readAiraArchiveSummary
291
322
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@airalogy/aira-core",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Core parser and validator for Airalogy .aira archives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export const AIRA_MANIFEST_PATH = '_airalogy_archive/manifest.json'
2
2
  export const AIRA_ARCHIVE_FORMAT = 'airalogy.archive'
3
+ export const AIRALOGY_RECORD_FORMAT = 'airalogy.record'
4
+ export const AIRALOGY_RECORD_SCHEMA_VERSION = 1
3
5
 
4
6
  export type AiraArchiveKind = 'protocol' | 'protocols' | 'records'
5
7
 
@@ -34,6 +36,24 @@ export interface AiraRecordManifest {
34
36
  embedded_protocol_root?: string | null
35
37
  }
36
38
 
39
+ export interface AiralogyRecordPayload {
40
+ format?: string
41
+ schema_version?: number
42
+ airalogy_record_id?: string | null
43
+ record_id?: string | null
44
+ record_version?: number | null
45
+ metadata?: Record<string, unknown> | null
46
+ data?: {
47
+ var?: Record<string, unknown>
48
+ step?: Record<string, unknown>
49
+ check?: Record<string, unknown>
50
+ quiz?: Record<string, unknown>
51
+ [key: string]: unknown
52
+ }
53
+ files?: Array<Record<string, unknown>>
54
+ [key: string]: unknown
55
+ }
56
+
37
57
  export interface AiraBlobManifest {
38
58
  blob_id: string
39
59
  archive_path: string
@@ -112,6 +132,88 @@ function validateMemberPath(name: string): string | null {
112
132
  return null
113
133
  }
114
134
 
135
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
136
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
137
+ }
138
+
139
+ function validateRecordPayload(record: unknown, label: string): string[] {
140
+ const issues: string[] = []
141
+ if (!isPlainObject(record)) {
142
+ return [`${label} must be a JSON object.`]
143
+ }
144
+
145
+ if (record.format !== undefined && record.format !== AIRALOGY_RECORD_FORMAT) {
146
+ issues.push(`${label} format must be '${AIRALOGY_RECORD_FORMAT}' when present.`)
147
+ }
148
+ if (
149
+ record.schema_version !== undefined
150
+ && record.schema_version !== AIRALOGY_RECORD_SCHEMA_VERSION
151
+ ) {
152
+ issues.push(`${label} schema_version must be ${AIRALOGY_RECORD_SCHEMA_VERSION} when present.`)
153
+ }
154
+ if (
155
+ record.record_id !== undefined
156
+ && record.record_id !== null
157
+ && (typeof record.record_id !== 'string' || record.record_id.length === 0)
158
+ ) {
159
+ issues.push(`${label} record_id must be a non-empty string when present.`)
160
+ }
161
+ if (
162
+ record.airalogy_record_id !== undefined
163
+ && record.airalogy_record_id !== null
164
+ && (typeof record.airalogy_record_id !== 'string' || record.airalogy_record_id.length === 0)
165
+ ) {
166
+ issues.push(`${label} airalogy_record_id must be a non-empty string when present.`)
167
+ }
168
+ const recordVersion = record.record_version
169
+ if (
170
+ recordVersion !== undefined
171
+ && recordVersion !== null
172
+ && (typeof recordVersion !== 'number' || !Number.isInteger(recordVersion) || recordVersion < 1)
173
+ ) {
174
+ issues.push(`${label} record_version must be a positive integer when present.`)
175
+ }
176
+ if (
177
+ record.metadata !== undefined
178
+ && record.metadata !== null
179
+ && !isPlainObject(record.metadata)
180
+ ) {
181
+ issues.push(`${label} metadata must be an object when present.`)
182
+ }
183
+
184
+ if (!isPlainObject(record.data)) {
185
+ issues.push(`${label} data must be an object.`)
186
+ return issues
187
+ }
188
+ if (!isPlainObject(record.data.var)) {
189
+ issues.push(`${label} data.var must be an object.`)
190
+ }
191
+ for (const section of ['step', 'check', 'quiz']) {
192
+ const value = record.data[section]
193
+ if (value !== undefined && value !== null && !isPlainObject(value)) {
194
+ issues.push(`${label} data.${section} must be an object when present.`)
195
+ }
196
+ }
197
+
198
+ if (record.files !== undefined) {
199
+ if (!Array.isArray(record.files)) {
200
+ issues.push(`${label} files must be a list when present.`)
201
+ }
202
+ else {
203
+ for (const [index, fileRef] of record.files.entries()) {
204
+ if (!isPlainObject(fileRef)) {
205
+ issues.push(`${label} files[${index + 1}] must be an object.`)
206
+ continue
207
+ }
208
+ if (!['file_id', 'source_uri', 'blob_id'].some(key => typeof fileRef[key] === 'string' && fileRef[key])) {
209
+ issues.push(`${label} files[${index + 1}] must include file_id, source_uri, or blob_id.`)
210
+ }
211
+ }
212
+ }
213
+ }
214
+ return issues
215
+ }
216
+
115
217
  function findEndOfCentralDirectory(bytes: Uint8Array): number {
116
218
  const minOffset = Math.max(0, bytes.length - 0xffff - 22)
117
219
  for (let offset = bytes.length - 22; offset >= minOffset; offset -= 1) {
@@ -407,14 +509,16 @@ export class AiraArchive {
407
509
  }
408
510
  recordPaths.add(path)
409
511
  let raw: Uint8Array
512
+ let parsedRecord: unknown
410
513
  try {
411
514
  raw = await this.readBytes(path)
412
- JSON.parse(textDecoder.decode(raw))
515
+ parsedRecord = JSON.parse(textDecoder.decode(raw))
413
516
  }
414
517
  catch {
415
518
  issues.push(`Record file '${path}' is not valid UTF-8 JSON.`)
416
519
  continue
417
520
  }
521
+ issues.push(...validateRecordPayload(parsedRecord, `Record file '${path}'`))
418
522
  if (record.sha256) {
419
523
  const actualHash = await sha256Hex(raw)
420
524
  if (actualHash !== record.sha256) {