@swell/cli 2.9.32 → 2.9.33

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.
Files changed (41) hide show
  1. package/dist/commands/app/dev.js +2 -1
  2. package/dist/commands/app/frontend/dev.js +4 -2
  3. package/dist/commands/app/push.d.ts +1 -0
  4. package/dist/commands/app/push.js +38 -3
  5. package/dist/commands/app/version.js +3 -0
  6. package/dist/commands/create/function.js +14 -4
  7. package/dist/commands/create/model.js +9 -24
  8. package/dist/commands/inspect/components.d.ts +31 -0
  9. package/dist/commands/inspect/components.js +110 -0
  10. package/dist/commands/inspect/extensions.js +18 -9
  11. package/dist/commands/inspect/frontend.d.ts +37 -0
  12. package/dist/commands/inspect/frontend.js +75 -0
  13. package/dist/commands/inspect/functions.d.ts +11 -1
  14. package/dist/commands/inspect/functions.js +89 -6
  15. package/dist/commands/inspect/index.js +2 -0
  16. package/dist/inspect-resource-command.d.ts +1 -1
  17. package/dist/inspect-resource-command.js +8 -2
  18. package/dist/lib/api-errors.d.ts +1 -0
  19. package/dist/lib/api-errors.js +28 -0
  20. package/dist/lib/apps/components.d.ts +7 -0
  21. package/dist/lib/apps/components.js +10 -0
  22. package/dist/lib/apps/extension-manifest.d.ts +1 -0
  23. package/dist/lib/apps/extension-manifest.js +43 -0
  24. package/dist/lib/apps/index.d.ts +1 -0
  25. package/dist/lib/apps/index.js +30 -0
  26. package/dist/lib/apps/paths.d.ts +8 -0
  27. package/dist/lib/apps/paths.js +34 -4
  28. package/dist/lib/bundle.js +15 -2
  29. package/dist/lib/create/function.d.ts +1 -1
  30. package/dist/lib/create/function.js +9 -5
  31. package/dist/lib/create/index.js +2 -1
  32. package/dist/lib/create/model.d.ts +7 -3
  33. package/dist/lib/create/model.js +67 -9
  34. package/dist/lib/inspect/extensions.d.ts +4 -3
  35. package/dist/lib/inspect/extensions.js +36 -5
  36. package/dist/lib/swell-function-wrapper.js +5 -4
  37. package/dist/push-app-command.js +32 -19
  38. package/dist/remote-app-command.d.ts +1 -1
  39. package/dist/remote-app-command.js +78 -47
  40. package/oclif.manifest.json +139 -6
  41. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { InspectResourceCommand, inspectResourceBaseArgs, inspectResourceBaseFlags, } from '../../inspect-resource-command.js';
