Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ Alternatively, if you are using pure JavaScript, you can generate JSDoc instead:

When you `harper dev`, it will generate types based on the schema that's actually in your Harper database. If you change the schema, we will automatically regenerate the types for you.

## Options

| Option | Default | Description |
| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `globalTypes` | — | Path to write the ambient module augmentation (`.d.ts`). |
| `schemaTypes` | — | Path to write the exported interfaces (`.ts`). |
| `jsdoc` | — | Path to write JSDoc types instead, for pure JavaScript projects. |
| `module` | `harper` | The runtime package to augment. Harper 5.x apps import from `harper`; set this to `harperdb` for Harper 4.x apps. |
| `includeDatabases` | (all) | List of database names to generate types for. When set, only matching databases are emitted. Supports `*` as a wildcard, e.g. `metrics-*`. |
| `excludeDatabases` | (none) | List of database names to skip. Useful on a shared instance to keep other applications' databases out of your generated types. Supports `*` as a wildcard. |

`includeDatabases`/`excludeDatabases` scope generation to your application's own
databases. On a shared local instance, codegen otherwise sees every database on
the instance — including ones belonging to other projects — so scoping keeps
your generated types focused:

```yaml
'@harperfast/schema-codegen':
package: '@harperfast/schema-codegen'
globalTypes: 'schemas/globalTypes.d.ts'
schemaTypes: 'schemas/types.ts'
includeDatabases:
- data
- 'metrics-*'
```

## Example

For example, here's a tracks.graphql schema:
Expand Down Expand Up @@ -70,10 +96,10 @@ An ambient declaration will also be generated in globalTypes.d.ts to enhance the
Manual changes will be lost!
> harper dev .
*/
import type { Table } from 'harperdb';
import type { Table } from 'harper';
import type { Track } from './types.ts';

