zitejs 0.9.95 → 0.9.96

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.
@@ -27,13 +27,22 @@ export type ZiteErrorCode = "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_
27
27
  * through a fixed table (BAD_REQUEST -> 400, and so on); it reads `code` and
28
28
  * nothing else, so an error carrying only a numeric `statusCode` fell through
29
29
  * to a generic 500.
30
+ *
31
+ * Both call shapes, mirroring the worker's own class (`zite-runtime.ts`): the
32
+ * object form is preferred, and the positional form is what pre-monorepo app
33
+ * code was written against. Typing only the object form would have failed the
34
+ * typecheck of every migrated app that throws one.
30
35
  */
31
36
  export declare class ZiteError extends Error {
32
37
  code: ZiteErrorCode;
38
+ /** Short, non-technical message suitable for showing to an end user. */
39
+ userFacingMessage?: string;
33
40
  constructor(options: {
34
41
  code: ZiteErrorCode;
35
42
  message: string;
43
+ userFacingMessage?: string;
36
44
  });
45
+ constructor(message: string, code?: ZiteErrorCode);
37
46
  }
38
47
  /**
39
48
  * Structurally matches a zod schema. `TIn` is the schema's *input* type, which
@@ -49,7 +58,7 @@ export type ZiteStreamInterface = {
49
58
  write: (data: unknown) => Promise<void>;
50
59
  forward: (asyncIterable: AsyncIterable<string>) => Promise<string>;
51
60
  };
52
- export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput> {
61
+ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {
53
62
  description?: string;
54
63
  inputSchema?: SchemaLike<TInput, TRawInput>;
55
64
  outputSchema?: SchemaLike<TOutput>;
@@ -61,12 +70,17 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
61
70
  * Declaring one widens `context` so `context.user` must be null-checked.
62
71
  */
63
72
  schedule?: TSchedule;
64
- webhook?: ZiteWebhook;
73
+ /**
74
+ * When set, the endpoint can also be triggered by an inbound webhook. Like
75
+ * `schedule`, this widens `context` — the workflow-runner sends
76
+ * `{ user: null }` for a webhook fire exactly as it does for a cron one.
77
+ */
78
+ webhook?: TWebhook;
65
79
  execute: (params: {
66
80
  input: TInput;
67
- context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
81
+ context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
68
82
  } & (TStream extends true ? {
69
83
  stream: ZiteStreamInterface;
70
84
  } : {})) => Promise<TOutput> | TOutput;
71
85
  }
72
- export declare function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput>(config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>): EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>;
86
+ export declare function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput>(config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>): EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>;
@@ -7,13 +7,27 @@ exports.createEndpoint = createEndpoint;
7
7
  * through a fixed table (BAD_REQUEST -> 400, and so on); it reads `code` and
8
8
  * nothing else, so an error carrying only a numeric `statusCode` fell through
9
9
  * to a generic 500.
10
+ *
11
+ * Both call shapes, mirroring the worker's own class (`zite-runtime.ts`): the
12
+ * object form is preferred, and the positional form is what pre-monorepo app
13
+ * code was written against. Typing only the object form would have failed the
14
+ * typecheck of every migrated app that throws one.
10
15
  */