2
+ import { slugFromInstalledApp } from '../../lib/apps/slug.js';
2
3
  function deriveTrigger(r) {
3
4
  if (r.cron?.schedule) {
4
5
  return `cron (${r.cron.schedule})`;
@@ -25,7 +26,7 @@ export default class Functions extends InspectResourceCommand {
25
26
  static summary = 'Functions with scheduling and trigger state.';
26
27
  static description = `Lists functions across all apps, grouped by app. Pass --app=<slug> or --app=. (current swell.json) to scope.
27
28
 
28
- List output: paste-back key + status (disabled, trigger, next cron run, last failure).
29
+ List output: paste-back key + status (disabled or auto-disabled, failing since and failure count, the date it gets disabled, trigger, next cron run) and whether its app is inactive, which stops all of its functions. The detail view also shows the last failed delivery.
29
30
 
30
31
  Pass an identifier to view a single record as JSON.
31
32
 
@@ -47,15 +48,37 @@ Identifier forms:
47
48
  resourceLabelSingular = 'function';
48
49
  adminPath = '/data/:functions';
49
50
  commandName = 'functions';
51
+ inactiveApps = [];
50
52
  async run() {
51
53
  const { args, flags } = await this.parse(Functions);
52
54
  await this.runInspect({ args, flags });
53
55
  }
56
+ async getListResults(scope, flags) {
57
+ const [results, inactiveApps] = await Promise.all([
58
+ super.getListResults(scope, flags),
59
+ this.getInactiveApps(),
60
+ ]);
61
+ this.inactiveApps = inactiveApps;
62
+ return results;
63
+ }
54
64
  metaFor(r) {
55
65
  const parts = [];
56
- if (!r.enabled) {
66
+ if (this.inactiveApps.some((app) => app.app_id === r.app_id)) {
67
+ parts.push('app inactive');
68
+ }
69
+ if (r.auto_disabled) {
70
+ parts.push('auto-disabled');
71
+ }
72
+ else if (!r.enabled) {
57
73
  parts.push('disabled');
58
74
  }
75
+ const failed = r.attempts_failed ?? 0;
76
+ if (failed > 0) {
77
+ const since = r.date_first_failed
78
+ ? `failing since ${r.date_first_failed} · `
79
+ : '';
80
+ parts.push(`${since}${failed} fail${failed === 1 ? '' : 's'}`);
81
+ }
59
82
  const trigger = deriveTrigger(r);
60
83
  if (trigger) {
61
84
  parts.push(trigger);
@@ -63,21 +86,60 @@ Identifier forms:
63
86
  if (r.date_cron_scheduled) {
64
87
  parts.push(`next ${r.date_cron_scheduled}`);
65
88
  }
66
- if (r.date_final_attempt) {
67
- parts.push(`last fail ${r.date_final_attempt}`);
89
+ const disableDate = failed > 0 && !r.auto_disabled && getDisableDate(r);
90
+ if (disableDate) {
91
+ parts.push(`may disable from ${disableDate}`);
68
92
  }
69
93
  return parts.length > 0 ? parts.join(' · ') : undefined;
70
94
  }
71
95
  filterListResults(results) {
72
96
  return results.filter((record) => record.kind !== 'workflow');
73
97
  }
74
- emitDetail(record, flags) {
98
+ async emitDetail(record, flags) {
75
99
  if (record.kind === 'workflow') {
76
100
  const workflowName = record.name || record.id;
77
101
  const workflowRef = record.id || record.name;
78
102
  throw new Error(`Function '${workflowName}' is a workflow. Use "swell inspect workflows ${workflowRef}" instead.`);
79
103
  }
80
- super.emitDetail(record, flags);
104
+ super.emitDetail(record, flags, flags.json ? [] : await this.detailNotes(record));
105
+ }
106
+ // Notes are best effort: a function shows without those it cannot read
107
+ async detailNotes(record) {
108
+ const notes = [];
109
+ const inactiveApps = await this.getInactiveApps().catch(() => []);
110
+ const inactiveApp = inactiveApps.find((app) => app.app_id === record.app_id);
111
+ if (inactiveApp) {
112
+ notes.push(`App ${slugFromInstalledApp(inactiveApp) ?? inactiveApp.app_id} is inactive: none of its functions run.`);
113
+ }
114
+ if (record.id &&
115
+ (record.auto_disabled || (record.attempts_failed ?? 0) > 0)) {
116
+ const failure = await this.lastFailedDelivery(record.id).catch(() => { });
117
+ if (failure) {
118
+ notes.push(`Last failed delivery: ${failure}`);
119
+ }
120
+ }
121
+ return notes;
122
+ }
123
+ async lastFailedDelivery(functionId) {
124
+ const response = await this.api.get({ adminPath: '/data/events:webhooks' }, { query: { function_id: functionId, sort: 'date_sent desc', limit: 1 } });
125
+ const delivery = response?.results?.[0];
126
+ if (!delivery) {
127
+ return undefined;
128
+ }
129
+ const { date_sent: dateSent, response: answer, status } = delivery;
130
+ if (status >= 200 && status < 300) {
131
+ return undefined;
132
+ }
133
+ const message = answer?.error?.message ??
134
+ answer?.message ??
135
+ (typeof answer === 'string' ? answer : undefined);
136
+ return [dateSent, status ?? 'no response', message]
137
+ .filter(Boolean)
138
+ .join(' · ');
139
+ }
140
+ async getInactiveApps() {
141
+ const response = await this.api.get({ adminPath: '/client/apps' });
142
+ return (response?.results ?? []).filter((app) => app.active === false);
81
143
  }
82
144
  /**
83
145
  * Model-triggered functions emit `/events:webhooks` rows on each invocation.
@@ -96,3 +158,24 @@ Identifier forms:
96
158
  return lines;
97
159
  }
98
160
  }
161
+ // A function with a timeout over this is switched off sooner
162
+ const LONG_TIMEOUT_MS = 10000;
163
+ const LONG_TIMEOUT_DISABLE_DAYS = 2;
164
+ /**
165
+ * When a failing function is switched off if it keeps failing: at its final
166
+ * attempt, or two days after its first failure when its timeout is long. A
167
+ * 404 switches it off at once.
168
+ * @param r The function
169
+ * @returns The date, as the API gives dates
170
+ */
171
+ function getDisableDate(r) {
172
+ if ((r.timeout ?? 0) > LONG_TIMEOUT_MS) {
173
+ if (!r.date_first_failed) {
174
+ return undefined;
175
+ }
176
+ const date = new Date(r.date_first_failed);
177
+ date.setUTCDate(date.getUTCDate() + LONG_TIMEOUT_DISABLE_DAYS);
178
+ return date.toISOString();
179
+ }
180
+ return r.date_final_attempt;
181
+ }
@@ -11,6 +11,8 @@ export default class Inspect extends Command {
11
11
  'swell inspect workflow-runs --workflow app.my-app.run-import --status active',
12
12
  'swell inspect notifications com.orders.receipt.v2',
13
13
  'swell inspect settings app.my-app',
14
+ 'swell inspect components --app=.',
15
+ 'swell inspect frontend app.my-app',
14
16
  ];
15
17
  async run() {
16
18
  this.log('Run "swell inspect --help" to see available subcommands.');
@@ -87,7 +87,7 @@ export declare abstract class InspectResourceCommand extends SwellCommand {
87
87
  * `--json` is the machine-readable contract — hints belong in the same
88
88
  * stream as the human-facing output and are simply omitted there.
89
89
  */
90
- protected emitDetail(record: any, flags: InspectResourceCommandParsed['flags']): void;
90
+ protected emitDetail(record: any, flags: InspectResourceCommandParsed['flags'], notes?: string[]): Promise<void> | void;
91
91
  private lookupByAppAndName;
92
92
  private printPreamble;
93
93
  protected throwNotFound(identifier: string): never;
@@ -189,7 +189,7 @@ export class InspectResourceCommand extends SwellCommand {
189
189
  if (!record) {
190
190
  this.throwNotFound(identifier);
191
191
  }
192
- this.emitDetail(record, flags);
192
+ await this.emitDetail(record, flags);
193
193
  }
194
194
  /**
195
195
  * Per-subclass detail-mode hints. Return runnable commands pointing to
@@ -208,11 +208,17 @@ export class InspectResourceCommand extends SwellCommand {
208
208
  * `--json` is the machine-readable contract — hints belong in the same
209
209
  * stream as the human-facing output and are simply omitted there.
210
210
  */
211
- emitDetail(record, flags) {
211
+ emitDetail(record, flags, notes = []) {
212
212
  this.log(JSON.stringify(record, null, 2));
213
213
  if (flags.json) {
214
214
  return;
215
215
  }
216
+ if (notes.length > 0) {
217
+ this.log();
218
+ for (const note of notes) {
219
+ this.log(note);
220
+ }
221
+ }
216
222
  const lines = this.hints(record);
217
223
  if (lines.length === 0) {
218
224
  return;
@@ -0,0 +1 @@
1
+ export declare function errorReasons(errors: unknown, path?: string[]): string[];
@@ -0,0 +1,28 @@
1
+ // Every reason an API error answer gives, each with the path of the field it
2
+ // is about: errors come as { field: { message } }, nested, as strings or as
3
+ // { error } without a field
4
+ export function errorReasons(errors, path = []) {
5
+ if (typeof errors === 'string') {
6
+ return [formatReason(path, errors)];
7
+ }
8
+ if (Array.isArray(errors)) {
9
+ return errors.flatMap((item) => errorReasons(item, path));
10
+ }
11
+ if (!errors || typeof errors !== 'object') {
12
+ return [];
13
+ }
14
+ const { message, ...fields } = errors;
15
+ if (typeof message === 'string') {
16
+ return [formatReason(path, message)];
17
+ }
18
+ return Object.entries(fields).flatMap(([key, value]) =>
19
+ // An error's code names its kind, not a reason
20
+ key === 'code' && typeof value === 'string'
21
+ ? []
22
+ : // Developers know an app's private_id as the id in swell.json
23
+ errorReasons(value, [...path, key === 'private_id' ? 'id' : key]));
24
+ }
25
+ function formatReason(path, message) {
26
+ const fieldPath = path.filter((key) => key !== 'error').join('.');
27
+ return fieldPath ? `${fieldPath}: ${message}` : message;
28
+ }
@@ -8,6 +8,13 @@ export declare const COMPONENT_DEV_PATH = "/.swell/components/";
8
8
  * @returns {boolean} true for a root component
9
9
  */
10
10
  export declare function isRootComponentFile(file: string): boolean;
11
+ /**
12
+ * Whether a component file has a name the platform cannot serve: component
13
+ * frames load by a name of letters, digits and dashes
14
+ * @param {string} file path relative to the app
15
+ * @returns {boolean} true for a root component that can never load
16
+ */
17
+ export declare function isUnloadableComponentFile(file: string): boolean;
11
18
  /**
12
19
  * Component entries of an app
13
20
  * @param {string} appPath app directory
@@ -16,6 +16,16 @@ export function isRootComponentFile(file) {
16
16
  parts[0] === 'components' &&
17
17
  COMPONENT_EXTENSIONS.has(path.extname(parts[1])));
18
18
  }
19
+ /**
20
+ * Whether a component file has a name the platform cannot serve: component
21
+ * frames load by a name of letters, digits and dashes
22
+ * @param {string} file path relative to the app
23
+ * @returns {boolean} true for a root component that can never load
24
+ */
25
+ export function isUnloadableComponentFile(file) {
26
+ return (isRootComponentFile(file) &&
27
+ !/^[\dA-Za-z-]+$/.test(getNameFromFilePath(file)));
28
+ }
19
29
  /**
20
30
  * Component entries of an app
21
31
  * @param {string} appPath app directory
@@ -0,0 +1 @@
1
+ export declare function extensionManifestWarnings(extensions: unknown): string[];
@@ -0,0 +1,43 @@
1
+ // The fields of an extension in swell.json that the platform reads: the
2
+ // dashboard shows name, description and setting, and reads method (card or
3
+ // another method) and subscriptions of a payment; it shows the icons, and the
4
+ // logo of a card gateway. Checkout itself reads only the settings the
5
+ // merchant activates.
6
+ const READ_FIELDS = {
7
+ payment: [
8
+ 'method',
9
+ 'subscriptions',
10
+ 'method_icon_src',
11
+ 'gateway_icon_src',
12
+ 'gateway_logo_src',
13
+ ],
14
+ shipping: ['carrier_icon_src'],
15
+ tax: [],
16
+ };
17
+ const COMMON_FIELDS = ['id', 'type', 'name', 'description', 'setting'];
18
+ // Declared on the app model, but nothing reads them
19
+ const UNREAD_FIELDS = new Set([
20
+ 'gateway',
21
+ 'carrier',
22
+ 'method_logo_src',
23
+ 'carrier_logo_src',
24
+ ]);
25
+ export function extensionManifestWarnings(extensions) {
26
+ if (!Array.isArray(extensions)) {
27
+ return [];
28
+ }
29
+ return extensions.flatMap((extension) => {
30
+ if (!extension || typeof extension !== 'object') {
31
+ return [];
32
+ }
33
+ const read = new Set([
34
+ ...COMMON_FIELDS,
35
+ ...(READ_FIELDS[extension.type] ?? []),
36
+ ]);
37
+ return Object.keys(extension)
38
+ .filter((field) => !read.has(field))
39
+ .map((field) => UNREAD_FIELDS.has(field)
40
+ ? `swell.json extension "${extension.id}": ${field} has no effect`
41
+ : `swell.json extension "${extension.id}": unknown field ${field}`);
42
+ });
43
+ }
@@ -206,6 +206,7 @@ export declare function hashFile(filePath: string, fileContents?: Buffer, relati
206
206
  export declare function hashString(...contents: (Buffer | string)[]): string;
207
207
  export declare function appAssetImage(appPath: string, fileName: string): Promise<string | undefined>;
208
208
  export declare function getNameFromFilePath(filePath: string): string;
209
+ export declare function kebabCaseFilePath(filePath: string): string | undefined;
209
210
  export declare function appConfigFromFile(filePath: string, configType: ConfigType, appPath: string): Promise<AppConfig>;
210
211
  export declare function findAppConfig(app: App, filePath: string, configType: ConfigType): AppConfig | undefined;
211
212
  interface BatchAppFiles {
@@ -364,6 +364,36 @@ export async function appAssetImage(appPath, fileName) {
364
364
  export function getNameFromFilePath(filePath) {
365
365
  return path.parse(filePath).name.replaceAll('_', '-');
366
366
  }
367
+ // Configs in the root of these directories are named after their file
368
+ const FILE_NAMED_CONFIG_DIRS = new Set([
369
+ AllConfigPaths.COMPONENT,
370
+ AllConfigPaths.CONTENT,
371
+ AllConfigPaths.FUNCTION,
372
+ AllConfigPaths.MODEL,
373
+ AllConfigPaths.NOTIFICATION,
374
+ AllConfigPaths.SETTING,
375
+ AllConfigPaths.WEBHOOK,
376
+ ]);
377
+ // Among components, only .jsx and .tsx files are components; others are
378
+ // modules the components import
379
+ const COMPONENT_FILE_EXTENSIONS = new Set(['.jsx', '.tsx']);
380
+ // The name of a config turns an underscore in its file name into a dash, so
381
+ // the file goes by another name than what it deploys as unless renamed to this
382
+ export function kebabCaseFilePath(filePath) {
383
+ const [dir, file, ...nested] = filePath.split('/');
384
+ if (nested.length > 0 ||
385
+ !FILE_NAMED_CONFIG_DIRS.has(dir) ||
386
+ !file?.includes('_') ||
387
+ (dir === AllConfigPaths.COMPONENT &&
388
+ !COMPONENT_FILE_EXTENSIONS.has(path.extname(file)))) {
389
+ return undefined;
390
+ }
391
+ const name = getNameFromFilePath(file);
392
+ if (name.startsWith('-')) {
393
+ return undefined;
394
+ }
395
+ return `${dir}/${name}${path.extname(file)}`;
396
+ }
367
397
  export async function appConfigFromFile(filePath, configType, appPath) {
368
398
  const name = getNameFromFilePath(filePath);
369
399
  // Notification basenames must not contain dots: the inspect identifier
@@ -1,5 +1,13 @@
1
1
  import { type GlobbyFilterFunction } from 'globby';
2
2
  import { ConfigType } from './index.js';
3
+ export declare function isCredentialFile(relativePath: string): boolean;
4
+ /**
5
+ * The .env files of the frontend that the push would have pushed, which its
6
+ * build reads: their change deploys the frontend again, though they stay local
7
+ * @param {string} appPath the app directory
8
+ * @returns {Promise<string[]>} paths relative to the app, sorted
9
+ */
10
+ export declare function globFrontendEnvFiles(appPath: string): Promise<string[]>;
3
11
  export declare function toPosixPath(inputPath: string): string;
4
12
  export declare function globAllFilesByPath(appPath: string, dirPath?: string): Promise<string[]>;
5
13
  export declare function getGlobIgnorePathsChecker(appPath: string): Promise<GlobbyFilterFunction>;
@@ -2,6 +2,39 @@ import { globby, globbySync, isGitIgnored, } from 'globby';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { AllConfigPaths, ConfigType, StandardConfigPaths, StorefrontConfigPaths, ThemeConfigPaths, filePathExists, } from './index.js';
5
+ // .env and .dev.vars files can hold real credentials: never pushed, whatever
6
+ // the user's .gitignore says
7
+ const CREDENTIAL_FILE_GLOBS = [
8
+ '**/.dev.vars',
9
+ '**/.dev.vars.*',
10
+ '**/.env',
11
+ '**/.env.*',
12
+ ];
13
+ export function isCredentialFile(relativePath) {
14
+ const name = path.posix.basename(toPosixPath(relativePath));
15
+ return (name === '.env' ||
16
+ name.startsWith('.env.') ||
17
+ name === '.dev.vars' ||
18
+ name.startsWith('.dev.vars.'));
19
+ }
20
+ /**
21
+ * The .env files of the frontend that the push would have pushed, which its
22
+ * build reads: their change deploys the frontend again, though they stay local
23
+ * @param {string} appPath the app directory
24
+ * @returns {Promise<string[]>} paths relative to the app, sorted
25
+ */
26
+ export async function globFrontendEnvFiles(appPath) {
27
+ const files = await globby([
28
+ `${AllConfigPaths.FRONTEND}/**/.env`,
29
+ `${AllConfigPaths.FRONTEND}/**/.env.*`,
30
+ ], {
31
+ cwd: appPath,
32
+ dot: true,
33
+ gitignore: true,
34
+ ignore: ['**/node_modules/**', '**/.wrangler/**'],
35
+ });
36
+ return files.sort();
37
+ }
5
38
  function globOptions(appPath, options) {
6
39
  return {
7
40
  cwd: appPath,
@@ -9,8 +42,6 @@ function globOptions(appPath, options) {
9
42
  gitignore: true,
10
43
  ...options,
11
44
  expandDirectories: true,
12
- // .dev.vars can hold real credentials — ignored here in case the
13
- // user's .gitignore doesn't cover it.
14
45
  ignore: [
15
46
  '**/.swellrc',
16
47
  '**/.git/**',
@@ -21,8 +52,7 @@ function globOptions(appPath, options) {
21
52
  '**/bun.lockb',
22
53
  '**/bun.lock',
23
54
  '**/.wrangler/**',
24
- '**/.dev.vars',
25
- '**/.dev.vars.*',
55
+ ...CREDENTIAL_FILE_GLOBS,
26
56
  ...(options?.ignore || []),
27
57
  ],
28
58
  };
@@ -266,13 +266,26 @@ class SwellWorkflowRuntimeProxy {
266
266
  this.apiHost = getRuntimeApiHost(runtime);
267
267
  }
268
268
 
269
- api(method, path, data) {
270
- return this.request({
269
+ async api(method, path, data) {
270
+ const result = await this.request({
271
271
  type: 'api',
272
272
  method,
273
273
  path,
274
274
  data,
275
275
  });
276
+
277
+ // A write the API refuses answers with its errors. It fails as it does in
278
+ // functions, and is not retried, since the same write would fail again
279
+ if (method !== 'get' && result && result.errors) {
280
+ const error = new NonRetryableError(
281
+ method.toUpperCase() + ' ' + path + '\\n' + JSON.stringify(result.errors, null, 2),
282
+ );
283
+ error.status = 400;
284
+ error.body = result.errors;
285
+ throw error;
286
+ }
287
+
288
+ return result;
276
289
  }
277
290
 
278
291
  settings() {
@@ -1,4 +1,4 @@
1
- export type FunctionType = 'cron' | 'model' | 'route' | 'workflow';
1
+ export type FunctionType = 'action' | 'cron' | 'model' | 'route' | 'workflow';
2
2
  export type FunctionLanguage = 'js' | 'ts';
3
3
  export interface CreateFunctionArgs {
4
4
  functionName: string;
@@ -11,7 +11,7 @@ export function getFunctionTemplate({ description, events, extension, functionNa
11
11
  const eventsList = events
12
12
  .split(/\s*,\s*/g)
13
13
  .filter(Boolean)
14
- .map((eventName) => `'${eventName}'`)
14
+ .map((eventName) => JSON.stringify(eventName))
15
15
  .join(', ');
16
16
  typeAppend = `,
17
17
  model: {
@@ -28,15 +28,19 @@ export function getFunctionTemplate({ description, events, extension, functionNa
28
28
  if (trigger === 'cron') {
29
29
  typeAppend = `,
30
30
  cron: {
31
- schedule: '${schedule}',
31
+ schedule: ${JSON.stringify(schedule)},
32
32
  },`;
33
+ }
34
+ if (trigger === 'action') {
35
+ typeAppend = `,
36
+ action: true,`;
33
37
  }
34
38
  let extensionAppend = '';
35
39
  if (extension) {
36
- extensionAppend = `extension: '${extension}',\n `;
40
+ extensionAppend = `extension: ${JSON.stringify(extension)},\n `;
37
41
  }
38
42
  return `export const config${language === 'ts' ? ': SwellConfig' : ''} = {
39
- ${extensionAppend}description: '${description}'${typeAppend}
43
+ ${extensionAppend}description: ${JSON.stringify(description)}${typeAppend}
40
44
  };
41
45
 
42
46
  export default async function (req${language === 'ts' ? ': SwellRequest' : ''}) {
@@ -50,7 +54,7 @@ function getWorkflowTemplate({ description, functionName, language, }) {
50
54
  const stepParam = language === 'ts' ? 'step: SwellWorkflowStep' : 'step';
51
55
  return `export const config = {
52
56
  kind: 'workflow',
53
- description: '${description}'
57
+ description: ${JSON.stringify(description)}
54
58
  };
55
59
 
56
60
  export default class ${className} {
@@ -15,8 +15,9 @@ export const toAppId = (value) => String(value || '')
15
15
  .replaceAll(/([a-z])([A-Z])/g, '$1_$2')
16
16
  .toLowerCase();
17
17
  // Example: 'My Thing' => 'my-thing'
18
+ // And: 'sync_order' => 'sync-order', the name a function deploys under
18
19
  // And: 'myThing' => 'myThing'
19
- export const toFunctionFileName = (value) => value.split('.')[0].replace(' ', '-');
20
+ export const toFunctionFileName = (value) => value.split('.')[0].replaceAll(/[\s_]+/g, '-');
20
21
  // Example: 'My Thing' => 'my-things'
21
22
  export const toCollectionName = (value) => pluralize(value.replaceAll(/[^A-Za-z]/g, '-').replace(' ', '')).toLowerCase();
22
23
  // Example: 'my-things' => 'My Things'
@@ -13,11 +13,15 @@ interface ModelEvent {
13
13
  id: ModelEventID;
14
14
  }
15
15
  type ModelEventID = 'created' | 'deleted' | 'updated';
16
- type ModelFieldResponse = Record<string, ModelFieldTypeObject>;
17
- type FieldType = 'collection' | 'currency' | 'image' | 'int' | 'link' | 'string';
18
- interface ModelFieldTypeObject {
16
+ type ModelFieldResponse = Record<string, ModelField>;
17
+ export declare const FIELD_TYPES: readonly ["string", "int", "float", "bool", "date", "currency", "objectid", "array", "object", "collection", "link", "file", "filedata"];
18
+ type FieldType = (typeof FIELD_TYPES)[number];
19
+ interface ModelField {
20
+ model?: string;
19
21
  type: FieldType;
22
+ value_type?: string;
20
23
  }
24
+ export declare function fieldPairProblem(pair: string): string | undefined;
21
25
  export declare function parseFields(fields: string[]): ModelFieldResponse;
22
26
  export declare function parseEvents(events: string[]): ModelEventObject;
23
27
  export {};
@@ -1,14 +1,72 @@
1
- export function parseFields(fields) {
2
- if (fields.length === 0) {
3
- return {};
1
+ // The field types of the model schema, https://json.swell.store/model.json
2
+ export const FIELD_TYPES = [
3
+ 'string',
4
+ 'int',
5
+ 'float',
6
+ 'bool',
7
+ 'date',
8
+ 'currency',
9
+ 'objectid',
10
+ 'array',
11
+ 'object',
12
+ 'collection',
13
+ 'link',
14
+ 'file',
15
+ 'filedata',
16
+ ];
17
+ // Items of an array given as a flag; object and link items need more than a type
18
+ const ARRAY_VALUE_TYPES = [
19
+ 'string',
20
+ 'int',
21
+ 'float',
22
+ 'bool',
23
+ 'date',
24
+ 'currency',
25
+ 'objectid',
26
+ 'file',
27
+ 'filedata',
28
+ ];
29
+ function splitFieldPair(pair) {
30
+ return pair.split(':').map((part) => part.trim());
31
+ }
32
+ // Why a field given as id:type, id:link:<model> or id:array:<type> cannot be
33
+ // written, if it cannot
34
+ export function fieldPairProblem(pair) {
35
+ const [id, type, target] = splitFieldPair(pair);
36
+ if (!id || !type) {
37
+ return `${pair}: expected id:type`;
38
+ }
39
+ if (type === 'image') {
40
+ return `${pair}: Invalid field type 'image'; use file`;
41
+ }
42
+ if (type === 'collection') {
43
+ return `${pair}: A collection needs its fields; add it with them to the model file`;
44
+ }
45
+ if (!FIELD_TYPES.includes(type)) {
46
+ return `${pair}: unknown type '${type}'`;
47
+ }
48
+ if (type === 'link' && !target) {
49
+ return `${pair}: a link needs the model it links to, e.g. ${id}:link:accounts`;
4
50
  }
51
+ if (type === 'array' && target && !ARRAY_VALUE_TYPES.includes(target)) {
52
+ return `${pair}: an array given here holds ${ARRAY_VALUE_TYPES.join(', ')}`;
53
+ }
54
+ if (target && type !== 'link' && type !== 'array') {
55
+ return `${pair}: only link and array take a third part`;
56
+ }
57
+ }
58
+ export function parseFields(fields) {
5
59
  const fieldResponse = {};
6
- for (const fieldPair of fields) {
7
- if (fieldPair.includes(':')) {
8
- const [id, type] = fieldPair.split(':');
9
- fieldResponse[id] = {
10
- type,
11
- };
60
+ for (const pair of fields) {
61
+ const [id, type, target] = splitFieldPair(pair);
62
+ if (type === 'link') {
63
+ fieldResponse[id] = { type, model: target };
64
+ }
65
+ else if (type === 'array') {
66
+ fieldResponse[id] = { type, value_type: target || 'string' };
67
+ }
68
+ else {
69
+ fieldResponse[id] = { type };
12
70
  }
13
71
  }
14
72
  return fieldResponse;
@@ -24,7 +24,7 @@ export interface ManifestEntry {
24
24
  }
25
25
  /** CLI-resolved type. Diverges from raw manifest `type` for payment (card vs alt). */
26
26
  export type ExtensionType = 'card' | 'alt' | 'shipping' | 'tax';
27
- export type ExtensionStatus = 'not deployed' | 'not activated' | 'app id mismatch' | 'id mismatch' | 'not selected' | 'gateway missing' | 'not enabled' | 'no handler' | 'handler mismatch' | 'activated';
27
+ export type ExtensionStatus = 'not deployed' | 'settings unavailable' | 'not activated' | 'app id mismatch' | 'id mismatch' | 'not selected' | 'gateway missing' | 'not enabled' | 'no handler' | 'handler mismatch' | 'activated';
28
28
  export type ActionOwner = 'dev' | 'merchant' | null;
29
29
  export interface FunctionRecord {
30
30
  id?: string;
@@ -163,8 +163,6 @@ export declare function formatExtensionKey(slug: string, extId: string): string;
163
163
  * resolves to `card` even with no explicit `method`.
164
164
  */
165
165
  export declare function resolveExtensionType(entry: Pick<ManifestEntry, 'type' | 'method' | 'id'>): ExtensionType;
166
- /** Apply the platform's `if(method, method, id)` formula. */
167
- export declare function paymentMethodIdFor(entry: Pick<ManifestEntry, 'method' | 'id'>): string;
168
166
  /** Shipping records use `app_<appId>_<extId>` as both the row id and the carrier id. */
169
167
  export declare function shippingBindId(appId: string, extId: string): string;
170
168
  /** Card-gateway records use the same id form as shipping rows. */
@@ -229,12 +227,15 @@ export interface BuildDetailInput {
229
227
  payments?: PaymentSettings | null;
230
228
  shipments?: ShippingSettings | null;
231
229
  taxes?: TaxSettingsRecord | null;
230
+ /** Why each settings record could not be read, if it could not. */
231
+ unavailableSettings?: UnavailableSettings;
232
232
  /** Functions in the same app whose top-level `extension` matches this extId. */
233
233
  functions: FunctionRecord[];
234
234
  /** Components in the same app whose `values.extension` matches this extId. */
235
235
  components: ComponentRecord[];
236
236
  localDiff?: LocalDiff | null;
237
237
  }
238
+ export type UnavailableSettings = Partial<Record<'payments' | 'shipments' | 'taxes', string>>;
238
239
  export interface BuildOrphanInput {
239
240
  appId: string;
240
241
  appSlug: string;