@protobase/cli 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ import { checkJobsSchema, startJobs } from "@protobase/server";
2
+ //#region src/dev/dev-jobs.ts
3
+ const store = globalThis;
4
+ /**
5
+ * Runs the project's jobs in the dev server: started on the first load, once the jobs schema checks out, and given the
6
+ * reloaded definitions on every load after it, so an edited job runs its new code from the next job on.
7
+ */
8
+ const devJobs = async (db, definitions) => {
9
+ if (store.__protobaseJobs) return store.__protobaseJobs.update(definitions);
10
+ if (definitions.length === 0) return;
11
+ await checkJobsSchema(db);
12
+ store.__protobaseJobs = await startJobs({
13
+ db,
14
+ jobs: definitions,
15
+ onError: (error) => console.error(error)
16
+ });
17
+ };
18
+ /** Lets running jobs finish and stops them, when the dev server closes. */
19
+ const stopDevJobs = async () => {
20
+ const runtime = store.__protobaseJobs;
21
+ delete store.__protobaseJobs;
22
+ await runtime?.stop();
23
+ };
24
+ //#endregion
25
+ export { devJobs, stopDevJobs };
package/dist/dev/entry.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { devJobs } from "./dev-jobs.js";
1
2
  import { loadProject, projectDb } from "./project.js";
2
- import { checkAuthSchema, configExports, createAdmin, hasUsers } from "@protobase/server";
3
+ import { checkAuthSchema, checkChanges, configExports, createAdmin, hasUsers, jobDefinitions } from "@protobase/server";
3
4
  import { Hono } from "hono";
4
5
  //#region src/dev/entry.ts
5
6
  const projectDir = process.env.PROTOBASE_PROJECT;
@@ -12,6 +13,8 @@ if (project.auth && !store.__protobaseAuthSchemaChecked) {
12
13
  await checkAuthSchema(project.auth);
13
14
  store.__protobaseAuthSchemaChecked = true;
14
15
  }
