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

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.
@@ -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
+ } }
@@ -2,4 +2,5 @@ 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;
5
6
  }>;
@@ -2,84 +2,56 @@ 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 databases = await Promise.all(databaseEntries(config).map(async ([key, databaseConfig]) => {
6
+ const discovered = await databaseConfig.source.introspect();
7
+ const selected = databaseConfig.layouts ? discovered.layouts.filter(layout => databaseConfig.layouts.includes(layout.name)) : discovered.layouts;
8
+ const missing = databaseConfig.layouts?.filter(name => !selected.some(layout => layout.name === name)) ?? [];
9
+ if (missing.length)
10
+ throw new Error(`Layouts not found in database ${key}: ${missing.join(', ')}`);
11
+ return { key, config: databaseConfig, schema: { version: 1, layouts: [...selected].sort((a, b) => a.name.localeCompare(b.name)) }, validator: databaseConfig.validator ?? config.validator ?? zodTypegenValidator() };
12
+ }));
11
13
  const output = resolve(config.output);
12
14
  const temporary = `${output}.tmp-${process.pid}`;
13
15
  await rm(temporary, { recursive: true, force: true });
14
16
  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');
17
+ await writeFile(resolve(temporary, 'client.ts'), renderClient(databases), 'utf8');
18
+ 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'] }])) };
19
+ await writeFile(resolve(temporary, 'schema.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
17
20
  await mkdir(dirname(output), { recursive: true });
18
21
  await rm(output, { recursive: true, force: true });
19
22
  await rename(temporary, output);
20
- return { output, layouts: schema.layouts.length };
21
- }
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 ');
23
+ return { output, databases: databases.length, layouts: databases.reduce((sum, db) => sum + db.schema.layouts.length, 0) };
24
+ }
25
+ function databaseEntries(config) {
26
+ if (config.databases && Object.keys(config.databases).length)
27
+ return Object.entries(config.databases);
28
+ if (!config.source)
29
+ throw new Error('Typegen config requires source or databases');
30
+ return [['database', { source: config.source, transports: config.transports, layouts: config.layouts, validator: config.validator }]];
31
+ }
32
+ function renderClient(databases) {
33
+ const imports = [...new Set(databases.flatMap(db => db.validator.imports))].join('\n');
34
+ const interfaces = databases.flatMap(db => db.schema.layouts.map(layout => renderLayout(db.key, layout))).join('\n\n');
35
+ 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');
36
+ 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(',');
37
+ 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
38
  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
-
39
+ 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'
40
+ ${imports}
31
41
  ${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
- }
42
+ ${structures}
43
+ export const easyFMSchemas = {${schemas}}
44
+ ${factories}
45
+ 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
46
  `;
54
47
  }
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
- }
48
+ 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(',')}} }`; }
49
+ 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}>`; }
50
+ function renderMetadata(layout) { return `{fields: ${metadataFields(layout.fields)}, portals: {${Object.entries(layout.portals).map(([name, fields]) => `${quote(name)}: ${metadataFields(fields)}`).join(',')}}}`; }
51
+ function metadataFields(fields) { return `{${fields.map(f => `${quote(f.name)}: {result: ${quote(f.result)}, type: ${quote(f.type)}}`).join(',')}}`; }
52
+ function databaseType(db) { return `${typeId(db, 'Database')}Database`; }
53
+ function layoutTypeName(db, layout) { return `${typeId(db, 'Database')}${typeId(layout.clientName ?? layout.name, 'Layout')}Layout`; }
54
+ function prop(value) { return id(value, 'item'); }
55
+ function typeId(value, fallback) { const valueId = id(value, fallback); return valueId[0].toUpperCase() + valueId.slice(1); }
56
+ function id(value, fallback) { const clean = value.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[^a-zA-Z_$]+/, ''); return clean || fallback; }
85
57
  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 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 {
@@ -28,13 +28,22 @@ export interface TypegenValidator {
28
28
  renderRecord(layout: TypegenLayout): string;
29
29
  }
30
30
  export interface TypegenConfig {
31
- source: SchemaSource;
32
31
  output: string;
32
+ /** Single-database shorthand. */
33
+ source?: SchemaSource;
33
34
  /** Placeholder describing intended runtime transports. */
34
35
  transports?: TypegenTransport[];
35
36
  /** Restrict output to these FileMaker layout names. */
36
37
  layouts?: string[];
37
38
  /** Defaults to the built-in strict Zod validator. */
38
39
  validator?: TypegenValidator;
40
+ databases?: Record<string, TypegenDatabaseConfig>;
41
+ }
42
+ export interface TypegenDatabaseConfig {
43
+ source: SchemaSource;
44
+ provider?: string;
45
+ transports?: TypegenTransport[];
46
+ layouts?: string[];
47
+ validator?: TypegenValidator;
39
48
  }
40
49
  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.5",
4
4
  "dependencies": {
5
5
  "chalk": "^5.6.2",
6
6
  "commander": "^15.0.0",