@impetik/xeer-mcp 0.2.6 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impetik/xeer-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Model Context Protocol server for Xeer: the scaffold, check, dev, build, and deploy loop as agent tools.",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "zod": "^4.0.10",
50
- "@impetik/xeer": "0.2.6"
50
+ "@impetik/xeer": "0.2.7"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^24.1.0"
@@ -74,6 +74,44 @@ export interface XeerReviewReceiptV0 {
74
74
  }
75
75
  export declare function artifactSourceHash(files: readonly SourceReceipt[]): `sha256:${string}`;
76
76
  export declare function artifactReviewMetadata(artifact: ApplicationArtifactV0): ArtifactReviewMetadataV0;
77
+ /**
78
+ * Why review metadata was refused. The three cases look alike from the outside and are nothing alike
79
+ * to repair, which is what #205 cost an afternoon to: two of them are version skew between the build
80
+ * that produced the metadata and the build reading it, and only the third means the bundle is wrong.
81
+ */
82
+ export type ArtifactReviewRejection = {
83
+ readonly code: 'artifact_review_malformed';
84
+ /**
85
+ * The failing paths within `artifactReview`, deduplicated, in the order they were reported —
86
+ * which is the order the payload declares them, and not sorted, because sorting shows `t1, t10,
87
+ * t2` and hides everything past the eighth lexicographic name.
88
+ *
89
+ * **Empty means the value is not an object of this protocol at all**, which is reported at the
90
+ * root where there is no field to name. It has no second meaning.
91
+ */
92
+ readonly fields: readonly string[];
93
+ /** Failing paths beyond the ones listed, so a truncated list never reads as a complete one. */
94
+ readonly omitted: number;
95
+ } | {
96
+ /**
97
+ * Fields this build has never heard of. Skew, and the usual shape of it: a newer CLI emitting a
98
+ * schema key an older plane does not know reaches the strict object before any hash is compared,
99
+ * so it can only be told apart here.
100
+ */
101
+ readonly code: 'artifact_review_unknown_fields';
102
+ readonly fields: readonly string[];
103
+ readonly omitted: number;
104
+ } | {
105
+ readonly code: 'artifact_review_schema_identity_mismatch';
106
+ /** What this build hashes the declared schema to. */
107
+ readonly expected: ApplicationSchemaIdentityV0;
108
+ /** What the metadata claims the same schema hashes to. */
109
+ readonly received: ApplicationSchemaIdentityV0;
110
+ };
111
+ export declare class ArtifactReviewError extends Error {
112
+ readonly rejection: ArtifactReviewRejection;
113
+ constructor(rejection: ArtifactReviewRejection, message: string);
114
+ }
77
115
  export declare function parseArtifactReviewMetadata(value: unknown): ArtifactReviewMetadataV0;
78
116
  export declare function parseTestEvidence(value: unknown): XeerTestEvidenceV0;
79
117
  export declare function parseReviewReceipt(value: unknown): XeerReviewReceiptV0;
@@ -135,12 +135,63 @@ export function artifactReviewMetadata(artifact) {
135
135
  schemaIdentity: artifact.schemaIdentity,
136
136
  });
137
137
  }
