@hypequery/protocol 0.17.0 → 0.18.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.
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/events/validate.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,wBAAwB,EAExB,kBAAkB,EAElB,yBAAyB,EAE1B,MAAM,YAAY,CAAC;AA4OpB,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,OAAO,EACd,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CAuDpB;AAED,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,OAAO,EACd,OAAO,GAAE,yBAA8B,GACtC,wBAAwB,CA+B1B"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/events/validate.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,wBAAwB,EAExB,kBAAkB,EAElB,yBAAyB,EAE1B,MAAM,YAAY,CAAC;AAuRpB,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,OAAO,EACd,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CAiDpB;AAED,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,OAAO,EACd,OAAO,GAAE,yBAA8B,GACtC,wBAAwB,CAiC1B"}
@@ -25,7 +25,7 @@ const DIAGNOSTICS_CONTEXT = {
25
25
  fail: diagnosticsError,
26
26
  };
27
27
  const HEX64_PATTERN = /^[0-9a-f]{64}$/;
28
- const OCCURRED_AT_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?Z$/;
28
+ const OCCURRED_AT_PATTERN = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]{3})?Z$/;
29
29
  const OPERATIONS = ['query', 'command', 'insert'];
30
30
  const OUTCOMES = ['success', 'failure'];
31
31
  const ERROR_CATEGORIES = [
@@ -84,20 +84,37 @@ function requireString(context, value, path) {
84
84
  context.fail(context.codes.type, path);
85
85
  return value;
86
86
  }
87
- function hasControlCharacter(text) {
87
+ // The compiler's debug form spans lines, so debugQuery alone may carry tab,
88
+ // line feed, and carriage return.
89
+ const LINE_BREAK_CHARACTERS = new Set([0x09, 0x0a, 0x0d]);
90
+ // Rejects C0 controls, DEL, the C1 range, and unpaired surrogates, which have
91
+ // no UTF-8 encoding and so no canonical bytes.
92
+ function hasForbiddenCodeUnit(text, allowLineBreaks) {
88
93
  for (let index = 0; index < text.length; index += 1) {
89
94
  const code = text.charCodeAt(index);
95
+ if (code <= 0x1f && allowLineBreaks && LINE_BREAK_CHARACTERS.has(code))
96
+ continue;
90
97
  if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
91
98
  return true;
99
+ if (code >= 0xd800 && code <= 0xdbff) {
100
+ // charCodeAt past the end is NaN, which fails the range check.
101
+ const low = text.charCodeAt(index + 1);
102
+ if (!(low >= 0xdc00 && low <= 0xdfff))
103
+ return true;
104
+ index += 1;
105
+ }
106
+ else if (code >= 0xdc00 && code <= 0xdfff) {
107
+ return true;
108
+ }
92
109
  }
93
110
  return false;
94
111
  }
95
- function boundedText(context, value, path, maximum) {
112
+ function boundedText(context, value, path, maximum, allowLineBreaks = false) {
96
113
  const text = requireString(context, value, path);
97
114
  if (text.length > maximum || textEncoder.encode(text).byteLength > maximum) {
98
115
  context.fail(context.codes.tooLarge, path);
99
116
  }
100
- if (hasControlCharacter(text)) {
117
+ if (hasForbiddenCodeUnit(text, allowLineBreaks)) {
101
118
  context.fail(context.codes.invalidValue, path);
102
119
  }
103
120
  return text;
@@ -110,7 +127,21 @@ function hexIdentity(context, value, path) {
110
127
  }
111
128
  function occurredAt(context, value, path) {
112
129
  const text = requireString(context, value, path);
113
- if (!OCCURRED_AT_PATTERN.test(text) || Number.isNaN(Date.parse(text))) {
130
+ const match = OCCURRED_AT_PATTERN.exec(text);
131
+ if (!match)
132
+ context.fail(context.codes.invalidValue, path);
133
+ const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
134
+ // Round-trip through Date so impossible dates (2026-02-30) and 24:00 fail
135
+ // rather than rolling over. setUTCFullYear avoids Date.UTC's 0-99 remap.
136
+ const date = new Date(0);
137
+ date.setUTCFullYear(year, month - 1, day);
138
+ date.setUTCHours(hour, minute, second, 0);
139
+ if (date.getUTCFullYear() !== year
140
+ || date.getUTCMonth() !== month - 1
141
+ || date.getUTCDate() !== day
142
+ || date.getUTCHours() !== hour
143
+ || date.getUTCMinutes() !== minute
144
+ || date.getUTCSeconds() !== second) {
114
145
  context.fail(context.codes.invalidValue, path);
115
146
  }
116
147
  return text;
@@ -166,10 +197,18 @@ function kindAndVersion(context, value, kind) {
166
197
  fail(codes.invalidVersion, '$.version');
167
198
  }
168
199
  }
200
+ function outcomeWithCategory(context, value, hasCategory) {
201
+ const outcome = stringChoice(context, value.outcome, '$.outcome', OUTCOMES);
202
+ if ((outcome === 'failure') !== hasCategory) {
203
+ context.fail(context.codes.invalidValue, '$.errorCategory');
204
+ }
205
+ return outcome;
206
+ }
169
207
  export function validateProtocolQueryEvent(input, options = {}) {
170
208
  const limits = resolveQueryEventLimits(options);
171
209
  const context = EVENT_CONTEXT;
172
210
  const value = requireRecord(context, input, '$');
211
+ kindAndVersion(context, value, 'hypequery-query-event');
173
212
  exactFields(context, value, [
174
213
  'kind',
175
214
  'version',
@@ -181,15 +220,8 @@ export function validateProtocolQueryEvent(input, options = {}) {
181
220
  'outcome',
182
221
  'durationMs',
183
222
  ], ['errorCategory', 'rowCount', 'tenantFingerprint', 'correlationId'], '$');
184
- kindAndVersion(context, value, 'hypequery-query-event');
185
- const outcome = stringChoice(context, value.outcome, '$.outcome', OUTCOMES);
186
223
  const hasCategory = Object.hasOwn(value, 'errorCategory');
187
- if (outcome === 'failure' && !hasCategory) {
188
- eventError('HQ_EVENT_INVALID_VALUE', '$.errorCategory');
189
- }
190
- if (outcome === 'success' && hasCategory) {
191
- eventError('HQ_EVENT_INVALID_VALUE', '$.errorCategory');
192
- }
224
+ // Object literal entries evaluate in order, so fields are checked in RFC order.
193
225
  return freezeRecord({
194
226
  kind: 'hypequery-query-event',
195
227
  version: 1,
@@ -198,7 +230,7 @@ export function validateProtocolQueryEvent(input, options = {}) {
198
230
  target: queryTarget(context, value.target, '$.target'),
199
231
  queryName: queryName(context, value.queryName, '$.queryName'),
200
232
  operation: stringChoice(context, value.operation, '$.operation', OPERATIONS),
201
- outcome,
233
+ outcome: outcomeWithCategory(context, value, hasCategory),
202
234
  ...(hasCategory ? {
203
235
  errorCategory: stringChoice(context, value.errorCategory, '$.errorCategory', ERROR_CATEGORIES),
204
236
  } : {}),
@@ -218,8 +250,8 @@ export function validateProtocolQueryDiagnostics(input, options = {}) {
218
250
  const limits = resolveQueryEventLimits(options);
219
251
  const context = DIAGNOSTICS_CONTEXT;
220
252
  const value = requireRecord(context, input, '$');
221
- exactFields(context, value, ['kind', 'version', 'eventId', 'queryId', 'terminalReason', 'attempts'], ['runtimeIdentity', 'debugQuery', 'safeMessage'], '$');
222
253
  kindAndVersion(context, value, 'hypequery-query-diagnostics');
254
+ exactFields(context, value, ['kind', 'version', 'eventId', 'queryId', 'terminalReason', 'attempts'], ['runtimeIdentity', 'debugQuery', 'safeMessage'], '$');
223
255
  return freezeRecord({
224
256
  kind: 'hypequery-query-diagnostics',
225
257
  version: 1,
@@ -231,7 +263,7 @@ export function validateProtocolQueryDiagnostics(input, options = {}) {
231
263
  runtimeIdentity: hexIdentity(context, value.runtimeIdentity, '$.runtimeIdentity'),
232
264
  } : {}),
233
265
  ...(Object.hasOwn(value, 'debugQuery') ? {
234
- debugQuery: boundedText(context, value.debugQuery, '$.debugQuery', limits.maxDebugBytes),
266
+ debugQuery: boundedText(context, value.debugQuery, '$.debugQuery', limits.maxDebugBytes, true),
235
267
  } : {}),
236
268
  ...(Object.hasOwn(value, 'safeMessage') ? {
237
269
  safeMessage: boundedText(context, value.safeMessage, '$.safeMessage', limits.maxStringBytes),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/protocol",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Portable analytics contracts and TypeScript reference implementation for Hypequery",
5
5
  "keywords": [
6
6
  "hypequery",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "devDependencies": {
33
33
  "typescript": "^5.7.3",
34
- "vitest": "^3.2.6"
34
+ "vitest": "^5.0.2"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",
@@ -48,7 +48,7 @@
48
48
  "scripts": {
49
49
  "build": "tsc --project tsconfig.json",
50
50
  "dev": "tsc --project tsconfig.json --watch",
51
- "lint": "eslint --ext .ts \"src/**/*.ts\"",
51
+ "lint": "eslint \"src/**/*.ts\"",
52
52
  "test": "vitest run",
53
53
  "types": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.test.json"
54
54
  }