@jd-data-limited/easy-fm 5.0.0-beta.4 → 5.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -40,7 +40,10 @@ program
40
40
  if (await typegen.ensureZodInstalled(dirname(configPath)))
41
41
  console.log(chalk.green('Installed Zod for generated runtime validation.'));
42
42
  const loaded = await import(pathToFileURL(configPath).href);
43
- const result = await typegen.generate(loaded.default);
43
+ const result = await typegen.generate({ ...loaded.default, onDiagnostic: (diagnostic) => {
44
+ const location = [diagnostic.database, diagnostic.layout, diagnostic.portal].filter(Boolean).join('/');
45
+ console.warn(chalk.yellow(`Warning [${location}]: ${diagnostic.message}`));
46
+ } });
44
47
  console.log(chalk.green(`Generated ${result.layouts} layouts in ${result.output}`));
45
48
  });
46
49
  program.parse(process.argv);
@@ -2,5 +2,4 @@ export { configExists } from '../typegen/init.js';
2
2
  export interface InitOptions {
3
3
  config: string;
4
4
  }
5
- /** Interactive initializer shared by `easyfm init` and first `easyfm typegen`. */
6
5
  export declare function initializeTypegen({ config }: InitOptions): Promise<boolean>;
package/dist/bin/init.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import { dirname, resolve } from 'node:path';
4
- import { configExists, readEnvironment, writeDataApiInitialization } from '../typegen/init.js';
4
+ import { configExists, writeMultiDatabaseInitialization } from '../typegen/init.js';
5
5
  export { configExists } from '../typegen/init.js';