138
+ export class ArtifactReviewError extends Error {
139
+ rejection;
140
+ constructor(rejection, message) {
141
+ super(message);
142
+ this.rejection = rejection;
143
+ this.name = 'ArtifactReviewError';
144
+ }
145
+ }
146
+ /** How many failing paths are worth showing a person, and how long one segment of one may be. */
147
+ const REPORTED_PATHS = 8;
148
+ const SEGMENT_LENGTH = 40;
149
+ const PLAIN_SEGMENT = /^[A-Za-z0-9_$-]+$/u;
150
+ /**
151
+ * One segment of a path, as it can safely be shown.
152
+ *
153
+ * A segment is not always ours: `tables` and `fields` are records keyed by caller-supplied names, so a
154
+ * table name arrives here verbatim. It is length-bounded because the rendered path reaches a 400 body
155
+ * and an operator's log, and quoted unless it is a plain name, because a table called `a.b` would
156
+ * otherwise read as two segments and send someone looking for a field that does not exist.
157
+ */
158
+ function segment(value) {
159
+ const text = String(value);
160
+ const shown = text.length > SEGMENT_LENGTH ? `${text.slice(0, SEGMENT_LENGTH)}…` : text;
161
+ return PLAIN_SEGMENT.test(shown) ? shown : JSON.stringify(shown);
162
+ }
163
+ function renderPath(parts) {
164
+ return parts.map(segment).join('.');
165
+ }
166
+ /** The first {@link REPORTED_PATHS} of a deduplicated path list, with the count of the rest. */
167
+ function reported(paths) {
168
+ const unique = [...new Set(paths)];
169
+ return { fields: unique.slice(0, REPORTED_PATHS), omitted: Math.max(0, unique.length - REPORTED_PATHS) };
170
+ }
171
+ /**
172
+ * The rejection a failed parse describes: unknown fields where that is the *only* complaint, and a
173
+ * malformed bundle otherwise. Requiring it to be the only complaint is deliberate — a payload that is
174
+ * both unrecognized and broken is broken, and calling it skew would point the repair at the wrong side.
175
+ */
176
+ function parseRejection(error) {
177
+ if (error.issues.every((issue) => issue.code === 'unrecognized_keys')) {
178
+ return { code: 'artifact_review_unknown_fields',
179
+ ...reported(error.issues.flatMap((issue) => issue.code === 'unrecognized_keys'
180
+ ? issue.keys.map((key) => renderPath([...issue.path, key])) : [])) };
181
+ }
182
+ return { code: 'artifact_review_malformed',
183
+ ...reported(error.issues.map((issue) => renderPath(issue.path)).filter((path) => path !== '')) };
184
+ }
138
185
  export function parseArtifactReviewMetadata(value) {
139
- const parsed = artifactReview.parse(value);
186
+ const result = artifactReview.safeParse(value);
187
+ if (!result.success) {
188
+ throw new ArtifactReviewError(parseRejection(result.error), 'Artifact review metadata does not match xeer.artifact-review.v0.');
189
+ }
190
+ const parsed = result.data;
140
191
  const expected = applicationSchemaIdentity(parsed.schema);
141
192
  if (parsed.schemaIdentity.applicationSchemaVersion !== expected.applicationSchemaVersion
142
193
  || parsed.schemaIdentity.schemaHash !== expected.schemaHash) {
143
- throw new TypeError('Artifact review schemaIdentity does not match its normalized schema.');
194
+ throw new ArtifactReviewError({ code: 'artifact_review_schema_identity_mismatch', expected, received: parsed.schemaIdentity }, 'Artifact review schemaIdentity does not match its normalized schema.');
144
195
  }
145
196
  return parsed;
146
197
  }
@@ -3,4 +3,25 @@ import { type NormalizedDatabaseSchemaV0, type SchemaPlanV0 } from './schema-pla
3
3
  export * from './schema-plan.js';
4
4
  export declare function applicationSchemaHash(schema: NormalizedDatabaseSchemaV0): `sha256:${string}`;
5
5
  export declare function applicationSchemaIdentity(schema: NormalizedDatabaseSchemaV0): ApplicationSchemaIdentityV0;
6
+ /** A fresh copy of the reference schema, so nothing that reads it can move the revision for everyone. */
7
+ export declare function schemaIdentityReference(): NormalizedDatabaseSchemaV0;
8
+ /**
9
+ * Which schema-identity projection *this build* computes, as the hash it gives the reference schema
10
+ * above.
11
+ *
12
+ * A schema hash is only an identity because two builds agree on it, and when they stop agreeing every
13
+ * symptom is somewhere else: the control plane refuses a `schemaIdentity` the CLI computed correctly
14
+ * for its own build, and the deployment reads as a malformed bundle (#205). Nothing about a version
15
+ * number settles it either way — a release can move the projection or leave it untouched — so the
16
+ * answer has to be a value derived from the projection itself. Two builds reporting the same revision
17
+ * hash the same schema to the same identity; two reporting different ones do not, and the older is the
18
+ * one to upgrade.
19
+ *
20
+ * Derived rather than declared, so it cannot be forgotten: adding, removing or renaming a key in
21
+ * `normalizedSchemaPayload` moves this hash in the same commit that moves it, because every key is
22
+ * emitted unconditionally and the reference declares one of each. What it cannot see is a change to how
23
+ * a value is *derived* for a shape the reference does not declare — index normalization has its own
24
+ * spellings, and only the ones written here are covered.
25
+ */
26
+ export declare function schemaIdentityRevision(): `sha256:${string}`;
6
27
  export declare function planSchemaChange(application: string, fromSchema: NormalizedDatabaseSchemaV0, toSchema: NormalizedDatabaseSchemaV0): SchemaPlanV0;
