├── .github ├── CODEOWNERS ├── logo.png ├── icon-white.png ├── FUNDING.yml ├── codecov.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── publish_nest.yml │ ├── publish_docker.yml │ ├── scheduled.yml │ └── ci.yml ├── pull_request_template.md ├── CONTRIBUTING.md ├── bw-small.svg └── CODE_OF_CONDUCT.md ├── tests ├── image │ └── .gitignore ├── integration │ ├── update_timestamps │ │ ├── .gitignore │ │ ├── config │ │ │ ├── sqlite.config.ts │ │ │ ├── mysql.config.ts │ │ │ └── pg.config.ts │ │ └── update_timestamps.test.ts │ └── cli │ │ ├── mysql1 │ │ ├── seed.ts │ │ └── 20210508125213_test2.ts │ │ ├── sqlite1 │ │ ├── seed.ts │ │ └── 20210508125213_test2.ts │ │ ├── pg1 │ │ ├── seed.ts │ │ └── 20210508125213_test2.ts │ │ ├── config │ │ ├── sqlite.config.ts │ │ ├── mysql.config.ts │ │ ├── pg.config.ts │ │ └── cli.config.ts │ │ ├── mysql2 │ │ ├── 20210508115213_test1.ts │ │ └── 20210508135213_test3.ts │ │ ├── sqlite2 │ │ ├── 20210508115213_test1.ts │ │ └── 20210508135213_test3.ts │ │ ├── pg2 │ │ ├── 20210508115213_test1.ts │ │ └── 20210508135213_test3.ts │ │ └── cli.test.ts └── unit │ ├── utils.test.ts │ └── templates.test.ts ├── .dockerignore ├── helpers ├── commons.ts ├── get_image_tags.ts └── prepare_release.ts ├── examples ├── dockerfile-extended.dockerfile ├── config-sqlite.ts ├── config-remote-migration-files.ts ├── config-custom-templates.ts ├── seed.ts ├── config-postgres-connection-string.ts ├── migration.ts ├── seed-template.ts ├── config-mysql.ts ├── config-postgres.ts ├── migration-template.ts ├── abstract-classes-extended.ts ├── README.md ├── config-remote-migration-files-github-api.ts └── migration-dex.ts ├── image ├── Dockerfile └── README.md ├── wrappers ├── AbstractSeed.ts └── AbstractMigration.ts ├── mod.ts ├── cli ├── errors.ts ├── utils.ts ├── templates.ts ├── commands.ts └── state.ts ├── clients ├── ClientMySQL55.ts ├── ClientSQLite.ts ├── ClientPostgreSQL.ts ├── ClientMySQL.ts └── AbstractClient.ts ├── egg.json ├── deno.json ├── LICENSE ├── deps.ts ├── consts.ts ├── Makefile ├── types.ts ├── CHANGELOG.md ├── cli.ts ├── .gitignore └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @halvardssm -------------------------------------------------------------------------------- /tests/image/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /tests/integration/update_timestamps/.gitignore: -------------------------------------------------------------------------------- 1 | *-test.ts -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halvardssm/deno-nessie/HEAD/.github/logo.png -------------------------------------------------------------------------------- /.github/icon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halvardssm/deno-nessie/HEAD/.github/icon-white.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | !cli 3 | !clients 4 | !wrappers 5 | !cli.ts 6 | !consts.ts 7 | !deps.ts 8 | !mod.ts 9 | !types.ts -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: halvardssm 4 | ko_fi: halvardm 5 | -------------------------------------------------------------------------------- /helpers/commons.ts: -------------------------------------------------------------------------------- 1 | export const REG_EXP_VERSION = /^\d+\.\d+\.\d+(-rc\d+)?$/; 2 | export const REG_EXP_VERSION_STABLE = /^\d+\.\d+\.\d+$/; 3 | export const REG_EXP_VERSION_NEXT = /^\d+\.\d+\.\d+-rc\d+$/; 4 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | codecov: 3 | require_ci_to_pass: true 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | informational: true 9 | ignore: 10 | - examples 11 | -------------------------------------------------------------------------------- /examples/dockerfile-extended.dockerfile: -------------------------------------------------------------------------------- 1 | FROM halvardm/nessie:latest 2 | 3 | # Uncomment this line if you migrations and config file is dependend on a deps.ts file 4 | # COPY deps.ts . 5 | 6 | COPY db . 7 | COPY nessie.config.ts . 8 | -------------------------------------------------------------------------------- /tests/integration/cli/mysql1/seed.ts: -------------------------------------------------------------------------------- 1 | import { AbstractSeed, ClientMySQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractSeed { 4 | async run({ dialect }: Info): Promise { 5 | await this.client.query("INSERT INTO testTable1 VALUES (1234)"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/integration/cli/sqlite1/seed.ts: -------------------------------------------------------------------------------- 1 | import { AbstractSeed, ClientSQLite, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractSeed { 4 | async run({ dialect }: Info): Promise { 5 | await this.client.query("INSERT INTO testTable1 VALUES (1234)"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/config-sqlite.ts: -------------------------------------------------------------------------------- 1 | import { ClientSQLite, NessieConfig } from "https://deno.land/x/nessie/mod.ts"; 2 | 3 | const config: NessieConfig = { 4 | client: new ClientSQLite("./sqlite.db"), 5 | migrationFolders: ["./db/migrations"], 6 | seedFolders: ["./db/seeds"], 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /tests/integration/cli/pg1/seed.ts: -------------------------------------------------------------------------------- 1 | import { AbstractSeed, ClientPostgreSQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractSeed { 4 | async run({ dialect }: Info): Promise { 5 | await this.client.queryArray("INSERT INTO testTable1 VALUES (1234)"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/config-remote-migration-files.ts: -------------------------------------------------------------------------------- 1 | import { ClientSQLite, NessieConfig } from "https://deno.land/x/nessie/mod.ts"; 2 | 3 | const config: NessieConfig = { 4 | client: new ClientSQLite("sqlite.db"), 5 | additionalMigrationFiles: ["https://example.com/some_migration_file.ts"], 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /image/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG DENO_VERSION=latest 2 | 3 | FROM denoland/deno:${DENO_VERSION} 4 | 5 | WORKDIR /opt/lib/nessie 6 | COPY . . 7 | 8 | RUN deno install -A --unstable --name nessie cli.ts 9 | RUN deno cache --unstable mod.ts 10 | 11 | WORKDIR /nessie 12 | 13 | VOLUME ["/nessie"] 14 | 15 | ENTRYPOINT [ "nessie" ] 16 | -------------------------------------------------------------------------------- /examples/config-custom-templates.ts: -------------------------------------------------------------------------------- 1 | import { ClientSQLite, NessieConfig } from "https://deno.land/x/nessie/mod.ts"; 2 | 3 | const config: NessieConfig = { 4 | client: new ClientSQLite("sqlite.db"), 5 | migrationTemplate: "./migration-template.ts", 6 | seedTemplate: "./seed-template.ts", 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /examples/seed.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractSeed, 3 | ClientPostgreSQL, 4 | Info, 5 | } from "https://deno.land/x/nessie/mod.ts"; 6 | export default class extends AbstractSeed { 7 | async run({ dialect }: Info): Promise { 8 | await this.client.queryArray("INSERT INTO table1 VALUES (1234)"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/halvardssm/deno-nessie/discussions 5 | about: Please ask and answer questions here. 6 | - name: Chat 7 | url: https://discord.gg/8WXfG2tvfr 8 | about: Join our discord channel which we are sharing with DenoDB -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[IDEA]: " 4 | labels: [idea] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: The Idea 9 | description: Add your idea, and be as descriptive as possible. 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /tests/integration/update_timestamps/config/sqlite.config.ts: -------------------------------------------------------------------------------- 1 | import { ClientSQLite } from "../../../../mod.ts"; 2 | 3 | export const dbConnection = "./tests/data/sqlite.db"; 4 | 5 | export default { 6 | client: new ClientSQLite(dbConnection), 7 | migrationFolders: ["./tests/integration/update_timestamps"], 8 | seedFolders: ["./tests/integration/update_timestamps"], 9 | }; 10 | -------------------------------------------------------------------------------- /examples/config-postgres-connection-string.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientPostgreSQL, 3 | NessieConfig, 4 | } from "https://deno.land/x/nessie/mod.ts"; 5 | 6 | const config: NessieConfig = { 7 | client: new ClientPostgreSQL("postgresql://root:pwd@localhost/nessie"), 8 | migrationFolders: ["./db/migrations"], 9 | seedFolders: ["./db/seeds"], 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /tests/integration/cli/config/sqlite.config.ts: -------------------------------------------------------------------------------- 1 | import { ClientSQLite } from "../../../../mod.ts"; 2 | 3 | export default { 4 | client: new ClientSQLite("./tests/data/sqlite.db"), 5 | migrationFolders: [ 6 | "./tests/integration/cli/sqlite1", 7 | "./tests/integration/cli/sqlite2", 8 | ], 9 | seedFolders: [ 10 | "./tests/integration/cli/sqlite1", 11 | "./tests/integration/cli/sqlite2", 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /tests/integration/cli/mysql1/20210508125213_test2.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientMySQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.query("CREATE TABLE testTable2 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.query("DROP TABLE testTable2"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/mysql2/20210508115213_test1.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientMySQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.query("CREATE TABLE testTable1 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.query("DROP TABLE testTable1"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/mysql2/20210508135213_test3.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientMySQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.query("CREATE TABLE testTable3 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.query("DROP TABLE testTable3"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/sqlite1/20210508125213_test2.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientSQLite, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.query("CREATE TABLE testTable2 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.query("DROP TABLE testTable2"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/sqlite2/20210508115213_test1.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientSQLite, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.query("CREATE TABLE testTable1 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.query("DROP TABLE testTable1"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/sqlite2/20210508135213_test3.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientSQLite, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.query("CREATE TABLE testTable3 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.query("DROP TABLE testTable3"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/update_timestamps/config/mysql.config.ts: -------------------------------------------------------------------------------- 1 | import { ClientMySQL } from "../../../../mod.ts"; 2 | 3 | export const dbConnection = { 4 | "hostname": "0.0.0.0", 5 | "port": 5001, 6 | "username": "root", 7 | "db": "nessie", 8 | }; 9 | 10 | export default { 11 | client: new ClientMySQL(dbConnection), 12 | migrationFolders: ["./tests/integration/update_timestamps"], 13 | seedFolders: ["./tests/integration/update_timestamps"], 14 | }; 15 | -------------------------------------------------------------------------------- /tests/integration/cli/pg1/20210508125213_test2.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientPostgreSQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.queryArray("CREATE TABLE testTable2 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.queryArray("DROP TABLE testTable2"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/pg2/20210508115213_test1.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientPostgreSQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.queryArray("CREATE TABLE testTable1 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.queryArray("DROP TABLE testTable1"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/cli/pg2/20210508135213_test3.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMigration, ClientPostgreSQL, Info } from "../../../../mod.ts"; 2 | 3 | export default class extends AbstractMigration { 4 | async up({ dialect }: Info): Promise { 5 | await this.client.queryArray("CREATE TABLE testTable3 (id int)"); 6 | } 7 | 8 | async down({ dialect }: Info): Promise { 9 | await this.client.queryArray("DROP TABLE testTable3"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/migration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractMigration, 3 | ClientPostgreSQL, 4 | Info, 5 | } from "https://deno.land/x/nessie/mod.ts"; 6 | 7 | export default class extends AbstractMigration { 8 | async up({ dialect }: Info): Promise { 9 | await this.client.queryArray("CREATE TABLE table1 (id int)"); 10 | } 11 | 12 | async down({ dialect }: Info): Promise { 13 | await this.client.queryArray("DROP TABLE table1"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/integration/update_timestamps/config/pg.config.ts: -------------------------------------------------------------------------------- 1 | import { ClientPostgreSQL } from "../../../../mod.ts"; 2 | 3 | export const dbConnection = { 4 | "database": "nessie", 5 | "hostname": "0.0.0.0", 6 | "port": 5000, 7 | "user": "root", 8 | "password": "pwd", 9 | }; 10 | 11 | export default { 12 | client: new ClientPostgreSQL(dbConnection), 13 | migrationFolders: ["./tests/integration/update_timestamps"], 14 | seedFolders: ["./tests/integration/update_timestamps"], 15 | }; 16 | -------------------------------------------------------------------------------- /examples/seed-template.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientPostgreSQL, 3 | Info, 4 | //As this is a custom template, I want to lock the nessie version to 2.0.0 5 | } from "https://deno.land/x/nessie@2.0.0/mod.ts"; 6 | // I can import what I want to be used in this template 7 | import { CustomAbstractSeed } from "./abstract-classes-extended.ts"; 8 | 9 | export default class extends CustomAbstractSeed { 10 | async run({ dialect }: Info): Promise { 11 | this.someHelperFunction(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/integration/cli/config/mysql.config.ts: -------------------------------------------------------------------------------- 1 | import { ClientMySQL } from "../../../../mod.ts"; 2 | 3 | export default { 4 | client: new ClientMySQL({ 5 | hostname: "0.0.0.0", 6 | port: 5001, 7 | username: "root", 8 | db: "nessie", 9 | }), 10 | migrationFolders: [ 11 | "./tests/integration/cli/mysql1", 12 | "./tests/integration/cli/mysql2", 13 | ], 14 | seedFolders: [ 15 | "./tests/integration/cli/mysql1", 16 | "./tests/integration/cli/mysql2", 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /tests/integration/cli/config/pg.config.ts: -------------------------------------------------------------------------------- 1 | import { ClientPostgreSQL } from "../../../../mod.ts"; 2 | 3 | export default { 4 | client: new ClientPostgreSQL({ 5 | database: "nessie", 6 | hostname: "0.0.0.0", 7 | port: 5000, 8 | user: "root", 9 | password: "pwd", 10 | }), 11 | migrationFolders: [ 12 | "./tests/integration/cli/pg1", 13 | "./tests/integration/cli/pg2", 14 | ], 15 | seedFolders: [ 16 | "./tests/integration/cli/pg1", 17 | "./tests/integration/cli/pg2", 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /examples/config-mysql.ts: -------------------------------------------------------------------------------- 1 | import { ClientMySQL, NessieConfig } from "https://deno.land/x/nessie/mod.ts"; 2 | import type { ClientConfig } from "https://deno.land/x/mysql@v2.8.0/mod.ts"; 3 | 4 | const connectionConfig: ClientConfig = { 5 | hostname: "localhost", 6 | port: 3306, 7 | username: "root", 8 | db: "nessie", 9 | }; 10 | 11 | const config: NessieConfig = { 12 | client: new ClientMySQL(connectionConfig), 13 | migrationFolders: ["./db/migrations"], 14 | seedFolders: ["./db/seeds"], 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /wrappers/AbstractSeed.ts: -------------------------------------------------------------------------------- 1 | import type { Info } from "../types.ts"; 2 | import { AbstractClient } from "../clients/AbstractClient.ts"; 3 | 4 | export type AbstractSeedProps = { 5 | client: Client; 6 | }; 7 | 8 | // deno-lint-ignore no-explicit-any 9 | export abstract class AbstractSeed = any> { 10 | protected client: T["client"]; 11 | 12 | protected constructor({ client }: AbstractSeedProps) { 13 | this.client = client; 14 | } 15 | 16 | abstract run(exposedObject: Info): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./clients/AbstractClient.ts"; 2 | export * from "./clients/ClientMySQL.ts"; 3 | export * from "./clients/ClientMySQL55.ts"; 4 | export * from "./clients/ClientPostgreSQL.ts"; 5 | export * from "./clients/ClientSQLite.ts"; 6 | export * from "./types.ts"; 7 | export * from "./wrappers/AbstractMigration.ts"; 8 | export * from "./wrappers/AbstractSeed.ts"; 9 | export { MAX_FILE_NAME_LENGTH, REGEXP_MIGRATION_FILE_NAME } from "./consts.ts"; 10 | export { isMigrationFile } from "./cli/utils.ts"; 11 | export { NessieError } from "./cli/errors.ts"; 12 | -------------------------------------------------------------------------------- /cli/errors.ts: -------------------------------------------------------------------------------- 1 | // Code inspired by https://stackoverflow.com/a/67230709 2 | export class NessieError extends Error { 3 | constructor(message: string) { 4 | super(message); 5 | 6 | // needed for CustomError instanceof Error => true 7 | Object.setPrototypeOf(this, new.target.prototype); 8 | 9 | // Set the name 10 | this.name = this.constructor.name; 11 | 12 | // Maintains proper stack trace for where our error was thrown (only available on V8) 13 | if (Error.captureStackTrace) { 14 | Error.captureStackTrace(this, this.constructor); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /clients/ClientMySQL55.ts: -------------------------------------------------------------------------------- 1 | import { 2 | COL_CREATED_AT, 3 | COL_FILE_NAME, 4 | MAX_FILE_NAME_LENGTH, 5 | TABLE_MIGRATIONS, 6 | } from "../consts.ts"; 7 | import { ClientMySQL } from "./ClientMySQL.ts"; 8 | 9 | /** MySQL 5.5 client */ 10 | export class ClientMySQL55 extends ClientMySQL { 11 | protected get QUERY_CREATE_MIGRATION_TABLE() { 12 | return `CREATE TABLE ${TABLE_MIGRATIONS} (id bigint UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, ${COL_FILE_NAME} varchar(${MAX_FILE_NAME_LENGTH}) NOT NULL UNIQUE, ${COL_CREATED_AT} timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP);`; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/config-postgres.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientPostgreSQL, 3 | NessieConfig, 4 | } from "https://deno.land/x/nessie/mod.ts"; 5 | import type { ConnectionOptions } from "https://deno.land/x/postgres@v0.11.2/mod.ts"; 6 | 7 | const connectionConfig: ConnectionOptions = { 8 | database: "nessie", 9 | hostname: "localhost", 10 | port: 5432, 11 | user: "root", 12 | password: "pwd", 13 | }; 14 | 15 | const config: NessieConfig = { 16 | client: new ClientPostgreSQL(connectionConfig), 17 | migrationFolders: ["./db/migrations"], 18 | seedFolders: ["./db/seeds"], 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /egg.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://x.nest.land/eggs@0.3.8/src/schema.json", 3 | "name": "Nessie", 4 | "description": "A modular database migration module for Deno.", 5 | "version": "2.0.11", 6 | "stable": true, 7 | "repository": "https://github.com/halvardssm/deno-nessie", 8 | "files": [ 9 | "./*.ts", 10 | "./cli/**/*", 11 | "./clients/**/*", 12 | "./wrappers/**/*", 13 | "./README.md" 14 | ], 15 | "checkAll": false, 16 | "checkFormat": false, 17 | "checkTests": false, 18 | "checkInstallation": false, 19 | "entry": "./mod.ts", 20 | "ignore": [], 21 | "unlisted": false 22 | } 23 | -------------------------------------------------------------------------------- /wrappers/AbstractMigration.ts: -------------------------------------------------------------------------------- 1 | import type { Info } from "../types.ts"; 2 | import { AbstractClient } from "../clients/AbstractClient.ts"; 3 | 4 | export type AbstractMigrationProps = { 5 | client: Client; 6 | }; 7 | 8 | // deno-lint-ignore no-explicit-any 9 | export abstract class AbstractMigration = any> { 10 | protected client: T["client"]; 11 | 12 | protected constructor({ client }: AbstractMigrationProps) { 13 | this.client = client; 14 | } 15 | 16 | abstract up(exposedObject: Info): Promise; 17 | 18 | abstract down(exposedObject: Info): Promise; 19 | } 20 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "test:unit": "deno test -A --unstable --coverage=coverage tests/unit", 4 | "test:integration:cli": "deno test -A --unstable --coverage=coverage tests/integration/cli", 5 | "test:integration:update_timestamp": "deno test -A --unstable --coverage=coverage tests/integration/update_timestamps", 6 | "bump_version": "deno run --allow-read --allow-write helpers/prepare_release.ts && deno fmt" 7 | }, 8 | "fmt": { 9 | "exclude": [ 10 | "coverage" 11 | ] 12 | }, 13 | "lint": { 14 | "exclude": [ 15 | "tests", 16 | "examples", 17 | "cli/templates" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/migration-template.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientPostgreSQL, 3 | Info, 4 | //As this is a custom template, I want to lock the nessie version to 2.0.0 5 | } from "https://deno.land/x/nessie@2.0.0/mod.ts"; 6 | // I can import what I want to be used in this template 7 | import { CustomAbstractMigration } from "./abstract-classes-extended.ts"; 8 | 9 | export default class extends CustomAbstractMigration { 10 | async up({ dialect }: Info): Promise { 11 | this.someHelperFunction(); 12 | } 13 | 14 | async down({ dialect }: Info): Promise { 15 | // For this custom template, I do not want the down method to be predefined, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/publish_nest.yml: -------------------------------------------------------------------------------- 1 | name: Publish Egg 2 | 3 | env: 4 | DENO_VERSION: 1.37.0 5 | NEST_VERSION: 0.3.10 6 | 7 | on: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | publish-egg: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: denoland/setup-deno@v1 18 | with: 19 | deno-version: ${{env.DENO_VERSION}} 20 | 21 | - name: Publish module 22 | run: | 23 | deno run -A --unstable https://x.nest.land/eggs@${{env.NEST_VERSION}}/eggs.ts link ${{ secrets.NESTAPIKEY }} 24 | deno run -A --unstable https://x.nest.land/eggs@${{env.NEST_VERSION}}/eggs.ts publish -Y 25 | -------------------------------------------------------------------------------- /examples/abstract-classes-extended.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractClient, 3 | AbstractMigration, 4 | AbstractSeed, 5 | ClientPostgreSQL, 6 | } from "https://deno.land/x/nessie/mod.ts"; 7 | 8 | // This is a custom abstract migration class which can be used in the migration files 9 | export class CustomAbstractMigration = any> 10 | extends AbstractMigration { 11 | someHelperFunction() { 12 | console.log("Hey, I am available to all child classes!"); 13 | } 14 | } 15 | 16 | // I want to always use postres client in this class 17 | export class CustomAbstractSeed extends AbstractSeed { 18 | someHelperFunction() { 19 | console.log("Hey, I am available to all child classes!"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | ## Is this a breaking change? 4 | 5 | - [ ] Yes 6 | - [ ] No 7 | 8 | ## Checklist: 9 | 10 | Please review the 11 | [guidelines for contributing](https://github.com/halvardssm/deno-nessie/blob/master/.github/CONTRIBUTING.md) 12 | to this repository. 13 | 14 | - [ ] Updated JSDoc (for methods changed/added) 15 | - [ ] Added tests to cover added functionalities. 16 | - [ ] Updated readme (if applicable). 17 | - [ ] Added/updated examples in example folder. 18 | - [ ] Make sure the pipeline passes. 19 | 20 | When all of the above is completed, request a review from one of the codeowners. 21 | 22 | ## Description: 23 | 24 | Some short description of what this PR solves if it is not covered by the 25 | related issue 26 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This folder contains examples of how to use this library. 4 | 5 | ## Config files 6 | 7 | The following are minimal example config files: 8 | 9 | - [PostgreSQL](./config-postgres.ts) 10 | - [MySQL](./config-mysql.ts) 11 | - [SQLite](./config-sqlite.ts) 12 | 13 | If you want to include external migrations, check out these examples: 14 | 15 | - [With url](./config-remote-migration-files.ts) 16 | - [With GitHub API](./config-remote-migration-files-github-api.ts) - this 17 | example uses the github api to get the folder content and parse migration 18 | files from it. 19 | - [With custom templates](./config-custom-templates.ts) - this example uses 20 | custom templates and shows how to deal with custom abstract classes. 21 | 22 | ## Migration files 23 | 24 | - [Basic migration](./migration.ts) 25 | - [Migration using Dex](./migration-dex.ts) 26 | - [Custom AbstractMigration](./abstract-classes-extended.ts) 27 | 28 | ## Seed files 29 | 30 | - [Basic seed](./seed.ts) 31 | - [Custom AbstractSeed](./abstract-classes-extended.ts) 32 | -------------------------------------------------------------------------------- /.github/workflows/publish_docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | 3 | env: 4 | DENO_VERSION: 1.37.0 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | publish-docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: denoland/setup-deno@v1 19 | with: 20 | deno-version: ${{ env.DENO_VERSION }} 21 | 22 | - name: Login to Docker Hub 23 | uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Get tags 29 | run: | 30 | IMAGE_TAGS=$(deno run --allow-run helpers/get_image_tags.ts) 31 | echo "IMAGE_TAGS=$IMAGE_TAGS" 32 | 33 | - name: Build and push 34 | uses: docker/build-push-action@v2 35 | with: 36 | context: . 37 | file: ./image/Dockerfile 38 | tags: ${{ env.IMAGE_TAGS }} 39 | build-args: | 40 | DENO_VERSION=${{ env.DENO_VERSION }} 41 | -------------------------------------------------------------------------------- /tests/integration/cli/config/cli.config.ts: -------------------------------------------------------------------------------- 1 | export const TYPE_MIGRATE = "migrate"; 2 | export const TYPE_ROLLBACK = "rollback"; 3 | export const TYPE_SEED = "seed"; 4 | export const TYPE_STATUS = "status"; 5 | 6 | export const DIALECT_PG = "pg"; 7 | export const DIALECT_MYSQL = "mysql"; 8 | export const DIALECT_SQLITE = "sqlite"; 9 | export const DIALECTS = [ 10 | DIALECT_PG, 11 | DIALECT_MYSQL, 12 | DIALECT_SQLITE, 13 | ]; 14 | 15 | export const decoder = new TextDecoder(); 16 | 17 | export const runner = async (dialect: string, type: any[]) => { 18 | const r = Deno.run({ 19 | cmd: [ 20 | "deno", 21 | "run", 22 | "-A", 23 | "--unstable", 24 | "cli.ts", 25 | ...type, 26 | "-c", 27 | `./tests/integration/cli/config/${dialect}.config.ts`, 28 | // "-d", 29 | ], 30 | stdout: "piped", 31 | }); 32 | 33 | const { code } = await r.status(); 34 | 35 | const rawOutput = await r.output(); 36 | r.close(); 37 | const result = decoder.decode(rawOutput).split("\n"); 38 | 39 | if (code !== 0) { 40 | result.push(`Code was ${code}`); 41 | } 42 | return result; 43 | }; 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2020] [Halvard Mørstad] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/config-remote-migration-files-github-api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientSQLite, 3 | isMigrationFile, 4 | NessieConfig, 5 | } from "https://deno.land/x/nessie/mod.ts"; 6 | 7 | /** 8 | * This example uses the github api to get the the folder content of a repository. 9 | * See https://docs.github.com/en/rest/reference/repos#get-repository-content for documentation. 10 | */ 11 | 12 | type ResponseFormat = { 13 | download_url: string; 14 | name: string; 15 | }; 16 | 17 | const res = await fetch( 18 | "https://api.github.com/repos/halvardssm/deno-nessie/contents/examples", 19 | { headers: { "Authorization": `token some-token` } }, 20 | ); 21 | 22 | // Here we arrume that the url is to a directory and that we get an array back 23 | const body = await res.json() as ResponseFormat[]; 24 | 25 | //We only want migration files 26 | const migrationFiles = body.filter((el) => isMigrationFile(el.name)) 27 | // We are only interested in the raw url of the file 28 | .map((el) => el.download_url); 29 | 30 | const config: NessieConfig = { 31 | client: new ClientSQLite("sqlite.db"), 32 | additionalMigrationFiles: migrationFiles, 33 | }; 34 | 35 | export default config; 36 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | /** Deno Standard Library */ 2 | export { 3 | basename, 4 | dirname, 5 | fromFileUrl, 6 | relative, 7 | resolve, 8 | toFileUrl, 9 | } from "https://deno.land/std@0.202.0/path/mod.ts"; 10 | export { 11 | assert, 12 | assertEquals, 13 | } from "https://deno.land/std@0.202.0/testing/asserts.ts"; 14 | export { exists } from "https://deno.land/std@0.202.0/fs/mod.ts"; 15 | export { format } from "https://deno.land/std@0.202.0/datetime/mod.ts"; 16 | export { 17 | bold, 18 | green, 19 | yellow, 20 | } from "https://deno.land/std@0.202.0/fmt/colors.ts"; 21 | 22 | /** Cliffy */ 23 | export { 24 | Command as CliffyCommand, 25 | CompletionsCommand as CliffyCompletionsCommand, 26 | HelpCommand as CliffyHelpCommand, 27 | Select as CliffySelect, 28 | Toggle as CliffyToggle, 29 | } from "https://deno.land/x/cliffy@v0.25.7/mod.ts"; 30 | export type { IAction as CliffyIAction } from "https://deno.land/x/cliffy@v0.25.7/mod.ts"; 31 | 32 | /** MySQL */ 33 | export { Client as MySQLClient } from "https://deno.land/x/mysql@v2.12.1/mod.ts"; 34 | 35 | /** PostgreSQL */ 36 | export { 37 | Client as PostgreSQLClient, 38 | } from "https://deno.land/x/postgres@v0.17.0/mod.ts"; 39 | 40 | /** SQLite */ 41 | export { DB as SQLiteClient } from "https://deno.land/x/sqlite@v3.8/mod.ts"; 42 | -------------------------------------------------------------------------------- /examples/migration-dex.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractMigration, 3 | ClientPostgreSQL, 4 | Info, 5 | } from "https://deno.land/x/nessie/mod.ts"; 6 | import Dex from "https://deno.land/x/dex/mod.ts"; 7 | 8 | export default class extends AbstractMigration { 9 | async up({ dialect }: Info): Promise { 10 | const query = Dex({ client: dialect }).schema.createTable( 11 | "test", 12 | (table: any) => { 13 | table.bigIncrements("id").primary(); 14 | table.string("file_name", 100).unique(); 15 | table.timestamps(undefined, true); 16 | }, 17 | ); 18 | 19 | await this.client.queryArray(query); 20 | 21 | await this.client.queryArray( 22 | 'insert into test (file_name) values ("test1"), ("test2")', 23 | ); 24 | 25 | const res = await this.client.queryObject("select * from test"); 26 | 27 | for await (const row of res.rows) { 28 | this.client.queryArray( 29 | `update test set file_name = ${ 30 | row.file_name + 31 | "_some_suffix" 32 | } where id = ${row.id}`, 33 | ); 34 | } 35 | } 36 | 37 | async down({ dialect }: Info): Promise { 38 | const query = Dex({ client: dialect }).schema.dropTable("test"); 39 | 40 | await this.client.queryArray(query); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /consts.ts: -------------------------------------------------------------------------------- 1 | import { bold, yellow } from "./deps.ts"; 2 | 3 | export const VERSION = "2.0.11"; 4 | 5 | export const SPONSOR_NOTICE = bold( 6 | yellow( 7 | "If you are using Nessie commercially, please consider supporting the future development.\nGive a donation here: https://github.com/halvardssm/deno-nessie", 8 | ), 9 | ); 10 | 11 | export const URL_BASE = `https://deno.land/x/nessie`; 12 | export const URL_BASE_VERSIONED = `${URL_BASE}@${VERSION}`; 13 | 14 | export const URL_TEMPLATE_BASE = `${URL_BASE}/cli/templates/`; 15 | export const URL_TEMPLATE_BASE_VERSIONED = 16 | `${URL_BASE_VERSIONED}/cli/templates/`; 17 | 18 | export const DEFAULT_CONFIG_FILE = "nessie.config.ts"; 19 | export const DEFAULT_MIGRATION_FOLDER = "./db/migrations"; 20 | export const DEFAULT_SEED_FOLDER = "./db/seeds"; 21 | 22 | export const MAX_FILE_NAME_LENGTH = 100; 23 | export const TABLE_MIGRATIONS = "nessie_migrations"; 24 | export const COL_FILE_NAME = "file_name"; 25 | export const COL_CREATED_AT = "created_at"; 26 | export const REGEXP_MIGRATION_FILE_NAME_LEGACY = /^\d{10,14}-.+.ts$/; 27 | /** RegExp to validate the file name */ 28 | export const REGEXP_MIGRATION_FILE_NAME = /^\d{14}_[a-z\d]+(_[a-z\d]+)*.ts$/; 29 | export const REGEXP_FILE_NAME = /^[a-z\d]+(_[a-z\d]+)*$/; 30 | 31 | export enum DB_DIALECTS { 32 | PGSQL = "pg", 33 | MYSQL = "mysql", 34 | SQLITE = "sqlite", 35 | } 36 | 37 | export enum DB_CLIENTS { 38 | pg = "ClientPostgreSQL", 39 | mysql = "ClientMySQL", 40 | mysql55 = "ClientMySQL55", 41 | sqlite = "ClientSQLite", 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: [bug] 5 | body: 6 | - type: input 7 | attributes: 8 | label: Operating System 9 | description: What operating system are you using? 10 | placeholder: macOS Big Sur 11.4 11 | validations: 12 | required: true 13 | - type: input 14 | attributes: 15 | label: Deno version 16 | description: What version of Deno are you using? 17 | placeholder: 1.0.0 18 | validations: 19 | required: true 20 | - type: input 21 | attributes: 22 | label: Nessie version 23 | description: What version of Nessie are you using? 24 | placeholder: 2.0.0 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: Bug description 30 | description: Describe the bug 31 | placeholder: A clear and concise description of what the bug is. 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Steps to reproduce 37 | description: Add steps to reproduce the issue 38 | placeholder: | 39 | Steps to reproduce the behavior: 40 | 41 | 1. Go to '...' 42 | 2. Click on '....' 43 | 3. Scroll down to '....' 44 | 4. See error 45 | validations: 46 | required: true 47 | - type: textarea 48 | attributes: 49 | label: Aditional information 50 | description: Add any aditional information you belive could help in fixing this issue 51 | validations: 52 | required: false 53 | - type: markdown 54 | attributes: 55 | value: "Thanks for using Nessie, and helping us to improve!" -------------------------------------------------------------------------------- /cli/utils.ts: -------------------------------------------------------------------------------- 1 | import { MAX_FILE_NAME_LENGTH, REGEXP_MIGRATION_FILE_NAME } from "../consts.ts"; 2 | import { LoggerFn } from "../types.ts"; 3 | 4 | export const isUrl = (path: string) => { 5 | return isRemoteUrl(path) || isFileUrl(path); 6 | }; 7 | 8 | export const isFileUrl = (path: string) => { 9 | return path.startsWith("file://"); 10 | }; 11 | 12 | export const isRemoteUrl = (path: string) => { 13 | return path.startsWith("http://") || 14 | path.startsWith("https://"); 15 | }; 16 | 17 | /** A logger to use throughout the application, outputs when the debugger is enabled */ 18 | export function getLogger(): LoggerFn { 19 | // deno-lint-ignore no-explicit-any 20 | return (output?: any, title?: string) => { 21 | try { 22 | title ? console.log(title + ": ") : null; 23 | console.log(output); 24 | } catch { 25 | console.error("Error at: " + title); 26 | } 27 | }; 28 | } 29 | 30 | /** Checks if an array only contains unique values */ 31 | export function arrayIsUnique(array: unknown[]): boolean { 32 | return array.length === new Set(array).size; 33 | } 34 | 35 | /** 36 | * Helper method to validate if a filename is a valid migration filename. 37 | * Checks both the filename syntax and length. 38 | */ 39 | export function isMigrationFile(name: string): boolean { 40 | return REGEXP_MIGRATION_FILE_NAME.test(name) && 41 | name.length < MAX_FILE_NAME_LENGTH; 42 | } 43 | 44 | /** Returns duration in milliseconds */ 45 | export async function getDurationForFunction( 46 | fn: () => Promise, 47 | ): Promise<[number, T]> { 48 | const t1 = performance.now(); 49 | 50 | const res = await fn(); 51 | 52 | const t2 = performance.now() - t1; 53 | 54 | return [t2, res]; 55 | } 56 | 57 | export function getDurationFromTimestamp( 58 | startTime: number, 59 | endTime?: number, 60 | ): string { 61 | return (((endTime ?? performance.now()) - startTime) / 1000).toFixed(2); 62 | } 63 | -------------------------------------------------------------------------------- /image/README.md: -------------------------------------------------------------------------------- 1 | # Nessie Docker Image 2 | 3 | This is the official Docker image for Nessie - a modular database migration tool 4 | for Deno. 5 | 6 | https://github.com/halvardssm/deno-nessie/ 7 | 8 | To use the Nessie Docker image, you can either extend it as you normally would 9 | and place the config file and migration files in `/nessie`. The default work 10 | directory is `/nessie` and cmd is set to the `nessie` cli. 11 | 12 | > Always use a fixed version for production software to reduce unexpected bugs, 13 | > and always backup your database before upgrading the nessie version. 14 | 15 | ## Tags 16 | 17 | In adition to the versioned tags, which are in sync with the Nessie version, we 18 | have `latest` and `next` which are always up to date. `latest` will give you the 19 | latest stable release, while `next` will either be the latest release candidate 20 | (unstable version), or equivalent to `latest` if there are no release 21 | candidates. Release candidates mostly occurs only for major releases. As a rule 22 | of thumb, you should only use a versioned image for production. 23 | 24 | ## General Usage 25 | 26 | ### Extend dockerfile 27 | 28 | ```Dockerfile 29 | FROM halvardm/nessie:latest 30 | 31 | # Uncomment this line if your migrations and config file is dependend on a deps.ts file and copy in other dependencies 32 | # COPY deps.ts . 33 | 34 | COPY db . 35 | COPY nessie.config.ts . 36 | ``` 37 | 38 | Build the dockerfile and run it as such: 39 | 40 | ```shell 41 | docker build -t migrations . 42 | docker run -it migrations 43 | ``` 44 | 45 | ### Local development 46 | 47 | ```shell 48 | docker run -v `pwd`:/nessie halvardm/nessie init --dialect sqlite 49 | docker run -v `pwd`:/nessie halvardm/nessie make new_migration 50 | docker run -v `pwd`:/nessie halvardm/nessie migrate 51 | docker run -v `pwd`:/nessie halvardm/nessie rollback 52 | ``` 53 | 54 | If you have a database running in docker, you will have to set up a docker 55 | network and connect it to the database container. 56 | 57 | ```shell 58 | docker run -v `pwd`:/nessie --network db halvardm/nessie migrate 59 | ``` 60 | -------------------------------------------------------------------------------- /helpers/get_image_tags.ts: -------------------------------------------------------------------------------- 1 | import { 2 | format, 3 | gte, 4 | parse, 5 | SemVer, 6 | } from "https://deno.land/std@0.202.0/semver/mod.ts"; 7 | import { tryParse } from "https://deno.land/std@0.202.0/semver/try_parse.ts"; 8 | 9 | async function getTags() { 10 | const decoder = new TextDecoder(); 11 | const processTags = new Deno.Command("git", { 12 | args: [ 13 | "tag", 14 | "--sort", 15 | "-version:refname", 16 | ], 17 | stdout: "piped", 18 | }); 19 | 20 | const { success, code, stdout } = await processTags.output(); 21 | 22 | const result = decoder.decode(stdout); 23 | 24 | if (!success) { 25 | console.error(result); 26 | Deno.exit(code); 27 | } 28 | 29 | return result.split("\n").map((tag) => tryParse(tag)).filter((tag) => 30 | !!tag 31 | ) as SemVer[]; 32 | } 33 | 34 | async function getCurrentTag() { 35 | const decoder = new TextDecoder(); 36 | const processTags = new Deno.Command("git", { 37 | args: [ 38 | "describe", 39 | "--tags", 40 | "--abbrev=0", 41 | ], 42 | stdout: "piped", 43 | }); 44 | 45 | const { code, success, stdout } = await processTags.output(); 46 | 47 | const result = decoder.decode(stdout); 48 | 49 | if (!success) { 50 | console.error(result); 51 | Deno.exit(code); 52 | } 53 | 54 | return parse(result.trim()); 55 | } 56 | 57 | async function generateTagsArray() { 58 | const IMAGE = "halvardm/nessie"; 59 | 60 | const tags = await getTags(); 61 | const current = await getCurrentTag(); 62 | 63 | const latestStable = tags.find((tag) => !tag.prerelease); 64 | const latestNext = tags.find((tag) => !!tag.prerelease); 65 | 66 | const outputArray = [`${IMAGE}:${format(current)}`]; 67 | 68 | try { 69 | if ( 70 | !current.prerelease.length && latestStable && gte(current, latestStable) 71 | ) { 72 | outputArray.push(`${IMAGE}:latest`); 73 | } 74 | 75 | if (latestNext && gte(current, latestNext)) { 76 | outputArray.push(`${IMAGE}:next`); 77 | } 78 | 79 | const result = outputArray.join(","); 80 | 81 | const encoder = new TextEncoder(); 82 | 83 | await Deno.stdout.write(encoder.encode(result + "\n")); 84 | } catch (e) { 85 | console.log({ current, latestStable, latestNext, outputArray }); 86 | console.error(e); 87 | Deno.exit(1); 88 | } 89 | } 90 | 91 | await generateTagsArray(); 92 | 93 | Deno.exit(0); 94 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This is a community project where every contribution is appreciated! 4 | 5 | There is a simple process to follow no matter if you are facing an bug, have an 6 | idea, or simply have a question. 7 | 8 | ## Process of issue filing 9 | 10 | Check if there is an existing issue covering your intention. 11 | 12 | 1. if you have the same problem but the outcome is different (or vice versa), 13 | add a comment stating the difference 14 | 2. if you have the exact same problem add a like (:+1:) 15 | 3. otherwise create a new issue 16 | 17 | ### Bugs 18 | 19 | When facing a bug, give steps to reproduce as well as the error. If you have a 20 | hypothesis to why this issue erupted, mention this. If you have already isolated 21 | the issue and have found a fix, you can open a PR. 22 | 23 | When you have a problem, these steps will often help: 24 | 25 | - Make sure you use the latest version of Deno 26 | - Add the `-r` or `--reload` flag to the Deno command to reload the cache 27 | - Use the `-d` or `--debug` flag to the Nessie command to see if every value is 28 | as expected. 29 | 30 | ### Ideas 31 | 32 | Ideas are always welcome, and if there is a good reason and/or many users agree, 33 | there is a good chance it will be incorporated. Before making a PR, get feedback 34 | from the maintainers and comunity. 35 | 36 | ### Questions 37 | 38 | If you can't find the answers in one of the open (or closed) issues, create a 39 | new one. If this project gains enough traction, a discord server will be 40 | created, until then you can use the issues. 41 | 42 | ## When creating a PR 43 | 44 | Before pushing commits, go through this checklist: 45 | 46 | - You have run `deno fmt` 47 | - All tests are running successfully locally (will save you time) 48 | 49 | For a PR to be accepted, the following needs to be applied: 50 | 51 | - Every method added, has a corresponding test 52 | - Pipeline is green 53 | - Nothing more than what the PR is supposed to solve is changed (unless 54 | discussed and approved) 55 | 56 | ## Testing 57 | 58 | If you have `make` and `Docker` available, you can simply run `make test` (or 59 | `make test_clean` if you have already started the docker containers, this will 60 | be optimized in the future), otherwise boot up your databases and change the 61 | connections accordingly (do not commit these changes). If you have `Docker` but 62 | not `make`, you can look at the `Makefile` to see the commands to run. 63 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DB_PG_PORT=5000 2 | DB_MYSQL_PORT=5001 3 | DB_USER=root 4 | DB_PWD=pwd 5 | DB_NAME=nessie 6 | NESSIE_VERSION=2.0.11 7 | DENO_VERSION?=1.24.1 8 | DOCKER_IMAGE=halvardm/nessie 9 | 10 | test_all: test_fmt test_unit db_all_restart test_integration_cli db_all_restart test_integration_update_timestamps image_build image_test_clean image_test image_test_clean 11 | test: test_all 12 | 13 | test_fmt: 14 | deno lint 15 | deno fmt --check 16 | test_unit: 17 | deno task test:unit 18 | test_integration_cli: 19 | deno task test:integration:cli 20 | test_integration_update_timestamps: 21 | deno task test:integration:update_timestamp 22 | 23 | db_all_restart: db_all_stop db_all_start 24 | db_all_start: db_pg_start db_mysql_start db_sqlite_start 25 | db_all_stop: db_pg_stop db_mysql_stop db_sqlite_stop 26 | db_pg_start: 27 | docker run -d --rm \ 28 | -p $(DB_PG_PORT):5432 \ 29 | -e POSTGRES_USER=$(DB_USER) \ 30 | -e POSTGRES_PASSWORD=$(DB_PWD) \ 31 | -e POSTGRES_DB=${DB_NAME} \ 32 | -v `pwd`/tests/data/pg:/var/lib/postgresql/data \ 33 | --name $(DB_NAME)-pg \ 34 | --health-cmd pg_isready \ 35 | --health-interval 10s \ 36 | --health-timeout 5s \ 37 | --health-retries 5 \ 38 | postgres:latest 39 | while [ "`docker inspect -f {{.State.Health.Status}} $(DB_NAME)-pg`" != "healthy" ]; do sleep 10; done 40 | sleep 5 41 | db_pg_stop: 42 | docker kill ${DB_NAME}-pg | true 43 | rm -rf tests/data/pg 44 | db_mysql_start: 45 | # docker run -d -p $(DB_MYSQL_PORT):3306 -e MYSQL_ROOT_PASSWORD=$(DB_PWD) -e MYSQL_DATABASE=${DB_NAME} -v `pwd`/tests/data/mysql:/var/lib/mysql --rm --name $(DB_NAME)-mysql mysql:5 46 | docker run -d --rm \ 47 | -p $(DB_MYSQL_PORT):3306 \ 48 | -e MYSQL_ALLOW_EMPTY_PASSWORD=true \ 49 | -e MYSQL_DATABASE=${DB_NAME} \ 50 | -v `pwd`/tests/data/mysql:/var/lib/mysql \ 51 | --name $(DB_NAME)-mysql \ 52 | --health-cmd "mysqladmin ping" \ 53 | --health-interval 10s \ 54 | --health-timeout 5s \ 55 | --health-retries 5 \ 56 | mysql:latest 57 | while [ "`docker inspect -f {{.State.Health.Status}} $(DB_NAME)-mysql`" != "healthy" ]; do sleep 10; done 58 | sleep 5 59 | db_mysql_stop: 60 | docker kill ${DB_NAME}-mysql | true 61 | rm -rf tests/data/mysql 62 | db_sqlite_start: 63 | mkdir -p tests/data && touch tests/data/sqlite.db 64 | db_sqlite_stop: 65 | rm -rf tests/data/sqlite.db 66 | 67 | image_build: 68 | docker build --pull --build-arg DENO_VERSION=$(DENO_VERSION) -f ./image/Dockerfile -t $(DOCKER_IMAGE):latest -t $(DOCKER_IMAGE):$(NESSIE_VERSION) . 69 | image_push: 70 | docker push -a $(DOCKER_IMAGE) 71 | image_test: 72 | docker run --rm -v `pwd`/tests/image:/nessie $(DOCKER_IMAGE) init --dialect sqlite 73 | docker run --rm -v `pwd`/tests/image:/nessie $(DOCKER_IMAGE) make test 74 | docker run --rm -v `pwd`/tests/image:/nessie $(DOCKER_IMAGE) migrate 75 | image_test_clean: 76 | rm -rf tests/image/* 77 | image_run: 78 | docker run --rm -v `pwd`/tests/image:/nessie $(DOCKER_IMAGE) help 79 | -------------------------------------------------------------------------------- /.github/bw-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /cli/templates.ts: -------------------------------------------------------------------------------- 1 | import { DB_CLIENTS, DB_DIALECTS, URL_BASE_VERSIONED } from "../consts.ts"; 2 | import { DBDialects } from "../types.ts"; 3 | 4 | export function getConfigTemplate(dialect?: DBDialects) { 5 | let client: string; 6 | let importString: string; 7 | 8 | if (dialect === DB_DIALECTS.PGSQL) { 9 | client = `const client = new ClientPostgreSQL({ 10 | database: "nessie", 11 | hostname: "localhost", 12 | port: 5432, 13 | user: "root", 14 | password: "pwd", 15 | });`; 16 | importString = `import { 17 | ClientPostgreSQL, 18 | NessieConfig, 19 | } from "${URL_BASE_VERSIONED}/mod.ts";`; 20 | } else if (dialect === DB_DIALECTS.MYSQL) { 21 | client = `const client = new ClientMySQL({ 22 | hostname: "localhost", 23 | port: 3306, 24 | username: "root", 25 | // password: "pwd", // uncomment this line for <8 26 | db: "nessie", 27 | });`; 28 | importString = `import { 29 | ClientMySQL, 30 | NessieConfig, 31 | } from "${URL_BASE_VERSIONED}/mod.ts";`; 32 | } else if (dialect === DB_DIALECTS.SQLITE) { 33 | client = `const client = new ClientSQLite("./sqlite.db");`; 34 | importString = `import { 35 | ClientSQLite, 36 | NessieConfig, 37 | } from "${URL_BASE_VERSIONED}/mod.ts";`; 38 | } else { 39 | client = `/** Select one of the supported clients */ 40 | // const client = new ClientPostgreSQL({ 41 | // database: "nessie", 42 | // hostname: "localhost", 43 | // port: 5432, 44 | // user: "root", 45 | // password: "pwd", 46 | // }); 47 | 48 | // const client = new ClientMySQL({ 49 | // hostname: "localhost", 50 | // port: 3306, 51 | // username: "root", 52 | // // password: "pwd", // uncomment this line for <8 53 | // db: "nessie", 54 | // }); 55 | 56 | // const client = new ClientSQLite("./sqlite.db");`; 57 | importString = `import { 58 | ClientMySQL, 59 | ClientPostgreSQL, 60 | ClientSQLite, 61 | NessieConfig, 62 | } from "${URL_BASE_VERSIONED}/mod.ts";`; 63 | } 64 | 65 | const template = `${importString} 66 | 67 | ${client} 68 | 69 | /** This is the final config object */ 70 | const config: NessieConfig = { 71 | client, 72 | migrationFolders: ["./db/migrations"], 73 | seedFolders: ["./db/seeds"], 74 | }; 75 | 76 | export default config; 77 | `; 78 | 79 | return template; 80 | } 81 | 82 | export function getMigrationTemplate(dialect?: DBDialects) { 83 | let generic; 84 | 85 | if (dialect && dialect in DB_CLIENTS) { 86 | generic = DB_CLIENTS[dialect as DB_DIALECTS]; 87 | } 88 | 89 | return `import { AbstractMigration, Info${ 90 | generic ? `, ${generic}` : "" 91 | } } from "${URL_BASE_VERSIONED}/mod.ts"; 92 | 93 | export default class extends AbstractMigration${generic ? `<${generic}>` : ""} { 94 | /** Runs on migrate */ 95 | async up(info: Info): Promise { 96 | } 97 | 98 | /** Runs on rollback */ 99 | async down(info: Info): Promise { 100 | } 101 | } 102 | `; 103 | } 104 | 105 | export function getSeedTemplate(dialect?: DBDialects) { 106 | let generic; 107 | 108 | if (dialect && dialect in DB_CLIENTS) { 109 | generic = DB_CLIENTS[dialect as DB_DIALECTS]; 110 | } 111 | 112 | return `import { AbstractSeed, Info${ 113 | generic ? `, ${generic}` : "" 114 | } } from "${URL_BASE_VERSIONED}/mod.ts"; 115 | 116 | export default class extends AbstractSeed${generic ? `<${generic}>` : ""} { 117 | /** Runs on seed */ 118 | async run(info: Info): Promise { 119 | } 120 | } 121 | `; 122 | } 123 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and 9 | expression, level of experience, education, socio-economic status, nationality, 10 | personal appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at contact@moerstad.no. All complaints 59 | will be reviewed and investigated and will result in a response that is deemed 60 | necessary and appropriate to the circumstances. The project team is obligated to 61 | maintain confidentiality with regard to the reporter of an incident. Further 62 | details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at 72 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 73 | 74 | [homepage]: https://www.contributor-covenant.org 75 | 76 | For answers to common questions about this code of conduct, see 77 | https://www.contributor-covenant.org/faq 78 | -------------------------------------------------------------------------------- /tests/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../../deps.ts"; 2 | import { 3 | arrayIsUnique, 4 | isFileUrl, 5 | isMigrationFile, 6 | isRemoteUrl, 7 | isUrl, 8 | } from "../../cli/utils.ts"; 9 | 10 | Deno.test("isUrl", () => { 11 | const paths = [ 12 | { original: ".", expected: false }, 13 | { original: "./", expected: false }, 14 | { original: "/", expected: false }, 15 | { original: "./tester", expected: false }, 16 | { 17 | original: "http://tester.com/somefile.ts", 18 | expected: true, 19 | }, 20 | { 21 | original: "https://tester.com/somefile.ts", 22 | expected: true, 23 | }, 24 | { 25 | original: "file://tester/somefile.ts", 26 | expected: true, 27 | }, 28 | ]; 29 | 30 | paths.forEach(({ original, expected }) => { 31 | const actual = isUrl(original); 32 | 33 | assertEquals(actual, expected, `original: '${original}'`); 34 | }); 35 | }); 36 | 37 | Deno.test("isFileUrl", () => { 38 | const paths = [ 39 | { original: ".", expected: false }, 40 | { original: "./", expected: false }, 41 | { original: "/", expected: false }, 42 | { original: "./tester", expected: false }, 43 | { 44 | original: "http://tester.com/somefile.ts", 45 | expected: false, 46 | }, 47 | { 48 | original: "https://tester.com/somefile.ts", 49 | expected: false, 50 | }, 51 | { 52 | original: "file://tester/somefile.ts", 53 | expected: true, 54 | }, 55 | ]; 56 | 57 | paths.forEach(({ original, expected }) => { 58 | const actual = isFileUrl(original); 59 | 60 | assertEquals(actual, expected, `original: '${original}'`); 61 | }); 62 | }); 63 | 64 | Deno.test("isRemoteUrl", () => { 65 | const paths = [ 66 | { original: ".", expected: false }, 67 | { original: "./", expected: false }, 68 | { original: "/", expected: false }, 69 | { original: "./tester", expected: false }, 70 | { 71 | original: "http://tester.com/somefile.ts", 72 | expected: true, 73 | }, 74 | { 75 | original: "https://tester.com/somefile.ts", 76 | expected: true, 77 | }, 78 | { 79 | original: "file://tester/somefile.ts", 80 | expected: false, 81 | }, 82 | ]; 83 | 84 | paths.forEach(({ original, expected }) => { 85 | const actual = isRemoteUrl(original); 86 | 87 | assertEquals(actual, expected, `original: '${original}'`); 88 | }); 89 | }); 90 | 91 | Deno.test("arrayIsUnique", () => { 92 | const paths = [ 93 | { original: ["a", "a"], expected: false }, 94 | { original: ["a", "b"], expected: true }, 95 | { original: ["a", "b", "a"], expected: false }, 96 | ]; 97 | 98 | paths.forEach(({ original, expected }) => { 99 | const actual = arrayIsUnique(original); 100 | 101 | assertEquals(actual, expected, `original: '${original}'`); 102 | }); 103 | }); 104 | 105 | Deno.test("isMigrationFile", () => { 106 | const paths = [ 107 | { original: "20210508125213_test2.ts", expected: true }, 108 | { original: "202105081252133_test2.ts", expected: false }, 109 | { original: "2021050812521_test2.ts", expected: false }, 110 | { original: "2021050812521a_test2.ts", expected: false }, 111 | { original: "20210508125213-test2.ts", expected: false }, 112 | { original: "20210508125213_test2_.ts", expected: false }, 113 | { original: "20210508125213_test2.", expected: false }, 114 | { original: "20210508125213_test2", expected: false }, 115 | { original: ".20210508125213_test2.ts", expected: false }, 116 | { original: "20210508125213_test2_a.ts", expected: true }, 117 | ]; 118 | 119 | paths.forEach(({ original, expected }) => { 120 | const actual = isMigrationFile(original); 121 | 122 | assertEquals(actual, expected, `original: '${original}'`); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractClient } from "./clients/AbstractClient.ts"; 2 | import { DB_DIALECTS } from "./consts.ts"; 3 | /** Supported dialects */ 4 | export type DBDialects = DB_DIALECTS | string; 5 | 6 | /** Exposed object in migration files. available in `up`/`down` methods. 7 | * queryBuilder is available when passing `exposeQueryBuilder: true` to the config file. 8 | */ 9 | export type Info = { 10 | dialect: DBDialects; 11 | }; 12 | 13 | /** Logger function. */ 14 | // deno-lint-ignore no-explicit-any 15 | export type LoggerFn = (output?: any, title?: string) => void; 16 | /** Handy type to cover printf. */ 17 | export type QueryWithString = (string: string) => string; 18 | /** Amount type for migrations. */ 19 | export type AmountMigrateT = number | undefined; 20 | /** Amount type for rollbacks. */ 21 | export type AmountRollbackT = AmountMigrateT | "all"; 22 | /** Query type. */ 23 | export type QueryT = string | string[]; 24 | /** Query handler function. */ 25 | // deno-lint-ignore no-explicit-any 26 | export type QueryHandler = (query: QueryT) => Promise; 27 | 28 | /** Nessie config options. */ 29 | export interface NessieConfig { 30 | /** Can be any class which extends `AbstractClient`. */ 31 | // deno-lint-ignore no-explicit-any 32 | client: AbstractClient; 33 | /** 34 | * The folders where migration files are located. 35 | * Can be a relative path or an absolute path. 36 | * Defaults to ['./db/migrations/'] if additionalMigrationFiles is not populated 37 | */ 38 | migrationFolders?: string[]; 39 | /** 40 | * The folders where seed files are located. 41 | * Can be a relative path or an absolute path. 42 | * Defaults to ['./db/seeds/'] if additionalSeedFiles is not populated 43 | */ 44 | seedFolders?: string[]; 45 | /** 46 | * Additional migration files which will be added to the 47 | * list to parse when running the migrate or rollback command. 48 | * Can be any format supported by `import()` e.g. url or path 49 | */ 50 | additionalMigrationFiles?: string[]; 51 | /** 52 | * Additional seed files which will be added to the list to 53 | * match against when running the seed command. 54 | * Can be any format supported by `import()` e.g. url or path 55 | */ 56 | additionalSeedFiles?: string[]; 57 | /** Custom migration template, can be path or url. When also using the CLI flag `--migrationTemplate`, it will have precidence. */ 58 | migrationTemplate?: string; 59 | /** Custom seed template, can be path or url. When also using the CLI flag `--seedTemplate`, it will have precidence. */ 60 | seedTemplate?: string; 61 | /** Enables verbose output for debugging */ 62 | debug?: boolean; 63 | } 64 | 65 | export interface AbstractClientOptions { 66 | client: Client; 67 | } 68 | 69 | export type FileEntryT = { 70 | name: string; 71 | path: string; 72 | }; 73 | 74 | export type CommandOptions = { 75 | debug: boolean; 76 | config: string; 77 | }; 78 | 79 | export interface CommandOptionsInit extends CommandOptions { 80 | mode?: "config" | "folders"; 81 | dialect?: DB_DIALECTS; 82 | } 83 | 84 | export interface CommandOptionsStatus extends CommandOptions { 85 | fileNames?: boolean; 86 | output?: "log" | "json"; 87 | } 88 | 89 | export interface CommandOptionsMakeSeed extends CommandOptions { 90 | seedTemplate?: string; 91 | } 92 | 93 | export interface CommandOptionsMakeMigration extends CommandOptions { 94 | migrationTemplate?: string; 95 | } 96 | 97 | export type AllCommandOptions = 98 | & CommandOptionsInit 99 | & CommandOptionsStatus 100 | & CommandOptionsMakeSeed 101 | & CommandOptionsMakeMigration; 102 | 103 | export interface StateOptions { 104 | debug: boolean; 105 | config: NessieConfig; 106 | migrationFolders: string[]; 107 | seedFolders: string[]; 108 | migrationFiles: FileEntryT[]; 109 | seedFiles: FileEntryT[]; 110 | } 111 | -------------------------------------------------------------------------------- /clients/ClientSQLite.ts: -------------------------------------------------------------------------------- 1 | import { SQLiteClient } from "../deps.ts"; 2 | import { AbstractClient } from "./AbstractClient.ts"; 3 | import type { 4 | AmountMigrateT, 5 | AmountRollbackT, 6 | DBDialects, 7 | QueryT, 8 | } from "../types.ts"; 9 | import { 10 | COL_CREATED_AT, 11 | COL_FILE_NAME, 12 | MAX_FILE_NAME_LENGTH, 13 | TABLE_MIGRATIONS, 14 | } from "../consts.ts"; 15 | import { NessieError } from "../cli/errors.ts"; 16 | 17 | export type SQLiteClientOptions = ConstructorParameters; 18 | 19 | /** SQLite client */ 20 | export class ClientSQLite extends AbstractClient { 21 | dialect: DBDialects = "sqlite"; 22 | 23 | protected get QUERY_TRANSACTION_START() { 24 | return `BEGIN TRANSACTION;`; 25 | } 26 | protected get QUERY_TRANSACTION_COMMIT() { 27 | return `COMMIT;`; 28 | } 29 | protected get QUERY_TRANSACTION_ROLLBACK() { 30 | return `ROLLBACK;`; 31 | } 32 | 33 | protected get QUERY_MIGRATION_TABLE_EXISTS() { 34 | return `SELECT name FROM sqlite_master WHERE type='table' AND name='${TABLE_MIGRATIONS}';`; 35 | } 36 | 37 | protected get QUERY_CREATE_MIGRATION_TABLE() { 38 | return `CREATE TABLE ${TABLE_MIGRATIONS} (id integer NOT NULL PRIMARY KEY autoincrement, ${COL_FILE_NAME} varchar(${MAX_FILE_NAME_LENGTH}) UNIQUE, ${COL_CREATED_AT} datetime NOT NULL DEFAULT CURRENT_TIMESTAMP);`; 39 | } 40 | 41 | protected get QUERY_UPDATE_TIMESTAMPS() { 42 | return `UPDATE ${TABLE_MIGRATIONS} SET ${COL_FILE_NAME} = strftime('%Y%m%d%H%M%S', CAST(substr(${COL_FILE_NAME}, 0, instr(${COL_FILE_NAME}, '-')) AS INTEGER) / 1000, 'unixepoch') || substr(${COL_FILE_NAME}, instr(${COL_FILE_NAME}, '-')) WHERE CAST(substr(${COL_FILE_NAME}, 0, instr(${COL_FILE_NAME}, '-')) AS INTEGER) < 1672531200000;`; 43 | } 44 | 45 | constructor(...params: SQLiteClientOptions) { 46 | super({ client: new SQLiteClient(...params) }); 47 | } 48 | 49 | async prepare() { 50 | const queryResult = await this.query(this.QUERY_MIGRATION_TABLE_EXISTS); 51 | 52 | const migrationTableExists = 53 | queryResult?.[0]?.[0]?.[0] === TABLE_MIGRATIONS; 54 | 55 | if (!migrationTableExists) { 56 | await this.query(this.QUERY_CREATE_MIGRATION_TABLE); 57 | console.info("Database setup complete"); 58 | } 59 | } 60 | 61 | async updateTimestamps() { 62 | const queryResult = await this.query(this.QUERY_MIGRATION_TABLE_EXISTS); 63 | 64 | const migrationTableExists = 65 | queryResult?.[0]?.[0]?.[0] === TABLE_MIGRATIONS; 66 | 67 | if (migrationTableExists) { 68 | await this.query(this.QUERY_TRANSACTION_START); 69 | try { 70 | await this.query(this.QUERY_UPDATE_TIMESTAMPS); 71 | await this.query(this.QUERY_TRANSACTION_COMMIT); 72 | console.info("Updated timestamps"); 73 | } catch (e) { 74 | await this.query(this.QUERY_TRANSACTION_ROLLBACK); 75 | throw e; 76 | } 77 | } 78 | } 79 | 80 | async query(query: QueryT) { 81 | if (typeof query === "string") query = this.splitAndTrimQueries(query); 82 | const ra = []; 83 | 84 | for await (const qs of query) { 85 | try { 86 | ra.push([...this.client!.query(qs)]); 87 | } catch (e) { 88 | if (e?.message === "Query was empty") { 89 | ra.push(undefined); 90 | } else { 91 | throw new NessieError(query + "\n" + e + "\n" + ra.join("\n")); 92 | } 93 | } 94 | } 95 | 96 | return ra; 97 | } 98 | 99 | // deno-lint-ignore require-await 100 | async close() { 101 | this.client?.close(); 102 | } 103 | 104 | async migrate(amount: AmountMigrateT) { 105 | const latestMigration = await this.query(this.QUERY_GET_LATEST); 106 | 107 | await this._migrate( 108 | amount, 109 | latestMigration?.[0]?.[0]?.[0] as string, 110 | this.query.bind(this), 111 | ); 112 | } 113 | 114 | async rollback(amount: AmountRollbackT) { 115 | const allMigrations = await this.query(this.QUERY_GET_ALL); 116 | 117 | await this._rollback( 118 | amount, 119 | allMigrations?.[0]?.flatMap((el) => el?.[0] as string), 120 | this.query.bind(this), 121 | ); 122 | } 123 | 124 | async seed(matcher?: string) { 125 | await this._seed(matcher); 126 | } 127 | 128 | async getAll() { 129 | const allMigrations = await this.query(this.QUERY_GET_ALL); 130 | const parsedMigrations = allMigrations?.[0] 131 | ?.flatMap((el) => el?.[0]) as string[]; 132 | 133 | return parsedMigrations; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /clients/ClientPostgreSQL.ts: -------------------------------------------------------------------------------- 1 | import { PostgreSQLClient } from "../deps.ts"; 2 | import { AbstractClient } from "./AbstractClient.ts"; 3 | import type { 4 | AmountMigrateT, 5 | AmountRollbackT, 6 | DBDialects, 7 | QueryT, 8 | } from "../types.ts"; 9 | import { 10 | COL_CREATED_AT, 11 | COL_FILE_NAME, 12 | MAX_FILE_NAME_LENGTH, 13 | TABLE_MIGRATIONS, 14 | } from "../consts.ts"; 15 | import { NessieError } from "../cli/errors.ts"; 16 | 17 | export type PostgreSQLClientOptions = ConstructorParameters< 18 | typeof PostgreSQLClient 19 | >; 20 | 21 | /** PostgreSQL client */ 22 | export class ClientPostgreSQL extends AbstractClient { 23 | dialect: DBDialects = "pg"; 24 | 25 | protected get QUERY_TRANSACTION_START() { 26 | return `BEGIN TRANSACTION;`; 27 | } 28 | protected get QUERY_TRANSACTION_COMMIT() { 29 | return `COMMIT TRANSACTION;`; 30 | } 31 | protected get QUERY_TRANSACTION_ROLLBACK() { 32 | return `ROLLBACK TRANSACTION;`; 33 | } 34 | protected get QUERY_MIGRATION_TABLE_EXISTS() { 35 | return `SELECT * FROM information_schema.tables WHERE table_name = '${TABLE_MIGRATIONS}' LIMIT 1;`; 36 | } 37 | protected get QUERY_CREATE_MIGRATION_TABLE() { 38 | return `CREATE TABLE ${TABLE_MIGRATIONS} (id bigserial PRIMARY KEY, ${COL_FILE_NAME} varchar(${MAX_FILE_NAME_LENGTH}) UNIQUE, ${COL_CREATED_AT} timestamp (0) default current_timestamp);`; 39 | } 40 | protected get QUERY_UPDATE_TIMESTAMPS() { 41 | return `UPDATE ${TABLE_MIGRATIONS} SET ${COL_FILE_NAME} = to_char(to_timestamp(CAST(SPLIT_PART(${COL_FILE_NAME}, '-', 1) AS BIGINT) / 1000), 'yyyymmddHH24MISS') || '-' || SPLIT_PART(${COL_FILE_NAME}, '-', 2) WHERE CAST(SPLIT_PART(${COL_FILE_NAME}, '-', 1) AS BIGINT) < 1672531200000;`; 42 | } 43 | 44 | constructor(...params: PostgreSQLClientOptions) { 45 | super({ client: new PostgreSQLClient(...params) }); 46 | } 47 | 48 | async prepare() { 49 | await this.client.connect(); 50 | 51 | const queryResult = await this.client 52 | .queryArray(this.QUERY_MIGRATION_TABLE_EXISTS); 53 | 54 | const migrationTableExists = queryResult.rows.length > 0 && 55 | queryResult.rows?.[0].includes(TABLE_MIGRATIONS); 56 | 57 | if (!migrationTableExists) { 58 | await this.client.queryArray(this.QUERY_CREATE_MIGRATION_TABLE); 59 | console.info("Database setup complete"); 60 | } 61 | } 62 | 63 | async updateTimestamps() { 64 | await this.client.connect(); 65 | const queryResult = await this.client.queryArray( 66 | this.QUERY_MIGRATION_TABLE_EXISTS, 67 | ); 68 | 69 | const migrationTableExists = 70 | queryResult.rows?.[0]?.[0] === TABLE_MIGRATIONS; 71 | 72 | if (migrationTableExists) { 73 | await this.client.queryArray(this.QUERY_TRANSACTION_START); 74 | try { 75 | await this.client.queryArray(this.QUERY_UPDATE_TIMESTAMPS); 76 | await this.client.queryArray(this.QUERY_TRANSACTION_COMMIT); 77 | console.info("Updated timestamps"); 78 | } catch (e) { 79 | await this.client.queryArray(this.QUERY_TRANSACTION_ROLLBACK); 80 | throw e; 81 | } 82 | } 83 | } 84 | 85 | async query(query: QueryT) { 86 | if (typeof query === "string") query = this.splitAndTrimQueries(query); 87 | const ra = []; 88 | 89 | for await (const qs of query) { 90 | try { 91 | ra.push(await this.client.queryArray(qs)); 92 | } catch (e) { 93 | throw new NessieError(query + "\n" + e + "\n" + ra.join("\n")); 94 | } 95 | } 96 | 97 | return ra; 98 | } 99 | 100 | async close() { 101 | await this.client.end(); 102 | } 103 | 104 | async migrate(amount: AmountMigrateT) { 105 | const latestMigration = await this.client.queryArray(this.QUERY_GET_LATEST); 106 | await this._migrate( 107 | amount, 108 | latestMigration.rows?.[0]?.[0] as undefined, 109 | this.query.bind(this), 110 | ); 111 | } 112 | 113 | async rollback(amount: AmountRollbackT) { 114 | const allMigrations = await this.getAll(); 115 | 116 | await this._rollback( 117 | amount, 118 | allMigrations, 119 | this.query.bind(this), 120 | ); 121 | } 122 | 123 | async seed(matcher?: string) { 124 | await this._seed(matcher); 125 | } 126 | 127 | async getAll() { 128 | const allMigrations = await this.client.queryArray<[string]>( 129 | this.QUERY_GET_ALL, 130 | ); 131 | 132 | const parsedMigrations: string[] = allMigrations.rows?.map((el) => el?.[0]); 133 | 134 | return parsedMigrations; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /helpers/prepare_release.ts: -------------------------------------------------------------------------------- 1 | import { format, SemVer } from "https://deno.land/std@0.202.0/semver/mod.ts"; 2 | import { tryParse } from "https://deno.land/std@0.202.0/semver/try_parse.ts"; 3 | 4 | const REG_EXP_README_DENO_VERSION = /shields\.io\/badge\/deno-v\d+\.\d+\.\d+/; 5 | const REG_EXP_MAKEFILE_DENO_VERSION = /DENO_VERSION=\d+\.\d+\.\d+/; 6 | const REG_EXP_CI_DENO_VERSION = /DENO_VERSION: \d+\.\d+\.\d+/; 7 | const REG_EXP_MAKEFILE_NESSIE_VERSION = /NESSIE_VERSION=\d+\.\d+\.\d+(-rc\d+)?/; 8 | const REG_EXP_PROGRAM_NESSIE_VERSION = 9 | /export const VERSION = \"\d+\.\d+\.\d+(-rc\d+)?\";/; 10 | const FILE_JSON_EGG = "egg.json"; 11 | const FILE_README = "README.md"; 12 | const FILE_PROGRAM = "consts.ts"; 13 | const FILE_MAKEFILE = "Makefile"; 14 | const FILES_CI = [ 15 | ".github/workflows/ci.yml", 16 | ".github/workflows/publish_nest.yml", 17 | ".github/workflows/publish_docker.yml", 18 | ]; 19 | 20 | type Versions = { 21 | nessie: SemVer | undefined; 22 | deno: SemVer | undefined; 23 | }; 24 | 25 | const setEggConfig = async (versions: Versions) => { 26 | if (versions.nessie) { 27 | // deno-lint-ignore no-explicit-any 28 | const eggFile = JSON.parse(await Deno.readTextFile(FILE_JSON_EGG)) as any; 29 | 30 | eggFile.version = format(versions.nessie); 31 | eggFile.stable = !versions.nessie.prerelease.length; 32 | 33 | await Deno.writeTextFile( 34 | FILE_JSON_EGG, 35 | JSON.stringify(eggFile, undefined, 2), 36 | ); 37 | 38 | console.info(`egg.json updated to ${eggFile.version}`); 39 | } 40 | }; 41 | 42 | const setReadMe = async (versions: Versions) => { 43 | if (versions.deno) { 44 | const readme = await Deno.readTextFile(FILE_README); 45 | 46 | const res = readme.replace( 47 | REG_EXP_README_DENO_VERSION, 48 | `shields.io/badge/deno-v${format(versions.deno)}`, 49 | ); 50 | 51 | await Deno.writeTextFile(FILE_README, res); 52 | 53 | console.info(`README.md updated to ${format(versions.deno)}`); 54 | } 55 | }; 56 | 57 | const setProgram = async (versions: Versions) => { 58 | if (versions.nessie) { 59 | const cli = await Deno.readTextFile(FILE_PROGRAM); 60 | 61 | const res = cli.replace( 62 | REG_EXP_PROGRAM_NESSIE_VERSION, 63 | `export const VERSION = "${format(versions.nessie)}";`, 64 | ); 65 | 66 | await Deno.writeTextFile(FILE_PROGRAM, res); 67 | 68 | console.info(`consts.ts updated to ${format(versions.nessie)}`); 69 | } 70 | }; 71 | 72 | const setCI = async (versions: Versions) => { 73 | if (versions.deno || versions.nessie) { 74 | for (const file of FILES_CI) { 75 | let res = await Deno.readTextFile(file); 76 | 77 | if (versions.deno) { 78 | res = res.replace( 79 | REG_EXP_CI_DENO_VERSION, 80 | `DENO_VERSION: ${format(versions.deno)}`, 81 | ); 82 | 83 | console.info( 84 | `${file} updated to Deno: ${format(versions.deno)}`, 85 | ); 86 | } 87 | 88 | await Deno.writeTextFile(file, res); 89 | } 90 | } 91 | }; 92 | 93 | const setMakefile = async (versions: Versions) => { 94 | if (versions.deno || versions.nessie) { 95 | let res = await Deno.readTextFile(FILE_MAKEFILE); 96 | 97 | if (versions.nessie) { 98 | res = res.replace( 99 | REG_EXP_MAKEFILE_NESSIE_VERSION, 100 | `NESSIE_VERSION=${format(versions.nessie)}`, 101 | ); 102 | } 103 | 104 | if (versions.deno) { 105 | res = res.replace( 106 | REG_EXP_MAKEFILE_DENO_VERSION, 107 | `DENO_VERSION=${format(versions.deno)}`, 108 | ); 109 | } 110 | 111 | await Deno.writeTextFile(FILE_MAKEFILE, res); 112 | 113 | console.info( 114 | `${FILE_MAKEFILE} updated to Nessie: ${ 115 | versions.nessie && format(versions.nessie) 116 | } and Deno: ${versions.deno && format(versions.deno)}`, 117 | ); 118 | } 119 | }; 120 | 121 | async function runProgram() { 122 | const versionsRaw = Deno.env.get("NESSIE_BUMP_VERSION"); 123 | 124 | // versions should be separated by `:` e.g. `[nessieVersion]:[denoVersion]` 125 | // and will not allow any other form than `1.2.3` or `` on the right side 126 | // and `1.2.3`, `1.2.3-rf4` or `` on the left side. 127 | // If this is not fulfilled, the version will not upgrade 128 | if (!versionsRaw || !versionsRaw.includes(":")) { 129 | console.error( 130 | "Separator not included. Must use format [nessieVersion]:[denoVersion]. E.g. NESSIE_BUMP_VERSION=1.2.3:1.2.3", 131 | ); 132 | Deno.exit(1); 133 | } 134 | 135 | const versions = { 136 | nessie: tryParse(versionsRaw.split(":")[0]), 137 | deno: tryParse(versionsRaw.split(":")[1]), 138 | }; 139 | 140 | await setEggConfig(versions); 141 | await setProgram(versions); 142 | await setReadMe(versions); 143 | await setCI(versions); 144 | await setMakefile(versions); 145 | } 146 | 147 | await runProgram(); 148 | -------------------------------------------------------------------------------- /clients/ClientMySQL.ts: -------------------------------------------------------------------------------- 1 | import { MySQLClient } from "../deps.ts"; 2 | import { AbstractClient } from "./AbstractClient.ts"; 3 | import type { 4 | AmountMigrateT, 5 | AmountRollbackT, 6 | DBDialects, 7 | QueryT, 8 | } from "../types.ts"; 9 | import { 10 | COL_CREATED_AT, 11 | COL_FILE_NAME, 12 | MAX_FILE_NAME_LENGTH, 13 | TABLE_MIGRATIONS, 14 | } from "../consts.ts"; 15 | import { NessieError } from "../cli/errors.ts"; 16 | 17 | export type MySQLClientOptions = Parameters; 18 | 19 | /** 20 | * MySQL client 21 | * 22 | * This is for MySQL versions >5.5, if you want to use version <=5.5, 23 | * use ClientMySQL55 instead. 24 | */ 25 | export class ClientMySQL extends AbstractClient { 26 | protected clientOptions: MySQLClientOptions; 27 | dialect: DBDialects = "mysql"; 28 | 29 | protected get QUERY_TRANSACTION_START() { 30 | return `START TRANSACTION;`; 31 | } 32 | 33 | protected get QUERY_TRANSACTION_COMMIT() { 34 | return `COMMIT;`; 35 | } 36 | 37 | protected get QUERY_TRANSACTION_ROLLBACK() { 38 | return `ROLLBACK;`; 39 | } 40 | 41 | protected get QUERY_MIGRATION_TABLE_EXISTS() { 42 | return `SELECT * FROM information_schema.tables WHERE table_name = '${TABLE_MIGRATIONS}' LIMIT 1;`; 43 | } 44 | 45 | protected get QUERY_CREATE_MIGRATION_TABLE() { 46 | return `CREATE TABLE ${TABLE_MIGRATIONS} (id bigint UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, ${COL_FILE_NAME} varchar(${MAX_FILE_NAME_LENGTH}) NOT NULL UNIQUE, ${COL_CREATED_AT} datetime NOT NULL DEFAULT CURRENT_TIMESTAMP);`; 47 | } 48 | 49 | protected get QUERY_UPDATE_TIMESTAMPS() { 50 | return `UPDATE ${TABLE_MIGRATIONS} SET ${COL_FILE_NAME} = CONCAT(FROM_UNIXTIME(CAST(substring_index(${COL_FILE_NAME}, '-', 1) AS SIGNED) / 1000, '%Y%m%d%H%i%S'), substring(file_name, instr( file_name,'-'))) WHERE CAST(substring_index(${COL_FILE_NAME}, '-', 1) AS SIGNED) < 1672531200000;`; 51 | } 52 | 53 | constructor(...connectionOptions: MySQLClientOptions) { 54 | super({ client: new MySQLClient() }); 55 | this.clientOptions = connectionOptions; 56 | } 57 | 58 | async prepare() { 59 | await this.client.connect(...this.clientOptions); 60 | const queryResult = await this.query(this.QUERY_MIGRATION_TABLE_EXISTS); 61 | 62 | const migrationTableExists = queryResult?.[0]?.length > 0; 63 | 64 | if (!migrationTableExists) { 65 | await this.query(this.QUERY_CREATE_MIGRATION_TABLE); 66 | console.info("Database setup complete"); 67 | } 68 | } 69 | 70 | async updateTimestamps() { 71 | await this.client.connect(...this.clientOptions); 72 | const queryResult = await this.query(this.QUERY_MIGRATION_TABLE_EXISTS); 73 | 74 | const migrationTableExists = queryResult?.[0]?.length > 0; 75 | 76 | if (migrationTableExists) { 77 | await this.query(this.QUERY_TRANSACTION_START); 78 | try { 79 | await this.query(this.QUERY_UPDATE_TIMESTAMPS); 80 | await this.query(this.QUERY_TRANSACTION_COMMIT); 81 | console.info("Updated timestamps"); 82 | } catch (e) { 83 | await this.query(this.QUERY_TRANSACTION_ROLLBACK); 84 | throw e; 85 | } 86 | } 87 | } 88 | 89 | async query(query: QueryT) { 90 | if (typeof query === "string") query = this.splitAndTrimQueries(query); 91 | const ra = []; 92 | 93 | for await (const qs of query) { 94 | try { 95 | if ( 96 | qs.trim().toLowerCase().startsWith("select") || 97 | qs.trim().toLowerCase().startsWith("show") 98 | ) { 99 | ra.push(await this.client.query(qs)); 100 | } else { 101 | ra.push(await this.client.execute(qs)); 102 | } 103 | } catch (e) { 104 | if (e?.message === "Query was empty") { 105 | ra.push(undefined); 106 | } else { 107 | throw new NessieError(query + "\n" + e + "\n" + ra.join("\n")); 108 | } 109 | } 110 | } 111 | 112 | return ra; 113 | } 114 | 115 | async close() { 116 | await this.client.close(); 117 | } 118 | 119 | async migrate(amount: AmountMigrateT) { 120 | const latestMigration = await this.query(this.QUERY_GET_LATEST); 121 | await this._migrate( 122 | amount, 123 | latestMigration?.[0]?.[0]?.[COL_FILE_NAME], 124 | this.query.bind(this), 125 | ); 126 | } 127 | 128 | async rollback(amount: AmountRollbackT) { 129 | const allMigrations = await this.getAll(); 130 | 131 | await this._rollback( 132 | amount, 133 | allMigrations, 134 | this.query.bind(this), 135 | ); 136 | } 137 | 138 | async seed(matcher?: string) { 139 | await this._seed(matcher); 140 | } 141 | 142 | async getAll() { 143 | const allMigrations = await this.query(this.QUERY_GET_ALL); 144 | 145 | const parsedMigrations: string[] = allMigrations?.[0] 146 | .map((el: Record) => el?.[COL_FILE_NAME]); 147 | 148 | return parsedMigrations; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Nessie Change Log 2 | 3 | ## Version 2.0.11 - 2023-09-24 4 | 5 | - Deno v1.37.0 6 | - Deno std v0.202.0 7 | - Cliffy v0.25.7 8 | - MySQL v2.12.1 9 | - SQLite v3.8 10 | 11 | ## Version 2.0.10 - 2022-10-28 12 | 13 | - Deno v1.27.0 14 | - Deno std v0.161.0 15 | - MySQL v2.10.3 16 | 17 | ## Version 2.0.9 - 2022-10-25 18 | 19 | - Deno v1.26.2 20 | - Deno std v0.160.0 21 | - PostgreSQL v0.17.0 22 | 23 | ## Version 2.0.8 - 2022-10-06 24 | 25 | - Deno v1.26.0 26 | - Deno std v0.159.0 27 | - Cliffy v0.25.2 28 | - SQLite v3.5.0 29 | - Added schedule ci script to test on regular intervals 30 | - Removed devcontainer 31 | 32 | ## Version 2.0.7 - 2022-07-31 33 | 34 | - Deno v1.24.1 35 | - Deno std v0.150.0 36 | - Cliffy v0.24.3 37 | - PostgreSQL v0.16.1 38 | - Support for CockroachDB with the PostgreSQL client 39 | 40 | ## Version 2.0.6 - 2022-04-15 41 | 42 | - Refactored comands so that Nessie can run programatically 43 | 44 | ## Version 2.0.5 - 2022-01-11 45 | 46 | - Deno v1.17.2 47 | - Deno std v0.119.0 48 | - Cliffy v0.20.1 49 | - MySQL v2.10.2 50 | - PostgreSQL v0.14.2 51 | - SQLite v3.2.0 52 | - Changed client queries from private constants to getters 53 | - Added client for MySQL 5.5 54 | 55 | ## Version 2.0.4 - 2021-10-31 56 | 57 | - Added support for custom seed and migration templates 58 | 59 | ## Version 2.0.3 - 2021-10-30 60 | 61 | - Deno v1.15.3 62 | - Deno std v0.113.0 63 | - Cliffy v0.20.0 64 | - Improved readme 65 | 66 | ## Version 2.0.2 - 2021-09-28 67 | 68 | - Deno v1.14.1 69 | - Deno std v0.108.0 70 | - Cliffy v0.19.6 71 | - MySQL v2.10.1 72 | - Improved constructor type for clients 73 | 74 | ## Version 2.0.1 - 2021-08-30 75 | 76 | - Deno v1.13.2 77 | - Deno std v0.104.0 78 | - Cliffy v0.19.5 79 | - MySQL v2.10.0 80 | - PostgreSQL v0.12.0 81 | - SQLite v3.1.1 82 | - Added status command 83 | - Added sponsor notice 84 | 85 | ## Version 2.0.0 - 2021-06-24 86 | 87 | - Deno v1.11.2 88 | - Deno std v0.99.0 89 | - Cliffy v0.19.2 90 | - MySQL v2.9.0 91 | - Added Coverage reporting 92 | - Limit filenames to only be lowercase, underscore and digits 93 | - Fixed parsing of migrate and rollback amount 94 | - Added Docker image 95 | - Improved output for migration, rollback and seeding 96 | - Added NessieError to give clearity to the errors origin 97 | - Removed fallback of config file to root 98 | - Templates are now strings and will no longer be fetched from remote 99 | - Fixed test 100 | - Added unit tests 101 | - Moved existing tests to integration test folder 102 | - Updated CLI options and commands 103 | - Renamed sqlite3 to sqlite 104 | - Changed badges to for-the-badge style 105 | 106 | ## Version 1.3.2 - 2021-05-17 107 | 108 | - Deno v1.10.1 109 | - Fixed bug where path for async import was not a file url 110 | 111 | ## Version 1.3.1 - 2021-05-08 112 | 113 | - Cliffy v0.18.2 114 | - Replaced Denomander with Cliffy 115 | - Added support for multiple migration and seed folders 116 | 117 | ## Version 1.3.0 - 2021-05-08 118 | 119 | - Added `CHANGELOG.md` file 120 | - Removed dex in abstract migration and seed 121 | - Removed ClientI and improved AbstractClient 122 | - Added make:migration command 123 | - removed nessie.config.ts file in root 124 | - Improved types 125 | - Added github funding 126 | 127 | ## Version 1.2.4 - 2021-04-30 128 | 129 | - Fixed broken Nest CI 130 | - Improved types 131 | - Fixed typehint for client in `AbstractMigration` and `AbstractSeed` 132 | 133 | ## Version 1.2.3 - 2021-04-29 134 | 135 | - Deno v1.9.2 136 | - PostgreSQL v0.11.2 137 | - SQLite v2.4.0 138 | - Added format and lint test to CI 139 | - doc fixes 140 | - Improved `updateTimestamps` code 141 | - Made private methods properly private with `#` 142 | - Fixed typo in `egg.json` 143 | 144 | ## Version 1.2.2 - 2021-04-12 145 | 146 | - Updated Nest config 147 | 148 | ## Version 1.2.1 - 2021-04-27 149 | 150 | - Updated Nest CI script to use `denoland/setup-deno` instead of 151 | `denolib/setup-deno` 152 | - Fixed Nest Nessie link in readme 153 | - Removed query builder from `egg.json` 154 | - Fixed `isUrl` parsing 155 | 156 | ## Version 1.2.0 - 2021-04-12 157 | 158 | - Deno v1.8.3 159 | - Deno std v0.55.0 160 | - PostgreSQL v0.4.6 161 | - MySQL v2.8.0 162 | - SQLite v2.3.0 163 | - Denomander v0.8.1 164 | - Changed branch name from `master` to `main` 165 | - Added VSCode Devcontainer setup 166 | - MD formatting now happens via Deno fmt 167 | - Added Codecoverage file (WIP) 168 | - Transferred QueryBuilder to its own repo 169 | - Added experimental migration names to use `yyyymmddHHMMss` instead of unix 170 | timestamp 171 | - Improved experimental class based migrations 172 | 173 | ## Version 1.1.0 - 2020-10-07 174 | 175 | - Deno v1.4.4 176 | - Deno std v0.73.0 177 | - PostgreSQL v0.4.5 178 | - MySQL v2.4.0 179 | - SQLite v2.3.0 180 | - Denomander v0.6.3 181 | - Added nest.land to CI 182 | - Improved CI 183 | - Added examples to examples folder and readme 184 | - Added experimental class based migrations 185 | - Improved typings and class properties 186 | - Improved tests 187 | 188 | ## Version 1.0.0 - 2020-06-10 189 | 190 | - Initial release 191 | - Deno v1.0.5 192 | - Deno std v0.55.0 193 | - PostgreSQL v0.4.1 194 | - MySQL v2.2.0 195 | - SQLite v2.0.0 196 | - Denomander v0.6.2 197 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CliffyCommand, 3 | CliffyCompletionsCommand, 4 | CliffyHelpCommand, 5 | yellow, 6 | } from "./deps.ts"; 7 | import { 8 | DB_CLIENTS, 9 | DB_DIALECTS, 10 | DEFAULT_CONFIG_FILE, 11 | SPONSOR_NOTICE, 12 | VERSION, 13 | } from "./consts.ts"; 14 | import { CommandOptions } from "./types.ts"; 15 | import { NessieError } from "./cli/errors.ts"; 16 | import { 17 | initNessie, 18 | makeMigration, 19 | makeSeed, 20 | migrate, 21 | rollback, 22 | seed, 23 | status, 24 | updateTimestamps, 25 | } from "./cli/commands.ts"; 26 | 27 | /** Initializes CliffyCommand */ 28 | const cli = async () => { 29 | await new CliffyCommand() 30 | .name("Nessie Migrations") 31 | .version(VERSION) 32 | .description("A database migration tool for Deno.\n" + SPONSOR_NOTICE) 33 | .option("-d, --debug", "Enables verbose output.", { global: true }) 34 | .option( 35 | "-c, --config ", 36 | "Path to config file.", 37 | { global: true, default: `./${DEFAULT_CONFIG_FILE}` }, 38 | ) 39 | .option( 40 | "--seedTemplate ", 41 | "Path or URL to a custom seed template. Only used together with the `make` commands.", 42 | { global: true }, 43 | ) 44 | .option( 45 | "--migrationTemplate ", 46 | "Path or URL to a custom migration template. Only used together with the `make` commands.", 47 | { global: true }, 48 | ) 49 | .command("init", "Generates the config file.") 50 | .option( 51 | "--mode ", 52 | "Select the mode for what to create, can be one of 'config' or 'folders'. If not sumbitted, it will create both the config file and folders.", 53 | { 54 | value: (value: string): string => { 55 | if (!["config", "folders"].includes(value)) { 56 | throw new NessieError( 57 | `Mode must be one of 'config' or 'folders', but got '${value}'.`, 58 | ); 59 | } 60 | return value; 61 | }, 62 | }, 63 | ) 64 | .option( 65 | "--dialect ", 66 | `Set the database dialect for the config file, can be one of '${DB_DIALECTS.PGSQL}', '${DB_DIALECTS.MYSQL}' or '${DB_DIALECTS.SQLITE}'. If not submitted, a general config file will be generated.`, 67 | { 68 | value: (value: string): string => { 69 | if (!(value in DB_CLIENTS)) { 70 | throw new NessieError( 71 | `Mode must be one of '${DB_DIALECTS.PGSQL}', '${DB_DIALECTS.MYSQL}' or '${DB_DIALECTS.SQLITE}', but got '${value}'.`, 72 | ); 73 | } 74 | return value; 75 | }, 76 | }, 77 | ) 78 | .action(initNessie) 79 | .command( 80 | "make:migration ", 81 | "Creates a migration file with the name. Allows lower snake case and digits e.g. `some_migration_1`.", 82 | ) 83 | .alias("make") 84 | .action(makeMigration) 85 | .command( 86 | "make:seed ", 87 | "Creates a seed file with the name. Allows lower snake case and digits e.g. `some_seed_1`.", 88 | ) 89 | .action(makeSeed) 90 | .command( 91 | "seed [matcher:string]", 92 | "Seeds the database with the files found with the matcher in the seed folder specified in the config file. Matcher is optional, and accepts string literals and RegExp.", 93 | ) 94 | .action(seed) 95 | .command( 96 | "migrate [amount:number]", 97 | "Migrates migrations. Optional number of migrations. If not provided, it will do all available.", 98 | ) 99 | .action(migrate) 100 | .command( 101 | "rollback [amount:string]", 102 | "Rolls back migrations. Optional number of rollbacks or 'all'. If not provided, it will do one.", 103 | ) 104 | .action(rollback) 105 | .command( 106 | "update_timestamps", 107 | "Update the timestamp format from milliseconds to timestamp. This command should be run inside of the folder where you store your migrations. Will only update timestams where the value is less than 1672531200000 (2023-01-01) so that the timestamps won't be updated multiple times.", 108 | ) 109 | .action(updateTimestamps) 110 | .command( 111 | "status", 112 | "Outputs the status of Nessie. Will output detailed information about current state of the migrations.", 113 | ) 114 | .action(status) 115 | .option( 116 | "--output ", 117 | `Sets the output format, can be one of 'log' or 'json'.`, 118 | { 119 | default: "log", 120 | value: (value: string): string => { 121 | if (!["log", "json"].includes(value)) { 122 | throw new NessieError( 123 | `Output must be one of 'log' or 'json', but got '${value}'.`, 124 | ); 125 | } 126 | return value; 127 | }, 128 | }, 129 | ) 130 | .option("--file-names", "Adds filenames to output") 131 | .command("completions", new CliffyCompletionsCommand()) 132 | .command("help", new CliffyHelpCommand()) 133 | .parse(Deno.args); 134 | }; 135 | 136 | /** Main application */ 137 | const run = async () => { 138 | try { 139 | await cli(); 140 | 141 | Deno.exit(); 142 | } catch (e) { 143 | if (e instanceof NessieError) { 144 | console.error(e); 145 | } else { 146 | console.error( 147 | e, 148 | "\n", 149 | yellow( 150 | "This error is most likely unrelated to Nessie, and is probably related to the client, the connection config or the query you are trying to execute.", 151 | ), 152 | ); 153 | } 154 | Deno.exit(1); 155 | } 156 | }; 157 | 158 | await run(); 159 | -------------------------------------------------------------------------------- /tests/integration/cli/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "../../../deps.ts"; 2 | import { 3 | DIALECTS, 4 | runner, 5 | TYPE_MIGRATE, 6 | TYPE_ROLLBACK, 7 | TYPE_SEED, 8 | TYPE_STATUS, 9 | } from "./config/cli.config.ts"; 10 | 11 | const strings = [ 12 | { 13 | name: "Status initial with name", 14 | string: [TYPE_STATUS, "--file-names"], 15 | solution: [ 16 | "Status", 17 | "totalAvailableMigrationFiles: 3", 18 | "completedMigrations: 0", 19 | "newAvailableMigrations: 3", 20 | "20210508115213_test1.ts", 21 | "20210508125213_test2.ts", 22 | "20210508135213_test3.ts", 23 | ], 24 | }, 25 | { 26 | name: "Status initial with json", 27 | string: [TYPE_STATUS, "--output=json"], 28 | solution: [ 29 | '{"totalAvailableMigrationFiles":3,"completedMigrations":0,"newAvailableMigrations":3}', 30 | ], 31 | }, 32 | { 33 | name: "Status initial with name and json", 34 | string: [TYPE_STATUS, "--file-names", "--output=json"], 35 | solution: [ 36 | '{"totalAvailableMigrationFiles":3,"completedMigrations":0,"newAvailableMigrations":3,"totalAvailableMigrationFileNames":["20210508115213_test1.ts","20210508125213_test2.ts","20210508135213_test3.ts"],"completedMigrationNames":[],"newAvailableMigrationNames":["20210508115213_test1.ts","20210508125213_test2.ts","20210508135213_test3.ts"]}', 37 | ], 38 | }, 39 | { 40 | name: "Status 1", 41 | string: [TYPE_STATUS], 42 | solution: [ 43 | "Status", 44 | "totalAvailableMigrationFiles: 3", 45 | "completedMigrations: 0", 46 | "newAvailableMigrations: 3", 47 | ], 48 | }, 49 | { 50 | name: "Rollback none", 51 | string: [TYPE_ROLLBACK, "all"], 52 | solution: ["Nothing to rollback"], 53 | }, 54 | { 55 | name: "Migrate 1", 56 | string: [TYPE_MIGRATE, "1"], 57 | solution: [ 58 | "Starting migration of 3 files", 59 | "Migrating 20210508115213_test1.ts", 60 | "Done in", 61 | "Migrations completed in", 62 | ], 63 | }, 64 | { 65 | name: "Status 2", 66 | string: [TYPE_STATUS], 67 | solution: [ 68 | "Status", 69 | "totalAvailableMigrationFiles: 3", 70 | "completedMigrations: 1", 71 | "newAvailableMigrations: 2", 72 | ], 73 | }, 74 | { 75 | name: "Migrate all", 76 | string: [TYPE_MIGRATE], 77 | solution: [ 78 | "Starting migration of 2 files", 79 | "Migrating 20210508125213_test2.ts", 80 | "Done in ", 81 | "Migrating 20210508135213_test3.ts", 82 | "Migrations completed in ", 83 | ], 84 | }, 85 | { 86 | name: "Status 3", 87 | string: [TYPE_STATUS], 88 | solution: [ 89 | "Status", 90 | "totalAvailableMigrationFiles: 3", 91 | "completedMigrations: 3", 92 | "newAvailableMigrations: 0", 93 | ], 94 | }, 95 | { 96 | name: "Seed", 97 | string: [TYPE_SEED, "seed.ts"], 98 | solution: [ 99 | "Starting seeding of 1 files", 100 | "Seeding seed.ts", 101 | "Done in", 102 | "Seeding completed in ", 103 | ], 104 | }, 105 | { 106 | name: "Migrate empty", 107 | string: [TYPE_MIGRATE], 108 | solution: ["Nothing to migrate"], 109 | }, 110 | { 111 | name: "Rollback test3 and test2", 112 | string: [TYPE_ROLLBACK, "2"], 113 | solution: [ 114 | "Starting rollback of 2 files", 115 | "Rolling back 20210508135213_test3.ts", 116 | "Done in ", 117 | "Rolling back 20210508125213_test2.ts", 118 | "Rollback completed in ", 119 | ], 120 | }, 121 | { 122 | name: "Status 4", 123 | string: [TYPE_STATUS], 124 | solution: [ 125 | "Status", 126 | "totalAvailableMigrationFiles: 3", 127 | "completedMigrations: 1", 128 | "newAvailableMigrations: 2", 129 | ], 130 | }, 131 | { 132 | name: "Migrate test2 and test3", 133 | string: [TYPE_MIGRATE, "2"], 134 | solution: [ 135 | "Starting migration of 2 files", 136 | "Migrating 20210508125213_test2.ts", 137 | "Done in ", 138 | "Migrating 20210508135213_test3.ts", 139 | "Migrations completed in ", 140 | ], 141 | }, 142 | { 143 | name: "Status 5", 144 | string: [TYPE_STATUS], 145 | solution: [ 146 | "Status", 147 | "totalAvailableMigrationFiles: 3", 148 | "completedMigrations: 3", 149 | "newAvailableMigrations: 0", 150 | ], 151 | }, 152 | { 153 | name: "Rollback all", 154 | string: [TYPE_ROLLBACK, "all"], 155 | solution: [ 156 | "Starting rollback of 3 files", 157 | "Done in", 158 | "Rolling back 20210508135213_test3.ts", 159 | "Rolling back 20210508125213_test2.ts", 160 | "Rolling back 20210508115213_test1.ts", 161 | "Rollback completed in ", 162 | ], 163 | }, 164 | { 165 | name: "Status 6", 166 | string: [TYPE_STATUS], 167 | solution: [ 168 | "Status", 169 | "totalAvailableMigrationFiles: 3", 170 | "completedMigrations: 0", 171 | "newAvailableMigrations: 3", 172 | ], 173 | }, 174 | { 175 | name: "Rollback empty", 176 | string: [TYPE_ROLLBACK], 177 | solution: ["Nothing to rollback"], 178 | }, 179 | ]; 180 | 181 | for await (const dialect of DIALECTS) { 182 | let hasFailed = false; 183 | 184 | for await (const { name, string, solution } of strings) { 185 | Deno.test(`Migration ${dialect}: ` + (name || "Empty"), async () => { 186 | const response = await runner(dialect, string); 187 | hasFailed = response[response.length - 1].includes("Code was"); 188 | 189 | assert(!hasFailed, response.join("\n")); 190 | 191 | solution.forEach((el) => 192 | assert( 193 | response.some((res) => res.includes(el)), 194 | `Missing '${el}' from response`, 195 | ) 196 | ); 197 | }); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /.github/workflows/scheduled.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled 2 | 3 | on: 4 | schedule: 5 | - cron: '24 8 * * 0,3,5' 6 | 7 | jobs: 8 | fmt: 9 | strategy: 10 | matrix: 11 | version: [vx.x.x, canary] 12 | name: Test format and lint 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Clone repo 17 | uses: actions/checkout@v3 18 | 19 | - name: Install deno 20 | uses: denoland/setup-deno@v1 21 | with: 22 | deno-version: ${{ matrix.version }} 23 | 24 | - name: Check fmt 25 | run: deno fmt --check 26 | 27 | - name: Check lint 28 | run: deno lint 29 | 30 | unit: 31 | strategy: 32 | matrix: 33 | version: [vx.x.x, canary] 34 | name: Test unit 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - name: Clone repo 39 | uses: actions/checkout@v3 40 | 41 | - name: Install deno 42 | uses: denoland/setup-deno@v1 43 | with: 44 | deno-version: ${{ matrix.version }} 45 | 46 | - name: Run unit tests 47 | run: deno task test:unit 48 | 49 | - name: Generate lcov 50 | run: deno coverage --unstable --lcov ./coverage > coverage.lcov 51 | 52 | - name: Upload coverage 53 | uses: codecov/codecov-action@v1 54 | with: 55 | files: coverage.lcov 56 | flags: unit 57 | 58 | cli: 59 | strategy: 60 | matrix: 61 | version: [vx.x.x, canary] 62 | name: Test CLI 63 | runs-on: ubuntu-latest 64 | env: 65 | URL_PATH: ${{github.repository}}/main 66 | 67 | steps: 68 | - name: Install deno 69 | uses: denoland/setup-deno@v1 70 | with: 71 | deno-version: ${{ matrix.version }} 72 | 73 | - name: Nessie Init 74 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --dialect sqlite 75 | 76 | - run: sed -i "s|from \".*\"|from \"https://raw.githubusercontent.com/$URL_PATH/mod.ts\"|" nessie.config.ts && cat nessie.config.ts 77 | 78 | - name: Create migration 79 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make test 80 | 81 | - name: Create migration 82 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:migration test2 83 | 84 | - name: Create seed 85 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:seed test 86 | 87 | - run: echo "test" >> test_template 88 | 89 | - name: Create migration from custom template 90 | run: | 91 | deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:migration --migrationTemplate test_template test_migration_template 92 | TEMPLATE_PATH=$(find db/migrations -type f -name "*test_migration_template.ts") 93 | TEMPLATE_CONTENT=$(cat $TEMPLATE_PATH) 94 | if [[ $TEMPLATE_CONTENT != "test" ]]; then echo "File $TEMPLATE_PATH was not correct, was:\n$TEMPLATE_CONTENT" && exit 1; fi 95 | 96 | - name: Create seed from custom template 97 | run: | 98 | deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:seed --seedTemplate test_template test_seed_template 99 | TEMPLATE_PATH=$(find db/seeds -type f -name "test_seed_template.ts") 100 | TEMPLATE_CONTENT=$(cat $TEMPLATE_PATH) 101 | if [[ $TEMPLATE_CONTENT != "test" ]]; then echo "File $TEMPLATE_PATH was not correct, was:\n$TEMPLATE_CONTENT" && exit 1; fi 102 | 103 | - name: Clean files and folders 104 | run: rm -rf db && rm -rf nessie.config.ts 105 | 106 | - name: Init with mode and pg 107 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode config --dialect pg 108 | 109 | - name: Init with mode and mysql 110 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode config --dialect mysql 111 | 112 | - name: Init with mode and sqlite 113 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode config --dialect sqlite 114 | 115 | - name: Init with folders only 116 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode folders 117 | 118 | cli-migrations: 119 | strategy: 120 | matrix: 121 | version: [vx.x.x, canary] 122 | name: Test CLI Migrations 123 | runs-on: ubuntu-latest 124 | 125 | services: 126 | postgres: 127 | image: postgres 128 | env: 129 | POSTGRES_USER: root 130 | POSTGRES_PASSWORD: pwd 131 | POSTGRES_DB: nessie 132 | options: >- 133 | --health-cmd pg_isready 134 | --health-interval 10s 135 | --health-timeout 5s 136 | --health-retries 5 137 | ports: 138 | - 5000:5432 139 | 140 | mysql: 141 | image: mysql 142 | env: 143 | MYSQL_ALLOW_EMPTY_PASSWORD: true 144 | MYSQL_DATABASE: nessie 145 | options: >- 146 | --health-cmd="mysqladmin ping" 147 | --health-interval=10s 148 | --health-timeout=5s 149 | --health-retries=3 150 | ports: 151 | - 5001:3306 152 | 153 | steps: 154 | - name: Clone repo 155 | uses: actions/checkout@v3 156 | 157 | - name: Install deno 158 | uses: denoland/setup-deno@v1 159 | with: 160 | deno-version: ${{ matrix.version }} 161 | 162 | - name: Create databases 163 | run: make db_sqlite_start 164 | 165 | - name: Run tests 166 | run: make test_integration_cli 167 | 168 | - name: Generate lcov 169 | run: deno coverage --unstable --lcov ./coverage > coverage.lcov 170 | 171 | - name: Upload coverage 172 | uses: codecov/codecov-action@v1 173 | with: 174 | files: coverage.lcov 175 | flags: integration-cli 176 | 177 | image-test: 178 | name: Test Docker image build 179 | runs-on: ubuntu-latest 180 | steps: 181 | - uses: actions/checkout@v3 182 | 183 | - name: Build image 184 | run: make image_build 185 | env: 186 | DENO_VERSION: latest 187 | 188 | - name: Test image 189 | run: make image_test 190 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.gitignore.io/api/visualstudiocode,macos,jetbrains+all,linux,node,windows 4 | # Edit at https://www.gitignore.io/?templates=visualstudiocode,macos,jetbrains+all,linux,node,windows 5 | 6 | ### JetBrains+all ### 7 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 8 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 9 | 10 | # User-specific stuff 11 | .idea/**/workspace.xml 12 | .idea/**/tasks.xml 13 | .idea/**/usage.statistics.xml 14 | .idea/**/dictionaries 15 | .idea/**/shelf 16 | 17 | # Generated files 18 | .idea/**/contentModel.xml 19 | 20 | # Sensitive or high-churn files 21 | .idea/**/dataSources/ 22 | .idea/**/dataSources.ids 23 | .idea/**/dataSources.local.xml 24 | .idea/**/sqlDataSources.xml 25 | .idea/**/dynamic.xml 26 | .idea/**/uiDesigner.xml 27 | .idea/**/dbnavigator.xml 28 | 29 | # Gradle 30 | .idea/**/gradle.xml 31 | .idea/**/libraries 32 | 33 | # Gradle and Maven with auto-import 34 | # When using Gradle or Maven with auto-import, you should exclude module files, 35 | # since they will be recreated, and may cause churn. Uncomment if using 36 | # auto-import. 37 | # .idea/modules.xml 38 | # .idea/*.iml 39 | # .idea/modules 40 | # *.iml 41 | # *.ipr 42 | 43 | # CMake 44 | cmake-build-*/ 45 | 46 | # Mongo Explorer plugin 47 | .idea/**/mongoSettings.xml 48 | 49 | # File-based project format 50 | *.iws 51 | 52 | # IntelliJ 53 | out/ 54 | 55 | # mpeltonen/sbt-idea plugin 56 | .idea_modules/ 57 | 58 | # JIRA plugin 59 | atlassian-ide-plugin.xml 60 | 61 | # Cursive Clojure plugin 62 | .idea/replstate.xml 63 | 64 | # Crashlytics plugin (for Android Studio and IntelliJ) 65 | com_crashlytics_export_strings.xml 66 | crashlytics.properties 67 | crashlytics-build.properties 68 | fabric.properties 69 | 70 | # Editor-based Rest Client 71 | .idea/httpRequests 72 | 73 | # Android studio 3.1+ serialized cache file 74 | .idea/caches/build_file_checksums.ser 75 | 76 | ### JetBrains+all Patch ### 77 | # Ignores the whole .idea folder and all .iml files 78 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 79 | 80 | .idea/ 81 | 82 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 83 | 84 | *.iml 85 | modules.xml 86 | .idea/misc.xml 87 | *.ipr 88 | 89 | # Sonarlint plugin 90 | .idea/sonarlint 91 | 92 | ### Linux ### 93 | *~ 94 | 95 | # temporary files which can be created if a process still has a handle open of a deleted file 96 | .fuse_hidden* 97 | 98 | # KDE directory preferences 99 | .directory 100 | 101 | # Linux trash folder which might appear on any partition or disk 102 | .Trash-* 103 | 104 | # .nfs files are created when an open file is removed but is still being accessed 105 | .nfs* 106 | 107 | ### macOS ### 108 | # General 109 | .DS_Store 110 | .AppleDouble 111 | .LSOverride 112 | 113 | # Icon must end with two \r 114 | Icon 115 | 116 | # Thumbnails 117 | ._* 118 | 119 | # Files that might appear in the root of a volume 120 | .DocumentRevisions-V100 121 | .fseventsd 122 | .Spotlight-V100 123 | .TemporaryItems 124 | .Trashes 125 | .VolumeIcon.icns 126 | .com.apple.timemachine.donotpresent 127 | 128 | # Directories potentially created on remote AFP share 129 | .AppleDB 130 | .AppleDesktop 131 | Network Trash Folder 132 | Temporary Items 133 | .apdisk 134 | 135 | ### Node ### 136 | # Logs 137 | logs 138 | *.log 139 | npm-debug.log* 140 | yarn-debug.log* 141 | yarn-error.log* 142 | lerna-debug.log* 143 | 144 | # Diagnostic reports (https://nodejs.org/api/report.html) 145 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 146 | 147 | # Runtime data 148 | pids 149 | *.pid 150 | *.seed 151 | *.pid.lock 152 | 153 | # Directory for instrumented libs generated by jscoverage/JSCover 154 | lib-cov 155 | 156 | # Coverage directory used by tools like istanbul 157 | coverage 158 | *.lcov 159 | 160 | # nyc test coverage 161 | .nyc_output 162 | 163 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 164 | .grunt 165 | 166 | # Bower dependency directory (https://bower.io/) 167 | bower_components 168 | 169 | # node-waf configuration 170 | .lock-wscript 171 | 172 | # Compiled binary addons (https://nodejs.org/api/addons.html) 173 | build/Release 174 | 175 | # Dependency directories 176 | node_modules/ 177 | jspm_packages/ 178 | 179 | # TypeScript v1 declaration files 180 | typings/ 181 | 182 | # TypeScript cache 183 | *.tsbuildinfo 184 | 185 | # Optional npm cache directory 186 | .npm 187 | 188 | # Optional eslint cache 189 | .eslintcache 190 | 191 | # Optional REPL history 192 | .node_repl_history 193 | 194 | # Output of 'npm pack' 195 | *.tgz 196 | 197 | # Yarn Integrity file 198 | .yarn-integrity 199 | 200 | # dotenv environment variables file 201 | .env 202 | .env.test 203 | 204 | # parcel-bundler cache (https://parceljs.org/) 205 | .cache 206 | 207 | # next.js build output 208 | .next 209 | 210 | # nuxt.js build output 211 | .nuxt 212 | 213 | # rollup.js default build output 214 | dist/ 215 | 216 | # Uncomment the public line if your project uses Gatsby 217 | # https://nextjs.org/blog/next-9-1#public-directory-support 218 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 219 | # public 220 | 221 | # Storybook build outputs 222 | .out 223 | .storybook-out 224 | 225 | # vuepress build output 226 | .vuepress/dist 227 | 228 | # Serverless directories 229 | .serverless/ 230 | 231 | # FuseBox cache 232 | .fusebox/ 233 | 234 | # DynamoDB Local files 235 | .dynamodb/ 236 | 237 | # Temporary folders 238 | tmp/ 239 | temp/ 240 | 241 | ### VisualStudioCode ### 242 | .vscode/* 243 | !.vscode/settings.json 244 | !.vscode/tasks.json 245 | !.vscode/launch.json 246 | !.vscode/extensions.json 247 | 248 | ### VisualStudioCode Patch ### 249 | # Ignore all local history of files 250 | .history 251 | 252 | ### Windows ### 253 | # Windows thumbnail cache files 254 | Thumbs.db 255 | Thumbs.db:encryptable 256 | ehthumbs.db 257 | ehthumbs_vista.db 258 | 259 | # Dump file 260 | *.stackdump 261 | 262 | # Folder config file 263 | [Dd]esktop.ini 264 | 265 | # Recycle Bin used on file shares 266 | $RECYCLE.BIN/ 267 | 268 | # Windows Installer files 269 | *.cab 270 | *.msi 271 | *.msix 272 | *.msm 273 | *.msp 274 | 275 | # Windows shortcuts 276 | *.lnk 277 | 278 | # End of https://www.gitignore.io/api/visualstudiocode,macos,jetbrains+all,linux,node,windows 279 | 280 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 281 | .vscode 282 | data 283 | /db 284 | deno.lock 285 | -------------------------------------------------------------------------------- /tests/integration/update_timestamps/update_timestamps.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | assertEquals, 4 | fromFileUrl, 5 | MySQLClient, 6 | PostgreSQLClient, 7 | resolve, 8 | SQLiteClient, 9 | } from "../../../deps.ts"; 10 | import { TABLE_MIGRATIONS } from "../../../consts.ts"; 11 | 12 | const emptyMigration = 13 | `import { AbstractMigration, Info } from "../../../mod.ts"; 14 | export default class extends AbstractMigration { 15 | async up({ dialect }: Info): Promise { 16 | } 17 | async down({ dialect }: Info): Promise { 18 | } 19 | }`; 20 | 21 | const DIALECT_PG = "pg"; 22 | const DIALECT_MYSQL = "mysql"; 23 | const DIALECT_SQLITE = "sqlite"; 24 | const DIALECTS = [ 25 | DIALECT_PG, 26 | DIALECT_MYSQL, 27 | DIALECT_SQLITE, 28 | ]; 29 | 30 | const fileDir = resolve(fromFileUrl(import.meta.url), ".."); 31 | const decoder = new TextDecoder(); 32 | 33 | for await (const dialect of DIALECTS) { 34 | Deno.test({ 35 | name: "Update timestamps " + dialect, 36 | sanitizeResources: false, 37 | sanitizeOps: false, 38 | async fn() { 39 | for await (const dirEntry of Deno.readDir(fileDir)) { 40 | if (dirEntry.isFile && /.+-test\.ts/.test(dirEntry.name)) { 41 | await Deno.remove(resolve(fileDir, dirEntry.name)); 42 | } 43 | } 44 | 45 | await Deno.writeTextFile( 46 | fileDir + "/999999999999-test.ts", 47 | emptyMigration, 48 | ); 49 | await Deno.writeTextFile( 50 | fileDir + "/1000000000000-test.ts", 51 | emptyMigration, 52 | ); 53 | await Deno.writeTextFile( 54 | fileDir + "/1587937822648-test.ts", 55 | emptyMigration, 56 | ); //2020-04-26 23:50:22 57 | await Deno.writeTextFile( 58 | fileDir + "/9999999999999-test.ts", 59 | emptyMigration, 60 | ); 61 | await Deno.writeTextFile( 62 | fileDir + "/10000000000000-test.ts", 63 | emptyMigration, 64 | ); 65 | 66 | let hasFailed = false; 67 | 68 | const rMigration = Deno.run({ 69 | cmd: [ 70 | "deno", 71 | "run", 72 | "-A", 73 | "--unstable", 74 | "cli.ts", 75 | "migrate", 76 | "-c", 77 | `./tests/integration/update_timestamps/config/${dialect}.config.ts`, 78 | // "-d", 79 | ], 80 | stdout: "piped", 81 | }); 82 | 83 | const { code: codeMigration } = await rMigration.status(); 84 | 85 | const rawOutputMigration = await rMigration.output(); 86 | rMigration.close(); 87 | 88 | const resultMigration = decoder.decode(rawOutputMigration).split("\n"); 89 | 90 | if (codeMigration !== 0) { 91 | resultMigration.push(`Code was ${codeMigration}`); 92 | } 93 | 94 | hasFailed = resultMigration[resultMigration.length - 1].includes( 95 | "Code was", 96 | ); 97 | 98 | assert(!hasFailed, resultMigration.join("\n")); 99 | 100 | const r = Deno.run({ 101 | cmd: [ 102 | "deno", 103 | "run", 104 | "-A", 105 | "--unstable", 106 | "cli.ts", 107 | "update_timestamps", 108 | "-c", 109 | `./tests/integration/update_timestamps/config/${dialect}.config.ts`, 110 | ], 111 | stdout: "piped", 112 | }); 113 | 114 | const { code } = await r.status(); 115 | 116 | const rawOutput = await r.output(); 117 | r.close(); 118 | 119 | const result = decoder.decode(rawOutput).split("\n"); 120 | 121 | if (code !== 0) { 122 | result.push(`Code was ${code}`); 123 | } 124 | 125 | const expected = [ 126 | "Updated timestamps", 127 | "1587937822648-test.ts => 20200426235022_test.ts", 128 | "999999999999-test.ts => 20010909034639_test.ts", 129 | "1000000000000-test.ts => 20010909034640_test.ts", 130 | ]; 131 | 132 | assertEquals(code, 0, result.join("\n")); 133 | 134 | const missing: string[] = []; 135 | 136 | result 137 | .filter((el) => el.trim().length > 0 && !el.includes("INFO")) 138 | .forEach((el) => { 139 | const exists = expected.some((ell) => ell === el); 140 | if (!exists) { 141 | missing.push(el); 142 | } 143 | }); 144 | 145 | assertEquals(missing.length, 0, missing.join("\n")); 146 | 147 | const configFile = await import( 148 | "file://" + 149 | resolve( 150 | `./tests/integration/update_timestamps/config/${dialect}.config.ts`, 151 | ) 152 | ); 153 | const { dbConnection } = configFile; 154 | 155 | let client; 156 | let migrationFilesDb: string[]; 157 | 158 | if (dialect === DIALECT_PG) { 159 | client = new PostgreSQLClient(dbConnection); 160 | await client.connect(); 161 | const { rows: migrationFilesDbRaw } = await client.queryObject( 162 | `SELECT * FROM ${TABLE_MIGRATIONS}`, 163 | ); 164 | await client.end(); 165 | migrationFilesDb = migrationFilesDbRaw 166 | .map((el: any) => el.file_name as string); 167 | } else if (dialect === DIALECT_MYSQL) { 168 | client = new MySQLClient(); 169 | await client.connect(dbConnection); 170 | const migrationFilesDbRaw = await client.query( 171 | `SELECT * FROM ${TABLE_MIGRATIONS}`, 172 | ); 173 | await client.close(); 174 | migrationFilesDb = migrationFilesDbRaw.map((el: any) => el.file_name); 175 | } else { 176 | client = new SQLiteClient(dbConnection); 177 | const migrationFilesDbRaw = [...client.query( 178 | `SELECT * FROM ${TABLE_MIGRATIONS}`, 179 | )]; 180 | client.close(); 181 | migrationFilesDb = migrationFilesDbRaw.map((el: any) => el[1]); 182 | } 183 | 184 | const missingDb: string[] = []; 185 | const expectedDb = [ 186 | "9999999999999-test.ts", 187 | "10000000000000-test.ts", 188 | "20010909014639-test.ts", 189 | "20010909014640-test.ts", 190 | "20200426215022-test.ts", 191 | ]; 192 | 193 | migrationFilesDb 194 | .filter((el) => el.trim().length > 0 && !el.includes("INFO")) 195 | .forEach((el) => { 196 | const exists = expectedDb.some((ell) => ell === el); 197 | if (!exists) { 198 | missingDb.push(el); 199 | } 200 | }); 201 | 202 | assertEquals(missingDb.length, 0, missingDb.join("\n")); 203 | 204 | for await (const dirEntry of Deno.readDir(fileDir)) { 205 | if (dirEntry.isFile && /.+-test\.ts/.test(dirEntry.name)) { 206 | await Deno.remove(resolve(fileDir, dirEntry.name)); 207 | } 208 | } 209 | }, 210 | }); 211 | } 212 | -------------------------------------------------------------------------------- /tests/unit/templates.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "../../deps.ts"; 2 | import { 3 | getConfigTemplate, 4 | getMigrationTemplate, 5 | getSeedTemplate, 6 | } from "../../cli/templates.ts"; 7 | import { DB_DIALECTS, URL_BASE_VERSIONED } from "../../consts.ts"; 8 | 9 | Deno.test("getConfigTemplate standard", () => { 10 | const expected = `import { 11 | ClientMySQL, 12 | ClientPostgreSQL, 13 | ClientSQLite, 14 | NessieConfig, 15 | } from "${URL_BASE_VERSIONED}/mod.ts"; 16 | 17 | /** Select one of the supported clients */ 18 | // const client = new ClientPostgreSQL({ 19 | // database: "nessie", 20 | // hostname: "localhost", 21 | // port: 5432, 22 | // user: "root", 23 | // password: "pwd", 24 | // }); 25 | 26 | // const client = new ClientMySQL({ 27 | // hostname: "localhost", 28 | // port: 3306, 29 | // username: "root", 30 | // // password: "pwd", // uncomment this line for <8 31 | // db: "nessie", 32 | // }); 33 | 34 | // const client = new ClientSQLite("./sqlite.db"); 35 | 36 | /** This is the final config object */ 37 | const config: NessieConfig = { 38 | client, 39 | migrationFolders: ["./db/migrations"], 40 | seedFolders: ["./db/seeds"], 41 | }; 42 | 43 | export default config; 44 | `; 45 | const actual = getConfigTemplate(); 46 | 47 | assertEquals(actual, expected); 48 | }); 49 | 50 | Deno.test("getConfigTemplate pg", () => { 51 | const expected = `import { 52 | ClientPostgreSQL, 53 | NessieConfig, 54 | } from "${URL_BASE_VERSIONED}/mod.ts"; 55 | 56 | const client = new ClientPostgreSQL({ 57 | database: "nessie", 58 | hostname: "localhost", 59 | port: 5432, 60 | user: "root", 61 | password: "pwd", 62 | }); 63 | 64 | /** This is the final config object */ 65 | const config: NessieConfig = { 66 | client, 67 | migrationFolders: ["./db/migrations"], 68 | seedFolders: ["./db/seeds"], 69 | }; 70 | 71 | export default config; 72 | `; 73 | const actual = getConfigTemplate(DB_DIALECTS.PGSQL); 74 | 75 | assertEquals(actual, expected); 76 | }); 77 | 78 | Deno.test("getConfigTemplate mysql", () => { 79 | const expected = `import { 80 | ClientMySQL, 81 | NessieConfig, 82 | } from "${URL_BASE_VERSIONED}/mod.ts"; 83 | 84 | const client = new ClientMySQL({ 85 | hostname: "localhost", 86 | port: 3306, 87 | username: "root", 88 | // password: "pwd", // uncomment this line for <8 89 | db: "nessie", 90 | }); 91 | 92 | /** This is the final config object */ 93 | const config: NessieConfig = { 94 | client, 95 | migrationFolders: ["./db/migrations"], 96 | seedFolders: ["./db/seeds"], 97 | }; 98 | 99 | export default config; 100 | `; 101 | const actual = getConfigTemplate(DB_DIALECTS.MYSQL); 102 | 103 | assertEquals(actual, expected); 104 | }); 105 | 106 | Deno.test("getConfigTemplate sqlite", () => { 107 | const expected = `import { 108 | ClientSQLite, 109 | NessieConfig, 110 | } from "${URL_BASE_VERSIONED}/mod.ts"; 111 | 112 | const client = new ClientSQLite("./sqlite.db"); 113 | 114 | /** This is the final config object */ 115 | const config: NessieConfig = { 116 | client, 117 | migrationFolders: ["./db/migrations"], 118 | seedFolders: ["./db/seeds"], 119 | }; 120 | 121 | export default config; 122 | `; 123 | const actual = getConfigTemplate(DB_DIALECTS.SQLITE); 124 | 125 | assertEquals(actual, expected); 126 | }); 127 | 128 | Deno.test("getMigrationTemplate standard", () => { 129 | const expected = 130 | `import { AbstractMigration, Info } from "${URL_BASE_VERSIONED}/mod.ts"; 131 | 132 | export default class extends AbstractMigration { 133 | /** Runs on migrate */ 134 | async up(info: Info): Promise { 135 | } 136 | 137 | /** Runs on rollback */ 138 | async down(info: Info): Promise { 139 | } 140 | } 141 | `; 142 | const actual = getMigrationTemplate(); 143 | 144 | assertEquals(actual, expected); 145 | }); 146 | 147 | Deno.test("getMigrationTemplate pg", () => { 148 | const expected = 149 | `import { AbstractMigration, Info, ClientPostgreSQL } from "${URL_BASE_VERSIONED}/mod.ts"; 150 | 151 | export default class extends AbstractMigration { 152 | /** Runs on migrate */ 153 | async up(info: Info): Promise { 154 | } 155 | 156 | /** Runs on rollback */ 157 | async down(info: Info): Promise { 158 | } 159 | } 160 | `; 161 | const actual = getMigrationTemplate(DB_DIALECTS.PGSQL); 162 | 163 | assertEquals(actual, expected); 164 | }); 165 | 166 | Deno.test("getMigrationTemplate mysql", () => { 167 | const expected = 168 | `import { AbstractMigration, Info, ClientMySQL } from "${URL_BASE_VERSIONED}/mod.ts"; 169 | 170 | export default class extends AbstractMigration { 171 | /** Runs on migrate */ 172 | async up(info: Info): Promise { 173 | } 174 | 175 | /** Runs on rollback */ 176 | async down(info: Info): Promise { 177 | } 178 | } 179 | `; 180 | const actual = getMigrationTemplate(DB_DIALECTS.MYSQL); 181 | 182 | assertEquals(actual, expected); 183 | }); 184 | 185 | Deno.test("getMigrationTemplate sqlite", () => { 186 | const expected = 187 | `import { AbstractMigration, Info, ClientSQLite } from "${URL_BASE_VERSIONED}/mod.ts"; 188 | 189 | export default class extends AbstractMigration { 190 | /** Runs on migrate */ 191 | async up(info: Info): Promise { 192 | } 193 | 194 | /** Runs on rollback */ 195 | async down(info: Info): Promise { 196 | } 197 | } 198 | `; 199 | const actual = getMigrationTemplate(DB_DIALECTS.SQLITE); 200 | 201 | assertEquals(actual, expected); 202 | }); 203 | 204 | Deno.test("getSeedTemplate standard", () => { 205 | const expected = 206 | `import { AbstractSeed, Info } from "${URL_BASE_VERSIONED}/mod.ts"; 207 | 208 | export default class extends AbstractSeed { 209 | /** Runs on seed */ 210 | async run(info: Info): Promise { 211 | } 212 | } 213 | `; 214 | const actual = getSeedTemplate(); 215 | 216 | assertEquals(actual, expected); 217 | }); 218 | 219 | Deno.test("getSeedTemplate pg", () => { 220 | const expected = 221 | `import { AbstractSeed, Info, ClientPostgreSQL } from "${URL_BASE_VERSIONED}/mod.ts"; 222 | 223 | export default class extends AbstractSeed { 224 | /** Runs on seed */ 225 | async run(info: Info): Promise { 226 | } 227 | } 228 | `; 229 | const actual = getSeedTemplate(DB_DIALECTS.PGSQL); 230 | 231 | assertEquals(actual, expected); 232 | }); 233 | 234 | Deno.test("getSeedTemplate mysql", () => { 235 | const expected = 236 | `import { AbstractSeed, Info, ClientMySQL } from "${URL_BASE_VERSIONED}/mod.ts"; 237 | 238 | export default class extends AbstractSeed { 239 | /** Runs on seed */ 240 | async run(info: Info): Promise { 241 | } 242 | } 243 | `; 244 | const actual = getSeedTemplate(DB_DIALECTS.MYSQL); 245 | 246 | assertEquals(actual, expected); 247 | }); 248 | 249 | Deno.test("getSeedTemplate sqlite", () => { 250 | const expected = 251 | `import { AbstractSeed, Info, ClientSQLite } from "${URL_BASE_VERSIONED}/mod.ts"; 252 | 253 | export default class extends AbstractSeed { 254 | /** Runs on seed */ 255 | async run(info: Info): Promise { 256 | } 257 | } 258 | `; 259 | const actual = getSeedTemplate(DB_DIALECTS.SQLITE); 260 | 261 | assertEquals(actual, expected); 262 | }); 263 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | DENO_VERSION: 1.37.0 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | fmt: 16 | name: Test format and lint 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Clone repo 21 | uses: actions/checkout@v3 22 | 23 | - name: Install deno 24 | uses: denoland/setup-deno@v1 25 | with: 26 | deno-version: ${{env.DENO_VERSION}} 27 | 28 | - name: Check fmt 29 | run: deno fmt --check 30 | 31 | - name: Check lint 32 | run: deno lint 33 | 34 | unit: 35 | name: Test unit 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Clone repo 40 | uses: actions/checkout@v3 41 | 42 | - name: Install deno 43 | uses: denoland/setup-deno@v1 44 | with: 45 | deno-version: ${{env.DENO_VERSION}} 46 | 47 | - name: Run unit tests 48 | run: deno task test:unit 49 | 50 | - name: Generate lcov 51 | run: deno coverage --unstable --lcov ./coverage > coverage.lcov 52 | 53 | - name: Upload coverage 54 | uses: codecov/codecov-action@v1 55 | with: 56 | files: coverage.lcov 57 | flags: unit 58 | 59 | cli: 60 | name: Test CLI 61 | runs-on: ubuntu-latest 62 | env: 63 | URL_PATH: ${{github.event.pull_request.head.repo.full_name||github.repository}}/${{github.event.pull_request.head.ref||'main'}} 64 | 65 | steps: 66 | - name: Install deno 67 | uses: denoland/setup-deno@v1 68 | with: 69 | deno-version: ${{env.DENO_VERSION}} 70 | 71 | - name: Nessie Init 72 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --dialect sqlite 73 | 74 | - run: sed -i "s|from \".*\"|from \"https://raw.githubusercontent.com/$URL_PATH/mod.ts\"|" nessie.config.ts && cat nessie.config.ts 75 | 76 | - name: Create migration 77 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make test 78 | 79 | - name: Create migration 80 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:migration test2 81 | 82 | - name: Create seed 83 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:seed test 84 | 85 | - run: echo "test" >> test_template 86 | 87 | - name: Create migration from custom template 88 | run: | 89 | deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:migration --migrationTemplate test_template test_migration_template 90 | TEMPLATE_PATH=$(find db/migrations -type f -name "*test_migration_template.ts") 91 | TEMPLATE_CONTENT=$(cat $TEMPLATE_PATH) 92 | if [[ $TEMPLATE_CONTENT != "test" ]]; then echo "File $TEMPLATE_PATH was not correct, was:\n$TEMPLATE_CONTENT" && exit 1; fi 93 | 94 | - name: Create seed from custom template 95 | run: | 96 | deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts make:seed --seedTemplate test_template test_seed_template 97 | TEMPLATE_PATH=$(find db/seeds -type f -name "test_seed_template.ts") 98 | TEMPLATE_CONTENT=$(cat $TEMPLATE_PATH) 99 | if [[ $TEMPLATE_CONTENT != "test" ]]; then echo "File $TEMPLATE_PATH was not correct, was:\n$TEMPLATE_CONTENT" && exit 1; fi 100 | 101 | - name: Clean files and folders 102 | run: rm -rf db && rm -rf nessie.config.ts 103 | 104 | - name: Init with mode and pg 105 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode config --dialect pg 106 | 107 | - name: Init with mode and mysql 108 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode config --dialect mysql 109 | 110 | - name: Init with mode and sqlite 111 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode config --dialect sqlite 112 | 113 | - name: Init with folders only 114 | run: deno run -A --unstable https://raw.githubusercontent.com/$URL_PATH/cli.ts init --mode folders 115 | 116 | cli-migrations: 117 | name: Test CLI Migrations 118 | runs-on: ubuntu-latest 119 | 120 | services: 121 | postgres: 122 | image: postgres 123 | env: 124 | POSTGRES_USER: root 125 | POSTGRES_PASSWORD: pwd 126 | POSTGRES_DB: nessie 127 | options: >- 128 | --health-cmd pg_isready 129 | --health-interval 10s 130 | --health-timeout 5s 131 | --health-retries 5 132 | ports: 133 | - 5000:5432 134 | 135 | mysql: 136 | image: mysql 137 | env: 138 | MYSQL_ALLOW_EMPTY_PASSWORD: true 139 | MYSQL_DATABASE: nessie 140 | options: >- 141 | --health-cmd="mysqladmin ping" 142 | --health-interval=10s 143 | --health-timeout=5s 144 | --health-retries=3 145 | ports: 146 | - 5001:3306 147 | 148 | steps: 149 | - name: Clone repo 150 | uses: actions/checkout@v3 151 | 152 | - name: Install deno 153 | uses: denoland/setup-deno@v1 154 | with: 155 | deno-version: ${{env.DENO_VERSION}} 156 | 157 | - name: Create databases 158 | run: make db_sqlite_start 159 | 160 | - name: Run tests 161 | run: make test_integration_cli 162 | 163 | - name: Generate lcov 164 | run: deno coverage --unstable --lcov ./coverage > coverage.lcov 165 | 166 | - name: Upload coverage 167 | uses: codecov/codecov-action@v1 168 | with: 169 | files: coverage.lcov 170 | flags: integration-cli 171 | 172 | cli-update-timestamps: 173 | name: Test CLI Update timestamps 174 | runs-on: ubuntu-latest 175 | 176 | services: 177 | postgres: 178 | image: postgres 179 | env: 180 | POSTGRES_USER: root 181 | POSTGRES_PASSWORD: pwd 182 | POSTGRES_DB: nessie 183 | options: >- 184 | --health-cmd pg_isready 185 | --health-interval 10s 186 | --health-timeout 5s 187 | --health-retries 5 188 | ports: 189 | - 5000:5432 190 | 191 | mysql: 192 | image: mysql 193 | env: 194 | MYSQL_ALLOW_EMPTY_PASSWORD: true 195 | MYSQL_DATABASE: nessie 196 | options: >- 197 | --health-cmd="mysqladmin ping" 198 | --health-interval=10s 199 | --health-timeout=5s 200 | --health-retries=3 201 | ports: 202 | - 5001:3306 203 | 204 | steps: 205 | - name: Clone repo 206 | uses: actions/checkout@v3 207 | 208 | - name: Install deno 209 | uses: denoland/setup-deno@v1 210 | with: 211 | deno-version: ${{env.DENO_VERSION}} 212 | 213 | - name: Create databases 214 | run: make db_sqlite_start 215 | 216 | - name: Run tests 217 | run: make test_integration_update_timestamps 218 | 219 | - name: Generate lcov 220 | run: deno coverage --unstable --lcov ./coverage > coverage.lcov 221 | 222 | - name: Upload coverage 223 | uses: codecov/codecov-action@v1 224 | with: 225 | files: coverage.lcov 226 | flags: integration-update-timestamps 227 | 228 | image-test: 229 | name: Test Docker image build 230 | runs-on: ubuntu-latest 231 | steps: 232 | - uses: actions/checkout@v3 233 | 234 | - name: Build image 235 | run: make image_build 236 | env: 237 | DENO_VERSION: latest 238 | 239 | - name: Test image 240 | run: make image_test 241 | -------------------------------------------------------------------------------- /cli/commands.ts: -------------------------------------------------------------------------------- 1 | import { State } from "./state.ts"; 2 | import { 3 | CliffyCommand, 4 | CliffyIAction, 5 | dirname, 6 | exists, 7 | format, 8 | green, 9 | resolve, 10 | } from "../deps.ts"; 11 | import { 12 | DEFAULT_CONFIG_FILE, 13 | DEFAULT_MIGRATION_FOLDER, 14 | DEFAULT_SEED_FOLDER, 15 | REGEXP_MIGRATION_FILE_NAME_LEGACY, 16 | SPONSOR_NOTICE, 17 | } from "../consts.ts"; 18 | import { 19 | AmountMigrateT, 20 | AmountRollbackT, 21 | CommandOptions, 22 | CommandOptionsInit, 23 | CommandOptionsMakeMigration, 24 | CommandOptionsMakeSeed, 25 | CommandOptionsStatus, 26 | } from "../types.ts"; 27 | import { getConfigTemplate } from "./templates.ts"; 28 | import { isFileUrl, isMigrationFile } from "./utils.ts"; 29 | 30 | type TCliffyAction< 31 | // deno-lint-ignore no-explicit-any 32 | T extends unknown[] = any[], 33 | O extends CommandOptions = CommandOptions, 34 | > = CliffyIAction< 35 | void, 36 | T, 37 | void, 38 | O, 39 | CliffyCommand 40 | >; 41 | 42 | // deno-lint-ignore no-explicit-any 43 | export const initNessie: TCliffyAction = async ( 44 | /** Initializes Nessie */ 45 | // deno-lint-ignore no-explicit-any 46 | options: any, 47 | ) => { 48 | const template = getConfigTemplate(options.dialect); 49 | 50 | if (options.mode !== "folders") { 51 | const filePath = resolve(Deno.cwd(), DEFAULT_CONFIG_FILE); 52 | const fileExists = await exists(filePath); 53 | 54 | if (fileExists) { 55 | console.info(green("Config file already exists")); 56 | } else { 57 | await Deno.writeTextFile( 58 | filePath, 59 | template, 60 | ); 61 | 62 | console.info(green("Created config file")); 63 | } 64 | } 65 | 66 | if (options.mode !== "config") { 67 | const migrationFolderExists = await exists( 68 | resolve(Deno.cwd(), DEFAULT_MIGRATION_FOLDER), 69 | ); 70 | const seedFolderExists = await exists( 71 | resolve(Deno.cwd(), DEFAULT_SEED_FOLDER), 72 | ); 73 | 74 | if (migrationFolderExists) { 75 | console.info(green("Migration folder already exists")); 76 | } else { 77 | await Deno.mkdir(resolve(Deno.cwd(), DEFAULT_MIGRATION_FOLDER), { 78 | recursive: true, 79 | }); 80 | await Deno.create( 81 | resolve(Deno.cwd(), DEFAULT_MIGRATION_FOLDER, ".gitkeep"), 82 | ); 83 | console.info(green("Created migration folder")); 84 | } 85 | 86 | if (seedFolderExists) { 87 | console.info(green("Seed folder already exists")); 88 | } else { 89 | await Deno.mkdir(resolve(Deno.cwd(), DEFAULT_SEED_FOLDER), { 90 | recursive: true, 91 | }); 92 | await Deno.create(resolve(Deno.cwd(), DEFAULT_SEED_FOLDER, ".gitkeep")); 93 | console.info(green("Created seed folder")); 94 | } 95 | } 96 | 97 | console.info(SPONSOR_NOTICE); 98 | }; 99 | 100 | // deno-lint-ignore no-explicit-any 101 | export const makeMigration: TCliffyAction = 102 | async ( 103 | // deno-lint-ignore no-explicit-any 104 | options: any, 105 | fileName: string, 106 | ) => { 107 | const state = await State.init(options); 108 | await state.makeMigration(fileName); 109 | }; 110 | 111 | // deno-lint-ignore no-explicit-any 112 | export const makeSeed: TCliffyAction = async ( 113 | // deno-lint-ignore no-explicit-any 114 | options: any, 115 | fileName: string, 116 | ) => { 117 | const state = await State.init(options); 118 | await state.makeSeed(fileName); 119 | }; 120 | 121 | export const seed: TCliffyAction = async ( 122 | // deno-lint-ignore no-explicit-any 123 | options: any, 124 | matcher: string | undefined, 125 | ) => { 126 | const state = await State.init(options); 127 | await state.client.prepare(); 128 | await state.client.seed(matcher); 129 | await state.client.close(); 130 | }; 131 | 132 | export const migrate: TCliffyAction = async ( 133 | options, 134 | amount: AmountMigrateT, 135 | ) => { 136 | const state = await State.init(options); 137 | await state.client.prepare(); 138 | await state.client.migrate(amount); 139 | await state.client.close(); 140 | }; 141 | 142 | export const rollback: TCliffyAction = async ( 143 | options, 144 | amount: AmountRollbackT, 145 | ) => { 146 | const state = await State.init(options); 147 | await state.client.prepare(); 148 | await state.client.rollback(amount); 149 | await state.client.close(); 150 | }; 151 | 152 | export const updateTimestamps: TCliffyAction = async (options) => { 153 | const state = await State.init(options); 154 | await state.client.prepare(); 155 | await state.client.updateTimestamps(); 156 | await state.client.close(); 157 | const migrationFiles = state.client.migrationFiles 158 | .filter((el) => 159 | isFileUrl(el.path) && 160 | REGEXP_MIGRATION_FILE_NAME_LEGACY.test(el.name) && 161 | parseInt(el.name.split("-")[0]) < 1672531200000 162 | ) 163 | .map((el) => { 164 | const filenameArray = el.name.split("-", 2); 165 | const milliseconds = filenameArray[0]; 166 | const filename = filenameArray[1]; 167 | const timestamp = new Date(parseInt(milliseconds)); 168 | const newDateTime = format(timestamp, "yyyyMMddHHmmss"); 169 | const newName = newDateTime + "_" + filename; 170 | 171 | if (!isMigrationFile(newName)) { 172 | console.warn( 173 | `Migration ${el.name} has been updated to ${newName}, but this is not a valid filename. Please change this filename manually. See the method 'isMigrationFile' from 'mod.ts' for filename validation`, 174 | ); 175 | } 176 | 177 | return { 178 | oldPath: el.path, 179 | newPath: resolve(dirname(el.path), newName), 180 | }; 181 | }); 182 | 183 | for await (const { oldPath, newPath } of migrationFiles) { 184 | await Deno.rename(oldPath, newPath); 185 | } 186 | 187 | const output = migrationFiles 188 | .map(({ oldPath, newPath }) => `${oldPath} => ${newPath}`) 189 | .join("\n"); 190 | 191 | console.info(output); 192 | }; 193 | 194 | // deno-lint-ignore no-explicit-any 195 | export const status: TCliffyAction = async ( 196 | options, 197 | ) => { 198 | const state = await State.init(options); 199 | await state.client.prepare(); 200 | const allCompletedMigrations = await state.client.getAll(); 201 | await state.client.close(); 202 | 203 | const newAvailableMigrations = state.client.migrationFiles 204 | .filter((el) => !allCompletedMigrations.includes(el.name)); 205 | 206 | // deno-lint-ignore no-explicit-any 207 | const outputJson: Record = { 208 | totalAvailableMigrationFiles: state.client.migrationFiles.length, 209 | completedMigrations: allCompletedMigrations.length, 210 | newAvailableMigrations: newAvailableMigrations.length, 211 | }; 212 | 213 | if (options.fileNames) { 214 | outputJson.totalAvailableMigrationFileNames = state.client.migrationFiles 215 | .map((el) => el.name); 216 | outputJson.completedMigrationNames = allCompletedMigrations; 217 | outputJson.newAvailableMigrationNames = state.client.migrationFiles 218 | .map((el) => el.name); 219 | } 220 | 221 | switch (options.output) { 222 | case "json": 223 | console.info(JSON.stringify(outputJson, undefined, 0)); 224 | break; 225 | case "log": 226 | default: 227 | { 228 | let output = "Status\n\n"; 229 | const tabbedLines = (str: string) => { 230 | output += `\t${str}\n`; 231 | }; 232 | 233 | output += 234 | `totalAvailableMigrationFiles: ${outputJson.totalAvailableMigrationFiles}\n`; 235 | if (options.fileNames) { 236 | outputJson.totalAvailableMigrationFileNames.forEach(tabbedLines); 237 | } 238 | 239 | output += `completedMigrations: ${outputJson.completedMigrations}\n`; 240 | if (options.fileNames) { 241 | outputJson.completedMigrationNames.forEach(tabbedLines); 242 | } 243 | 244 | output += 245 | `newAvailableMigrations: ${outputJson.newAvailableMigrations}\n`; 246 | if (options.fileNames) { 247 | outputJson.newAvailableMigrationNames.forEach(tabbedLines); 248 | } 249 | 250 | console.info(output); 251 | } 252 | break; 253 | } 254 | }; 255 | -------------------------------------------------------------------------------- /clients/AbstractClient.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AbstractClientOptions, 3 | AmountMigrateT, 4 | AmountRollbackT, 5 | DBDialects, 6 | FileEntryT, 7 | Info, 8 | LoggerFn, 9 | QueryHandler, 10 | QueryT, 11 | QueryWithString, 12 | } from "../types.ts"; 13 | import type { 14 | AbstractMigration, 15 | AbstractMigrationProps, 16 | } from "../wrappers/AbstractMigration.ts"; 17 | import { AbstractSeed, AbstractSeedProps } from "../wrappers/AbstractSeed.ts"; 18 | import { COL_FILE_NAME, TABLE_MIGRATIONS } from "../consts.ts"; 19 | import { green } from "../deps.ts"; 20 | import { getDurationFromTimestamp } from "../cli/utils.ts"; 21 | import { NessieError } from "../cli/errors.ts"; 22 | 23 | /** The abstract client which handles most of the logic related to database communication. */ 24 | export abstract class AbstractClient { 25 | protected logger: LoggerFn = () => undefined; 26 | 27 | client: Client; 28 | /** Migration files read from the migration folders */ 29 | migrationFiles: FileEntryT[] = []; 30 | /** Seed files read from the seed folders */ 31 | seedFiles: FileEntryT[] = []; 32 | /** The current dialect, given by the Client e.g. pg, mysql, sqlite */ 33 | dialect?: DBDialects | string; 34 | 35 | protected get QUERY_GET_LATEST() { 36 | return `SELECT ${COL_FILE_NAME} FROM ${TABLE_MIGRATIONS} ORDER BY ${COL_FILE_NAME} DESC LIMIT 1;`; 37 | } 38 | 39 | protected get QUERY_GET_ALL() { 40 | return `SELECT ${COL_FILE_NAME} FROM ${TABLE_MIGRATIONS} ORDER BY ${COL_FILE_NAME} DESC;`; 41 | } 42 | 43 | protected QUERY_MIGRATION_INSERT: QueryWithString = (fileName) => 44 | `INSERT INTO ${TABLE_MIGRATIONS} (${COL_FILE_NAME}) VALUES ('${fileName}');`; 45 | protected QUERY_MIGRATION_DELETE: QueryWithString = (fileName) => 46 | `DELETE FROM ${TABLE_MIGRATIONS} WHERE ${COL_FILE_NAME} = '${fileName}';`; 47 | 48 | constructor(options: AbstractClientOptions) { 49 | this.client = options.client; 50 | } 51 | 52 | protected _parseAmount( 53 | amount: AmountRollbackT, 54 | maxAmount = 0, 55 | isMigration = true, 56 | ): number { 57 | const defaultAmount = isMigration ? maxAmount : 1; 58 | 59 | if (amount === "all") return maxAmount; 60 | if (amount === undefined) return defaultAmount; 61 | if (typeof amount === "string") { 62 | amount = isNaN(parseInt(amount)) ? defaultAmount : parseInt(amount); 63 | } 64 | return Math.min(maxAmount, amount); 65 | } 66 | 67 | /** Runs the `up` method on all available migrations after filtering and sorting. */ 68 | protected async _migrate( 69 | amount: AmountMigrateT, 70 | latestMigration: string | undefined, 71 | queryHandler: QueryHandler, 72 | ) { 73 | this.logger(amount, "Amount pre"); 74 | this.logger(latestMigration, "Latest migrations"); 75 | 76 | this._sliceMigrationFiles(latestMigration); 77 | amount = this._parseAmount(amount, this.migrationFiles.length, true); 78 | 79 | this.logger( 80 | this.migrationFiles, 81 | "Filtered and sorted migration files", 82 | ); 83 | 84 | if (amount < 1) { 85 | console.info("Nothing to migrate"); 86 | return; 87 | } 88 | 89 | console.info( 90 | green(`Starting migration of ${this.migrationFiles.length} files`), 91 | "\n----\n", 92 | ); 93 | 94 | const t1 = performance.now(); 95 | 96 | for (const [i, file] of this.migrationFiles.entries()) { 97 | if (i >= amount) break; 98 | 99 | console.info(green(`Migrating ${file.name}`)); 100 | 101 | const t2 = performance.now(); 102 | 103 | await this._migrationHandler(file, queryHandler); 104 | 105 | const duration2 = getDurationFromTimestamp(t2); 106 | 107 | console.info(`Done in ${duration2} seconds\n----\n`); 108 | } 109 | 110 | const duration1 = getDurationFromTimestamp(t1); 111 | 112 | console.info(green(`Migrations completed in ${duration1} seconds`)); 113 | } 114 | 115 | /** Runs the `down` method on defined number of migrations after retrieving them from the DB. */ 116 | async _rollback( 117 | amount: AmountRollbackT, 118 | allMigrations: string[] | undefined, 119 | queryHandler: QueryHandler, 120 | ) { 121 | this.logger(allMigrations, "Files to rollback"); 122 | this.logger(amount, "Amount pre"); 123 | 124 | if (!allMigrations || allMigrations.length < 1) { 125 | console.info("Nothing to rollback"); 126 | return; 127 | } 128 | 129 | amount = this._parseAmount(amount, allMigrations.length, false); 130 | this.logger(amount, "Received amount to rollback"); 131 | 132 | console.info( 133 | green(`Starting rollback of ${amount} files`), 134 | "\n----\n", 135 | ); 136 | 137 | const t1 = performance.now(); 138 | 139 | for (const [i, fileName] of allMigrations.entries()) { 140 | if (i >= amount) break; 141 | 142 | const file = this.migrationFiles 143 | .find((migrationFile) => migrationFile.name === fileName); 144 | 145 | if (!file) { 146 | throw new NessieError(`Migration file '${fileName}' is not found`); 147 | } 148 | 149 | console.info(`Rolling back ${file.name}`); 150 | 151 | const t2 = performance.now(); 152 | 153 | await this._migrationHandler(file, queryHandler, true); 154 | 155 | const duration2 = getDurationFromTimestamp(t2); 156 | 157 | console.info(`Done in ${duration2} seconds\n----\n`); 158 | } 159 | 160 | const duration1 = getDurationFromTimestamp(t1); 161 | 162 | console.info(green(`Rollback completed in ${duration1} seconds`)); 163 | } 164 | 165 | /** Runs the `run` method on seed files. Filters on the matcher. */ 166 | async _seed(matcher = ".+.ts") { 167 | const files = this.seedFiles.filter((el) => 168 | el.name === matcher || new RegExp(matcher).test(el.name) 169 | ); 170 | 171 | if (files.length < 1) { 172 | console.info(`No seed file found with matcher '${matcher}'`); 173 | return; 174 | } 175 | 176 | console.info( 177 | green(`Starting seeding of ${files.length} files`), 178 | "\n----\n", 179 | ); 180 | 181 | const t1 = performance.now(); 182 | 183 | for await (const file of files) { 184 | // deno-lint-ignore no-explicit-any 185 | const exposedObject: Info = { 186 | dialect: this.dialect!, 187 | }; 188 | 189 | console.info(`Seeding ${file.name}`); 190 | 191 | const SeedClass: new ( 192 | props: AbstractSeedProps, 193 | ) => AbstractSeed = (await import(file.path)).default; 194 | 195 | const seed = new SeedClass({ client: this.client }); 196 | 197 | const t2 = performance.now(); 198 | 199 | await seed.run(exposedObject); 200 | 201 | const duration2 = getDurationFromTimestamp(t2); 202 | 203 | console.info(`Done in ${duration2} seconds\n----\n`); 204 | } 205 | 206 | const duration1 = getDurationFromTimestamp(t1); 207 | 208 | console.info(green(`Seeding completed in ${duration1} seconds`)); 209 | } 210 | 211 | /** Sets the logger for the client. Given by the State. */ 212 | setLogger(fn: LoggerFn) { 213 | this.logger = fn; 214 | } 215 | 216 | /** Splits and trims queries. */ 217 | protected splitAndTrimQueries(query: string) { 218 | return query.split(";").filter((el) => el.trim() !== ""); 219 | } 220 | 221 | /** Filters and sort files in ascending order. */ 222 | private _sliceMigrationFiles(queryResult: string | undefined): void { 223 | if (!queryResult) return; 224 | 225 | const sliceIndex = this.migrationFiles 226 | .findIndex((file) => file.name >= queryResult); 227 | 228 | if (sliceIndex !== undefined) { 229 | this.migrationFiles = this.migrationFiles.slice(sliceIndex + 1); 230 | } 231 | } 232 | 233 | /** Handles migration files. */ 234 | private async _migrationHandler( 235 | file: FileEntryT, 236 | queryHandler: QueryHandler, 237 | isDown = false, 238 | ) { 239 | // deno-lint-ignore no-explicit-any 240 | const exposedObject: Info = { 241 | dialect: this.dialect!, 242 | }; 243 | 244 | const MigrationClass: new ( 245 | props: AbstractMigrationProps, 246 | ) => AbstractMigration = (await import(file.path)).default; 247 | 248 | const migration = new MigrationClass({ client: this.client }); 249 | 250 | if (isDown) { 251 | await migration.down(exposedObject); 252 | await queryHandler(this.QUERY_MIGRATION_DELETE(file.name)); 253 | } else { 254 | await migration.up(exposedObject); 255 | await queryHandler(this.QUERY_MIGRATION_INSERT(file.name)); 256 | } 257 | } 258 | 259 | /** Prepares the db connection */ 260 | abstract prepare(): Promise; 261 | /** Updates timestamp format */ 262 | abstract updateTimestamps(): Promise; 263 | /** Closes the db connection */ 264 | abstract close(): Promise; 265 | /** Handles the migration */ 266 | abstract migrate(amount: AmountMigrateT): Promise; 267 | /** Handles the rollback */ 268 | abstract rollback(amount: AmountRollbackT): Promise; 269 | /** Handles the seeding */ 270 | abstract seed(matcher?: string): Promise; 271 | /** Universal wrapper for db query execution */ 272 | // deno-lint-ignore no-explicit-any 273 | abstract query(query: QueryT): Promise; 274 | /** Gets all entries from the migration table */ 275 | abstract getAll(): Promise; 276 | } 277 | -------------------------------------------------------------------------------- /cli/state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | basename, 3 | CliffySelect, 4 | CliffyToggle, 5 | exists, 6 | format, 7 | fromFileUrl, 8 | resolve, 9 | } from "../deps.ts"; 10 | import { 11 | arrayIsUnique, 12 | getLogger, 13 | isFileUrl, 14 | isMigrationFile, 15 | isUrl, 16 | } from "./utils.ts"; 17 | import type { 18 | AllCommandOptions, 19 | FileEntryT, 20 | LoggerFn, 21 | NessieConfig, 22 | StateOptions, 23 | } from "../types.ts"; 24 | import { 25 | DEFAULT_MIGRATION_FOLDER, 26 | DEFAULT_SEED_FOLDER, 27 | REGEXP_FILE_NAME, 28 | } from "../consts.ts"; 29 | import { getMigrationTemplate, getSeedTemplate } from "./templates.ts"; 30 | import { NessieError } from "./errors.ts"; 31 | 32 | /** The main state for the application. 33 | * 34 | * Contains the client, and handles the communication to the database. 35 | */ 36 | export class State { 37 | readonly #config: NessieConfig; 38 | readonly #migrationFolders: string[]; 39 | readonly #seedFolders: string[]; 40 | readonly #migrationFiles: FileEntryT[]; 41 | readonly #seedFiles: FileEntryT[]; 42 | client: NessieConfig["client"]; 43 | 44 | logger: LoggerFn = () => {}; 45 | 46 | constructor(options: StateOptions) { 47 | this.#config = options.config; 48 | this.#migrationFolders = options.migrationFolders; 49 | this.#seedFolders = options.seedFolders; 50 | this.#migrationFiles = options.migrationFiles; 51 | this.#seedFiles = options.seedFiles; 52 | 53 | if (options.debug || this.#config.debug) { 54 | this.logger = getLogger(); 55 | } 56 | 57 | this.client = options.config.client; 58 | this.client.setLogger(this.logger); 59 | 60 | this.logger({ 61 | migrationFolders: this.#migrationFolders, 62 | seedFolders: this.#seedFolders, 63 | migrationFiles: this.#migrationFiles, 64 | seedFiles: this.#seedFiles, 65 | }, "State"); 66 | } 67 | 68 | /** Initializes the state with a client */ 69 | static async init(options: AllCommandOptions) { 70 | if (options.debug) console.log("Checking config path"); 71 | 72 | const path = isUrl(options.config) 73 | ? options.config 74 | : "file://" + resolve(Deno.cwd(), options.config); 75 | 76 | if (!isFileUrl(path) && !(await exists(fromFileUrl(path)))) { 77 | throw new NessieError(`Config file is not found at ${path}`); 78 | } 79 | 80 | const configRaw = await import(path); 81 | const config: NessieConfig = configRaw.default; 82 | 83 | if (!config.client) { 84 | throw new NessieError("Client is not valid"); 85 | } 86 | 87 | const { migrationFolders, seedFolders } = this 88 | ._parseMigrationAndSeedFolders(config); 89 | const { migrationFiles, seedFiles } = this._parseMigrationAndSeedFiles( 90 | config, 91 | migrationFolders, 92 | seedFolders, 93 | ); 94 | 95 | config.client.migrationFiles = migrationFiles; 96 | config.client.seedFiles = seedFiles; 97 | 98 | if (options.migrationTemplate) { 99 | config.migrationTemplate = options.migrationTemplate; 100 | } 101 | if (options.seedTemplate) { 102 | config.seedTemplate = options.seedTemplate; 103 | } 104 | 105 | return new State({ 106 | config, 107 | debug: options.debug, 108 | migrationFolders, 109 | migrationFiles, 110 | seedFolders, 111 | seedFiles, 112 | }); 113 | } 114 | 115 | /** Parses and sets the migrationFolders and seedFolders */ 116 | private static _parseMigrationAndSeedFolders(options: NessieConfig) { 117 | const migrationFolders: string[] = []; 118 | const seedFolders: string[] = []; 119 | 120 | if ( 121 | options.migrationFolders && !arrayIsUnique(options.migrationFolders) 122 | ) { 123 | throw new NessieError( 124 | "Entries for the migration folders has to be unique", 125 | ); 126 | } 127 | 128 | if (options.seedFolders && !arrayIsUnique(options.seedFolders)) { 129 | throw new NessieError("Entries for the seed folders has to be unique"); 130 | } 131 | 132 | options.migrationFolders?.forEach((folder) => { 133 | migrationFolders.push(resolve(Deno.cwd(), folder)); 134 | }); 135 | 136 | if ( 137 | migrationFolders.length < 1 && 138 | options.additionalMigrationFiles === undefined 139 | ) { 140 | migrationFolders.push(resolve(Deno.cwd(), DEFAULT_MIGRATION_FOLDER)); 141 | } 142 | 143 | if (!arrayIsUnique(migrationFolders)) { 144 | throw new NessieError( 145 | "Entries for the resolved migration folders has to be unique", 146 | ); 147 | } 148 | 149 | options.seedFolders?.forEach((folder) => { 150 | seedFolders.push(resolve(Deno.cwd(), folder)); 151 | }); 152 | 153 | if (seedFolders.length < 1 && options.additionalSeedFiles === undefined) { 154 | seedFolders.push(resolve(Deno.cwd(), DEFAULT_SEED_FOLDER)); 155 | } 156 | 157 | if (!arrayIsUnique(seedFolders)) { 158 | throw new NessieError( 159 | "Entries for the resolved seed folders has to be unique", 160 | ); 161 | } 162 | return { migrationFolders, seedFolders }; 163 | } 164 | 165 | /** Parses and sets the migrationFiles and seedFiles */ 166 | private static _parseMigrationAndSeedFiles( 167 | options: NessieConfig, 168 | migrationFolders: string[], 169 | seedFolders: string[], 170 | ) { 171 | const migrationFiles: FileEntryT[] = []; 172 | const seedFiles: FileEntryT[] = []; 173 | 174 | migrationFolders.forEach((folder) => { 175 | const filesRaw: FileEntryT[] = Array.from(Deno.readDirSync(folder)) 176 | .filter((file) => file.isFile && isMigrationFile(file.name)) 177 | .map((file) => ({ 178 | name: file.name, 179 | path: "file://" + resolve(folder, file.name), 180 | })); 181 | 182 | migrationFiles.push(...filesRaw); 183 | }); 184 | 185 | options.additionalMigrationFiles?.forEach((file) => { 186 | const path = isUrl(file) ? file : "file://" + resolve(Deno.cwd(), file); 187 | 188 | const fileName = basename(path); 189 | 190 | if (isMigrationFile(fileName)) { 191 | migrationFiles.push({ 192 | name: fileName, 193 | path, 194 | }); 195 | } 196 | }); 197 | 198 | if (!arrayIsUnique(migrationFiles.map((file) => file.name))) { 199 | throw new NessieError( 200 | "Entries for the migration files has to be unique", 201 | ); 202 | } 203 | 204 | migrationFiles.sort((a, b) => parseInt(a.name) - parseInt(b.name)); 205 | 206 | seedFolders.forEach((folder) => { 207 | const filesRaw = Array.from(Deno.readDirSync(folder)) 208 | .filter((file) => file.isFile) 209 | .map((file) => ({ 210 | name: file.name, 211 | path: "file://" + resolve(folder, file.name), 212 | })); 213 | 214 | seedFiles.push(...filesRaw); 215 | }); 216 | 217 | options.additionalSeedFiles?.forEach((file) => { 218 | const path = isUrl(file) ? file : "file://" + resolve(Deno.cwd(), file); 219 | 220 | const fileName = basename(path); 221 | 222 | seedFiles.push({ 223 | name: fileName, 224 | path, 225 | }); 226 | }); 227 | 228 | if (!arrayIsUnique(seedFiles.map((file) => file.name))) { 229 | throw new NessieError( 230 | "Entries for the resolved seed files has to be unique", 231 | ); 232 | } 233 | 234 | seedFiles.sort((a, b) => a.name.localeCompare(b.name)); 235 | 236 | return { migrationFiles, seedFiles }; 237 | } 238 | 239 | /** Makes the migration */ 240 | async makeMigration(migrationName = "migration") { 241 | if (!REGEXP_FILE_NAME.test(migrationName) || migrationName.length >= 80) { 242 | throw new NessieError( 243 | "Migration name has to be snakecase and only include a-z (all lowercase) and 1-9", 244 | ); 245 | } 246 | 247 | const prefix = format(new Date(), "yyyyMMddHHmmss"); 248 | 249 | const fileName = `${prefix}_${migrationName}.ts`; 250 | 251 | this.logger(fileName, "Migration file name"); 252 | 253 | if (!isMigrationFile(fileName)) { 254 | throw new NessieError(`Migration name '${fileName}' is not valid`); 255 | } 256 | 257 | const selectedFolder = await this._folderPrompt( 258 | this.#migrationFolders.filter((folder) => !isUrl(folder)), 259 | ); 260 | 261 | const template = this.#config.migrationTemplate 262 | ? await Deno.readTextFile(this.#config.migrationTemplate) 263 | : getMigrationTemplate(this.client.dialect); 264 | 265 | const filePath = resolve(selectedFolder, fileName); 266 | 267 | if (await exists(filePath)) { 268 | const overwrite = await this._fileExistsPrompt(filePath); 269 | if (!overwrite) return; 270 | } 271 | 272 | await Deno.writeTextFile(filePath, template); 273 | 274 | console.info(`Created migration ${filePath}`); 275 | } 276 | 277 | /** Makes the seed */ 278 | async makeSeed(seedName = "seed") { 279 | if (!REGEXP_FILE_NAME.test(seedName)) { 280 | throw new NessieError( 281 | "Seed name has to be snakecase and only include a-z (all lowercase) and 1-9", 282 | ); 283 | } 284 | 285 | const fileName = `${seedName}.ts`; 286 | 287 | this.logger(fileName, "Seed file name"); 288 | 289 | const selectedFolder = await this._folderPrompt( 290 | this.#seedFolders.filter((folder) => !isUrl(folder)), 291 | ); 292 | 293 | const template = this.#config.seedTemplate 294 | ? await Deno.readTextFile(this.#config.seedTemplate) 295 | : getSeedTemplate(this.client.dialect); 296 | 297 | const filePath = resolve(selectedFolder, fileName); 298 | 299 | if (await exists(filePath)) { 300 | const overwrite = await this._fileExistsPrompt(filePath); 301 | if (!overwrite) return; 302 | } 303 | 304 | await Deno.writeTextFile(filePath, template); 305 | 306 | console.info(`Created seed ${fileName} at ${selectedFolder}`); 307 | } 308 | 309 | private async _folderPrompt(folders: string[]) { 310 | let promptSelection = 0; 311 | 312 | if (folders.length > 1) { 313 | const promptResult = await CliffySelect.prompt({ 314 | message: 315 | `You have multiple folder sources, where do you want to create the new file?`, 316 | options: folders.map((folder, i) => ({ 317 | value: i.toString(), 318 | name: folder, 319 | })), 320 | }); 321 | 322 | promptSelection = parseInt(promptResult); 323 | } 324 | 325 | this.logger(promptSelection, "Prompt input final"); 326 | 327 | return folders[promptSelection]; 328 | } 329 | 330 | private async _fileExistsPrompt(file: string): Promise { 331 | const result: boolean = await CliffyToggle.prompt( 332 | `The file ${file} already exists, do you want to overwrite the existing file?`, 333 | ); 334 | 335 | this.logger(result, "Toggle selection"); 336 | 337 | return result; 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Nessie