6
- /** Interactive initializer shared by `easyfm init` and first `easyfm typegen`. */
7
6
  export async function initializeTypegen({ config }) {
8
7
  const configPath = resolve(config);
9
8
  if (await configExists(configPath)) {
@@ -11,36 +10,34 @@ export async function initializeTypegen({ config }) {
11
10
  return false;
12
11
  }
13
12
  console.log(chalk.bold.cyan('EasyFM typegen setup'));
14
- const envPath = resolve(dirname(configPath), '.env');
15
- const existingEnvironment = await readEnvironment(envPath);
16
- const answers = await inquirer.prompt([
17
- { type: 'select', name: 'provider', message: 'Provider', choices: [{ name: 'Data API/OData', value: 'data-api/odata' }] },
18
- { type: 'select', name: 'transports', message: 'Transports', default: 'both', choices: [
19
- { name: 'Both', value: 'both' },
20
- { name: 'Data API', value: 'data-api' },
21
- { name: 'OData', value: 'odata' }
22
- ] },
23
- { type: 'input', name: 'hostname', message: 'FileMaker host URL', default: existingEnvironment.FM_HOST ?? 'https://', validate: validHost },
24
- { type: 'input', name: 'database', message: 'Database filename', default: existingEnvironment.FM_DATABASE, validate: required('Database filename') },
25
- { type: 'input', name: 'username', message: 'Username', default: existingEnvironment.FM_USERNAME, validate: required('Username') },
26
- { type: 'password', name: 'password', message: existingEnvironment.FM_PASSWORD ? 'Password (leave blank to keep existing)' : 'Password', mask: '*', validate: value => existingEnvironment.FM_PASSWORD || value ? true : 'Password is required' },
27
- { type: 'input', name: 'output', message: 'Generated client directory', default: './src/easyfm-generated', validate: required('Output directory') }
28
- ]);
29
- if (!answers.password)
30
- answers.password = existingEnvironment.FM_PASSWORD ?? '';
31
- await writeDataApiInitialization(configPath, answers);
13
+ const { output } = await inquirer.prompt([{ type: 'input', name: 'output', message: 'Generated client directory', default: './src/easyfm-generated', validate: required('Output directory') }]);
14
+ const databases = [];
15
+ let more = true;
16
+ while (more) {
17
+ const answers = await inquirer.prompt([
18
+ { type: 'input', name: 'key', message: 'Database client name', validate: (value) => !value.trim() ? 'Client name is required' : databases.some(db => db.key === value) ? 'Client name must be unique' : true },
19
+ { type: 'select', name: 'provider', message: 'Provider', choices: [{ name: 'Data API/OData', value: 'data-api/odata' }] },
20
+ { type: 'select', name: 'transports', message: 'Transports', default: 'both', choices: [{ name: 'Both', value: 'both' }, { name: 'Data API', value: 'data-api' }, { name: 'OData', value: 'odata' }] },
21
+ { type: 'input', name: 'hostname', message: 'FileMaker host URL', default: 'https://', validate: validHost },
22
+ { type: 'input', name: 'database', message: 'Database filename', validate: required('Database filename') },
23
+ { type: 'input', name: 'username', message: 'Username', validate: required('Username') },
24
+ { type: 'password', name: 'password', message: 'Password', mask: '*', validate: required('Password') }
25
+ ]);
26
+ databases.push(answers);
27
+ const result = await inquirer.prompt([{ type: 'confirm', name: 'more', message: 'Add another database?', default: false }]);
28
+ more = result.more;
29
+ }
30
+ await writeMultiDatabaseInitialization(configPath, { output, databases });
32
31
  console.log(chalk.green(`Created ${configPath}`));
33
- console.log(chalk.green(`Saved Data API credentials in ${envPath}`));
32
+ console.log(chalk.green(`Saved database-scoped credentials in ${resolve(dirname(configPath), '.env')}`));
34
33
  console.log(chalk.dim('Keep .env out of version control.'));
35
34
  return true;
36
35
  }
37
36
  function required(label) { return (value) => value.trim() ? true : `${label} is required`; }
38
- function validHost(value) {
39
- try {
40
- const url = new URL(value);
41
- return url.protocol === 'http:' || url.protocol === 'https:' ? true : 'Use an http:// or https:// URL';
42
- }
43
- catch {
44
- return 'Use a valid http:// or https:// URL';
45
- }
37
+ function validHost(value) { try {
38
+ const url = new URL(value);
39
+ return url.protocol === 'http:' || url.protocol === 'https:' ? true : 'Use an http:// or https:// URL';
46
40
  }
41
+ catch {
42
+ return 'Use a valid http:// or https:// URL';
43
+ } }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { LayoutRecordManager } from './layoutRecordManager.js';
5
5
  import { FMError } from '../FMError.js';
6
- import { ApiLayoutMetadata } from '../models/apiResults.js';
6
+ import { ApiLayoutMetadata, stripInaccessibleMetadata } from '../models/apiResults.js';
7
7
  export class Layout {
8
8
  database;
9
9
  name;
@@ -37,7 +37,7 @@ export class Layout {
37
37
  return this.metadata;
38
38
  }
39
39
  const { value: res } = await this.database.execute({ type: 'layout.metadata', layout: this.name });
40
- this.metadata = ApiLayoutMetadata.parse(res);
40
+ this.metadata = stripInaccessibleMetadata(ApiLayoutMetadata.parse(res));
41
41
  return this.metadata;
42
42
  }
43
43
  }
@@ -7,6 +7,7 @@ export declare const ApiFieldTypes: {
7
7
  export declare const ApiFieldDisplayTypes: {
8
8
  readonly EDIT_TEXT: "editText";
9
9
  readonly POPUP_LIST: "popupList";
10
+ readonly POPUP_MENU: "popupMenu";
10
11
  readonly CHECKBOX: "checkBox";
11
12
  readonly RADIO_BUTTONS: "radioButtons";
12
13
  readonly SELECTION_LIST: "selectionList";
@@ -39,28 +40,29 @@ export declare const ApiLayout: z.ZodType<{
39
40
  }>;
40
41
  export declare const ApiFieldMetadata: z.ZodObject<{
41
42
  name: z.ZodString;
42
- type: z.ZodEnum<{
43
+ type: z.ZodUnion<readonly [z.ZodEnum<{
43
44
  readonly NORMAL: "normal";
44
45
  readonly CALCULATION: "calculation";
45
46
  readonly SUMMARY: "summary";
46
- }>;
47
+ }>, z.ZodLiteral<"invalid">]>;
47
48
  displayType: z.ZodEnum<{
48
49
  readonly EDIT_TEXT: "editText";
49
50
  readonly POPUP_LIST: "popupList";
51
+ readonly POPUP_MENU: "popupMenu";
50
52
  readonly CHECKBOX: "checkBox";
51
53
  readonly RADIO_BUTTONS: "radioButtons";
52
54
  readonly SELECTION_LIST: "selectionList";
53
55
  readonly CALENDAR: "calendar";
54
56
  readonly SECURE_TEXT: "secureText";
55
57
  }>;
56
- result: z.ZodEnum<{
58
+ result: z.ZodUnion<readonly [z.ZodEnum<{
57
59
  readonly TEXT: "text";
58
60
  readonly NUMBER: "number";
59
61
  readonly DATE: "date";
60
62
  readonly TIME: "time";
61
63
  readonly TIMESTAMP: "timeStamp";
62
64
  readonly CONTAINER: "container";
63
- }>;
65
+ }>, z.ZodLiteral<"invalid">]>;
64
66
  global: z.ZodBoolean;
65
67
  autoEnter: z.ZodBoolean;
66
68
  fourDigitYear: z.ZodBoolean;
@@ -78,34 +80,35 @@ export declare const ApiValueList: z.ZodObject<{
78
80
  type: z.ZodString;
79
81
  values: z.ZodArray<z.ZodObject<{
80
82
  value: z.ZodString;
81
- displayName: z.ZodString;
83
+ displayName: z.ZodOptional<z.ZodString>;
82
84
  }, z.core.$strip>>;
83
85
  }, z.core.$strip>;
84
86
  export declare const ApiLayoutMetadata: z.ZodObject<{
85
87
  fieldMetaData: z.ZodArray<z.ZodObject<{
86
88
  name: z.ZodString;
87
- type: z.ZodEnum<{
89
+ type: z.ZodUnion<readonly [z.ZodEnum<{
88
90
  readonly NORMAL: "normal";
89
91
  readonly CALCULATION: "calculation";
90
92
  readonly SUMMARY: "summary";
91
- }>;
93
+ }>, z.ZodLiteral<"invalid">]>;
92
94
  displayType: z.ZodEnum<{
93
95
  readonly EDIT_TEXT: "editText";
94
96
  readonly POPUP_LIST: "popupList";
97
+ readonly POPUP_MENU: "popupMenu";
95
98
  readonly CHECKBOX: "checkBox";
96
99
  readonly RADIO_BUTTONS: "radioButtons";
97
100
  readonly SELECTION_LIST: "selectionList";
98
101
  readonly CALENDAR: "calendar";
99
102
  readonly SECURE_TEXT: "secureText";
100
103
  }>;
101
- result: z.ZodEnum<{
104
+ result: z.ZodUnion<readonly [z.ZodEnum<{
102
105
  readonly TEXT: "text";
103
106
  readonly NUMBER: "number";
104
107
  readonly DATE: "date";
105
108
  readonly TIME: "time";
106
109
  readonly TIMESTAMP: "timeStamp";
107
110
  readonly CONTAINER: "container";
108
- }>;
111
+ }>, z.ZodLiteral<"invalid">]>;
109
112
  global: z.ZodBoolean;
110
113
  autoEnter: z.ZodBoolean;
111
114
  fourDigitYear: z.ZodBoolean;
@@ -120,28 +123,29 @@ export declare const ApiLayoutMetadata: z.ZodObject<{
120
123
  }, z.core.$strip>>;
121
124
  portalMetaData: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
122
125
  name: z.ZodString;
123
- type: z.ZodEnum<{
126
+ type: z.ZodUnion<readonly [z.ZodEnum<{
124
127
  readonly NORMAL: "normal";
125
128
  readonly CALCULATION: "calculation";
126
129
  readonly SUMMARY: "summary";
127
- }>;
130
+ }>, z.ZodLiteral<"invalid">]>;
128
131
  displayType: z.ZodEnum<{
129
132
  readonly EDIT_TEXT: "editText";
130
133
  readonly POPUP_LIST: "popupList";
134
+ readonly POPUP_MENU: "popupMenu";
131
135
  readonly CHECKBOX: "checkBox";
132
136
  readonly RADIO_BUTTONS: "radioButtons";
133
137
  readonly SELECTION_LIST: "selectionList";
134
138
  readonly CALENDAR: "calendar";
135
139
  readonly SECURE_TEXT: "secureText";
136
140
  }>;
137
- result: z.ZodEnum<{
141
+ result: z.ZodUnion<readonly [z.ZodEnum<{
138
142
  readonly TEXT: "text";
139
143
  readonly NUMBER: "number";
140
144
  readonly DATE: "date";
141
145
  readonly TIME: "time";
142
146
  readonly TIMESTAMP: "timeStamp";
143
147
  readonly CONTAINER: "container";
144
- }>;
148
+ }>, z.ZodLiteral<"invalid">]>;
145
149
  global: z.ZodBoolean;
146
150
  autoEnter: z.ZodBoolean;
147
151
  fourDigitYear: z.ZodBoolean;
@@ -159,10 +163,19 @@ export declare const ApiLayoutMetadata: z.ZodObject<{
159
163
  type: z.ZodString;
160
164
  values: z.ZodArray<z.ZodObject<{
161
165
  value: z.ZodString;
162
- displayName: z.ZodString;
166
+ displayName: z.ZodOptional<z.ZodString>;
163
167
  }, z.core.$strip>>;
164
168
  }, z.core.$strip>>>;
165
169
  }, z.core.$strip>;
170
+ export type ApiLayoutMetadataValue = z.infer<typeof ApiLayoutMetadata>;
171
+ export declare function isAccessibleFieldMetadata(field: z.infer<typeof ApiFieldMetadata>): field is z.infer<typeof ApiFieldMetadata> & {
172
+ type: z.infer<typeof ApiFieldTypes>;
173
+ result: z.infer<typeof ApiFieldResultTypes>;
174
+ };
175
+ /** Removes FileMaker metadata placeholders for fields unavailable to the current account. */
176
+ export declare function stripInaccessibleMetadata(metadata: ApiLayoutMetadataValue): ApiLayoutMetadataValue;
177
+ /** Removes inaccessible placeholder values before strict generated validation. */
178
+ export declare function stripInaccessibleFieldData<T extends Record<string, unknown>>(fields: T): T;
166
179
  export declare const ApiScriptResult: z.ZodObject<{
167
180
  scriptError: z.ZodString;
168
181
  scriptResult: z.ZodOptional<z.ZodString>;
@@ -10,6 +10,7 @@ export const ApiFieldTypes = {
10
10
  export const ApiFieldDisplayTypes = {
11
11
  EDIT_TEXT: 'editText',
12
12
  POPUP_LIST: 'popupList',
13
+ POPUP_MENU: 'popupMenu',
13
14
  CHECKBOX: 'checkBox',
14
15
  RADIO_BUTTONS: 'radioButtons',
15
16
  SELECTION_LIST: 'selectionList',
@@ -38,9 +39,9 @@ export const ApiLayout = z.lazy(() => z.object({
38
39
  }));
39
40
  export const ApiFieldMetadata = z.object({
40
41
  name: z.string(),
41
- type: z.enum(ApiFieldTypes),
42
+ type: z.union([z.enum(ApiFieldTypes), z.literal('invalid')]),
42
43
  displayType: z.enum(ApiFieldDisplayTypes),
43
- result: z.enum(ApiFieldResultTypes),
44
+ result: z.union([z.enum(ApiFieldResultTypes), z.literal('invalid')]),
44
45
  global: z.boolean(),
45
46
  autoEnter: z.boolean(),
46
47
  fourDigitYear: z.boolean(),
@@ -58,7 +59,7 @@ export const ApiValueList = z.object({
58
59
  type: z.string(),
59
60
  values: z.array(z.object({
60
61
  value: z.string(),
61
- displayName: z.string()
62
+ displayName: z.string().optional()
62
63
  }))
63
64
  });
64
65
  export const ApiLayoutMetadata = z.object({
@@ -66,6 +67,25 @@ export const ApiLayoutMetadata = z.object({
66
67
  portalMetaData: z.record(z.string(), z.array(ApiFieldMetadata)),
67
68
  valueLists: z.array(ApiValueList).optional()
68
69
  });
70
+ export function isAccessibleFieldMetadata(field) {
71
+ return field.type !== 'invalid' && field.result !== 'invalid' && field.name !== '<No Access>';
72
+ }
73
+ /** Removes FileMaker metadata placeholders for fields unavailable to the current account. */
74
+ export function stripInaccessibleMetadata(metadata) {
75
+ return {
76
+ ...metadata,
77
+ fieldMetaData: metadata.fieldMetaData.filter(isAccessibleFieldMetadata),
78
+ portalMetaData: Object.fromEntries(Object.entries(metadata.portalMetaData).map(([name, fields]) => [name, fields.filter(isAccessibleFieldMetadata)]))
79
+ };
80
+ }
81
+ /** Removes inaccessible placeholder values before strict generated validation. */
82
+ export function stripInaccessibleFieldData(fields) {
83
+ if (!Object.hasOwn(fields, '<No Access>'))
84
+ return fields;
85
+ const result = { ...fields };
86
+ delete result['<No Access>'];
87
+ return result;
88
+ }
69
89
  export const ApiScriptResult = z.object({
70
90
  scriptError: z.string(),
71
91
  scriptResult: z.string().optional()
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { RecordTypes } from '../types.js';
6
+ import { stripInaccessibleFieldData } from '../models/apiResults.js';
6
7
  import { DateField, NumberField, TextField, TimeField, TimeStampField, ValueFieldBase } from "./fields/valueField.js";
7
8
  import { ContainerField } from "./fields/containerField.js";
8
9
  export class RecordBase extends EventEmitter {
@@ -41,7 +42,7 @@ export class RecordBase extends EventEmitter {
41
42
  }
42
43
  processFieldData(fieldData) {
43
44
  const fields = {};
44
- for (const [key, value] of Object.entries(fieldData)) {
45
+ for (const [key, value] of Object.entries(stripInaccessibleFieldData(fieldData))) {
45
46
  const fieldMeta = this.getFieldMetadata(key);
46
47
  let field;
47
48
  switch (fieldMeta.result) {
package/dist/schema.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { stripInaccessibleFieldData, stripInaccessibleMetadata } from './models/apiResults.js';
1
2
  /** Built-in runtime adapter used by Zod-generated clients. */
2
3
  export const zodValidator = {
3
4
  validate(schema, value) {
@@ -29,19 +30,28 @@ export function withSchemaValidation(provider, schema) {
29
30
  return { formatting: connection.formatting, maxSessions: connection.maxSessions, close: connection.close?.bind(connection), async openSession(signal) {
30
31
  const session = await connection.openSession(signal);
31
32
  return { close: session.close.bind(session), fetchContainer: session.fetchContainer.bind(session), async execute(operation) {
32
- const result = await session.execute(operation);
33
- if (operation.type === 'layout.metadata')
33
+ let result = await session.execute(operation);
34
+ if (operation.type === 'layout.metadata') {
35
+ result = stripInaccessibleMetadata(result);
34
36
  validateMetadata(schema, operation.layout, result);
35
- if (operation.type === 'record.get')
37
+ }
38
+ if (operation.type === 'record.get') {
39
+ result = stripRecord(result);
36
40
  validateRecord(schema, operation.layout, result);
37
- if (operation.type === 'record.list')
41
+ }
42
+ if (operation.type === 'record.list') {
43
+ result = result.map(stripRecord);
38
44
  for (const record of result)
39
45
  validateRecord(schema, operation.layout, record);
46
+ }
40
47
  return result;
41
48
  } };
42
49
  } };
43
50
  } };
44
51
  }
52
+ function stripRecord(record) {
53
+ return { ...record, fieldData: stripInaccessibleFieldData(record.fieldData), portalData: record.portalData && Object.fromEntries(Object.entries(record.portalData).map(([name, rows]) => [name, rows.map(row => stripInaccessibleFieldData(row))])) };
54
+ }
45
55
  function validateRecord(schema, layout, record) {
46
56
  const layoutSchema = schema.layouts[layout];
47
57
  if (!layoutSchema)
@@ -1,22 +1,26 @@
1
1
  import { Database } from '../connection/database.js';
2
2
  import { DataApiProvider } from '../connection/DataApiProvider.js';
3
+ import { ApiLayoutMetadata, isAccessibleFieldMetadata } from '../models/apiResults.js';
3
4
  export function dataApiSchemaSource(options) {
4
5
  return {
5
6
  async introspect() {
6
7
  const database = Database.create({ provider: new DataApiProvider(options) });
7
8
  try {
8
9
  const layouts = await database.listLayouts();
9
- return {
10
- version: 1,
11
- layouts: await Promise.all(layouts.map(async (layout) => {
12
- const metadata = await layout.getLayoutMeta();
13
- return {
14
- name: layout.name,
15
- fields: unique(metadata.fieldMetaData.map(toField)),
16
- portals: Object.fromEntries(Object.entries(metadata.portalMetaData).map(([name, fields]) => [name, unique(fields.map(toField))]))
17
- };
18
- }))
19
- };
10
+ const inspected = await Promise.all(layouts.map(async (layout) => {
11
+ // Read raw metadata here so inaccessible placeholders can produce diagnostics before stripping.
12
+ const { value } = await database.execute({ type: 'layout.metadata', layout: layout.name });
13
+ const metadata = ApiLayoutMetadata.parse(value);
14
+ const diagnostics = metadata.fieldMetaData.filter(isInaccessible).map(field => ({ level: 'warning', layout: layout.name, message: `Field ${JSON.stringify(field.name)} is inaccessible and was excluded from generated types. Check this account's field privileges.` }));
15
+ for (const [portal, fields] of Object.entries(metadata.portalMetaData))
16
+ for (const field of fields.filter(isInaccessible))
17
+ diagnostics.push({ level: 'warning', layout: layout.name, portal, message: `Portal field ${JSON.stringify(field.name)} is inaccessible and was excluded from generated types. Check this account's field privileges.` });
18
+ return {
19
+ layout: { name: layout.name, fields: unique(metadata.fieldMetaData.filter(isAccessibleFieldMetadata).map(toField)), portals: Object.fromEntries(Object.entries(metadata.portalMetaData).map(([name, fields]) => [name, unique(fields.filter(isAccessibleFieldMetadata).map(toField))])) },
20
+ diagnostics
21
+ };
22
+ }));
23
+ return { version: 1, layouts: inspected.map(item => item.layout), diagnostics: inspected.flatMap(item => item.diagnostics) };
20
24
  }
21
25
  finally {
22
26
  await database.close();
@@ -24,7 +28,10 @@ export function dataApiSchemaSource(options) {
24
28
  }
25
29
  };
26
30
  }
31
+ function isInaccessible(field) { return field.type === 'invalid' || field.result === 'invalid' || field.name === '<No Access>'; }
27
32
  function toField(field) {
33
+ if (!isAccessibleFieldMetadata(field))
34
+ throw new Error('Cannot generate an inaccessible field');
28
35
  return { name: field.name, result: field.result, type: field.type, writable: field.type === 'normal', required: field.notEmpty };
29
36
  }
30
37
  function unique(fields) {
@@ -2,4 +2,8 @@ import type { TypegenConfig } from './types.js';
2
2
  export declare function generate(config: TypegenConfig): Promise<{
3
3
  output: string;
4
4
  layouts: number;
5
+ databases: number;
6
+ diagnostics: Array<import('./types.js').TypegenDiagnostic & {
7
+ database: string;
8
+ }>;
5
9
  }>;
@@ -2,84 +2,63 @@ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { zodTypegenValidator } from './zod.js';
4
4
  export async function generate(config) {
5
- const discovered = await config.source.introspect();
6
- const selected = config.layouts ? discovered.layouts.filter(layout => config.layouts.includes(layout.name)) : discovered.layouts;
7
- const missing = config.layouts?.filter(name => !selected.some(layout => layout.name === name)) ?? [];
8
- if (missing.length)
9
- throw new Error(`Layouts not found: ${missing.join(', ')}`);
10
- const schema = { version: 1, layouts: [...selected].sort((a, b) => a.name.localeCompare(b.name)) };
5
+ const diagnostics = [];
6
+ const databases = await Promise.all(databaseEntries(config).map(async ([key, databaseConfig]) => {
7
+ const discovered = await databaseConfig.source.introspect();
8
+ for (const diagnostic of discovered.diagnostics ?? []) {
9
+ const item = { ...diagnostic, database: key };
10
+ diagnostics.push(item);
11
+ (config.onDiagnostic ?? defaultDiagnosticReporter)(item);
12
+ }
13
+ const selected = databaseConfig.layouts ? discovered.layouts.filter(layout => databaseConfig.layouts.includes(layout.name)) : discovered.layouts;
14
+ const missing = databaseConfig.layouts?.filter(name => !selected.some(layout => layout.name === name)) ?? [];
15
+ if (missing.length)
16
+ throw new Error(`Layouts not found in database ${key}: ${missing.join(', ')}`);
17
+ return { key, config: databaseConfig, schema: { version: 1, layouts: [...selected].sort((a, b) => a.name.localeCompare(b.name)) }, validator: databaseConfig.validator ?? config.validator ?? zodTypegenValidator() };
18
+ }));
11
19
  const output = resolve(config.output);
12
20
  const temporary = `${output}.tmp-${process.pid}`;
13
21
  await rm(temporary, { recursive: true, force: true });
14
22
  await mkdir(temporary, { recursive: true });
15
- await writeFile(resolve(temporary, 'client.ts'), renderClient(schema, config.validator ?? zodTypegenValidator()), 'utf8');
16
- await writeFile(resolve(temporary, 'schema.json'), `${JSON.stringify({ ...schema, transports: config.transports ?? ['data-api', 'odata'] }, null, 2)}\n`, 'utf8');
23
+ await writeFile(resolve(temporary, 'client.ts'), renderClient(databases), 'utf8');
24
+ const manifest = { version: 1, databases: Object.fromEntries(databases.map(db => [db.key, { ...db.schema, provider: db.config.provider, transports: db.config.transports ?? ['data-api', 'odata'] }])) };
25
+ await writeFile(resolve(temporary, 'schema.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
17
26
  await mkdir(dirname(output), { recursive: true });
18
27
  await rm(output, { recursive: true, force: true });
19
28
  await rename(temporary, output);
20
- return { output, layouts: schema.layouts.length };
29
+ return { output, databases: databases.length, layouts: databases.reduce((sum, db) => sum + db.schema.layouts.length, 0), diagnostics };
21
30
  }
22
- function renderClient(schema, validator) {
23
- const interfaces = schema.layouts.map(renderLayout).join('\n\n');
24
- const structure = schema.layouts.map(layout => `${quote(layout.name)}: ${layoutTypeName(layout)}`).join(',\n ');
25
- const validators = schema.layouts.map(layout => `${quote(layout.name)}: {\n record: ${validator.renderRecord(layout)},\n metadata: ${renderMetadata(layout)}\n }`).join(',\n ');
26
- const properties = schema.layouts.map(layout => `${safeProperty(layout.clientName ?? layout.name)}: database.layout<${layoutTypeName(layout)}>(${quote(layout.name)})`).join(',\n ');
31
+ function defaultDiagnosticReporter(diagnostic) { console.warn(`[easyfm:typegen] warning [${diagnostic.database}${diagnostic.layout ? `/${diagnostic.layout}` : ''}${diagnostic.portal ? `/${diagnostic.portal}` : ''}]: ${diagnostic.message}`); }
32
+ function databaseEntries(config) {
33
+ if (config.databases && Object.keys(config.databases).length)
34
+ return Object.entries(config.databases);
35
+ if (!config.source)
36
+ throw new Error('Typegen config requires source or databases');
37
+ return [['database', { source: config.source, transports: config.transports, layouts: config.layouts, validator: config.validator }]];
38
+ }
39
+ function renderClient(databases) {
40
+ const imports = [...new Set(databases.flatMap(db => db.validator.imports))].join('\n');
41
+ const interfaces = databases.flatMap(db => db.schema.layouts.map(layout => renderLayout(db.key, layout))).join('\n\n');
42
+ const structures = databases.map(db => `export interface ${databaseType(db.key)} extends DatabaseStructure {\n layouts: {${db.schema.layouts.map(layout => `${quote(layout.name)}: ${layoutTypeName(db.key, layout)}`).join(',')}}\n}`).join('\n\n');
43
+ const schemas = databases.map(db => `${prop(db.key)}: {validator: ${db.validator.runtimeValidator}, layouts: {${db.schema.layouts.map(layout => `${quote(layout.name)}: {record: ${db.validator.renderRecord(layout)}, metadata: ${renderMetadata(layout)}}`).join(',')}}}`).join(',');
44
+ const factories = databases.map(db => `function create${typeId(db.key, 'Database')}Client (provider: DatabaseProvider) {\n const database = Database.create<${databaseType(db.key)}>({provider: withSchemaValidation(provider, easyFMSchemas.${prop(db.key)})})\n return {database, ${db.schema.layouts.map(layout => `${prop(layout.clientName ?? layout.name)}: database.layout<${layoutTypeName(db.key, layout)}>(${quote(layout.name)})`).join(',')}}\n}`).join('\n\n');
27
45
  return `/* Generated by easyfm typegen. Do not edit. */
28
- import {Database, withSchemaValidation, ${validator.runtimeValidator}, type DatabaseProvider, type DatabaseStructure, type LayoutInterface, type Portal, type ReadonlyField, type TextField, type NumberField, type DateField, type TimeField, type TimeStampField, type ContainerField} from '@jd-data-limited/easy-fm'
29
- ${validator.imports.join('\n')}
30
-
46
+ import {Database, withSchemaValidation, zodValidator, type DatabaseProvider, type DatabaseStructure, type LayoutInterface, type Portal, type ReadonlyField, type TextField, type NumberField, type DateField, type TimeField, type TimeStampField, type ContainerField} from '@jd-data-limited/easy-fm'
47
+ ${imports}
31
48
  ${interfaces}
32
-
33
- export interface GeneratedDatabase extends DatabaseStructure {
34
- layouts: {
35
- ${structure}
36
- }
37
- }
38
-
39
- export const easyFMSchema = {
40
- validator: ${validator.runtimeValidator},
41
- layouts: {
42
- ${validators}
43
- }
44
- } satisfies Parameters<typeof withSchemaValidation>[1]
45
-
46
- export function createEasyFMClient (provider: DatabaseProvider) {
47
- const database = Database.create<GeneratedDatabase>({provider: withSchemaValidation(provider, easyFMSchema)})
48
- return {
49
- database,
50
- ${properties}
51
- }
52
- }
49
+ ${structures}
50
+ export const easyFMSchemas = {${schemas}}
51
+ ${factories}
52
+ export function createEasyFMClient (providers: {${databases.map(db => `${prop(db.key)}: DatabaseProvider`).join(',')}}) { return {${databases.map(db => `${prop(db.key)}: create${typeId(db.key, 'Database')}Client(providers.${prop(db.key)})`).join(',')}} }
53
53
  `;
54
54
  }
55
- function renderLayout(layout) {
56
- const fields = layout.fields.map(field => `${quote(field.name)}: ${fieldType(field)}`).join('\n ');
57
- const portals = Object.entries(layout.portals).map(([name, portalFields]) => `${quote(name)}: Portal<{\n ${portalFields.map(field => `${quote(field.name)}: ${fieldType(field)}`).join('\n ')}\n }>`).join('\n ');
58
- return `export interface ${layoutTypeName(layout)} extends LayoutInterface {
59
- fields: {
60
- ${fields}
61
- }
62
- portals: {
63
- ${portals}
64
- }
65
- }`;
66
- }
67
- function fieldType(field) {
68
- const type = { text: 'TextField', number: 'NumberField', date: 'DateField', time: 'TimeField', timeStamp: 'TimeStampField', container: 'ContainerField' }[field.result];
69
- return field.writable ? type : `ReadonlyField<${type}>`;
70
- }
71
- function renderMetadata(layout) {
72
- const fields = renderMetadataFields(layout.fields);
73
- const portals = Object.entries(layout.portals).map(([name, portalFields]) => `${quote(name)}: ${renderMetadataFields(portalFields)}`).join(', ');
74
- return `{fields: ${fields}, portals: {${portals}}}`;
75
- }
76
- function renderMetadataFields(fields) {
77
- return `{${fields.map(field => `${quote(field.name)}: {result: ${quote(field.result)}, type: ${quote(field.type)}}`).join(', ')}}`;
78
- }
79
- function layoutTypeName(layout) { return `${identifier(layout.clientName ?? layout.name, 'Layout')}Layout`; }
80
- function safeProperty(value) { return identifier(value, 'layout'); }
81
- function identifier(value, fallback) {
82
- const cleaned = value.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[^a-zA-Z_$]+/, '');
83
- return cleaned || fallback;
84
- }
55
+ function renderLayout(db, layout) { return `export interface ${layoutTypeName(db, layout)} extends LayoutInterface { fields: {${layout.fields.map(f => `${quote(f.name)}: ${fieldType(f)}`).join(',')}}; portals: {${Object.entries(layout.portals).map(([name, fields]) => `${quote(name)}: Portal<{${fields.map(f => `${quote(f.name)}: ${fieldType(f)}`).join(',')}}>`).join(',')}} }`; }
56
+ function fieldType(f) { const t = { text: 'TextField', number: 'NumberField', date: 'DateField', time: 'TimeField', timeStamp: 'TimeStampField', container: 'ContainerField' }[f.result]; return f.writable ? t : `ReadonlyField<${t}>`; }
57
+ function renderMetadata(layout) { return `{fields: ${metadataFields(layout.fields)}, portals: {${Object.entries(layout.portals).map(([name, fields]) => `${quote(name)}: ${metadataFields(fields)}`).join(',')}}}`; }
58
+ function metadataFields(fields) { return `{${fields.map(f => `${quote(f.name)}: {result: ${quote(f.result)}, type: ${quote(f.type)}}`).join(',')}}`; }
59
+ function databaseType(db) { return `${typeId(db, 'Database')}Database`; }
60
+ function layoutTypeName(db, layout) { return `${typeId(db, 'Database')}${typeId(layout.clientName ?? layout.name, 'Layout')}Layout`; }
61
+ function prop(value) { return id(value, 'item'); }
62
+ function typeId(value, fallback) { const valueId = id(value, fallback); return valueId[0].toUpperCase() + valueId.slice(1); }
63
+ function id(value, fallback) { const clean = value.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[^a-zA-Z_$]+/, ''); return clean || fallback; }
85
64
  function quote(value) { return JSON.stringify(value); }
@@ -1,6 +1,6 @@
1
1
  export { generate } from './generate.js';
2
2
  export { dataApiSchemaSource } from './dataApiSchemaSource.js';
3
3
  export { zodTypegenValidator } from './zod.js';
4
- export { configExists, readEnvironment, writeDataApiInitialization, type DataApiInitAnswers } from './init.js';
4
+ export { configExists, readEnvironment, writeDataApiInitialization, writeMultiDatabaseInitialization, type DataApiInitAnswers, type MultiDatabaseInitAnswers } from './init.js';
5
5
  export { ensureZodInstalled, getZodInstallPlan, type InstallPlan } from './dependencies.js';
6
- export { defineTypegenConfig, type SchemaSource, type TypegenConfig, type TypegenField, type TypegenLayout, type TypegenSchema, type TypegenTransport, type TypegenValidator } from './types.js';
6
+ export { defineTypegenConfig, type SchemaSource, type TypegenConfig, type TypegenDatabaseConfig, type TypegenDiagnostic, type TypegenField, type TypegenLayout, type TypegenSchema, type TypegenTransport, type TypegenValidator } from './types.js';
@@ -1,6 +1,6 @@
1
1
  export { generate } from './generate.js';
2
2
  export { dataApiSchemaSource } from './dataApiSchemaSource.js';
3
3
  export { zodTypegenValidator } from './zod.js';
4
- export { configExists, readEnvironment, writeDataApiInitialization } from './init.js';
4
+ export { configExists, readEnvironment, writeDataApiInitialization, writeMultiDatabaseInitialization } from './init.js';
5
5
  export { ensureZodInstalled, getZodInstallPlan } from './dependencies.js';
6
6
  export { defineTypegenConfig } from './types.js';
@@ -7,7 +7,14 @@ export interface DataApiInitAnswers {
7
7
  password: string;
8
8
  output: string;
9
9
  }
10
+ export interface MultiDatabaseInitAnswers {
11
+ output: string;
12
+ databases: Array<DataApiInitAnswers & {
13
+ key: string;
14
+ }>;
15
+ }
10
16
  export declare function configExists(path: string): Promise<boolean>;
11
17
  /** Non-interactive write step, usable by alternate setup UIs. */
12
18
  export declare function writeDataApiInitialization(config: string, answers: DataApiInitAnswers): Promise<void>;
19
+ export declare function writeMultiDatabaseInitialization(config: string, answers: MultiDatabaseInitAnswers): Promise<void>;
13
20
  export declare function readEnvironment(path: string): Promise<Record<string, string>>;
@@ -27,6 +27,25 @@ export async function writeDataApiInitialization(config, answers) {
27
27
  await updateEnvironment(envPath, { FM_HOST: answers.hostname, FM_DATABASE: answers.database, FM_USERNAME: answers.username, FM_PASSWORD: answers.password });
28
28
  await ensureEnvironmentIgnored(directory);
29
29
  }
30
+ export async function writeMultiDatabaseInitialization(config, answers) {
31
+ const configPath = resolve(config);
32
+ const directory = dirname(configPath);
33
+ await mkdir(directory, { recursive: true });
34
+ const file = await open(configPath, 'wx');
35
+ try {
36
+ await file.writeFile(renderMultiConfig(answers), 'utf8');
37
+ }
38
+ finally {
39
+ await file.close();
40
+ }
41
+ const environment = {};
42
+ for (const db of answers.databases) {
43
+ const prefix = envPrefix(db.key);
44
+ Object.assign(environment, { [`${prefix}_HOST`]: db.hostname, [`${prefix}_DATABASE`]: db.database, [`${prefix}_USERNAME`]: db.username, [`${prefix}_PASSWORD`]: db.password });
45
+ }
46
+ await updateEnvironment(resolve(directory, '.env'), environment);
47
+ await ensureEnvironmentIgnored(directory);
48
+ }
30
49
  export async function readEnvironment(path) {
31
50
  try {
32
51
  const contents = await readFile(path, 'utf8');
@@ -60,6 +79,15 @@ export default defineTypegenConfig({
60
79
  })
61
80
  `;
62
81
  }
82
+ function renderMultiConfig(answers) {
83
+ const databases = answers.databases.map(db => {
84
+ const prefix = envPrefix(db.key);
85
+ const transports = db.transports === 'both' ? ['data-api', 'odata'] : [db.transports];
86
+ return `${JSON.stringify(db.key)}: {provider: 'data-api/odata', transports: ${JSON.stringify(transports)}, source: dataApiSchemaSource({hostname: required('${prefix}_HOST'), database: required('${prefix}_DATABASE'), credentials: {method: 'filemaker', username: required('${prefix}_USERNAME'), password: required('${prefix}_PASSWORD')}, externalSources: []})}`;
87
+ }).join(',\n ');
88
+ return `import {dataApiSchemaSource, defineTypegenConfig, zodTypegenValidator} from '@jd-data-limited/easy-fm/typegen'\n\nfunction required(name) { const value = process.env[name]; if (!value) throw new Error(\`Missing required environment variable \${name}\`); return value }\n\nexport default defineTypegenConfig({output: ${JSON.stringify(answers.output)}, validator: zodTypegenValidator(), databases: {\n ${databases}\n }})\n`;
89
+ }
90
+ function envPrefix(key) { return `FM_${key.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase()}`; }
63
91
  async function updateEnvironment(path, values) {
64
92
  let contents = '';
65
93
  try {
@@ -12,9 +12,16 @@ export interface TypegenLayout {
12
12
  fields: TypegenField[];
13
13
  portals: Record<string, TypegenField[]>;
14
14
  }
15
+ export interface TypegenDiagnostic {
16
+ level: 'warning';
17
+ message: string;
18
+ layout?: string;
19
+ portal?: string;
20
+ }
15
21
  export interface TypegenSchema {
16
22
  version: 1;
17
23
  layouts: TypegenLayout[];
24
+ diagnostics?: TypegenDiagnostic[];
18
25
  }
19
26
  export type TypegenTransport = 'data-api' | 'odata';
20
27
  export interface SchemaSource {
@@ -28,13 +35,25 @@ export interface TypegenValidator {
28
35
  renderRecord(layout: TypegenLayout): string;
29
36
  }
30
37
  export interface TypegenConfig {
31
- source: SchemaSource;
32
38
  output: string;
39
+ /** Single-database shorthand. */
40
+ source?: SchemaSource;
33
41
  /** Placeholder describing intended runtime transports. */
34
42
  transports?: TypegenTransport[];
35
43
  /** Restrict output to these FileMaker layout names. */
36
44
  layouts?: string[];
37
45
  /** Defaults to the built-in strict Zod validator. */
38
46
  validator?: TypegenValidator;
47
+ databases?: Record<string, TypegenDatabaseConfig>;
48
+ onDiagnostic?: (diagnostic: TypegenDiagnostic & {
49
+ database: string;
50
+ }) => void;
51
+ }
52
+ export interface TypegenDatabaseConfig {
53
+ source: SchemaSource;
54
+ provider?: string;
55
+ transports?: TypegenTransport[];
56
+ layouts?: string[];
57
+ validator?: TypegenValidator;
39
58
  }
40
59
  export declare function defineTypegenConfig(config: TypegenConfig): TypegenConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jd-data-limited/easy-fm",
3
- "version": "5.0.0-beta.4",
3
+ "version": "5.0.0-beta.7",
4
4
  "dependencies": {
5
5
  "chalk": "^5.6.2",
6
6
  "commander": "^15.0.0",