@validation-os/api 0.3.0 → 0.5.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/dist/index.d.ts CHANGED
@@ -54,6 +54,8 @@ interface ValidationOsApi {
54
54
  create(req: Request, ctx: RouteContext): Promise<Response>;
55
55
  /** PATCH /api/[register]/[id] — body { version, ...patch }. */
56
56
  update(req: Request, ctx: RouteContext): Promise<Response>;
57
+ /** POST /api/link — body { relation, from, to }; sets both ends. */
58
+ link(req: Request): Promise<Response>;
57
59
  /** GET /api/counts — per-register row counts (the walking-skeleton value). */
58
60
  counts(req: Request): Promise<Response>;
59
61
  /** POST /api/recompute — run the derived-fields backstop pass. */
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/index.ts
2
2
  import {
3
3
  REGISTERS,
4
+ RELATIONS,
4
5
  isNotFoundError,
5
6
  isStaleVersionError
6
7
  } from "@validation-os/core";
@@ -70,6 +71,34 @@ function assertRegister(value) {
70
71
  }
71
72
  var BadRequest = class extends Error {
72
73
  };
74
+ function parseLinkBody(body) {
75
+ const b = body;
76
+ const relation = b.relation;
77
+ if (typeof relation !== "string" || !(relation in RELATIONS)) {
78
+ throw new BadRequest(`Unknown relation: ${String(relation)}`);
79
+ }
80
+ const spec = RELATIONS[relation];
81
+ const from = parseRef(b.from, "from");
82
+ const to = parseRef(b.to, "to");
83
+ if (from.register !== spec.from.register) {
84
+ throw new BadRequest(
85
+ `Relation ${relation} starts on ${spec.from.register}, not ${from.register}.`
86
+ );
87
+ }
88
+ if (to.register !== spec.targetRegister) {
89
+ throw new BadRequest(
90
+ `Relation ${relation} ends on ${spec.targetRegister}, not ${to.register}.`
91
+ );
92
+ }
93
+ return { relation, from, to };
94
+ }
95
+ function parseRef(value, which) {
96
+ const ref = value;
97
+ if (!ref || typeof ref.id !== "string" || !ref.id) {
98
+ throw new BadRequest(`Missing \`${which}.id\`.`);
99
+ }
100
+ return { register: assertRegister(ref.register), id: ref.id };
101
+ }
73
102
  function toErrorResponse(e) {
74
103
  if (e instanceof BadRequest) return json({ error: e.message }, 400);
75
104
  if (isNotFoundError(e)) return json({ error: e.message }, 404);
@@ -151,6 +180,20 @@ function createApi(options) {
151
180
  return handle(e);
152
181
  }
153
182
  },
183
+ async link(req) {
184
+ try {
185
+ await guard(req);
186
+ const { relation, from, to } = parseLinkBody(await req.json());
187
+ await provider.link(relation, from, to);
188
+ const spec = RELATIONS[relation];
189
+ if (DERIVED_TRIGGERS.has(spec.from.register) || spec.to != null && DERIVED_TRIGGERS.has(spec.to.register)) {
190
+ await recomputeAllDerived(provider);
191
+ }
192
+ return json({ data: { linked: true } });
193
+ } catch (e) {
194
+ return handle(e);
195
+ }
196
+ },
154
197
  async counts(req) {
155
198
  try {
156
199
  await guard(req);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/derive-on-write.ts"],"sourcesContent":["/**\n * @validation-os/api — framework-neutral route-handler logic.\n *\n * Handlers operate on the Web `Request`/`Response` types (what Next.js app-\n * router route handlers receive), so the same functions drop into a Next app\n * or any Web-standard runtime. Authentication is injected — the deployed app\n * supplies a Clerk-verifying `authenticate`, so this package stays free of any\n * auth-vendor dependency. Every write runs the derivation module server-side.\n */\nimport {\n REGISTERS,\n isNotFoundError,\n isStaleVersionError,\n type Collection,\n type DataProvider,\n} from \"@validation-os/core\";\nimport {\n deriveReadingFields,\n recomputeAllDerived,\n} from \"./derive-on-write.js\";\n\nexport { deriveReadingFields, recomputeAllDerived };\n\nconst COLLECTIONS = new Set<string>([...REGISTERS, \"people\"]);\n/** Writes to these trigger the cross-record derived recompute pass. */\nconst DERIVED_TRIGGERS = new Set<string>([\"assumptions\", \"readings\", \"decisions\"]);\n\nexport interface AuthResult {\n userId: string;\n}\n\nexport interface CreateApiOptions {\n provider: DataProvider;\n /** Return the authenticated principal, or null to reject with 401. */\n authenticate: (req: Request) => Promise<AuthResult | null> | AuthResult | null;\n /** Run derive-on-write after writes (default true). */\n deriveOnWrite?: boolean;\n}\n\ntype Params = Record<string, string | undefined>;\nexport interface RouteContext {\n params: Params | Promise<Params>;\n}\n\nfunction json(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\" },\n });\n}\n\nasync function resolveParams(ctx: RouteContext): Promise<Params> {\n return await ctx.params;\n}\n\nfunction assertRegister(value: string | undefined): Collection {\n if (!value || !COLLECTIONS.has(value)) {\n throw new BadRequest(`Unknown register: ${String(value)}`);\n }\n return value as Collection;\n}\n\nclass BadRequest extends Error {}\n\n/** Turn a thrown error into the right HTTP response, in plain language. */\nfunction toErrorResponse(e: unknown): Response {\n if (e instanceof BadRequest) return json({ error: e.message }, 400);\n if (isNotFoundError(e)) return json({ error: e.message }, 404);\n if (isStaleVersionError(e)) {\n return json(\n {\n error: \"conflict\",\n // Plain-language copy — never version jargon (spec user story 12).\n message:\n \"Someone edited this while you had it open — your changes are \" +\n \"safe, take a look before saving again.\",\n },\n 409,\n );\n }\n return json({ error: \"internal\", message: \"Something went wrong.\" }, 500);\n}\n\nexport interface ValidationOsApi {\n /** GET /api/[register] — list every row of a register. */\n list(req: Request, ctx: RouteContext): Promise<Response>;\n /** GET /api/[register]/[id] — one record. */\n get(req: Request, ctx: RouteContext): Promise<Response>;\n /** POST /api/[register] — create; body is the record fields. */\n create(req: Request, ctx: RouteContext): Promise<Response>;\n /** PATCH /api/[register]/[id] — body { version, ...patch }. */\n update(req: Request, ctx: RouteContext): Promise<Response>;\n /** GET /api/counts — per-register row counts (the walking-skeleton value). */\n counts(req: Request): Promise<Response>;\n /** POST /api/recompute — run the derived-fields backstop pass. */\n recompute(req: Request): Promise<Response>;\n}\n\nexport function createApi(options: CreateApiOptions): ValidationOsApi {\n const { provider } = options;\n const deriveOn = options.deriveOnWrite ?? true;\n\n async function guard(req: Request): Promise<AuthResult> {\n const auth = await options.authenticate(req);\n if (!auth) throw new Unauthorized();\n return auth;\n }\n\n async function maybeRecompute(register: Collection): Promise<void> {\n if (deriveOn && DERIVED_TRIGGERS.has(register)) {\n await recomputeAllDerived(provider);\n }\n }\n\n return {\n async list(req, ctx) {\n try {\n await guard(req);\n const register = assertRegister((await resolveParams(ctx)).register);\n return json({ data: await provider.list(register) });\n } catch (e) {\n return handle(e);\n }\n },\n\n async get(req, ctx) {\n try {\n await guard(req);\n const p = await resolveParams(ctx);\n const register = assertRegister(p.register);\n if (!p.id) throw new BadRequest(\"Missing id\");\n return json({ data: await provider.get(register, p.id) });\n } catch (e) {\n return handle(e);\n }\n },\n\n async create(req, ctx) {\n try {\n await guard(req);\n const register = assertRegister((await resolveParams(ctx)).register);\n let data = (await req.json()) as Record<string, unknown>;\n if (register === \"readings\") data = deriveReadingFields(data);\n const created = await provider.create(register, data);\n await maybeRecompute(register);\n return json({ data: created }, 201);\n } catch (e) {\n return handle(e);\n }\n },\n\n async update(req, ctx) {\n try {\n await guard(req);\n const p = await resolveParams(ctx);\n const register = assertRegister(p.register);\n if (!p.id) throw new BadRequest(\"Missing id\");\n const body = (await req.json()) as Record<string, unknown>;\n const { version, ...rawPatch } = body;\n if (typeof version !== \"number\") {\n throw new BadRequest(\"A numeric `version` is required for updates.\");\n }\n const patch =\n register === \"readings\" ? deriveReadingFields(rawPatch) : rawPatch;\n const updated = await provider.update(register, p.id, patch, version);\n await maybeRecompute(register);\n return json({ data: updated });\n } catch (e) {\n return handle(e);\n }\n },\n\n async counts(req) {\n try {\n await guard(req);\n const registers = [...COLLECTIONS] as Collection[];\n const counts: Record<string, number> = {};\n await Promise.all(\n registers.map(async (r) => {\n counts[r] = (await provider.list(r)).length;\n }),\n );\n return json({ counts });\n } catch (e) {\n return handle(e);\n }\n },\n\n async recompute(req) {\n try {\n await guard(req);\n const updated = await recomputeAllDerived(provider);\n return json({ updated });\n } catch (e) {\n return handle(e);\n }\n },\n };\n}\n\nclass Unauthorized extends Error {}\n\nfunction handle(e: unknown): Response {\n if (e instanceof Unauthorized) {\n return json({ error: \"unauthorized\" }, 401);\n }\n return toErrorResponse(e);\n}\n","/**\n * Derive-on-write — the API runs the shared derivation module server-side so\n * stored derived values are always authoritative, whatever the client sent.\n *\n * Two layers:\n * - `deriveReadingFields`: a reading's own Source quality + Strength are pure\n * functions of the record itself — stamped inline on every reading write.\n * - `recomputeAllDerived`: the cross-record pass (Confidence, Derived Impact,\n * Risk over the whole assumptions register) — the backstop the spec keeps\n * for non-dashboard writes, run after any assumption/reading/decision write.\n */\nimport {\n recomputeDerived,\n recomputeSourceQuality,\n recomputeStrength,\n type AnyRecord,\n type AssumptionRecord,\n type DataProvider,\n type DecisionRecord,\n type ReadingRecord,\n} from \"@validation-os/core\";\n\n/** Stamp Source quality + Strength onto a reading's `derived` field. */\nexport function deriveReadingFields(data: Partial<AnyRecord>): Partial<AnyRecord> {\n const r = data as Partial<ReadingRecord>;\n if (r.Rung == null || r.Result == null) return data; // not enough to derive\n const sourceQuality = recomputeSourceQuality(\n Number(r.Representativeness ?? 0),\n Number(r.Credibility ?? 0),\n );\n const strength = recomputeStrength({\n rung: r.Rung,\n result: r.Result,\n magnitudeBand: r.magnitudeBand,\n });\n return { ...data, derived: { sourceQuality, strength } };\n}\n\n/**\n * Recompute Confidence / Derived Impact / Risk for every assumption and write\n * back only those whose derived tuple actually changed (so unrelated rows\n * don't churn their version). Returns the number of rows updated.\n */\nexport async function recomputeAllDerived(\n provider: DataProvider,\n): Promise<number> {\n const [assumptions, readings, decisions] = await Promise.all([\n provider.list(\"assumptions\"),\n provider.list(\"readings\"),\n provider.list(\"decisions\"),\n ]);\n\n const derived = recomputeDerived({\n assumptions: assumptions as unknown as AssumptionRecord[],\n readings: readings as unknown as ReadingRecord[],\n decisions: decisions as unknown as DecisionRecord[],\n });\n\n let updated = 0;\n for (const a of assumptions) {\n const next = derived.get(a.id);\n if (!next) continue;\n const cur = (a.derived ?? {}) as Partial<AssumptionRecord[\"derived\"]>;\n if (\n cur.confidence === next.confidence &&\n cur.risk === next.risk &&\n cur.derivedImpact === next.derivedImpact\n ) {\n continue;\n }\n await provider.update(\"assumptions\", a.id, { derived: next }, a.version);\n updated += 1;\n }\n return updated;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/derive-on-write.ts"],"sourcesContent":["/**\n * @validation-os/api — framework-neutral route-handler logic.\n *\n * Handlers operate on the Web `Request`/`Response` types (what Next.js app-\n * router route handlers receive), so the same functions drop into a Next app\n * or any Web-standard runtime. Authentication is injected — the deployed app\n * supplies a Clerk-verifying `authenticate`, so this package stays free of any\n * auth-vendor dependency. Every write runs the derivation module server-side.\n */\nimport {\n REGISTERS,\n RELATIONS,\n isNotFoundError,\n isStaleVersionError,\n type Collection,\n type DataProvider,\n type RecordRef,\n type Relation,\n} from \"@validation-os/core\";\nimport {\n deriveReadingFields,\n recomputeAllDerived,\n} from \"./derive-on-write.js\";\n\nexport { deriveReadingFields, recomputeAllDerived };\n\nconst COLLECTIONS = new Set<string>([...REGISTERS, \"people\"]);\n/** Writes to these trigger the cross-record derived recompute pass. */\nconst DERIVED_TRIGGERS = new Set<string>([\"assumptions\", \"readings\", \"decisions\"]);\n\nexport interface AuthResult {\n userId: string;\n}\n\nexport interface CreateApiOptions {\n provider: DataProvider;\n /** Return the authenticated principal, or null to reject with 401. */\n authenticate: (req: Request) => Promise<AuthResult | null> | AuthResult | null;\n /** Run derive-on-write after writes (default true). */\n deriveOnWrite?: boolean;\n}\n\ntype Params = Record<string, string | undefined>;\nexport interface RouteContext {\n params: Params | Promise<Params>;\n}\n\nfunction json(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\" },\n });\n}\n\nasync function resolveParams(ctx: RouteContext): Promise<Params> {\n return await ctx.params;\n}\n\nfunction assertRegister(value: string | undefined): Collection {\n if (!value || !COLLECTIONS.has(value)) {\n throw new BadRequest(`Unknown register: ${String(value)}`);\n }\n return value as Collection;\n}\n\nclass BadRequest extends Error {}\n\ninterface LinkBody {\n relation: Relation;\n from: RecordRef;\n to: RecordRef;\n}\n\n/** Validate a POST /api/link body against the relation config. */\nfunction parseLinkBody(body: unknown): LinkBody {\n const b = body as Partial<{ relation: string; from: RecordRef; to: RecordRef }>;\n const relation = b.relation;\n if (typeof relation !== \"string\" || !(relation in RELATIONS)) {\n throw new BadRequest(`Unknown relation: ${String(relation)}`);\n }\n const spec = RELATIONS[relation as Relation];\n const from = parseRef(b.from, \"from\");\n const to = parseRef(b.to, \"to\");\n // The `from` end must be the register the relation config names; otherwise a\n // caller could write the wrong field. The `to` end is checked when it is a\n // stored (non-derived) end.\n if (from.register !== spec.from.register) {\n throw new BadRequest(\n `Relation ${relation} starts on ${spec.from.register}, not ${from.register}.`,\n );\n }\n if (to.register !== spec.targetRegister) {\n throw new BadRequest(\n `Relation ${relation} ends on ${spec.targetRegister}, not ${to.register}.`,\n );\n }\n return { relation: relation as Relation, from, to };\n}\n\nfunction parseRef(value: unknown, which: string): RecordRef {\n const ref = value as Partial<RecordRef> | undefined;\n if (!ref || typeof ref.id !== \"string\" || !ref.id) {\n throw new BadRequest(`Missing \\`${which}.id\\`.`);\n }\n return { register: assertRegister(ref.register), id: ref.id };\n}\n\n/** Turn a thrown error into the right HTTP response, in plain language. */\nfunction toErrorResponse(e: unknown): Response {\n if (e instanceof BadRequest) return json({ error: e.message }, 400);\n if (isNotFoundError(e)) return json({ error: e.message }, 404);\n if (isStaleVersionError(e)) {\n return json(\n {\n error: \"conflict\",\n // Plain-language copy — never version jargon (spec user story 12).\n message:\n \"Someone edited this while you had it open — your changes are \" +\n \"safe, take a look before saving again.\",\n },\n 409,\n );\n }\n return json({ error: \"internal\", message: \"Something went wrong.\" }, 500);\n}\n\nexport interface ValidationOsApi {\n /** GET /api/[register] — list every row of a register. */\n list(req: Request, ctx: RouteContext): Promise<Response>;\n /** GET /api/[register]/[id] — one record. */\n get(req: Request, ctx: RouteContext): Promise<Response>;\n /** POST /api/[register] — create; body is the record fields. */\n create(req: Request, ctx: RouteContext): Promise<Response>;\n /** PATCH /api/[register]/[id] — body { version, ...patch }. */\n update(req: Request, ctx: RouteContext): Promise<Response>;\n /** POST /api/link — body { relation, from, to }; sets both ends. */\n link(req: Request): Promise<Response>;\n /** GET /api/counts — per-register row counts (the walking-skeleton value). */\n counts(req: Request): Promise<Response>;\n /** POST /api/recompute — run the derived-fields backstop pass. */\n recompute(req: Request): Promise<Response>;\n}\n\nexport function createApi(options: CreateApiOptions): ValidationOsApi {\n const { provider } = options;\n const deriveOn = options.deriveOnWrite ?? true;\n\n async function guard(req: Request): Promise<AuthResult> {\n const auth = await options.authenticate(req);\n if (!auth) throw new Unauthorized();\n return auth;\n }\n\n async function maybeRecompute(register: Collection): Promise<void> {\n if (deriveOn && DERIVED_TRIGGERS.has(register)) {\n await recomputeAllDerived(provider);\n }\n }\n\n return {\n async list(req, ctx) {\n try {\n await guard(req);\n const register = assertRegister((await resolveParams(ctx)).register);\n return json({ data: await provider.list(register) });\n } catch (e) {\n return handle(e);\n }\n },\n\n async get(req, ctx) {\n try {\n await guard(req);\n const p = await resolveParams(ctx);\n const register = assertRegister(p.register);\n if (!p.id) throw new BadRequest(\"Missing id\");\n return json({ data: await provider.get(register, p.id) });\n } catch (e) {\n return handle(e);\n }\n },\n\n async create(req, ctx) {\n try {\n await guard(req);\n const register = assertRegister((await resolveParams(ctx)).register);\n let data = (await req.json()) as Record<string, unknown>;\n if (register === \"readings\") data = deriveReadingFields(data);\n const created = await provider.create(register, data);\n await maybeRecompute(register);\n return json({ data: created }, 201);\n } catch (e) {\n return handle(e);\n }\n },\n\n async update(req, ctx) {\n try {\n await guard(req);\n const p = await resolveParams(ctx);\n const register = assertRegister(p.register);\n if (!p.id) throw new BadRequest(\"Missing id\");\n const body = (await req.json()) as Record<string, unknown>;\n const { version, ...rawPatch } = body;\n if (typeof version !== \"number\") {\n throw new BadRequest(\"A numeric `version` is required for updates.\");\n }\n const patch =\n register === \"readings\" ? deriveReadingFields(rawPatch) : rawPatch;\n const updated = await provider.update(register, p.id, patch, version);\n await maybeRecompute(register);\n return json({ data: updated });\n } catch (e) {\n return handle(e);\n }\n },\n\n async link(req) {\n try {\n await guard(req);\n const { relation, from, to } = parseLinkBody(await req.json());\n await provider.link(relation, from, to);\n // A relation can move a derived number (a reading joins a belief, a\n // standing decision lands, a dependency edge appears), so run the same\n // backstop recompute the write routes use whenever an end is a trigger.\n const spec = RELATIONS[relation];\n if (\n DERIVED_TRIGGERS.has(spec.from.register) ||\n (spec.to != null && DERIVED_TRIGGERS.has(spec.to.register))\n ) {\n await recomputeAllDerived(provider);\n }\n return json({ data: { linked: true } });\n } catch (e) {\n return handle(e);\n }\n },\n\n async counts(req) {\n try {\n await guard(req);\n const registers = [...COLLECTIONS] as Collection[];\n const counts: Record<string, number> = {};\n await Promise.all(\n registers.map(async (r) => {\n counts[r] = (await provider.list(r)).length;\n }),\n );\n return json({ counts });\n } catch (e) {\n return handle(e);\n }\n },\n\n async recompute(req) {\n try {\n await guard(req);\n const updated = await recomputeAllDerived(provider);\n return json({ updated });\n } catch (e) {\n return handle(e);\n }\n },\n };\n}\n\nclass Unauthorized extends Error {}\n\nfunction handle(e: unknown): Response {\n if (e instanceof Unauthorized) {\n return json({ error: \"unauthorized\" }, 401);\n }\n return toErrorResponse(e);\n}\n","/**\n * Derive-on-write — the API runs the shared derivation module server-side so\n * stored derived values are always authoritative, whatever the client sent.\n *\n * Two layers:\n * - `deriveReadingFields`: a reading's own Source quality + Strength are pure\n * functions of the record itself — stamped inline on every reading write.\n * - `recomputeAllDerived`: the cross-record pass (Confidence, Derived Impact,\n * Risk over the whole assumptions register) — the backstop the spec keeps\n * for non-dashboard writes, run after any assumption/reading/decision write.\n */\nimport {\n recomputeDerived,\n recomputeSourceQuality,\n recomputeStrength,\n type AnyRecord,\n type AssumptionRecord,\n type DataProvider,\n type DecisionRecord,\n type ReadingRecord,\n} from \"@validation-os/core\";\n\n/** Stamp Source quality + Strength onto a reading's `derived` field. */\nexport function deriveReadingFields(data: Partial<AnyRecord>): Partial<AnyRecord> {\n const r = data as Partial<ReadingRecord>;\n if (r.Rung == null || r.Result == null) return data; // not enough to derive\n const sourceQuality = recomputeSourceQuality(\n Number(r.Representativeness ?? 0),\n Number(r.Credibility ?? 0),\n );\n const strength = recomputeStrength({\n rung: r.Rung,\n result: r.Result,\n magnitudeBand: r.magnitudeBand,\n });\n return { ...data, derived: { sourceQuality, strength } };\n}\n\n/**\n * Recompute Confidence / Derived Impact / Risk for every assumption and write\n * back only those whose derived tuple actually changed (so unrelated rows\n * don't churn their version). Returns the number of rows updated.\n */\nexport async function recomputeAllDerived(\n provider: DataProvider,\n): Promise<number> {\n const [assumptions, readings, decisions] = await Promise.all([\n provider.list(\"assumptions\"),\n provider.list(\"readings\"),\n provider.list(\"decisions\"),\n ]);\n\n const derived = recomputeDerived({\n assumptions: assumptions as unknown as AssumptionRecord[],\n readings: readings as unknown as ReadingRecord[],\n decisions: decisions as unknown as DecisionRecord[],\n });\n\n let updated = 0;\n for (const a of assumptions) {\n const next = derived.get(a.id);\n if (!next) continue;\n const cur = (a.derived ?? {}) as Partial<AssumptionRecord[\"derived\"]>;\n if (\n cur.confidence === next.confidence &&\n cur.risk === next.risk &&\n cur.derivedImpact === next.derivedImpact\n ) {\n continue;\n }\n await provider.update(\"assumptions\", a.id, { derived: next }, a.version);\n updated += 1;\n }\n return updated;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validation-os/api",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Framework-neutral route-handler logic for validation-os: auth-guarded CRUD over a DataProvider, with derive-on-write via the shared derivation module.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@validation-os/core": "0.3.0"
24
+ "@validation-os/core": "0.5.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsup": "^8.3.0",