2 | 3 |

4 | 5 | GitHub release (latest SemVer) 6 | 7 | 8 | GitHub release (latest by date including pre-releases) 9 | 10 | 11 | Docs 12 | 13 | 14 | Deno Version 15 | 16 | 17 | GitHub Workflow Status (branch) 18 | 19 | 20 | Codecov 21 | 22 | 23 | License 24 | 25 | 26 | Discord 27 | 28 | 29 | Docker Image Size (tag) 30 | 31 |
32 | 33 | deno.land 34 | 35 | 36 | nest.land 37 | 38 |

39 | 40 |

41 | A modular database migration tool for Deno inspired by Laravel and Phinx.
42 | Supports PostgreSQL, MySQL, MariaDB and SQLite. 43 |

44 | 45 |

Nessie logo

46 | 47 | **Call for donations**: If you are using Nessie commercially, please consider 48 | supporting the future development. See 49 | [this issue](https://github.com/halvardssm/deno-nessie/issues/130) for more 50 | information. 51 | 52 | > ⚠️ With the native Prisma support for Deno, I no longer use Nessie for my 53 | > projects. This means that Nessie will be unmaintained in the near future. See 54 | > the related [issue](https://github.com/halvardssm/deno-nessie/issues/165) for 55 | > more information. 56 | 57 | 🎉 **Version 2 is released**: To migrate from version 1 follow the steps in the 58 | [migration section](#migrate-from-version-1) bellow. 59 | 60 | > See documentation for the 61 | > [clients](https://doc.deno.land/https/deno.land/x/nessie/mod.ts). 62 | 63 | > Even though all examples in this readme applies unversioned usage, you should 64 | > always use a version when using Nessie. 65 | 66 | --- 67 | 68 | ## Contents 69 | 70 | - [Contents](#contents) 71 | - [Available Via](#available-via) 72 | - [CLI Usage](#cli-usage) 73 | - [Flags](#flags) 74 | - [Deno flags and Permissions](#deno-flags-and-permissions) 75 | - [Config file](#config-file) 76 | - [Remote Migration or Seed files](#remote-migration-or-seed-files) 77 | - [Custom Migration or Seed templates](#custom-migration-or-seed-templates) 78 | - [Docker usage](#docker-usage) 79 | - [Uses](#uses) 80 | - [Examples](#examples) 81 | - [Clients](#clients) 82 | - [How to make a client](#how-to-make-a-client) 83 | - [Migrate from version 1](#migrate-from-version-1) 84 | - [Contributing](#contributing) 85 | 86 | ## Available Via 87 | 88 | - https://deno.land/x/nessie 89 | - https://raw.githubusercontent.com/halvardssm/deno-nessie 90 | - https://nest.land/package/Nessie 91 | - https://hub.docker.com/repository/docker/halvardm/nessie 92 | 93 | ## CLI Usage 94 | 95 | > It is suggested you restrict the permissions Nessie has as much as possible, 96 | > to only the permissions its needs. An example of this is: 97 | > 98 | > ```shell 99 | > deno install --unstable --allow-net=: --allow-read=. --allow-write=nessie.config.ts,db -f https://deno.land/x/nessie/cli.ts 100 | > ``` 101 | 102 | - `init`: Generates a `nessie.config.ts` file and also the `db` folder where 103 | migration and seed files will be placed. Two options are available: `--mode` 104 | and `--dialect`. 105 | 106 | - `--mode` can be one of `config` or `folders`. If mode is not set, it will 107 | create a `nessie.config.ts` file and the `db` folder structure, otherwise it 108 | will create the selected one. 109 | 110 | - `--dialect` is used for the config file and can be one of `pg`, `mysql` or 111 | `sqlite`. If not set, it will create a general config file including all 112 | three dialects, otherwise it will include only the selected one. 113 | 114 | ```shell 115 | deno run -A --unstable https://deno.land/x/nessie/cli.ts init 116 | 117 | deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode folders 118 | 119 | deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect pg 120 | 121 | deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect sqlite 122 | 123 | deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect mysql 124 | ``` 125 | 126 | - `make:migration [name]` & `make [name]`: Create migration, `name` has to be 127 | snake- and lowercase, it can also include numbers. You can also provide the 128 | flag `--migrationTemplate