@@ -54,6 +54,93 @@ export function applicationSchemaHash(schema) {
54
54
  export function applicationSchemaIdentity(schema) {
55
55
  return { applicationSchemaVersion: schema.version, schemaHash: applicationSchemaHash(schema) };
56
56
  }
57
+ /**
58
+ * A fixed schema declaring one of everything the projection above reads, so that hashing it says which
59
+ * projection a build carries.
60
+ *
61
+ * Every attribute sits on a field named after it, which is what lets the test delete them one at a time
62
+ * and prove the revision notices. The declaration is a hash probe and not a manifest: `onDelete` and
63
+ * `onUpdate` need a `ref` field to sit on, so there are three of those, and nothing here is validated
64
+ * against the manifest rules because nothing here is ever deployed.
65
+ *
66
+ * Two properties of the *shape* are load-bearing, and both exist to close a way the revision could
67
+ * otherwise agree while the builds do not.
68
+ *
69
+ * **Two of everything the projection can iterate.** One table, one check, one index, one unique tuple
70
+ * would each let a projection that read only the first of them hash this schema exactly as a correct
71
+ * one does.
72
+ *
73
+ * **Every order-preserving array declared in an order sorting would change.** Object keys are sorted by
74
+ * canonical JSON and cannot carry order, but four arrays can and all four are semantic: an enum's
75
+ * values, the outer and inner arrays of a composite UNIQUE, and an index's columns — `UNIQUE (a, b)`
76
+ * and `UNIQUE (b, a)` build different indexes. A reference declaring a single unique tuple, or a tuple
77
+ * that happened to be in sorted order, would survive a future commit that started sorting them
78
+ * unchanged, while the hash of every real schema with two moved. Two builds would then compute
79
+ * different schema identities and report the *same* revision, and a `schema_identity_mismatch` would
80
+ * quote it as proof that they agree.
81
+ */
82
+ const SCHEMA_IDENTITY_REFERENCE = {
83
+ version: 1,
84
+ tables: {
85
+ reference: {
86
+ fields: {
87
+ bytes: { type: 'bytes' },
88
+ collate: { type: 'string', collate: 'nocase' },
89
+ datetime: { type: 'datetime' },
90
+ default: { type: 'string', default: 'a' },
91
+ enum: { type: 'string', enum: ['b', 'a'] },
92
+ generated: { type: 'string', generated: { expression: 'lower(plain)', stored: true } },
93
+ json: { type: 'json' },
94
+ maxLength: { type: 'string', maxLength: 64 },
95
+ number: { type: 'number' },
96
+ onDelete: { type: 'ref', table: 'reference', onDelete: 'cascade' },
97
+ onUpdate: { type: 'ref', table: 'reference', onUpdate: 'restrict' },
98
+ optional: { type: 'string', optional: true },
99
+ plain: { type: 'string' },
100
+ table: { type: 'ref', table: 'reference' },
101
+ unique: { type: 'string', unique: true },
102
+ },
103
+ indexes: {
104
+ by_plain: ['plain'],
105
+ by_pair: ['plain', 'number'],
106
+ by_unique: { columns: [{ column: 'unique', desc: true }], unique: true, where: 'plain IS NOT NULL' },
107
+ },
108
+ unique: [['plain', 'number'], ['optional', 'maxLength']],
109
+ checks: { positive: 'number > 0', bounded: 'maxLength IS NOT NULL' },
110
+ },
111
+ beta: {
112
+ fields: { second: { type: 'string' }, first: { type: 'number' } },
113
+ indexes: { by_second: ['second'], by_first: ['first'] },
114
+ unique: [['second', 'first']],
115
+ checks: { nonzero: 'first <> 0', named: 'second <> \'\'' },
116
+ },
117
+ },
118
+ };
119
+ /** A fresh copy of the reference schema, so nothing that reads it can move the revision for everyone. */
120
+ export function schemaIdentityReference() {
121
+ return structuredClone(SCHEMA_IDENTITY_REFERENCE);
122
+ }
123
+ /**
124
+ * Which schema-identity projection *this build* computes, as the hash it gives the reference schema
125
+ * above.
126
+ *
127
+ * A schema hash is only an identity because two builds agree on it, and when they stop agreeing every
128
+ * symptom is somewhere else: the control plane refuses a `schemaIdentity` the CLI computed correctly
129
+ * for its own build, and the deployment reads as a malformed bundle (#205). Nothing about a version
130
+ * number settles it either way — a release can move the projection or leave it untouched — so the
131
+ * answer has to be a value derived from the projection itself. Two builds reporting the same revision
132
+ * hash the same schema to the same identity; two reporting different ones do not, and the older is the
133
+ * one to upgrade.
134
+ *
135
+ * Derived rather than declared, so it cannot be forgotten: adding, removing or renaming a key in
136
+ * `normalizedSchemaPayload` moves this hash in the same commit that moves it, because every key is
137
+ * emitted unconditionally and the reference declares one of each. What it cannot see is a change to how
138
+ * a value is *derived* for a shape the reference does not declare — index normalization has its own
139
+ * spellings, and only the ones written here are covered.
140
+ */
141
+ export function schemaIdentityRevision() {
142
+ return applicationSchemaHash(SCHEMA_IDENTITY_REFERENCE);
143
+ }
57
144
  export function planSchemaChange(application, fromSchema, toSchema) {
58
145
  return planSchemaChangeWithIdentities(application, fromSchema, applicationSchemaIdentity(fromSchema), toSchema, applicationSchemaIdentity(toSchema));
59
146
  }