declare module 'harperdb' {
declare module 'harper' {
export const tables: {
Tracks: { new (...args: any[]): Table<Track> };
};
Expand Down
16 changes: 12 additions & 4 deletions extensionModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export async function handleApplication(scope) {
const globalTypes = /** @type {string} */ (scope.options.get(['globalTypes']));
const schemaTypes = /** @type {string} */ (scope.options.get(['schemaTypes']));
const jsdoc = /** @type {string | undefined} */ (scope.options.get(['jsdoc']));
const moduleName = /** @type {string | undefined} */ (scope.options.get(['module']));
const includeDatabases = /** @type {string[] | undefined} */ (
scope.options.get(['includeDatabases'])
);
const excludeDatabases = /** @type {string[] | undefined} */ (
scope.options.get(['excludeDatabases'])
);
const options = { module: moduleName, includeDatabases, excludeDatabases };

if (shouldWatch) {
scope.on('close', scopeClosed);
Expand All @@ -29,7 +37,7 @@ export async function handleApplication(scope) {
// Do not await this.
delay(5000).then(() => {
// Initial generation
regenerateAll(globalTypes, schemaTypes, jsdoc);
regenerateAll(globalTypes, schemaTypes, jsdoc, options);

if (shouldWatch) {
// Watch for schema/database changes via events
Expand All @@ -40,15 +48,15 @@ export async function handleApplication(scope) {
});

function updateTable() {
regenerateAll(globalTypes, schemaTypes, jsdoc);
regenerateAll(globalTypes, schemaTypes, jsdoc, options);
}

function dropTable() {
regenerateAll(globalTypes, schemaTypes, jsdoc);
regenerateAll(globalTypes, schemaTypes, jsdoc, options);
}

function dropDatabase() {
regenerateAll(globalTypes, schemaTypes, jsdoc);
regenerateAll(globalTypes, schemaTypes, jsdoc, options);
}

function scopeClosed() {
Expand Down
11 changes: 10 additions & 1 deletion utils/collectTables.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
/** @typedef {import('harperdb').Table} Table */
import { shouldIncludeDatabase } from './databaseFilter.js';

/**
* Collects every table from the live `databases` object, optionally scoped to a
* subset of databases. Without options, every database on the instance is
* included — on a shared instance that can pull in databases from other
* applications, so pass `include`/`exclude` to scope output to this app.
* @param {{ include?: string[], exclude?: string[] }} [options]
* @returns {(Table & { databaseName: string })[]}
*/
export function collectTables() {
export function collectTables(options = {}) {
/** @type {(Table & { databaseName: string })[]} */
const tablesList = [];
for (const databaseName of Object.keys(databases || {})) {
if (!shouldIncludeDatabase(databaseName, options)) {
continue;
}
const tables = databases[databaseName];
for (const tableName of Object.keys(tables || {})) {
const TableClass = tables[tableName];
Expand Down
40 changes: 40 additions & 0 deletions utils/databaseFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Matches a database name against a list of patterns. Each pattern is either an
* exact name or a glob using `*` as a wildcard for any run of characters, e.g.
* `metrics-*` matches both `metrics-github` and `metrics-jira`. Matching is
* case-sensitive and anchored (the whole name must match).
* @param {string} name
* @param {(string | number)[]} patterns Patterns come from YAML config, so a
* purely numeric, unquoted name (e.g. `101`) arrives as a number; each pattern
* is coerced to a string before matching.
* @returns {boolean}
*/
export function matchesDatabase(name, patterns) {
return patterns.some((pattern) => {
// escape regex metacharacters, then turn the wildcard back into `.*`
const source = String(pattern)
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\\*/g, '.*');
return new RegExp(`^${source}$`).test(name);
});
Comment thread
dawsontoth marked this conversation as resolved.
}

/**
* Decides whether a database should be included in codegen given optional
* include/exclude lists. An empty or absent `include` list includes every
* database; a non-empty `include` list restricts output to matching databases.
* `exclude` then removes any matching database. Exclude wins over include.
* @param {string} name
* @param {{ include?: string[], exclude?: string[] }} [options]
* @returns {boolean}
*/
export function shouldIncludeDatabase(name, options = {}) {
const { include, exclude } = options;
if (Array.isArray(include) && include.length > 0 && !matchesDatabase(name, include)) {
return false;
}
if (Array.isArray(exclude) && exclude.length > 0 && matchesDatabase(name, exclude)) {
return false;
}
return true;
}
63 changes: 63 additions & 0 deletions utils/databaseFilter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { matchesDatabase, shouldIncludeDatabase } from './databaseFilter.js';

describe('matchesDatabase', () => {
it('matches exact names', () => {
expect(matchesDatabase('data', ['data'])).toBe(true);
expect(matchesDatabase('data', ['blog'])).toBe(false);
});

it('is anchored (no partial matches)', () => {
expect(matchesDatabase('metrics-github', ['metrics'])).toBe(false);
expect(matchesDatabase('metrics-github', ['github'])).toBe(false);
});

it('supports the * wildcard', () => {
expect(matchesDatabase('metrics-github', ['metrics-*'])).toBe(true);
expect(matchesDatabase('metrics-jira', ['metrics-*'])).toBe(true);
expect(matchesDatabase('billing', ['metrics-*'])).toBe(false);
expect(matchesDatabase('anything', ['*'])).toBe(true);
});

it('does not treat other regex metacharacters specially', () => {
// the dot must be a literal, not "any character"
expect(matchesDatabase('myXdb', ['my.db'])).toBe(false);
expect(matchesDatabase('my.db', ['my.db'])).toBe(true);
});

it('coerces numeric patterns from unquoted YAML without crashing', () => {
// an unquoted numeric database name in YAML parses as a number
expect(matchesDatabase('101', [101])).toBe(true);
expect(matchesDatabase('202', [101])).toBe(false);
});
});

describe('shouldIncludeDatabase', () => {
it('includes everything when no options are given', () => {
expect(shouldIncludeDatabase('anything')).toBe(true);
expect(shouldIncludeDatabase('anything', {})).toBe(true);
});

it('restricts to the include list when provided', () => {
expect(shouldIncludeDatabase('data', { include: ['data', 'blog'] })).toBe(true);
expect(shouldIncludeDatabase('other', { include: ['data', 'blog'] })).toBe(false);
});

it('treats an empty include list as "include everything"', () => {
expect(shouldIncludeDatabase('anything', { include: [] })).toBe(true);
});

it('removes databases in the exclude list', () => {
expect(shouldIncludeDatabase('system', { exclude: ['system'] })).toBe(false);
expect(shouldIncludeDatabase('data', { exclude: ['system'] })).toBe(true);
});

it('lets exclude win over include', () => {
expect(
shouldIncludeDatabase('metrics-internal', {
include: ['metrics-*'],
exclude: ['metrics-internal'],
}),
).toBe(false);
});
});
22 changes: 22 additions & 0 deletions utils/dbTypePrefix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { toIdentifier } from './toIdentifier.js';

/**
* Builds the prefix applied to generated *type names* for tables that live in a
* non-default database. Tables in the default 'data' database are not
* namespaced, so they receive no prefix.
*
* The database name is passed through {@link toIdentifier} so that databases
* whose names are not valid identifiers still produce valid TypeScript type
* names, e.g. a "metrics-github" database yields the prefix "metricsGithub_"
* (and thus the type "metricsGithub_Repository" rather than the invalid
* "metrics-github_Repository", which TypeScript parses as a subtraction).
*
* This is for type-name positions only. Runtime object keys (the actual
* database and table names on the `databases` object) must be preserved
* verbatim — quote those with {@link safeKey} instead.
* @param {string | undefined} databaseName
* @returns {string}
*/
export function dbTypePrefix(databaseName) {
return databaseName && databaseName !== 'data' ? `${toIdentifier(databaseName)}_` : '';
}
32 changes: 32 additions & 0 deletions utils/dbTypePrefix.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { dbTypePrefix } from './dbTypePrefix.js';

describe('dbTypePrefix', () => {
it('returns an empty prefix for the default data database', () => {
expect(dbTypePrefix('data')).toBe('');
});

it('returns an empty prefix when the database name is missing', () => {
expect(dbTypePrefix(undefined)).toBe('');
expect(dbTypePrefix('')).toBe('');
});

it('namespaces a valid database name unchanged', () => {
expect(dbTypePrefix('blog')).toBe('blog_');
});

it('sanitizes a hyphenated database name into a valid identifier prefix', () => {
expect(dbTypePrefix('metrics-github')).toBe('metricsGithub_');
});

it('sanitizes a database name that starts with a digit', () => {
expect(dbTypePrefix('9lives')).toBe('_9lives_');
});

it('always produces an identifier-safe prefix', () => {
const names = ['metrics-github', 'my.db', 'weird name', '9lives'];
for (const name of names) {
expect(dbTypePrefix(name)).toMatch(/^[a-zA-Z_$][a-zA-Z0-9_$]*_$/);
}
});
});
4 changes: 2 additions & 2 deletions utils/generateInterface.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** @typedef {import('harperdb').Table} Table */
import { dbTypePrefix } from './dbTypePrefix.js';
import { escapeSingleQuoted } from './escapeSingleQuoted.js';
import { isNullable } from './isNullable.js';
import { mapType } from './mapType.js';
Expand All @@ -12,8 +13,7 @@ import { toIdentifier } from './toIdentifier.js';
export function generateInterface(table) {
const pluralRaw = toIdentifier(table.tableName);
const singularRaw = toIdentifier(singularize(table.tableName));
const dbPrefix =
table.databaseName && table.databaseName !== 'data' ? `${table.databaseName}_` : '';
const dbPrefix = dbTypePrefix(table.databaseName);
const plural = `${dbPrefix}${pluralRaw}`;
const singular = `${dbPrefix}${singularRaw}`;
const isDifferent = plural !== singular;
Expand Down
19 changes: 19 additions & 0 deletions utils/generateInterface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,23 @@ describe('generateInterface', () => {
expect(result).toContain('export type mydb_auditLogs = mydb_auditLog[];');
expect(result).not.toContain('audit-log');
});

it('should produce valid identifiers when the database name contains dashes', () => {
const table = {
tableName: 'Repository',
databaseName: 'metrics-github',
attributes: [{ name: 'id', type: 'ID', isPrimaryKey: true }],
};
const result = generateInterface(table);
// the hyphenated database name must be sanitized in every type position;
// "metrics-github_Repository" would parse as a subtraction
expect(result).toContain('export interface metricsGithub_Repository {');
expect(result).toContain(
"export type metricsGithub_NewRepository = Omit<metricsGithub_Repository, 'id'>;",
);
expect(result).toContain(
'export type metricsGithub_RepositoryRecords = metricsGithub_Repository[];',
);
expect(result).not.toContain('metrics-github_Repository');
});
});
4 changes: 2 additions & 2 deletions utils/generateJSDoc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** @typedef {import('harperdb').Table} Table */
import { dbTypePrefix } from './dbTypePrefix.js';
import { escapeSingleQuoted } from './escapeSingleQuoted.js';
import { isNullable } from './isNullable.js';
import { mapType } from './mapType.js';
Expand All @@ -21,8 +22,7 @@ import { toIdentifier } from './toIdentifier.js';
export function generateJSDoc(table) {
const pluralRaw = toIdentifier(table.tableName);
const singularRaw = toIdentifier(singularize(table.tableName));
const dbPrefix =
table.databaseName && table.databaseName !== 'data' ? `${table.databaseName}_` : '';
const dbPrefix = dbTypePrefix(table.databaseName);
const plural = `${dbPrefix}${pluralRaw}`;
const singular = `${dbPrefix}${singularRaw}`;
const isDifferent = plural !== singular;
Expand Down
14 changes: 14 additions & 0 deletions utils/generateJSDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,18 @@ describe('generateJSDoc', () => {
expect(code).toContain("@typedef {Omit<blogPost, 'id'>} NewblogPost");
expect(code).not.toContain('blog-post');
});

it('should produce valid identifiers when the database name contains dashes', () => {
const table = {
tableName: 'Repository',
databaseName: 'metrics-github',
attributes: [{ name: 'id', type: 'ID', isPrimaryKey: true }],
};
const code = generateJSDoc(table);
expect(code).toContain('@typedef {Object} metricsGithub_Repository');
expect(code).toContain(
"@typedef {Omit<metricsGithub_Repository, 'id'>} metricsGithub_NewRepository",
);
expect(code).not.toContain('metrics-github_Repository');
});
});
9 changes: 4 additions & 5 deletions utils/generateJSDocFromTables.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/** @typedef {import('harperdb').Table} Table */
/** @import { TableMeta } from './tableMeta.js' */
import { dbTypePrefix } from './dbTypePrefix.js';
import { generateJSDoc } from './generateJSDoc.js';
import { singularize } from './singularize.js';
import { toIdentifier } from './toIdentifier.js';

/**
* @param {(Table & { databaseName: string })[]} tablesInput
Expand All @@ -18,11 +20,8 @@ export function generateJSDocFromTables(tablesInput, label = 'HarperDB schemas')

for (const table of tablesInput) {
jsCode += generateJSDoc(table);
const dbPrefix =
table.databaseName && table.databaseName !== 'data' ? `${table.databaseName}_` : '';
const plural = `${dbPrefix}${table.tableName}`;
const singular = `${dbPrefix}${singularize(table.tableName)}`;
tables.push({ plural, singular, databaseName: table.databaseName });
const singular = `${dbTypePrefix(table.databaseName)}${toIdentifier(singularize(table.tableName))}`;
tables.push({ tableName: table.tableName, singular, databaseName: table.databaseName });
}

return { jsCode, tables };
Expand Down
8 changes: 3 additions & 5 deletions utils/generateTS.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @typedef {import('harperdb').Table} Table */
/** @import { TableMeta } from './tableMeta.js' */
import { dbTypePrefix } from './dbTypePrefix.js';
import { generateInterface } from './generateInterface.js';
import { singularize } from './singularize.js';
import { toIdentifier } from './toIdentifier.js';
Expand All @@ -20,11 +21,8 @@ export function generateTSFromTables(tablesInput, label = 'HarperDB schemas') {

for (const table of tablesInput) {
tsCode += generateInterface(table);
const dbPrefix =
table.databaseName && table.databaseName !== 'data' ? `${table.databaseName}_` : '';
const plural = `${dbPrefix}${table.tableName}`;
const singular = `${dbPrefix}${toIdentifier(singularize(table.tableName))}`;
tables.push({ plural, singular, databaseName: table.databaseName });
const singular = `${dbTypePrefix(table.databaseName)}${toIdentifier(singularize(table.tableName))}`;
tables.push({ tableName: table.tableName, singular, databaseName: table.databaseName });
}

return { tsCode, tables };
Expand Down
Loading