webspresso 0.0.112 → 0.0.113

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.
@@ -118,6 +118,9 @@ function defineFragment(config = {}) {
118
118
  : config.persist !== undefined
119
119
  ? config.persist
120
120
  : undefined,
121
+ lazy: config.lazy !== undefined ? Boolean(config.lazy) : undefined,
122
+ placeholder: config.placeholder !== undefined ? config.placeholder : undefined,
123
+ placeholderTemplate: config.placeholderTemplate !== undefined ? config.placeholderTemplate : undefined,
121
124
  };
122
125
  }
123
126
 
@@ -403,6 +406,7 @@ function wrapFragmentRootHtml({
403
406
  refetchInterval,
404
407
  refetchIntervalInBackground,
405
408
  persistInput,
409
+ lazy,
406
410
  }) {
407
411
  const safeTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(tag || 'div') ? tag : 'div';
408
412
  const attrs = [
@@ -449,6 +453,10 @@ function wrapFragmentRootHtml({
449
453
  attrs.push(`data-w-persist-input="${escapeHtmlAttr(persistAttrVal)}"`);
450
454
  }
451
455
 
456
+ if (lazy) {
457
+ attrs.push('data-w-lazy="visible"');
458
+ }
459
+
452
460
  if (className) {
453
461
  attrs.push(`class="${escapeHtmlAttr(className)}"`);
454
462
  }
@@ -512,10 +520,18 @@ async function executeAndRenderFragment({
512
520
  // 3. Validate & coerce fragment input
513
521
  const validatedInput = validateFragmentInput(name, fragmentDef, rawInput);
514
522
 
515
- // 4. Execute fragment loader (`load(fragmentCtx)`)
523
+ const isLazy = Boolean(options.lazy !== undefined ? options.lazy : fragmentDef.lazy);
524
+ const isRefreshTarget = Boolean(
525
+ req &&
526
+ req.__isWebspressoFragmentRefresh &&
527
+ (!req.__refreshedFragmentIds || req.__refreshedFragmentIds.has(instanceId))
528
+ );
529
+ const renderAsLazyPlaceholder = isLazy && !isRefreshTarget;
530
+
531
+ // 4. Execute fragment loader (`load(fragmentCtx)`) unless rendering initial lazy placeholder
516
532
  const loadStart = Date.now();
517
533
  let fragmentData = {};
518
- if (typeof fragmentDef.load === 'function') {
534
+ if (!renderAsLazyPlaceholder && typeof fragmentDef.load === 'function') {
519
535
  const fragmentCtx = {
520
536
  ...ctx,
521
537
  req,
@@ -535,20 +551,45 @@ async function executeAndRenderFragment({
535
551
  }
536
552
  const loadMs = Math.max(0, Date.now() - loadStart);
537
553
 
538
- // 5. Render fragment template via Nunjucks
554
+ // 5. Render fragment template (or lazy placeholder) via Nunjucks
539
555
  const renderStart = Date.now();
540
- const templateCtx = {
541
- ...(res && res.locals ? res.locals : {}),
542
- ...baseRenderCtx,
543
- ...fragmentData,
544
- fragmentInput: validatedInput,
545
- fragmentId: instanceId,
546
- fragmentName: name,
547
- };
556
+ let innerHtml = '';
557
+ if (renderAsLazyPlaceholder) {
558
+ const phTemplate = options.placeholderTemplate || fragmentDef.placeholderTemplate;
559
+ const phOption = options.placeholder !== undefined ? options.placeholder : fragmentDef.placeholder;
560
+ if (typeof phTemplate === 'string' && phTemplate.trim()) {
561
+ const cleanPh = phTemplate.trim().replace(/^\/+/, '');
562
+ if (cleanPh.includes('..')) {
563
+ throw new BadRequestError(`Invalid placeholder template path for fragment "${name}".`);
564
+ }
565
+ innerHtml = nunjucksEnv.render(cleanPh, {
566
+ ...(res && res.locals ? res.locals : {}),
567
+ ...baseRenderCtx,
568
+ fragmentInput: validatedInput,
569
+ fragmentId: instanceId,
570
+ fragmentName: name,
571
+ });
572
+ } else if (typeof phOption === 'function') {
573
+ innerHtml = String(phOption({ input: validatedInput, instanceId, name }) || '');
574
+ } else if (typeof phOption === 'string') {
575
+ innerHtml = phOption;
576
+ } else {
577
+ innerHtml = '<div class="w-fragment-placeholder" role="status" aria-busy="true" aria-live="polite" style="min-height:48px">Loading...</div>';
578
+ }
579
+ } else {
580
+ const templateCtx = {
581
+ ...(res && res.locals ? res.locals : {}),
582
+ ...baseRenderCtx,
583
+ ...fragmentData,
584
+ fragmentInput: validatedInput,
585
+ fragmentId: instanceId,
586
+ fragmentName: name,
587
+ };
548
588
 
549
- let innerHtml = nunjucksEnv.render(cleanTemplate, templateCtx);
550
- // Resolve any nested async SSR fragments inside this fragment
551
- innerHtml = await resolvePendingSsrFragments(innerHtml, req);
589
+ innerHtml = nunjucksEnv.render(cleanTemplate, templateCtx);
590
+ // Resolve any nested async SSR fragments inside this fragment
591
+ innerHtml = await resolvePendingSsrFragments(innerHtml, req);
592
+ }
552
593
  const renderMs = Math.max(0, Date.now() - renderStart);
553
594
 
554
595
  if (renderedInstanceIds) {
@@ -591,6 +632,7 @@ async function executeAndRenderFragment({
591
632
  : options.persist !== undefined
592
633
  ? options.persist
593
634
  : fragmentDef.persistInput,
635
+ lazy: renderAsLazyPlaceholder,
594
636
  });
595
637
 
596
638
  return {
@@ -811,6 +853,9 @@ function createFragmentRefreshHandler(config) {
811
853
  const startTotal = Date.now();
812
854
  try {
813
855
  const isGet = req.method === 'GET';
856
+ if (!isGet && req.headers && req.headers['sec-fetch-site'] === 'cross-site') {
857
+ throw new ForbiddenError('Cross-site fragment refresh requests are forbidden.');
858
+ }
814
859
  const payload = isGet ? req.query || {} : req.body || {};
815
860
 
816
861
  const pagePath = payload.pagePath || payload.page || req.headers['x-webspresso-page'] || '/';
@@ -894,6 +939,8 @@ function createFragmentRefreshHandler(config) {
894
939
  const ctx = typeof buildContext === 'function' ? buildContext(req, res, { fsy }) : { req, res, fsy };
895
940
  ensureRequestMemo(req, ctx);
896
941
 
942
+ req.__isWebspressoFragmentRefresh = true;
943
+ req.__refreshedFragmentIds = new Set(requestedFragments.map((it) => it.instanceId || `${it.name}:default`));
897
944
  req.__fragmentChildInputs =
898
945
  payload.childInputs && typeof payload.childInputs === 'object' && !isArray(payload.childInputs)
899
946
  ? payload.childInputs
@@ -912,6 +959,8 @@ function createFragmentRefreshHandler(config) {
912
959
  });
913
960
 
914
961
  const results = {};
962
+ const fragmentErrors = {};
963
+ let firstError = null;
915
964
 
916
965
  for (const reqItem of requestedFragments) {
917
966
  const fragName = reqItem.name.trim();
@@ -932,34 +981,51 @@ function createFragmentRefreshHandler(config) {
932
981
  : rawFragDef.middleware || [],
933
982
  };
934
983
 
935
- const rendered = await executeAndRenderFragment({
936
- name: fragName,
937
- fragmentDef: fragDef,
938
- instanceId,
939
- pagePath,
940
- rawInput: reqItem.input || {},
941
- req,
942
- res,
943
- ctx,
944
- nunjucksEnv,
945
- baseRenderCtx: { fsy },
946
- renderedInstanceIds,
947
- });
984
+ try {
985
+ const rendered = await executeAndRenderFragment({
986
+ name: fragName,
987
+ fragmentDef: fragDef,
988
+ instanceId,
989
+ pagePath,
990
+ rawInput: reqItem.input || {},
991
+ req,
992
+ res,
993
+ ctx,
994
+ nunjucksEnv,
995
+ baseRenderCtx: { fsy },
996
+ renderedInstanceIds,
997
+ });
998
+
999
+ results[instanceId] = {
1000
+ instanceId: rendered.instanceId,
1001
+ name: rendered.name,
1002
+ html: rendered.html,
1003
+ input: rendered.input,
1004
+ loadMs: rendered.loadMs,
1005
+ renderMs: rendered.renderMs,
1006
+ };
1007
+ } catch (fragErr) {
1008
+ if (!firstError) firstError = fragErr;
1009
+ fragmentErrors[instanceId] = {
1010
+ instanceId,
1011
+ name: fragName,
1012
+ status: fragErr.statusCode || fragErr.status || 500,
1013
+ code: fragErr.code || 'FRAGMENT_REFRESH_ERROR',
1014
+ message: fragErr.message || `Failed to render fragment "${fragName}"`,
1015
+ };
1016
+ }
1017
+ }
948
1018
 
949
- results[instanceId] = {
950
- instanceId: rendered.instanceId,
951
- name: rendered.name,
952
- html: rendered.html,
953
- input: rendered.input,
954
- loadMs: rendered.loadMs,
955
- renderMs: rendered.renderMs,
956
- };
1019
+ if (firstError && Object.keys(results).length === 0) {
1020
+ return next(firstError);
957
1021
  }
958
1022
 
959
1023
  const totalMs = Math.max(0, Date.now() - startTotal);
960
1024
  res.setHeader('Cache-Control', 'no-store');
1025
+ const hasPartialErrors = Object.keys(fragmentErrors).length > 0;
961
1026
  return res.status(200).json({
962
- ok: true,
1027
+ ok: !hasPartialErrors,
1028
+ ...(hasPartialErrors ? { partial: true, errors: fragmentErrors } : {}),
963
1029
  pagePath,
964
1030
  totalMs,
965
1031
  fragments: results,
@@ -185,7 +185,12 @@ function parseDataValue(val) {
185
185
  if (val === 'undefined') return undefined;
186
186
  if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(val)) {
187
187
  const num = Number(val);
188
- if (isNumber(num)) return num;
188
+ if (isNumber(num)) {
189
+ if (/^-?(?:0|[1-9]\d*)$/.test(val) && !Number.isSafeInteger(num)) {
190
+ return val;
191
+ }
192
+ return num;
193
+ }
189
194
  }
190
195
  if ((val.startsWith('{') && val.endsWith('}')) || (val.startsWith('[') && val.endsWith(']'))) {
191
196
  try {
@@ -350,9 +355,31 @@ function uid(prefix = 'id', len = 5) {
350
355
  return `${prefix}_${Date.now()}_${randomId(len)}`;
351
356
  }
352
357
 
358
+ /**
359
+ * Parse a delay/duration attribute such as `"200"`, `"200ms"`, or `"1s"` into milliseconds.
360
+ *
361
+ * @param {string|number|null|undefined} raw
362
+ * @param {number} [fallback=0]
363
+ * @returns {number}
364
+ */
365
+ function parseDelayMs(raw, fallback = 0) {
366
+ if (raw === null || raw === undefined || raw === '') return fallback;
367
+ if (typeof raw === 'number') return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
368
+ const s = String(raw).trim().toLowerCase();
369
+ if (!s) return fallback;
370
+ const msMatch = s.match(/^(\d+(?:\.\d+)?)\s*ms$/);
371
+ if (msMatch) return Math.max(0, Math.round(Number(msMatch[1])));
372
+ const sMatch = s.match(/^(\d+(?:\.\d+)?)\s*s$/);
373
+ if (sMatch) return Math.max(0, Math.round(Number(sMatch[1]) * 1000));
374
+ const num = Number(s);
375
+ return Number.isFinite(num) && num >= 0 ? Math.round(num) : fallback;
376
+ }
377
+
353
378
  /**
354
379
  * Apply or restore automatic busy/loading state (`data-w-busy`, `aria-busy`, `disabled`,
355
380
  * `data-w-loading-text` / `data-w-disable-with`, and `data-w-loading-class`) on an interactive element.
381
+ * Respects `data-w-loading-delay` for visual indicators (`loading-text`, `loading-class`) while
382
+ * applying `data-w-busy`, `aria-busy`, and `disabled` double-submit protection immediately.
356
383
  *
357
384
  * @param {Element|null|undefined} el
358
385
  * @param {boolean} isBusy
@@ -360,6 +387,29 @@ function uid(prefix = 'id', len = 5) {
360
387
  function setElementBusyState(el, isBusy) {
361
388
  if (!el || typeof el.setAttribute !== 'function') return;
362
389
 
390
+ const applyVisualBusy = () => {
391
+ const tag = el.tagName || '';
392
+ const loadingText = el.getAttribute('data-w-loading-text') ?? el.getAttribute('data-w-disable-with');
393
+ if (loadingText !== null) {
394
+ if (tag === 'INPUT' && (el.type === 'submit' || el.type === 'button')) {
395
+ el.__wOrigValue = el.value;
396
+ el.value = loadingText;
397
+ } else {
398
+ el.__wOrigText = el.textContent;
399
+ el.textContent = loadingText;
400
+ }
401
+ }
402
+
403
+ const loadingClass = safeTrim(el.getAttribute('data-w-loading-class'));
404
+ if (loadingClass && el.classList) {
405
+ const classes = loadingClass.split(/\s+/).filter(Boolean);
406
+ el.__wAddedClasses = classes;
407
+ for (let i = 0; i < classes.length; i++) {
408
+ el.classList.add(classes[i]);
409
+ }
410
+ }
411
+ };
412
+
363
413
  if (isBusy) {
364
414
  el.__wBusyCount = (el.__wBusyCount || 0) + 1;
365
415
  if (el.__wBusyCount > 1) return;
@@ -386,24 +436,22 @@ function setElementBusyState(el, isBusy) {
386
436
  el.setAttribute('aria-disabled', 'true');
387
437
  }
388
438
 
389
- const loadingText = el.getAttribute('data-w-loading-text') ?? el.getAttribute('data-w-disable-with');
390
- if (loadingText !== null) {
391
- if (tag === 'INPUT' && (el.type === 'submit' || el.type === 'button')) {
392
- el.__wOrigValue = el.value;
393
- el.value = loadingText;
394
- } else {
395
- el.__wOrigText = el.textContent;
396
- el.textContent = loadingText;
397
- }
439
+ const delayMs = parseDelayMs(el.getAttribute('data-w-loading-delay'), 0);
440
+ el.__wBusyGen = (el.__wBusyGen || 0) + 1;
441
+ if (el.__wBusyTimer) {
442
+ clearTimeout(el.__wBusyTimer);
443
+ el.__wBusyTimer = null;
398
444
  }
399
445
 
400
- const loadingClass = safeTrim(el.getAttribute('data-w-loading-class'));
401
- if (loadingClass && el.classList) {
402
- const classes = loadingClass.split(/\s+/).filter(Boolean);
403
- el.__wAddedClasses = classes;
404
- for (let i = 0; i < classes.length; i++) {
405
- el.classList.add(classes[i]);
406
- }
446
+ if (delayMs > 0) {
447
+ const gen = el.__wBusyGen;
448
+ el.__wBusyTimer = setTimeout(() => {
449
+ el.__wBusyTimer = null;
450
+ if (el.__wBusyGen !== gen || !el.__wActionBusy || el.isConnected === false) return;
451
+ applyVisualBusy();
452
+ }, delayMs);
453
+ } else {
454
+ applyVisualBusy();
407
455
  }
408
456
  } else {
409
457
  if (!el.__wBusyCount) return;
@@ -411,6 +459,12 @@ function setElementBusyState(el, isBusy) {
411
459
  if (el.__wBusyCount > 0) return;
412
460
 
413
461
  el.__wActionBusy = false;
462
+ el.__wBusyGen = (el.__wBusyGen || 0) + 1;
463
+ if (el.__wBusyTimer) {
464
+ clearTimeout(el.__wBusyTimer);
465
+ el.__wBusyTimer = null;
466
+ }
467
+
414
468
  el.removeAttribute('data-w-busy');
415
469
  el.removeAttribute('aria-busy');
416
470
 
@@ -459,6 +513,7 @@ module.exports = {
459
513
  parseDataValue,
460
514
  decorateElement,
461
515
  parseDurationMs,
516
+ parseDelayMs,
462
517
  parseRefetchMode,
463
518
  randomId,
464
519
  uid,
@@ -229,13 +229,71 @@ async function executeApiPipeline(params) {
229
229
  req.service.invalidateTags = req.service.invalidateByTags;
230
230
  }
231
231
 
232
+ const rawValidateHeader = req && req.headers ? req.headers['x-webspresso-validate-fields'] : null;
233
+ const hasValidateHeader = Boolean(rawValidateHeader && String(rawValidateHeader).trim());
234
+ if (hasValidateHeader && !(apiDef && apiDef.validateFields)) {
235
+ if (res && typeof res.status === 'function' && typeof res.json === 'function') {
236
+ return res.status(400).json({
237
+ status: 400,
238
+ error: 'Field validation not enabled on this endpoint',
239
+ message: 'Field validation not enabled on this endpoint',
240
+ code: 'FIELD_VALIDATION_NOT_ENABLED',
241
+ });
242
+ }
243
+ const err = new Error('Field validation not enabled on this endpoint');
244
+ err.status = 400;
245
+ throw err;
246
+ }
247
+
248
+ const isFieldValidationRequest = Boolean(hasValidateHeader && apiDef && apiDef.validateFields);
249
+ const requestedValidateFields = isFieldValidationRequest
250
+ ? String(rawValidateHeader).split(',').map((s) => s.trim()).filter(Boolean)
251
+ : [];
252
+ const allowedValidateFields =
253
+ isFieldValidationRequest && Array.isArray(apiDef.validateFields)
254
+ ? requestedValidateFields.filter((f) => apiDef.validateFields.includes(f))
255
+ : requestedValidateFields;
256
+ const fieldValidationErrors = {};
257
+
232
258
  // 2. Schema validation
233
259
  const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
234
260
  const routeDir = descriptor._routeDir !== undefined
235
261
  ? descriptor._routeDir
236
262
  : (descriptor._routeDir = descriptor.file ? path.dirname(descriptor.file) : '');
237
263
 
238
- if (compiledSchema) {
264
+ if (isFieldValidationRequest) {
265
+ req.input = {
266
+ body: req.body,
267
+ params: req.params,
268
+ query: req.query,
269
+ };
270
+ if (compiledSchema && compiledSchema.body) {
271
+ let issues = null;
272
+ if (typeof compiledSchema.body.safeParse === 'function') {
273
+ const parsed = compiledSchema.body.safeParse(req.body);
274
+ if (parsed.success) {
275
+ req.input.body = parsed.data;
276
+ } else if (parsed.error && Array.isArray(parsed.error.issues)) {
277
+ issues = parsed.error.issues;
278
+ }
279
+ } else if (typeof compiledSchema.body.parse === 'function') {
280
+ try {
281
+ req.input.body = compiledSchema.body.parse(req.body);
282
+ } catch (parseErr) {
283
+ if (Array.isArray(parseErr?.issues)) issues = parseErr.issues;
284
+ }
285
+ }
286
+ if (issues) {
287
+ const translated = translateValidationIssues(issues, { req, pagesDir, routeDir, isDev });
288
+ for (const issue of translated) {
289
+ const pathKey = Array.isArray(issue.path) && issue.path.length > 0 ? String(issue.path[0]) : null;
290
+ if (pathKey && allowedValidateFields.includes(pathKey) && !fieldValidationErrors[pathKey]) {
291
+ fieldValidationErrors[pathKey] = issue.message || 'Invalid value';
292
+ }
293
+ }
294
+ }
295
+ }
296
+ } else if (compiledSchema) {
239
297
  try {
240
298
  validateRequestInput(req, compiledSchema);
241
299
  } catch (err) {
@@ -348,6 +406,29 @@ async function executeApiPipeline(params) {
348
406
  if (res && (res.headersSent || res.writableEnded || res.finished)) return;
349
407
  }
350
408
 
409
+ if (isFieldValidationRequest) {
410
+ if (typeof apiDef.validate === 'function') {
411
+ const customErrs = await apiDef.validate(apiCtx, allowedValidateFields);
412
+ if (customErrs && typeof customErrs === 'object') {
413
+ for (const [k, v] of Object.entries(customErrs)) {
414
+ if (allowedValidateFields.includes(k) && v && !fieldValidationErrors[k]) {
415
+ fieldValidationErrors[k] = String(v);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ const payload = {
421
+ ok: Object.keys(fieldValidationErrors).length === 0,
422
+ valid: Object.keys(fieldValidationErrors).length === 0,
423
+ errors: fieldValidationErrors,
424
+ validatedFields: allowedValidateFields,
425
+ };
426
+ if (res && typeof res.status === 'function' && typeof res.json === 'function') {
427
+ return res.status(200).json(payload);
428
+ }
429
+ return payload;
430
+ }
431
+
351
432
  const result = await handlerFn(req, res, apiCtx);
352
433
 
353
434
  // 5. Auto-respond if handler returned a value and response not yet sent
@@ -7,7 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { zodToJsonSchema } = require('zod-to-json-schema');
10
+ const { zodToJsonSchemaCompat, getZodTypeName } = require('../validation/zod-introspect');
11
11
  const { compileSchema } = require('../compileSchema');
12
12
  const { generateOrmOpenApiSchemas } = require('./orm-components');
13
13
 
@@ -100,13 +100,13 @@ function inferOperationTags(routePath, explicitTags) {
100
100
  * @returns {object[]}
101
101
  */
102
102
  function expandZodObjectToParameters(zodObj, where) {
103
- if (!zodObj || zodObj._def?.typeName !== 'ZodObject') return [];
103
+ if (!zodObj || getZodTypeName(zodObj) !== 'ZodObject') return [];
104
104
  const shape = zodObj.shape;
105
105
  return Object.entries(shape).map(([name, zType]) => ({
106
106
  name,
107
107
  in: where,
108
108
  required: where === 'path' ? true : !zType.isOptional(),
109
- schema: zodToJsonSchema(zType, { target: 'openApi3', $refStrategy: 'none' }),
109
+ schema: zodToJsonSchemaCompat(zType, { target: 'openApi3', $refStrategy: 'none' }),
110
110
  }));
111
111
  }
112
112
 
@@ -169,7 +169,7 @@ function buildOperation(route, compiled, mod = null) {
169
169
  description: 'OK',
170
170
  content: {
171
171
  'application/json': {
172
- schema: zodToJsonSchema(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
172
+ schema: zodToJsonSchemaCompat(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
173
173
  },
174
174
  },
175
175
  };
@@ -188,7 +188,7 @@ function buildOperation(route, compiled, mod = null) {
188
188
  op.requestBody = {
189
189
  content: {
190
190
  'application/json': {
191
- schema: zodToJsonSchema(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
191
+ schema: zodToJsonSchemaCompat(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
192
192
  },
193
193
  },
194
194
  };
@@ -3,6 +3,8 @@
3
3
  * @module core/orm/json-fields
4
4
  */
5
5
 
6
+ const { getZodDef, getZodInner, getZodTypeName } = require('../validation/zod-introspect');
7
+
6
8
  /**
7
9
  * Get JSON column names from model
8
10
  * @param {import('./types').ModelDefinition} model - Model definition
@@ -31,16 +33,15 @@ function getJsonColumns(model) {
31
33
  if (model && model.schema && model.schema.shape) {
32
34
  for (const [name, fieldSchema] of Object.entries(model.schema.shape)) {
33
35
  let current = fieldSchema;
34
- while (current && current._def) {
35
- if (current._def.innerType) {
36
- current = current._def.innerType;
37
- } else if (current._def.schema) {
38
- current = current._def.schema;
36
+ while (current && getZodDef(current)) {
37
+ const inner = getZodInner(current);
38
+ if (inner) {
39
+ current = inner;
39
40
  } else {
40
41
  break;
41
42
  }
42
43
  }
43
- const typeName = current?._def?.typeName;
44
+ const typeName = getZodTypeName(current);
44
45
  if (typeName === 'ZodRecord' || typeName === 'ZodObject' || typeName === 'ZodArray') {
45
46
  jsonCols.add(name);
46
47
  }
@@ -5,6 +5,13 @@
5
5
  */
6
6
 
7
7
  const { safeJsonParse } = require('../utils/json');
8
+ const {
9
+ getZodDef,
10
+ getZodTypeName,
11
+ getZodInner,
12
+ getZodEnumValues,
13
+ isZodInt,
14
+ } = require('../validation/zod-introspect');
8
15
 
9
16
  const METADATA_MARKER = '__wdb__';
10
17
  const metaWeakMap = new WeakMap();
@@ -665,10 +672,7 @@ function createSchemaHelpers(z) {
665
672
  */
666
673
  json(options = {}) {
667
674
  const { nullable = false, ...rest } = options;
668
- let schema = z.unknown();
669
- if (nullable) {
670
- schema = schema.nullable().optional();
671
- }
675
+ let schema = nullable ? z.unknown().nullable().optional() : z.unknown().optional();
672
676
  return createSchemaBuilder(schema, {
673
677
  type: 'json',
674
678
  nullable,
@@ -888,14 +892,14 @@ function extractColumnsFromSchema(schema) {
888
892
  // Unwrap optional/nullable wrappers to get to the base schema
889
893
  let current = schemaToCheck;
890
894
  let isNullable = false;
891
- while (current && current._def) {
892
- if (current._def.typeName === 'ZodNullable' || current._def.typeName === 'ZodOptional') {
895
+ while (current && getZodDef(current)) {
896
+ const currentType = getZodTypeName(current);
897
+ if (currentType === 'ZodNullable' || currentType === 'ZodOptional') {
893
898
  isNullable = true;
894
899
  }
895
- if (current._def.innerType) {
896
- current = current._def.innerType;
897
- } else if (current._def.schema) {
898
- current = current._def.schema;
900
+ const inner = getZodInner(current);
901
+ if (inner) {
902
+ current = inner;
899
903
  } else {
900
904
  break;
901
905
  }
@@ -905,16 +909,15 @@ function extractColumnsFromSchema(schema) {
905
909
  const meta = getColumnMeta(schemaToCheck) || (current ? getColumnMeta(current) : null);
906
910
  if (meta) {
907
911
  columns.set(key, meta);
908
- } else if (current && current._def && current._def.typeName) {
909
- const typeName = current._def.typeName;
912
+ } else if (current && getZodTypeName(current)) {
913
+ const typeName = getZodTypeName(current);
910
914
  let inferred = null;
911
915
  switch (typeName) {
912
916
  case 'ZodString':
913
917
  inferred = { type: 'string', maxLength: 255, nullable: isNullable };
914
918
  break;
915
919
  case 'ZodNumber': {
916
- const isInt = current._def.checks?.some((c) => c.kind === 'int');
917
- inferred = { type: isInt ? 'integer' : 'float', nullable: isNullable };
920
+ inferred = { type: isZodInt(current) ? 'integer' : 'float', nullable: isNullable };
918
921
  break;
919
922
  }
920
923
  case 'ZodBoolean':
@@ -924,7 +927,7 @@ function extractColumnsFromSchema(schema) {
924
927
  inferred = { type: 'timestamp', nullable: isNullable };
925
928
  break;
926
929
  case 'ZodEnum':
927
- inferred = { type: 'enum', enumValues: current._def.values || [], nullable: isNullable };
930
+ inferred = { type: 'enum', enumValues: getZodEnumValues(current), nullable: isNullable };
928
931
  break;
929
932
  case 'ZodArray':
930
933
  inferred = { type: 'files', nullable: isNullable };
@@ -63,14 +63,13 @@ function resolveNanoidSize(arg) {
63
63
  * @param {typeof import('zod').z} zod
64
64
  */
65
65
  function extendZ(zod) {
66
- return new Proxy(zod, {
67
- get(target, prop, receiver) {
68
- if (prop === 'nanoid') {
69
- return function nanoid(arg) {
70
- return zodNanoid(zod, resolveNanoidSize(arg));
71
- };
72
- }
73
- return Reflect.get(target, prop, receiver);
66
+ return Object.create(zod, {
67
+ nanoid: {
68
+ value: function nanoid(arg) {
69
+ return zodNanoid(zod, resolveNanoidSize(arg));
70
+ },
71
+ enumerable: true,
72
+ configurable: true,
74
73
  },
75
74
  });
76
75
  }
@@ -293,7 +293,18 @@ function validateServiceInput(schema, input, serviceName, options = {}) {
293
293
  // 1. Zod schema validation
294
294
  if (compiled instanceof z.ZodType || typeof compiled.safeParse === 'function') {
295
295
  try {
296
- const result = compiled.safeParse(sanitized);
296
+ let result;
297
+ if (compiled._zod && typeof compiled._zod.run === 'function') {
298
+ const rawRun = compiled._zod.run({ value: sanitized, issues: [] }, { async: true });
299
+ if (rawRun instanceof Promise) {
300
+ // Attach no-op catch so async refinements rejected during sync preflight don't emit UnhandledRejection
301
+ rawRun.catch(() => {});
302
+ throw new Error('Encountered Promise during synchronous parse');
303
+ }
304
+ result = rawRun.issues.length ? compiled.safeParse(sanitized) : { success: true, data: rawRun.value };
305
+ } else {
306
+ result = compiled.safeParse(sanitized);
307
+ }
297
308
  if (!result.success) {
298
309
  const { message, fields, fieldErrors } = formatZodErrors(result.error, serviceName);
299
310
  throw createServiceValidationError(message, {
@@ -6,6 +6,9 @@
6
6
  * @module core/validation/apply
7
7
  */
8
8
 
9
+ const { ensureZodCompat } = require('./zod-introspect');
10
+ ensureZodCompat();
11
+
9
12
  const EMPTY_INPUT = Object.freeze({
10
13
  body: undefined,
11
14
  params: undefined,