16
+ await checkChanges(db, resources.map((resource) => resource.toModel()), project.events);
17
+ await devJobs(db, jobDefinitions(project.jobs ?? {}));
15
18
  const admin = createAdmin({
16
19
  resources,
17
20
  views,
@@ -22,6 +25,8 @@ const admin = createAdmin({
22
25
  ...project.auth && { auth: project.auth },
23
26
  ...project.functions && { functions: project.functions },
24
27
  ...project.files && { files: project.files },
28
+ ...project.events && { events: project.events },
29
+ jobs: jobDefinitions(project.jobs ?? {}),
25
30
  options: {
26
31
  onUnhandledError: (error) => console.error(error),
27
32
  ...project.options
package/dist/dev/run.js CHANGED
@@ -5,6 +5,7 @@ import { moduleFile } from "../module-file.js";
5
5
  import { layoutCheck } from "../build/layout-check.js";
6
6
  import { projectUiPlugin } from "../project/project-ui.js";
7
7
  import { checkDatabase } from "./check-database.js";
8
+ import { stopDevJobs } from "./dev-jobs.js";
8
9
  import { existsSync } from "node:fs";
9
10
  import path from "node:path";
10
11
  import { createServer, searchForWorkspaceRoot } from "vite";
@@ -23,6 +24,10 @@ const reloadOnProjectChange = (dirs) => ({
23
24
  if (dirs.some((dir) => file.startsWith(`${dir}${path.sep}`)) && !file.includes(`${path.sep}node_modules${path.sep}`)) server.environments.ssr.moduleGraph.invalidateAll();
24
25
  }
25
26
  });
27
+ const stopJobsOnClose = {
28
+ name: "protobase-jobs",
29
+ closeBundle: () => stopDevJobs()
30
+ };
26
31
  const appDependencies = [
27
32
  "react",
28
33
  "react/jsx-runtime",
@@ -65,6 +70,7 @@ const runDev = async (options, out) => {
65
70
  },
66
71
  plugins: [
67
72
  reloadOnProjectChange([options.projectDir, ...extensions.map((extension) => extension.dir)]),
73
+ stopJobsOnClose,
68
74
  projectUiPlugin(options.projectDir, extensions),
69
75
  extensionImports(options.projectDir, extensions),
70
76
  extensionStyles(extensions),
package/dist/main.js CHANGED
@@ -9,10 +9,13 @@ import { parseHosts } from "./dev/hosts.js";
9
9
  import { runDev } from "./dev/run.js";
10
10
  import { runDoctor } from "./doctor/run.js";
11
11
  import { runScaffold } from "./scaffold/run.js";
12
+ import { closeOnSignals } from "./serve/signals.js";
12
13
  import { serveBundle } from "./serve/serve-bundle.js";
13
14
  import { serveBundleDir } from "./serve/serve-dir.js";
15
+ import { loadWorkerBundle, startWorker } from "./worker/start-worker.js";
14
16
  import { registerOrganizations } from "./organizations/register.js";
15
17
  import { registerFiles } from "./files/register.js";
18
+ import { registerMigration } from "./migration/register.js";
16
19
  import { registerToken, registerUsers } from "./users/register.js";
17
20
  import { statSync } from "node:fs";
18
21
  import path from "node:path";
@@ -85,10 +88,20 @@ program.command("serve").description("Serve a bundle written by `protobase build
85
88
  if (statSync(bundle).isDirectory()) await serveBundleDir(bundle, process.env, out);
86
89
  else await serveBundle(bundle, process.env, out);
87
90
  });
91
+ program.command("worker").description("Run the jobs of a bundle from `protobase build` in a process of their own, until SIGTERM or SIGINT, as `serve` runs them beside the API").argument("<bundle>", "the bundle folder, e.g. dist, or its dist/protobase.config.js").action(async (bundle) => {
92
+ loadNearestEnvFile(process.cwd());
93
+ const worker = await startWorker({
94
+ project: await loadWorkerBundle(bundle),
95
+ databaseUrl: process.env.DATABASE_URL || void 0
96
+ });
97
+ closeOnSignals(worker.stop);
98
+ out(`protobase worker running ${worker.jobs} job${worker.jobs === 1 ? "" : "s"}\n`);
99
+ });
88
100
  registerUsers(program);
89
101
  registerOrganizations(program);
90
102
  registerToken(program);
91
103
  registerAuth(program);
104
+ registerMigration(program);
92
105
  registerFiles(program);
93
106
  const run = (argv) => program.parseAsync(argv).catch((error) => {
94
107
  process.stderr.write(`error: ${error.message}\n`);
@@ -0,0 +1,42 @@
1
+ import { loadNearestEnvFile } from "../dev/env.js";
2
+ import { importProjectFile } from "../project/import-project-file.js";
3
+ import { createPgDb } from "../project/pg-db.js";
4
+ import { existsSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { configExports, jobDefinitions, mergeConfig } from "@protobase/server";
7
+ import pg from "pg";
8
+ //#region src/migration/load-project.ts
9
+ const configOf = async (file) => (await importProjectFile(file)).default ?? {};
10
+ /**
11
+ * The project in `dir` as `protobase dev` merges it, for what its database needs: protobase.config.ts, or
12
+ * config/index.ts when that has no `config`, after the configs of `extensions` and the configs it extends. `close`
13
+ * ends the pool it opened; a `db` the project exports stays the project's.
14
+ */
15
+ const loadMigrationProject = async (dir, extensions = []) => {
16
+ loadNearestEnvFile(dir);
17
+ const file = path.join(dir, "protobase.config.ts");
18
+ const custom = existsSync(file) ? await configOf(file) : {};
19
+ const index = path.join(dir, "config", "index.ts");
20
+ if (!custom.config && !existsSync(index)) throw new Error(`No config/index.ts or protobase.config.ts in ${dir}; run this inside a project`);
21
+ const own = {
22
+ ...custom,
23
+ config: custom.config ?? await importProjectFile(index)
24
+ };
25
+ const extended = await Promise.all(extensions.flatMap((extension) => extension.config ? [configOf(extension.config)] : []));
26
+ const project = mergeConfig(...extended, own);
27
+ const url = process.env.DATABASE_URL;
28
+ if (!project.db && !url) throw new Error("No database: set DATABASE_URL or export `db` from protobase.config.ts");
29
+ const ownDb = project.db ? void 0 : createPgDb(pg, url);
30
+ return {
31
+ models: configExports(project.config ?? {}).resources.map((source) => source.toModel()),
32
+ db: project.db ?? ownDb,
33
+ ...project.auth && { auth: project.auth },
34
+ ...project.events && { events: project.events },
35
+ jobs: jobDefinitions(project.jobs ?? {}),
36
+ close: async () => {
37
+ await ownDb?.destroy();
38
+ }
39
+ };
40
+ };
41
+ //#endregion
42
+ export { loadMigrationProject };
@@ -0,0 +1,22 @@
1
+ import { authSchemaMigration, changesMigration, jobsSchemaMigration } from "@protobase/server";
2
+ //#region src/migration/parts.ts
3
+ /** Every feature the project's config enables, in the order their files are written. */
4
+ const migrationParts = (project) => [
5
+ ...project.auth ? [{
6
+ name: "auth",
7
+ title: "The auth schema changes this version of Protobase needs",
8
+ sql: () => authSchemaMigration(project.auth)
9
+ }] : [],
10
+ ...project.jobs.length > 0 ? [{
11
+ name: "jobs",
12
+ title: "The jobs schema this version of Protobase needs",
13
+ sql: () => jobsSchemaMigration(project.db)
14
+ }] : [],
15
+ {
16
+ name: "changes",
17
+ title: "The change capture the resources' events need",
18
+ sql: () => changesMigration(project.db, project.models, project.events)
19
+ }
20
+ ];
21
+ //#endregion
22
+ export { migrationParts };
@@ -0,0 +1,22 @@
1
+ import { resolveExtensions } from "../project/extensions.js";
2
+ import { loadMigrationProject } from "./load-project.js";
3
+ import { migrationParts } from "./parts.js";
4
+ import { writeMigrations } from "./write-migrations.js";
5
+ import path from "node:path";
6
+ //#region src/migration/register.ts
7
+ const out = (text) => process.stdout.write(text);
8
+ const collect = (value, previous) => [...previous, value];
9
+ const registerMigration = (program) => {
10
+ program.command("migration").description("Write what the database still needs for every feature the config enables (auth, events, jobs) as the project's next SQL migrations").option("--dir <dir>", "migrations folder", "db/migrations").option("--extend <dir>", "merge the protobase.config.ts in this folder before the project's, as dev and build do, repeatable", collect, []).action(async (opts) => {
11
+ const project = await loadMigrationProject(process.cwd(), resolveExtensions(opts.extend));
12
+ try {
13
+ const files = await writeMigrations(migrationParts(project), path.resolve(opts.dir));
14
+ out(files.length > 0 ? files.map((file) => `wrote ${path.relative(process.cwd(), file)}\n`).join("") + "apply them with the project's migrations\n" : "the database is up to date; nothing to write\n");
15
+ } finally {
16
+ await project.close();
17
+ }
18
+ process.exit(0);
19
+ });
20
+ };
21
+ //#endregion
22
+ export { registerMigration };
@@ -0,0 +1,32 @@
1
+ import { nextMigrationName } from "../auth/migration-file.js";
2
+ import path from "node:path";
3
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
4
+ //#region src/migration/write-migrations.ts
5
+ /** A part's file: what wrote it and why, then its statements. */
6
+ const partText = (title, sql) => `-- ${title}, written by \`protobase migration\`.\n-- Applied with the project's other migrations; edit or remove statements the project handles differently.\n\n${sql}\n`;
7
+ /**
8
+ * Writes what each part still needs as the next migrations in `dir`, one file per part, and returns their paths; none
9
+ * when the database is current. Every part is compared with the database before any file is written, so the
10
+ * project's earlier migrations must be applied to it first.
11
+ */
12
+ const writeMigrations = async (parts, dir) => {
13
+ const pending = [];
14
+ for (const part of parts) {
15
+ const sql = await part.sql();
16
+ if (sql !== void 0) pending.push({
17
+ part,
18
+ sql
19
+ });
20
+ }
21
+ if (pending.length === 0) return [];
22
+ await mkdir(dir, { recursive: true });
23
+ const files = [];
24
+ for (const { part, sql } of pending) {
25
+ const file = path.join(dir, nextMigrationName(await readdir(dir), part.name));
26
+ await writeFile(file, partText(part.title, sql), { flag: "wx" });
27
+ files.push(file);
28
+ }
29
+ return files;
30
+ };
31
+ //#endregion
32
+ export { partText, writeMigrations };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.8.0";
2
+ var version = "0.8.2";
3
3
  //#endregion
4
4
  export { version };
package/dist/serve/app.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { isApiPath } from "../bundle/manifest.js";
2
2
  import { serveStaticSite } from "./static-site.js";
3
- import { configExports, createAdmin } from "@protobase/server";
3
+ import { configExports, createAdmin, jobDefinitions, jobsDrainApp } from "@protobase/server";
4
4
  import { Hono } from "hono";
5
5
  import { logger } from "hono/logger";
6
6
  //#region src/serve/app.ts
7
- const createServeApp = ({ project, db, requestLog, site }) => {
7
+ const createServeApp = ({ project, db, requestLog, site, jobs, drainToken }) => {
8
8
  const { resources, views, pages, userMenu } = configExports(project.config);
9
9
  const admin = createAdmin({
10
10
  resources,
@@ -16,6 +16,8 @@ const createServeApp = ({ project, db, requestLog, site }) => {
16
16
  ...project.auth && { auth: project.auth },
17
17
  ...project.functions && { functions: project.functions },
18
18
  ...project.files && { files: project.files },
19
+ ...project.events && { events: project.events },
20
+ jobs: jobDefinitions(project.jobs ?? {}),
19
21
  options: {
20
22
  onUnhandledError: (error) => console.error(error),
21
23
  ...project.options
@@ -25,6 +27,10 @@ const createServeApp = ({ project, db, requestLog, site }) => {
25
27
  if (requestLog) app.use(logger());
26
28
  app.get("/health", (c) => c.json({ status: "ok" }));
27
29
  if (site) app.use("*", (c, next) => isApiPath(site.api, c.req.path) ? next() : serveStaticSite(site, c.req.raw));
30
+ if (jobs && drainToken) app.route("/", jobsDrainApp({
31
+ runtime: jobs,
32
+ token: drainToken
33
+ }));
28
34
  app.route("/", admin);
29
35
  return app;
30
36
  };
package/dist/serve/env.js CHANGED
@@ -5,11 +5,15 @@ const readServeEnv = (env) => {
5
5
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT must be a port number, not "${env.PORT}"`);
6
6
  const minutes = env.PROTOBASE_FILES_CLEANUP_MINUTES ? Number(env.PROTOBASE_FILES_CLEANUP_MINUTES) : 60;
7
7
  if (!Number.isInteger(minutes) || minutes < 0) throw new Error(`PROTOBASE_FILES_CLEANUP_MINUTES must be a whole number of minutes, not "${env.PROTOBASE_FILES_CLEANUP_MINUTES}"`);
8
+ const jobs = env.PROTOBASE_JOBS ?? "on";
9
+ if (jobs !== "on" && jobs !== "off") throw new Error(`PROTOBASE_JOBS must be on or off, not "${jobs}"`);
8
10
  return {
9
11
  port,
10
12
  databaseUrl: env.DATABASE_URL || void 0,
11
13
  requestLog: Boolean(env.REQUEST_LOG),
12
- filesCleanupMinutes: minutes
14
+ filesCleanupMinutes: minutes,
15
+ runJobs: jobs === "on",
16
+ ...env.PROTOBASE_JOBS_DRAIN_TOKEN && { drainToken: env.PROTOBASE_JOBS_DRAIN_TOKEN }
13
17
  };
14
18
  };
15
19
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { createPgDb } from "../project/pg-db.js";
2
2
  import { createServeApp } from "./app.js";
3
3
  import { startCleanupTimer } from "./cleanup-timer.js";
4
- import { checkAuthSchema, configExports, createFileCleanup } from "@protobase/server";
4
+ import { checkAuthSchema, checkChanges, checkJobsSchema, configExports, createFileCleanup, jobDefinitions, startJobs } from "@protobase/server";
5
5
  import { createAdaptorServer } from "@hono/node-server";
6
6
  import pg from "pg";
7
7
  //#region src/serve/lifecycle.ts
@@ -18,19 +18,41 @@ const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url
18
18
  if (project.auth) await checkAuthSchema(project.auth);
19
19
  const ownDb = project.db ? void 0 : createDb(env.databaseUrl);
20
20
  const db = project.db ?? ownDb;
21
- const app = createServeApp({
22
- project,
23
- db,
24
- requestLog: env.requestLog,
25
- site
26
- });
27
- const server = createAdaptorServer({ fetch: app.fetch });
28
- await listen(server, env.port).catch(async (error) => {
21
+ const { resources } = configExports(project.config);
22
+ const definitions = jobDefinitions(project.jobs ?? {});
23
+ const startJobsAndListen = async () => {
24
+ await checkChanges(db, resources.map((resource) => resource.toModel()), project.events);
25
+ if (definitions.length > 0) await checkJobsSchema(db);
26
+ const jobs = definitions.length > 0 ? await startJobs({
27
+ db,
28
+ jobs: definitions,
29
+ work: env.runJobs,
30
+ onError: (error) => console.error(error)
31
+ }) : void 0;
32
+ const app = createServeApp({
33
+ project,
34
+ db,
35
+ requestLog: env.requestLog,
36
+ site,
37
+ ...jobs && { jobs },
38
+ ...env.drainToken && { drainToken: env.drainToken }
39
+ });
40
+ const server = createAdaptorServer({ fetch: app.fetch });
41
+ await listen(server, env.port).catch(async (error) => {
42
+ await jobs?.stop();
43
+ throw error;
44
+ });
45
+ return {
46
+ server,
47
+ jobs
48
+ };
49
+ };
50
+ const { server, jobs } = await startJobsAndListen().catch(async (error) => {
29
51
  await ownDb?.destroy();
30
52
  throw error;
31
53
  });
32
54
  const cleanup = createFileCleanup({
33
- resources: configExports(project.config).resources,
55
+ resources,
34
56
  db,
35
57
  ...project.files && { files: project.files }
36
58
  });
@@ -38,6 +60,7 @@ const startServe = async ({ project, env, createDb = (url) => createPgDb(pg, url
38
60
  const close = async () => {
39
61
  await closeServer(server);
40
62
  await timer?.stop();
63
+ await jobs?.stop();
41
64
  await ownDb?.destroy();
42
65
  };
43
66
  return {
@@ -0,0 +1,51 @@
1
+ import { readManifest } from "../bundle/manifest.js";
2
+ import { checkBundleVersion } from "../serve/bundle-version.js";
3
+ import { createPgDb } from "../project/pg-db.js";
4
+ import { loadBundle } from "../serve/load-bundle.js";
5
+ import { statSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { checkJobsSchema, jobDefinitions, startJobs } from "@protobase/server";
8
+ import pg from "pg";
9
+ //#region src/worker/start-worker.ts
10
+ const bundleConfigFile = async (bundle) => {
11
+ if (!statSync(bundle).isDirectory()) return bundle;
12
+ return path.join(bundle, (await readManifest(bundle)).server);
13
+ };
14
+ /**
15
+ * Runs the project's jobs, and nothing else, until `stop`: the same handlers `protobase serve` runs, in a process of
16
+ * their own. It reads the jobs schema at start and refuses one that is missing or behind, like `serve`.
17
+ */
18
+ const startWorker = async ({ project, databaseUrl, createDb = (url) => createPgDb(pg, url) }) => {
19
+ const definitions = jobDefinitions(project.jobs ?? {});
20
+ if (definitions.length === 0) throw new Error("The bundle has no jobs: export them from protobase.config.ts as `jobs`");
21
+ if (!project.db && !databaseUrl) throw new Error("No database: set DATABASE_URL or export `db` from protobase.config.ts");
22
+ const ownDb = project.db ? void 0 : createDb(databaseUrl);
23
+ const db = project.db ?? ownDb;
24
+ const start = async () => {
25
+ await checkJobsSchema(db);
26
+ return startJobs({
27
+ db,
28
+ jobs: definitions,
29
+ onError: (error) => console.error(error)
30
+ });
31
+ };
32
+ const jobs = await start().catch(async (error) => {
33
+ await ownDb?.destroy();
34
+ throw error;
35
+ });
36
+ return {
37
+ jobs: definitions.length,
38
+ stop: async () => {
39
+ await jobs.stop();
40
+ await ownDb?.destroy();
41
+ }
42
+ };
43
+ };
44
+ /** `protobase worker <bundle>`: the bundle's jobs until SIGTERM or SIGINT, once its manifest shows a version this CLI runs. */
45
+ const loadWorkerBundle = async (bundle) => {
46
+ const file = await bundleConfigFile(bundle);
47
+ await checkBundleVersion(file);
48
+ return loadBundle(file);
49
+ };
50
+ //#endregion
51
+ export { bundleConfigFile, loadWorkerBundle, startWorker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -34,11 +34,11 @@
34
34
  "dependencies": {
35
35
  "@hono/node-server": "^2.1.3",
36
36
  "@hono/vite-dev-server": "^0.26.1",
37
- "@protobase/layout": "0.8.0",
38
- "@protobase/query": "0.8.0",
39
- "@protobase/schema": "0.8.0",
40
- "@protobase/server": "0.8.0",
41
- "@protobase/ui": "0.8.0",
37
+ "@protobase/layout": "0.8.2",
38
+ "@protobase/query": "0.8.2",
39
+ "@protobase/schema": "0.8.2",
40
+ "@protobase/server": "0.8.2",
41
+ "@protobase/ui": "0.8.2",
42
42
  "@scalar/hono-api-reference": "^0.12.10",
43
43
  "@tailwindcss/vite": "^4.3.3",
44
44
  "@vitejs/plugin-react": "^6.1.2",