11
16
  class ZiteError extends Error {
12
17
  code;
13
- constructor(options) {
14
- super(options.message);
18
+ /** Short, non-technical message suitable for showing to an end user. */
19
+ userFacingMessage;
20
+ constructor(optionsOrMessage, legacyCode) {
21
+ if (typeof optionsOrMessage === "string") {
22
+ super(optionsOrMessage);
23
+ this.code = legacyCode ?? "INTERNAL_ERROR";
24
+ }
25
+ else {
26
+ super(optionsOrMessage.message);
27
+ this.code = optionsOrMessage.code;
28
+ this.userFacingMessage = optionsOrMessage.userFacingMessage;
29
+ }
15
30
  this.name = "ZiteError";
16
- this.code = options.code;
17
31
  }
18
32
  }
19
33
  exports.ZiteError = ZiteError;
@@ -599,8 +599,13 @@ async function runBundle() {
599
599
  if (appFlag !== -1 && args[appFlag + 1]) {
600
600
  baseDir = path.resolve(baseDir, 'apps', args[appFlag + 1]);
601
601
  }
602
- // If explicit endpoint names passed, use those; otherwise find all
603
- const explicitNames = args.filter(a => !a.startsWith('--'));
602
+ // If explicit endpoint names passed, use those; otherwise find all.
603
+ // Skipping `--app`'s VALUE as well as the flag: it is a positional arg, so
604
+ // filtering on the `--` prefix alone treated the app directory name as an
605
+ // endpoint name — `zitejs bundle --app myapp` tried to bundle
606
+ // `src/api/myapp.ts`, found nothing else, and reported every real endpoint
607
+ // as missing.
608
+ const explicitNames = args.filter((a, i) => !a.startsWith('--') && !(appFlag !== -1 && i === appFlag + 1));
604
609
  let endpointNames;
605
610
  if (explicitNames.length > 0) {
606
611
  endpointNames = explicitNames;
@@ -2,21 +2,30 @@ import type { NotificationsCreateParams, NotificationsCreateResult } from "../no
2
2
  /**
3
3
  * A comparison against one field. Mirrors base-runner's operator set
4
4
  * (`LegacyWhereConditionOperators`); anything else in the object is ignored.
5
+ *
6
+ * The operand types deliberately match the pre-monorepo `FilterOperators`
7
+ * rather than tightening to the field's own type. The value of typing filters
8
+ * is in the KEY — an unknown key silently returns every row — and tightening
9
+ * the operands buys little while breaking real migrated code: dates are ISO
10
+ * strings here but 1.0 accepted `number | Date` on every comparison, and
11
+ * `contains` was `any`.
5
12
  */
6
13
  export type FilterCondition<V> = {
7
14
  /** Substring match (text fields) or "has any of these" (linked records). */
8
- contains?: V extends Array<infer E> ? E | E[] : V;
15
+ contains?: any;
9
16
  /** Not equal, or — against `null` — "is set". */
10
17
  not?: V | null;
11
18
  /** Empty array means "no filter", not "match nothing". */
12
19
  in?: V extends Array<infer E> ? E[] : V[];
13
20
  /** Empty array means "no filter", not "match everything". */
14
21
  notIn?: V extends Array<infer E> ? E[] : V[];
15
- lt?: V;
16
- lte?: V;
17
- gt?: V;
18
- gte?: V;
22
+ lt?: V | number | Date;
23
+ lte?: V | number | Date;
24
+ gt?: V | number | Date;
25
+ gte?: V | number | Date;
19
26
  };
27
+ /** Pre-monorepo name for {@link FilterCondition}. Kept so migrated app code that spells the type out still compiles. */
28
+ export type FilterOperators<V> = FilterCondition<V> | V;
20
29
  /**
21
30
  * Filters keyed by the record's own field names. Typed rather than `unknown`
22
31
  * because an unrecognized key is not an error at runtime — it passes through
@@ -24,8 +33,16 @@ export type FilterCondition<V> = {
24
33
  * comes back **unfiltered**. A typo silently returns every row.
25
34
  */
26
35
  export type RecordFilters<T> = {
27
- [K in keyof T]?: T[K] | FilterCondition<T[K]>;
36
+ [K in keyof T]?: T[K] | (T[K] extends Array<infer E> ? E : never) | FilterCondition<T[K]>;
28
37
  };
38
+ /**
39
+ * Resolves a zod schema to the type it produces. `createEndpoint` infers this
40
+ * for you, so it is rarely needed — it exists because the pre-monorepo SDK
41
+ * exposed it and migrated app code may still name it.
42
+ */
43
+ export type InferSchemaType<T> = T extends {
44
+ _output: infer U;
45
+ } ? U : T;
29
46
  export interface TableFindAllOptions<T = Record<string, unknown>> {
30
47
  limit?: number;
31
48
  offset?: number;
@@ -52,10 +52,15 @@ const FIELD_INPUT_TYPE_MAP = {
52
52
  user: "string | string[] | null",
53
53
  };
54
54
  /**
55
- * Computed and system-managed fields. base-runner rejects a write to any of
56
- * these outright (`createRecordDataSchema` in `trpc/schemas/records.ts`), so
57
- * they are omitted from the generated input type rather than typed and then
58
- * refused at runtime.
55
+ * Computed and system-managed fields, omitted from the generated input type
56
+ * rather than typed and then refused at runtime.
57
+ *
58
+ * Mirrors `COMPUTED_FIELD_TYPES` in the product's own `shared/bases/fields`.
59
+ * The first six are rejected by `createRecordDataSchema` — each has a
60
+ * `refine(val => val == null)`, so a non-null write errors (null is a no-op
61
+ * clear). `created_at` / `updated_at` are refused differently: they have no
62
+ * column at all, so a write is silently dropped, which is the worse outcome to
63
+ * leave typed as writable.
59
64
  */
60
65
  const READ_ONLY_FIELD_TYPES = new Set([
61
66
  "lookup",
@@ -64,6 +69,8 @@ const READ_ONLY_FIELD_TYPES = new Set([
64
69
  "formula",
65
70
  "autonumber",
66
71
  "updated_by",
72
+ "created_at",
73
+ "updated_at",
67
74
  ]);
68
75
  /**
69
76
  * Emitted once per `db.ts`. The old inline `{ url: string; name?: string }` was
@@ -110,6 +117,19 @@ const DURATION_FORMAT_EXAMPLES = {
110
117
  "h:mm:ss.ss": "1:23:03.00",
111
118
  "h:mm:ss.sss": "1:23:03.000",
112
119
  };
120
+ // Naming the format alone ("display as `european` format") doesn't say what to
121
+ // render — these mirror the pickers in the database UI, which is what the user
122
+ // chose from. `local` is the viewer's system locale, so it has no fixed example.
123
+ const DATE_FORMAT_EXAMPLES = {
124
+ long: "January 15, 2024",
125
+ us: "1/15/2024",
126
+ european: "15/01/2024",
127
+ iso: "2024-01-15",
128
+ };
129
+ const withDateFormatExample = (format) => {
130
+ const example = DATE_FORMAT_EXAMPLES[format];
131
+ return example ? `"${format}" format (e.g. ${example})` : `"${format}" format`;
132
+ };
113
133
  function toPascalCase(name) {
114
134
  const pascal = name
115
135
  .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
@@ -188,13 +208,13 @@ function fieldJsdoc(schemaField, table, schema) {
188
208
  if (def.type === "date") {
189
209
  const tpl = def.template;
190
210
  if (tpl.dateFormat)
191
- parts.push(`Date-only field (YYYY-MM-DD string), null when unset, display as "${tpl.dateFormat}" format`);
211
+ parts.push(`Date-only field (YYYY-MM-DD string), null when unset, display as ${withDateFormatExample(tpl.dateFormat)}`);
192
212
  }
193
213
  if (def.type === "datetime") {
194
214
  const tpl = def.template;
195
215
  const timeParts = ["Date+time field (ISO 8601 timestamp), null when unset"];
196
216
  if (tpl.dateFormat)
197
- timeParts.push(`date: ${tpl.dateFormat}`);
217
+ timeParts.push(`date: ${withDateFormatExample(tpl.dateFormat)}`);
198
218
  if (tpl.timeFormat)
199
219
  timeParts.push(`time: ${tpl.timeFormat}`);
200
220
  if (tpl.timezone)
@@ -393,13 +413,13 @@ function generateDbTs(schema) {
393
413
  lines.push("// operators: contains, not, in, notIn, lt, lte, gt, gte");
394
414
  lines.push('// `not: null` means "is set"; `in`/`notIn` with an empty array apply NO filter');
395
415
  lines.push("// sort: [{ field: 'fieldName', direction: 'asc' | 'desc' }]");
396
- lines.push("// limit defaults to 500, max 5000; offset is a row count (a number)");
416
+ lines.push("// limit defaults to 500, max 2000; offset is a row count (a number)");
397
417
  lines.push("// .findOne({ id?, filters?, fields? }) → T | undefined");
398
418
  lines.push("// .create({ record }) → T");
399
419
  lines.push("// .update({ id, record }) → { id: string, fields: Partial<T> }");
400
420
  lines.push("// .delete({ id }) → { success: true, id: string }");
401
421
  lines.push("// .bulkCreate({ records, matchOn? }) → { success: boolean, records: T[] }");
402
- lines.push("// up to 500 records per call; matchOn upserts on those fields");
422
+ lines.push("// up to 100 records per call; matchOn upserts on those fields");
403
423
  lines.push("//");
404
424
  lines.push("// Reading these types:");
405
425
  lines.push("// SDK names are stable identifiers — they do NOT change when a table or field is");
@@ -410,8 +430,9 @@ function generateDbTs(schema) {
410
430
  lines.push("// display yourself, following the display config in each field's comment.");
411
431
  lines.push("// PERCENT fields are stored as decimals — 0.5 is 50%, 1.0 is 100%.");
412
432
  lines.push("// LINKED RECORD fields hold UUID record ids from findOne/findAll — never invent one.");
413
- lines.push("// Computed fields (formula, rollup, lookup, autonumber) are absent from the *Input");
414
- lines.push("// types — writing to one is rejected.");
433
+ lines.push("// Computed and system-managed fields (formula, rollup, lookup, autonumber,");
434
+ lines.push("// created/updated timestamps and more) are absent from the *Input types —");
435
+ lines.push("// a write to one is rejected or silently dropped.");
415
436
  lines.push("//");
416
437
  lines.push("// Zite auth users:");
417
438
  lines.push("// zite.auth.findAllUsers({ appIds?, filter?, filters?, sort?, limit?, offset? })");
@@ -425,7 +446,7 @@ function generateDbTs(schema) {
425
446
  lines.push("// → { rows: Record<string, unknown>[], columns, rowCount, truncated }");
426
447
  lines.push("// Capped at 2000 rows — `truncated: true` means there were more, so paginate in");
427
448
  lines.push("// SQL rather than assuming you got everything. Queries time out after 10s.");
428
- lines.push("// columns[].name is the SDK name; columns[].originalName is the user-facing one.");
449
+ lines.push("// columns[].name is the SDK name; columns[].originalName is the underlying", "// database column (or your own SQL alias).");
429
450
  lines.push("//");
430
451
  lines.push("// SQL guidelines:");
431
452
  lines.push("// - Use SDK names for tables (PascalCase) and fields (camelCase)");
@@ -483,38 +504,74 @@ function generateDbTs(schema) {
483
504
  lines.push("");
484
505
  return lines.join("\n");
485
506
  }
507
+ const NOT_AN_ENDPOINT = {
508
+ isEndpoint: false,
509
+ hasDefaultExport: false,
510
+ stream: false,
511
+ };
512
+ /** `createEndpoint(...)` anywhere in the expression, through wrappers. */
513
+ function callsCreateEndpoint(node) {
514
+ if (!node || typeof node !== "object")
515
+ return false;
516
+ const n = node;
517
+ if (n.type === "CallExpression" &&
518
+ n.callee?.name ===
519
+ "createEndpoint") {
520
+ return true;
521
+ }
522
+ return Object.values(n).some((v) => Array.isArray(v) ? v.some(callsCreateEndpoint) : callsCreateEndpoint(v));
523
+ }
524
+ /** Unwrap `x satisfies T` / `x as const` to the expression underneath. */
525
+ function unwrapExpression(node) {
526
+ let current = node;
527
+ while ((current?.type === "TSAsExpression" ||
528
+ current?.type === "TSSatisfiesExpression" ||
529
+ current?.type === "TSNonNullExpression" ||
530
+ current?.type === "ParenthesizedExpression") &&
531
+ current.expression) {
532
+ current = current.expression;
533
+ }
534
+ return current;
535
+ }
486
536
  function inspectEndpointFile(source) {
537
+ let ast;
487
538
  try {
488
- const ast = (0, parser_1.parse)(source, {
489
- sourceType: "module",
490
- plugins: ["typescript"],
491
- });
492
- const defaultExport = ast.program.body.find((n) => n.type === "ExportDefaultDeclaration" &&
493
- n.declaration.type === "CallExpression");
494
- if (!defaultExport || defaultExport.type !== "ExportDefaultDeclaration")
495
- return { isEndpoint: false, stream: false };
496
- const call = defaultExport.declaration;
497
- if (call.type !== "CallExpression" || call.arguments.length === 0)
498
- return { isEndpoint: false, stream: false };
499
- const arg = call.arguments[0];
500
- // `createEndpoint(config)` — anything else defaulting out of src/api/ is
501
- // not an endpoint we can type a caller for.
502
- if (arg.type !== "ObjectExpression")
503
- return { isEndpoint: false, stream: false };
504
- const streamProp = arg.properties.find((p) => p.type === "ObjectProperty" &&
505
- ((p.key.type === "Identifier" && p.key.name === "stream") ||
506
- (p.key.type === "StringLiteral" && p.key.value === "stream")));
507
- const stream = !!streamProp &&
508
- streamProp.type === "ObjectProperty" &&
509
- streamProp.value.type === "BooleanLiteral" &&
510
- streamProp.value.value === true;
511
- return { isEndpoint: true, stream };
539
+ ast = (0, parser_1.parse)(source, { sourceType: "module", plugins: ["typescript"] });
512
540
  }
513
541
  catch {
514
- // A file that doesn't parse can't be typed either. Leaving it out keeps
515
- // one broken file from taking the whole generated module down with it.
516
- return { isEndpoint: false, stream: false };
542
+ // Unparseable with a TS-only plugin set — most likely TSX, which is still
543
+ // a real endpoint file. Assume yes and let the app's own typecheck judge
544
+ // it, rather than dropping a route the bundler will deploy regardless.
545
+ return { isEndpoint: true, hasDefaultExport: true, stream: false };
517
546
  }
547
+ const body = ast.program.body;
548
+ const hasDefaultExport = body.some((n) => n.type === "ExportDefaultDeclaration" ||
549
+ (n.type === "ExportNamedDeclaration" &&
550
+ n.specifiers?.some((spec) => spec.type === "ExportSpecifier" &&
551
+ (spec.exported.type === "Identifier"
552
+ ? spec.exported.name
553
+ : spec.exported.value) === "default")));
554
+ const isEndpoint = hasDefaultExport || body.some(callsCreateEndpoint);
555
+ if (!isEndpoint)
556
+ return NOT_AN_ENDPOINT;
557
+ // Stream detection is best-effort on the literal config: it is the only
558
+ // shape we can read `stream: true` out of statically.
559
+ const defaultExport = body.find((n) => n.type === "ExportDefaultDeclaration");
560
+ const call = defaultExport
561
+ ? unwrapExpression(defaultExport.declaration)
562
+ : undefined;
563
+ const arg = call?.type === "CallExpression"
564
+ ? unwrapExpression(call.arguments?.[0] ?? {})
565
+ : undefined;
566
+ if (arg?.type !== "ObjectExpression") {
567
+ return { isEndpoint, hasDefaultExport, stream: false };
568
+ }
569
+ const streamProp = arg.properties.find((prop) => prop.type === "ObjectProperty" &&
570
+ ((prop.key?.type === "Identifier" && prop.key.name === "stream") ||
571
+ (prop.key?.type === "StringLiteral" && prop.key.value === "stream")));
572
+ const stream = streamProp?.value?.type === "BooleanLiteral" &&
573
+ streamProp.value.value === true;
574
+ return { isEndpoint, hasDefaultExport, stream };
518
575
  }
519
576
  /**
520
577
  * Reserved words can't be `const` names. The route is the filename either way,
@@ -556,8 +613,9 @@ function generateApiTs(endpointFiles) {
556
613
  const ident = toSafeIdentifier(key);
557
614
  // Distinct files can collide on one identifier (`send-email.ts` and
558
615
  // `send_email.ts` both camelCase to `sendEmail`), which used to emit the
559
- // same `export const` twice. Input is sorted by every caller, so first-wins
560
- // is stable across runs.
616
+ // same `export const` twice. First-wins, which is stable because every
617
+ // caller sorts its input before calling — the two in this package and
618
+ // `generateApiClient` in the backend's migration assembler.
561
619
  if (seenIdents.has(ident)) {
562
620
  skipped.push(`${fileName} (name collides with '${ident}')`);
563
621
  continue;
@@ -569,6 +627,9 @@ function generateApiTs(endpointFiles) {
569
627
  ident,
570
628
  pascal: toPascalCase(key),
571
629
  stream: shape?.stream ?? false,
630
+ // No content to inspect means a bare-filename caller, which historically
631
+ // assumed a default export — keep that assumption.
632
+ typed: shape ? shape.hasDefaultExport : true,
572
633
  });
573
634
  }
574
635
  if (endpoints.length === 0)
@@ -588,11 +649,24 @@ function generateApiTs(endpointFiles) {
588
649
  lines.push(`// ${name}`);
589
650
  lines.push("");
590
651
  }
591
- for (const { pascal, baseName } of endpoints) {
652
+ for (const { pascal, baseName, typed } of endpoints) {
653
+ if (!typed)
654
+ continue;
592
655
  lines.push(`import type { default as _${pascal}Ep } from '../src/api/${baseName}';`);
593
656
  }
594
657
  lines.push("");
595
- for (const { baseName, ident, pascal, stream } of endpoints) {
658
+ for (const { baseName, ident, pascal, stream, typed } of endpoints) {
659
+ if (!typed) {
660
+ // Declared with `export const x = createEndpoint(...)` — the 1.0 shape.
661
+ // The route is real and the bundler deploys it, so the caller has to
662
+ // exist; there is just no default export to read its types from.
663
+ lines.push(`// '${baseName}' has no default export, so its input/output are untyped.`);
664
+ lines.push(`export type ${pascal}InputType = unknown;`);
665
+ lines.push(`export type ${pascal}OutputType = unknown;`);
666
+ lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
667
+ lines.push("");
668
+ continue;
669
+ }
596
670
  lines.push(`type _${pascal}Cfg = typeof _${pascal}Ep;`);
597
671
  // A caller sends the schema's INPUT type, not its output: a field with a
598
672
  // `.default()` or a transform is optional to send and guaranteed on the
@@ -839,12 +913,14 @@ function generateAirtableTs(lock) {
839
913
  "// Fields marked \"Links to X\" hold Airtable record ids (rec...) from",
840
914
  "// findOne/findAll — never invent one.",
841
915
  "// Read-only fields are absent from the *RecordInput types: Airtable rejects a",
842
- "// write to a formula, rollup, lookup or autonumber with a 422.",
916
+ "// write to a computed one (formula, rollup, lookup, autonumber, the created/",
917
+ "// modified metadata, button, aiText) with a 422.",
843
918
  "//",
844
919
  "// Usage tips:",
845
920
  "// - Airtable has a strict rate limit of 5 requests/second per base",
846
- "// - Use bulkCreate() instead of calling create() in a loop. Pass any number of",
847
- "// records — it chunks into Airtable's 10-per-request batches for you",
921
+ "// - Use bulkCreate() instead of calling create() in a loop, but chunk it",
922
+ "// yourself: Airtable rejects more than 10 records in one create request",
923
+ "// with a 422, and not every runtime path batches for you",
848
924
  "// - findAll() offsets are opaque cursor strings from a previous call, NOT row",
849
925
  "// counts (unlike zite.<table>.findAll, whose offset IS a number)",
850
926
  "// - Always destructure record properties individually in create/update calls",
@@ -906,13 +982,31 @@ function generateBackendWrapperTs(envVarNames = []) {
906
982
  "import type { User } from 'zitejs/auth';",
907
983
  "",
908
984
  "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteWebhook };",
985
+ // The pre-monorepo SDK put this in scope for every endpoint, so migrated
986
+ // code can name it. `createEndpoint` infers the same thing without it.
987
+ "export type InferSchemaType<T> = T extends { _output: infer U } ? U : T;",
909
988
  "",
910
989
  "export class ZiteError extends Error {",
911
990
  " code: ZiteErrorCode;",
912
- " constructor(options: { code: ZiteErrorCode; message: string }) {",
913
- " super(options.message);",
991
+ " /** Short, non-technical message suitable for showing to an end user. */",
992
+ " userFacingMessage?: string;",
993
+ " // Both shapes, mirroring the worker's own class: object form preferred,",
994
+ " // positional form is what pre-monorepo app code was written against.",
995
+ " constructor(options: { code: ZiteErrorCode; message: string; userFacingMessage?: string });",
996
+ " constructor(message: string, code?: ZiteErrorCode);",
997
+ " constructor(",
998
+ " optionsOrMessage: { code: ZiteErrorCode; message: string; userFacingMessage?: string } | string,",
999
+ " legacyCode?: ZiteErrorCode,",
1000
+ " ) {",
1001
+ " if (typeof optionsOrMessage === 'string') {",
1002
+ " super(optionsOrMessage);",
1003
+ " this.code = legacyCode ?? 'INTERNAL_ERROR';",
1004
+ " } else {",
1005
+ " super(optionsOrMessage.message);",
1006
+ " this.code = optionsOrMessage.code;",
1007
+ " this.userFacingMessage = optionsOrMessage.userFacingMessage;",
1008
+ " }",
914
1009
  " this.name = 'ZiteError';",
915
- " this.code = options.code;",
916
1010
  " }",
917
1011
  "}",
918
1012
  "",
@@ -928,7 +1022,7 @@ function generateBackendWrapperTs(envVarNames = []) {
928
1022
  // TStream mirrors zitejs/backend/base. Without it `stream: true` endpoints
929
1023
  // get no `stream` argument here — and this wrapper, not the base module, is
930
1024
  // what `zitejs/backend` resolves to in every app.
931
- "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput> {",
1025
+ "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
932
1026
  " description?: string;",
933
1027
  " inputSchema?: SchemaLike<TInput, TRawInput>;",
934
1028
  " outputSchema?: SchemaLike<TOutput>;",
@@ -936,18 +1030,19 @@ function generateBackendWrapperTs(envVarNames = []) {
936
1030
  " authenticated?: boolean;",
937
1031
  " /** When set, the endpoint also fires on this cron schedule. It stays request-callable — declaring one widens `context`, so `context.user` must be null-checked. */",
938
1032
  " schedule?: TSchedule;",
939
- " webhook?: ZiteWebhook;",
1033
+ " /** When set, an inbound webhook can also trigger this endpoint. Like `schedule`, it widens `context` — a webhook fire has no session. */",
1034
+ " webhook?: TWebhook;",
940
1035
  " execute: (",
941
1036
  " params: {",
942
1037
  " input: TInput;",
943
- " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1038
+ " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
944
1039
  " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}),",
945
1040
  " ) => Promise<TOutput> | TOutput;",
946
1041
  "}",
947
1042
  "",
948
- "export function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput>(",
949
- " config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>,",
950
- "): EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput> {",
1043
+ "export function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput>(",
1044
+ " config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>,",
1045
+ "): EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput> {",
951
1046
  " return config;",
952
1047
  "}",
953
1048
  "",
@@ -27,13 +27,22 @@ export type ZiteErrorCode = "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_
27
27
  * through a fixed table (BAD_REQUEST -> 400, and so on); it reads `code` and
28
28
  * nothing else, so an error carrying only a numeric `statusCode` fell through
29
29
  * to a generic 500.
30
+ *
31
+ * Both call shapes, mirroring the worker's own class (`zite-runtime.ts`): the
32
+ * object form is preferred, and the positional form is what pre-monorepo app
33
+ * code was written against. Typing only the object form would have failed the
34
+ * typecheck of every migrated app that throws one.
30
35
  */
31
36
  export declare class ZiteError extends Error {
32
37
  code: ZiteErrorCode;
38
+ /** Short, non-technical message suitable for showing to an end user. */
39
+ userFacingMessage?: string;
33
40
  constructor(options: {
34
41
  code: ZiteErrorCode;
35
42
  message: string;
43
+ userFacingMessage?: string;
36
44
  });
45
+ constructor(message: string, code?: ZiteErrorCode);
37
46
  }
38
47
  /**
39
48
  * Structurally matches a zod schema. `TIn` is the schema's *input* type, which
@@ -49,7 +58,7 @@ export type ZiteStreamInterface = {
49
58
  write: (data: unknown) => Promise<void>;
50
59
  forward: (asyncIterable: AsyncIterable<string>) => Promise<string>;
51
60
  };
52
- export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput> {
61
+ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {
53
62
  description?: string;
54
63
  inputSchema?: SchemaLike<TInput, TRawInput>;
55
64
  outputSchema?: SchemaLike<TOutput>;
@@ -61,12 +70,17 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
61
70
  * Declaring one widens `context` so `context.user` must be null-checked.
62
71
  */
63
72
  schedule?: TSchedule;
64
- webhook?: ZiteWebhook;
73
+ /**
74
+ * When set, the endpoint can also be triggered by an inbound webhook. Like
75
+ * `schedule`, this widens `context` — the workflow-runner sends
76
+ * `{ user: null }` for a webhook fire exactly as it does for a cron one.
77
+ */
78
+ webhook?: TWebhook;
65
79
  execute: (params: {
66
80
  input: TInput;
67
- context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
81
+ context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
68
82
  } & (TStream extends true ? {
69
83
  stream: ZiteStreamInterface;
70
84
  } : {})) => Promise<TOutput> | TOutput;
71
85
  }
72
- export declare function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput>(config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>): EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>;
86
+ export declare function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput>(config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>): EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>;
@@ -3,13 +3,27 @@
3
3
  * through a fixed table (BAD_REQUEST -> 400, and so on); it reads `code` and
4
4
  * nothing else, so an error carrying only a numeric `statusCode` fell through
5
5
  * to a generic 500.
6
+ *
7
+ * Both call shapes, mirroring the worker's own class (`zite-runtime.ts`): the
8
+ * object form is preferred, and the positional form is what pre-monorepo app
9
+ * code was written against. Typing only the object form would have failed the
10
+ * typecheck of every migrated app that throws one.
6
11
  */
7
12
  export class ZiteError extends Error {
8
13
  code;
9
- constructor(options) {
10
- super(options.message);
14
+ /** Short, non-technical message suitable for showing to an end user. */
15
+ userFacingMessage;
16
+ constructor(optionsOrMessage, legacyCode) {
17
+ if (typeof optionsOrMessage === "string") {
18
+ super(optionsOrMessage);
19
+ this.code = legacyCode ?? "INTERNAL_ERROR";
20
+ }
21
+ else {
22
+ super(optionsOrMessage.message);
23
+ this.code = optionsOrMessage.code;
24
+ this.userFacingMessage = optionsOrMessage.userFacingMessage;
25
+ }
11
26
  this.name = "ZiteError";
12
- this.code = options.code;
13
27
  }
14
28
  }
15
29
  export function createEndpoint(config) {
@@ -563,8 +563,13 @@ export async function runBundle() {
563
563
  if (appFlag !== -1 && args[appFlag + 1]) {
564
564
  baseDir = path.resolve(baseDir, 'apps', args[appFlag + 1]);
565
565
  }
566
- // If explicit endpoint names passed, use those; otherwise find all
567
- const explicitNames = args.filter(a => !a.startsWith('--'));
566
+ // If explicit endpoint names passed, use those; otherwise find all.
567
+ // Skipping `--app`'s VALUE as well as the flag: it is a positional arg, so
568
+ // filtering on the `--` prefix alone treated the app directory name as an
569
+ // endpoint name — `zitejs bundle --app myapp` tried to bundle
570
+ // `src/api/myapp.ts`, found nothing else, and reported every real endpoint
571
+ // as missing.
572
+ const explicitNames = args.filter((a, i) => !a.startsWith('--') && !(appFlag !== -1 && i === appFlag + 1));
568
573
  let endpointNames;
569
574
  if (explicitNames.length > 0) {
570
575
  endpointNames = explicitNames;
@@ -2,21 +2,30 @@ import type { NotificationsCreateParams, NotificationsCreateResult } from "../no
2
2
  /**
3
3
  * A comparison against one field. Mirrors base-runner's operator set
4
4
  * (`LegacyWhereConditionOperators`); anything else in the object is ignored.
5
+ *
6
+ * The operand types deliberately match the pre-monorepo `FilterOperators`
7
+ * rather than tightening to the field's own type. The value of typing filters
8
+ * is in the KEY — an unknown key silently returns every row — and tightening
9
+ * the operands buys little while breaking real migrated code: dates are ISO
10
+ * strings here but 1.0 accepted `number | Date` on every comparison, and
11
+ * `contains` was `any`.
5
12
  */
6
13
  export type FilterCondition<V> = {
7
14
  /** Substring match (text fields) or "has any of these" (linked records). */
8
- contains?: V extends Array<infer E> ? E | E[] : V;
15
+ contains?: any;
9
16
  /** Not equal, or — against `null` — "is set". */
10
17
  not?: V | null;
11
18
  /** Empty array means "no filter", not "match nothing". */
12
19
  in?: V extends Array<infer E> ? E[] : V[];
13
20
  /** Empty array means "no filter", not "match everything". */
14
21
  notIn?: V extends Array<infer E> ? E[] : V[];
15
- lt?: V;
16
- lte?: V;
17
- gt?: V;
18
- gte?: V;
22
+ lt?: V | number | Date;
23
+ lte?: V | number | Date;
24
+ gt?: V | number | Date;
25
+ gte?: V | number | Date;
19
26
  };
27
+ /** Pre-monorepo name for {@link FilterCondition}. Kept so migrated app code that spells the type out still compiles. */
28
+ export type FilterOperators<V> = FilterCondition<V> | V;
20
29
  /**
21
30
  * Filters keyed by the record's own field names. Typed rather than `unknown`
22
31
  * because an unrecognized key is not an error at runtime — it passes through
@@ -24,8 +33,16 @@ export type FilterCondition<V> = {
24
33
  * comes back **unfiltered**. A typo silently returns every row.
25
34
  */
26
35
  export type RecordFilters<T> = {
27
- [K in keyof T]?: T[K] | FilterCondition<T[K]>;
36
+ [K in keyof T]?: T[K] | (T[K] extends Array<infer E> ? E : never) | FilterCondition<T[K]>;
28
37
  };
38
+ /**
39
+ * Resolves a zod schema to the type it produces. `createEndpoint` infers this
40
+ * for you, so it is rarely needed — it exists because the pre-monorepo SDK
41
+ * exposed it and migrated app code may still name it.
42
+ */
43
+ export type InferSchemaType<T> = T extends {
44
+ _output: infer U;
45
+ } ? U : T;
29
46
  export interface TableFindAllOptions<T = Record<string, unknown>> {
30
47
  limit?: number;
31
48
  offset?: number;
@@ -42,10 +42,15 @@ const FIELD_INPUT_TYPE_MAP = {
42
42
  user: "string | string[] | null",
43
43
  };
44
44
  /**
45
- * Computed and system-managed fields. base-runner rejects a write to any of
46
- * these outright (`createRecordDataSchema` in `trpc/schemas/records.ts`), so
47
- * they are omitted from the generated input type rather than typed and then
48
- * refused at runtime.
45
+ * Computed and system-managed fields, omitted from the generated input type
46
+ * rather than typed and then refused at runtime.
47
+ *
48
+ * Mirrors `COMPUTED_FIELD_TYPES` in the product's own `shared/bases/fields`.
49
+ * The first six are rejected by `createRecordDataSchema` — each has a
50
+ * `refine(val => val == null)`, so a non-null write errors (null is a no-op
51
+ * clear). `created_at` / `updated_at` are refused differently: they have no
52
+ * column at all, so a write is silently dropped, which is the worse outcome to
53
+ * leave typed as writable.
49
54
  */
50
55
  const READ_ONLY_FIELD_TYPES = new Set([
51
56
  "lookup",
@@ -54,6 +59,8 @@ const READ_ONLY_FIELD_TYPES = new Set([
54
59
  "formula",
55
60
  "autonumber",
56
61
  "updated_by",
62
+ "created_at",
63
+ "updated_at",
57
64
  ]);
58
65
  /**
59
66
  * Emitted once per `db.ts`. The old inline `{ url: string; name?: string }` was
@@ -100,6 +107,19 @@ const DURATION_FORMAT_EXAMPLES = {
100
107
  "h:mm:ss.ss": "1:23:03.00",
101
108
  "h:mm:ss.sss": "1:23:03.000",
102
109
  };
110
+ // Naming the format alone ("display as `european` format") doesn't say what to
111
+ // render — these mirror the pickers in the database UI, which is what the user
112
+ // chose from. `local` is the viewer's system locale, so it has no fixed example.
113
+ const DATE_FORMAT_EXAMPLES = {
114
+ long: "January 15, 2024",
115
+ us: "1/15/2024",
116
+ european: "15/01/2024",
117
+ iso: "2024-01-15",
118
+ };
119
+ const withDateFormatExample = (format) => {
120
+ const example = DATE_FORMAT_EXAMPLES[format];
121
+ return example ? `"${format}" format (e.g. ${example})` : `"${format}" format`;
122
+ };
103
123
  export function toPascalCase(name) {
104
124
  const pascal = name
105
125
  .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
@@ -178,13 +198,13 @@ function fieldJsdoc(schemaField, table, schema) {
178
198
  if (def.type === "date") {
179
199
  const tpl = def.template;
180
200
  if (tpl.dateFormat)
181
- parts.push(`Date-only field (YYYY-MM-DD string), null when unset, display as "${tpl.dateFormat}" format`);
201
+ parts.push(`Date-only field (YYYY-MM-DD string), null when unset, display as ${withDateFormatExample(tpl.dateFormat)}`);
182
202
  }
183
203
  if (def.type === "datetime") {
184
204
  const tpl = def.template;
185
205
  const timeParts = ["Date+time field (ISO 8601 timestamp), null when unset"];
186
206
  if (tpl.dateFormat)
187
- timeParts.push(`date: ${tpl.dateFormat}`);
207
+ timeParts.push(`date: ${withDateFormatExample(tpl.dateFormat)}`);
188
208
  if (tpl.timeFormat)
189
209
  timeParts.push(`time: ${tpl.timeFormat}`);
190
210
  if (tpl.timezone)
@@ -383,13 +403,13 @@ export function generateDbTs(schema) {
383
403
  lines.push("// operators: contains, not, in, notIn, lt, lte, gt, gte");
384
404
  lines.push('// `not: null` means "is set"; `in`/`notIn` with an empty array apply NO filter');
385
405
  lines.push("// sort: [{ field: 'fieldName', direction: 'asc' | 'desc' }]");
386
- lines.push("// limit defaults to 500, max 5000; offset is a row count (a number)");
406
+ lines.push("// limit defaults to 500, max 2000; offset is a row count (a number)");
387
407
  lines.push("// .findOne({ id?, filters?, fields? }) → T | undefined");
388
408
  lines.push("// .create({ record }) → T");
389
409
  lines.push("// .update({ id, record }) → { id: string, fields: Partial<T> }");
390
410
  lines.push("// .delete({ id }) → { success: true, id: string }");
391
411
  lines.push("// .bulkCreate({ records, matchOn? }) → { success: boolean, records: T[] }");
392
- lines.push("// up to 500 records per call; matchOn upserts on those fields");
412
+ lines.push("// up to 100 records per call; matchOn upserts on those fields");
393
413
  lines.push("//");
394
414
  lines.push("// Reading these types:");
395
415
  lines.push("// SDK names are stable identifiers — they do NOT change when a table or field is");
@@ -400,8 +420,9 @@ export function generateDbTs(schema) {
400
420
  lines.push("// display yourself, following the display config in each field's comment.");
401
421
  lines.push("// PERCENT fields are stored as decimals — 0.5 is 50%, 1.0 is 100%.");
402
422
  lines.push("// LINKED RECORD fields hold UUID record ids from findOne/findAll — never invent one.");
403
- lines.push("// Computed fields (formula, rollup, lookup, autonumber) are absent from the *Input");
404
- lines.push("// types — writing to one is rejected.");
423
+ lines.push("// Computed and system-managed fields (formula, rollup, lookup, autonumber,");
424
+ lines.push("// created/updated timestamps and more) are absent from the *Input types —");
425
+ lines.push("// a write to one is rejected or silently dropped.");
405
426
  lines.push("//");
406
427
  lines.push("// Zite auth users:");
407
428
  lines.push("// zite.auth.findAllUsers({ appIds?, filter?, filters?, sort?, limit?, offset? })");
@@ -415,7 +436,7 @@ export function generateDbTs(schema) {
415
436
  lines.push("// → { rows: Record<string, unknown>[], columns, rowCount, truncated }");
416
437
  lines.push("// Capped at 2000 rows — `truncated: true` means there were more, so paginate in");
417
438
  lines.push("// SQL rather than assuming you got everything. Queries time out after 10s.");
418
- lines.push("// columns[].name is the SDK name; columns[].originalName is the user-facing one.");
439
+ lines.push("// columns[].name is the SDK name; columns[].originalName is the underlying", "// database column (or your own SQL alias).");
419
440
  lines.push("//");
420
441
  lines.push("// SQL guidelines:");
421
442
  lines.push("// - Use SDK names for tables (PascalCase) and fields (camelCase)");
@@ -473,38 +494,74 @@ export function generateDbTs(schema) {
473
494
  lines.push("");
474
495
  return lines.join("\n");
475
496
  }
497
+ const NOT_AN_ENDPOINT = {
498
+ isEndpoint: false,
499
+ hasDefaultExport: false,
500
+ stream: false,
501
+ };
502
+ /** `createEndpoint(...)` anywhere in the expression, through wrappers. */
503
+ function callsCreateEndpoint(node) {
504
+ if (!node || typeof node !== "object")
505
+ return false;
506
+ const n = node;
507
+ if (n.type === "CallExpression" &&
508
+ n.callee?.name ===
509
+ "createEndpoint") {
510
+ return true;
511
+ }
512
+ return Object.values(n).some((v) => Array.isArray(v) ? v.some(callsCreateEndpoint) : callsCreateEndpoint(v));
513
+ }
514
+ /** Unwrap `x satisfies T` / `x as const` to the expression underneath. */
515
+ function unwrapExpression(node) {
516
+ let current = node;
517
+ while ((current?.type === "TSAsExpression" ||
518
+ current?.type === "TSSatisfiesExpression" ||
519
+ current?.type === "TSNonNullExpression" ||
520
+ current?.type === "ParenthesizedExpression") &&
521
+ current.expression) {
522
+ current = current.expression;
523
+ }
524
+ return current;
525
+ }
476
526
  function inspectEndpointFile(source) {
527
+ let ast;
477
528
  try {
478
- const ast = parse(source, {
479
- sourceType: "module",
480
- plugins: ["typescript"],
481
- });
482
- const defaultExport = ast.program.body.find((n) => n.type === "ExportDefaultDeclaration" &&
483
- n.declaration.type === "CallExpression");
484
- if (!defaultExport || defaultExport.type !== "ExportDefaultDeclaration")
485
- return { isEndpoint: false, stream: false };
486
- const call = defaultExport.declaration;
487
- if (call.type !== "CallExpression" || call.arguments.length === 0)
488
- return { isEndpoint: false, stream: false };
489
- const arg = call.arguments[0];
490
- // `createEndpoint(config)` — anything else defaulting out of src/api/ is
491
- // not an endpoint we can type a caller for.
492
- if (arg.type !== "ObjectExpression")
493
- return { isEndpoint: false, stream: false };
494
- const streamProp = arg.properties.find((p) => p.type === "ObjectProperty" &&
495
- ((p.key.type === "Identifier" && p.key.name === "stream") ||
496
- (p.key.type === "StringLiteral" && p.key.value === "stream")));
497
- const stream = !!streamProp &&
498
- streamProp.type === "ObjectProperty" &&
499
- streamProp.value.type === "BooleanLiteral" &&
500
- streamProp.value.value === true;
501
- return { isEndpoint: true, stream };
529
+ ast = parse(source, { sourceType: "module", plugins: ["typescript"] });
502
530
  }
503
531
  catch {
504
- // A file that doesn't parse can't be typed either. Leaving it out keeps
505
- // one broken file from taking the whole generated module down with it.
506
- return { isEndpoint: false, stream: false };
532
+ // Unparseable with a TS-only plugin set — most likely TSX, which is still
533
+ // a real endpoint file. Assume yes and let the app's own typecheck judge
534
+ // it, rather than dropping a route the bundler will deploy regardless.
535
+ return { isEndpoint: true, hasDefaultExport: true, stream: false };
507
536
  }
537
+ const body = ast.program.body;
538
+ const hasDefaultExport = body.some((n) => n.type === "ExportDefaultDeclaration" ||
539
+ (n.type === "ExportNamedDeclaration" &&
540
+ n.specifiers?.some((spec) => spec.type === "ExportSpecifier" &&
541
+ (spec.exported.type === "Identifier"
542
+ ? spec.exported.name
543
+ : spec.exported.value) === "default")));
544
+ const isEndpoint = hasDefaultExport || body.some(callsCreateEndpoint);
545
+ if (!isEndpoint)
546
+ return NOT_AN_ENDPOINT;
547
+ // Stream detection is best-effort on the literal config: it is the only
548
+ // shape we can read `stream: true` out of statically.
549
+ const defaultExport = body.find((n) => n.type === "ExportDefaultDeclaration");
550
+ const call = defaultExport
551
+ ? unwrapExpression(defaultExport.declaration)
552
+ : undefined;
553
+ const arg = call?.type === "CallExpression"
554
+ ? unwrapExpression(call.arguments?.[0] ?? {})
555
+ : undefined;
556
+ if (arg?.type !== "ObjectExpression") {
557
+ return { isEndpoint, hasDefaultExport, stream: false };
558
+ }
559
+ const streamProp = arg.properties.find((prop) => prop.type === "ObjectProperty" &&
560
+ ((prop.key?.type === "Identifier" && prop.key.name === "stream") ||
561
+ (prop.key?.type === "StringLiteral" && prop.key.value === "stream")));
562
+ const stream = streamProp?.value?.type === "BooleanLiteral" &&
563
+ streamProp.value.value === true;
564
+ return { isEndpoint, hasDefaultExport, stream };
508
565
  }
509
566
  /**
510
567
  * Reserved words can't be `const` names. The route is the filename either way,
@@ -546,8 +603,9 @@ export function generateApiTs(endpointFiles) {
546
603
  const ident = toSafeIdentifier(key);
547
604
  // Distinct files can collide on one identifier (`send-email.ts` and
548
605
  // `send_email.ts` both camelCase to `sendEmail`), which used to emit the
549
- // same `export const` twice. Input is sorted by every caller, so first-wins
550
- // is stable across runs.
606
+ // same `export const` twice. First-wins, which is stable because every
607
+ // caller sorts its input before calling — the two in this package and
608
+ // `generateApiClient` in the backend's migration assembler.
551
609
  if (seenIdents.has(ident)) {
552
610
  skipped.push(`${fileName} (name collides with '${ident}')`);
553
611
  continue;
@@ -559,6 +617,9 @@ export function generateApiTs(endpointFiles) {
559
617
  ident,
560
618
  pascal: toPascalCase(key),
561
619
  stream: shape?.stream ?? false,
620
+ // No content to inspect means a bare-filename caller, which historically
621
+ // assumed a default export — keep that assumption.
622
+ typed: shape ? shape.hasDefaultExport : true,
562
623
  });
563
624
  }
564
625
  if (endpoints.length === 0)
@@ -578,11 +639,24 @@ export function generateApiTs(endpointFiles) {
578
639
  lines.push(`// ${name}`);
579
640
  lines.push("");
580
641
  }
581
- for (const { pascal, baseName } of endpoints) {
642
+ for (const { pascal, baseName, typed } of endpoints) {
643
+ if (!typed)
644
+ continue;
582
645
  lines.push(`import type { default as _${pascal}Ep } from '../src/api/${baseName}';`);
583
646
  }
584
647
  lines.push("");
585
- for (const { baseName, ident, pascal, stream } of endpoints) {
648
+ for (const { baseName, ident, pascal, stream, typed } of endpoints) {
649
+ if (!typed) {
650
+ // Declared with `export const x = createEndpoint(...)` — the 1.0 shape.
651
+ // The route is real and the bundler deploys it, so the caller has to
652
+ // exist; there is just no default export to read its types from.
653
+ lines.push(`// '${baseName}' has no default export, so its input/output are untyped.`);
654
+ lines.push(`export type ${pascal}InputType = unknown;`);
655
+ lines.push(`export type ${pascal}OutputType = unknown;`);
656
+ lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
657
+ lines.push("");
658
+ continue;
659
+ }
586
660
  lines.push(`type _${pascal}Cfg = typeof _${pascal}Ep;`);
587
661
  // A caller sends the schema's INPUT type, not its output: a field with a
588
662
  // `.default()` or a transform is optional to send and guaranteed on the
@@ -829,12 +903,14 @@ export function generateAirtableTs(lock) {
829
903
  "// Fields marked \"Links to X\" hold Airtable record ids (rec...) from",
830
904
  "// findOne/findAll — never invent one.",
831
905
  "// Read-only fields are absent from the *RecordInput types: Airtable rejects a",
832
- "// write to a formula, rollup, lookup or autonumber with a 422.",
906
+ "// write to a computed one (formula, rollup, lookup, autonumber, the created/",
907
+ "// modified metadata, button, aiText) with a 422.",
833
908
  "//",
834
909
  "// Usage tips:",
835
910
  "// - Airtable has a strict rate limit of 5 requests/second per base",
836
- "// - Use bulkCreate() instead of calling create() in a loop. Pass any number of",
837
- "// records — it chunks into Airtable's 10-per-request batches for you",
911
+ "// - Use bulkCreate() instead of calling create() in a loop, but chunk it",
912
+ "// yourself: Airtable rejects more than 10 records in one create request",
913
+ "// with a 422, and not every runtime path batches for you",
838
914
  "// - findAll() offsets are opaque cursor strings from a previous call, NOT row",
839
915
  "// counts (unlike zite.<table>.findAll, whose offset IS a number)",
840
916
  "// - Always destructure record properties individually in create/update calls",
@@ -896,13 +972,31 @@ export function generateBackendWrapperTs(envVarNames = []) {
896
972
  "import type { User } from 'zitejs/auth';",
897
973
  "",
898
974
  "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteWebhook };",
975
+ // The pre-monorepo SDK put this in scope for every endpoint, so migrated
976
+ // code can name it. `createEndpoint` infers the same thing without it.
977
+ "export type InferSchemaType<T> = T extends { _output: infer U } ? U : T;",
899
978
  "",
900
979
  "export class ZiteError extends Error {",
901
980
  " code: ZiteErrorCode;",
902
- " constructor(options: { code: ZiteErrorCode; message: string }) {",
903
- " super(options.message);",
981
+ " /** Short, non-technical message suitable for showing to an end user. */",
982
+ " userFacingMessage?: string;",
983
+ " // Both shapes, mirroring the worker's own class: object form preferred,",
984
+ " // positional form is what pre-monorepo app code was written against.",
985
+ " constructor(options: { code: ZiteErrorCode; message: string; userFacingMessage?: string });",
986
+ " constructor(message: string, code?: ZiteErrorCode);",
987
+ " constructor(",
988
+ " optionsOrMessage: { code: ZiteErrorCode; message: string; userFacingMessage?: string } | string,",
989
+ " legacyCode?: ZiteErrorCode,",
990
+ " ) {",
991
+ " if (typeof optionsOrMessage === 'string') {",
992
+ " super(optionsOrMessage);",
993
+ " this.code = legacyCode ?? 'INTERNAL_ERROR';",
994
+ " } else {",
995
+ " super(optionsOrMessage.message);",
996
+ " this.code = optionsOrMessage.code;",
997
+ " this.userFacingMessage = optionsOrMessage.userFacingMessage;",
998
+ " }",
904
999
  " this.name = 'ZiteError';",
905
- " this.code = options.code;",
906
1000
  " }",
907
1001
  "}",
908
1002
  "",
@@ -918,7 +1012,7 @@ export function generateBackendWrapperTs(envVarNames = []) {
918
1012
  // TStream mirrors zitejs/backend/base. Without it `stream: true` endpoints
919
1013
  // get no `stream` argument here — and this wrapper, not the base module, is
920
1014
  // what `zitejs/backend` resolves to in every app.
921
- "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput> {",
1015
+ "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
922
1016
  " description?: string;",
923
1017
  " inputSchema?: SchemaLike<TInput, TRawInput>;",
924
1018
  " outputSchema?: SchemaLike<TOutput>;",
@@ -926,18 +1020,19 @@ export function generateBackendWrapperTs(envVarNames = []) {
926
1020
  " authenticated?: boolean;",
927
1021
  " /** When set, the endpoint also fires on this cron schedule. It stays request-callable — declaring one widens `context`, so `context.user` must be null-checked. */",
928
1022
  " schedule?: TSchedule;",
929
- " webhook?: ZiteWebhook;",
1023
+ " /** When set, an inbound webhook can also trigger this endpoint. Like `schedule`, it widens `context` — a webhook fire has no session. */",
1024
+ " webhook?: TWebhook;",
930
1025
  " execute: (",
931
1026
  " params: {",
932
1027
  " input: TInput;",
933
- " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1028
+ " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
934
1029
  " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}),",
935
1030
  " ) => Promise<TOutput> | TOutput;",
936
1031
  "}",
937
1032
  "",
938
- "export function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput>(",
939
- " config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>,",
940
- "): EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput> {",
1033
+ "export function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput>(",
1034
+ " config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>,",
1035
+ "): EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput> {",
941
1036
  " return config;",
942
1037
  "}",
943
1038
  "",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.95",
4
- "description": "The Zite framework — build apps on Zite Database",
3
+ "version": "0.9.96",
4
+ "description": "The Zite framework \u2014 build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
7
7
  "module": "./dist/esm/index.js",