@artian-techs/adonis-orbit 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +275 -0
  3. package/build/configure.d.ts +8 -0
  4. package/build/configure.js +38 -0
  5. package/build/index.d.ts +10 -0
  6. package/build/index.js +9 -0
  7. package/build/providers/orbit_provider.d.ts +13 -0
  8. package/build/providers/orbit_provider.js +35 -0
  9. package/build/src/auth/basic_auth.d.ts +12 -0
  10. package/build/src/auth/basic_auth.js +36 -0
  11. package/build/src/controllers/dashboard_controller.d.ts +51 -0
  12. package/build/src/controllers/dashboard_controller.js +341 -0
  13. package/build/src/define_config.d.ts +5 -0
  14. package/build/src/define_config.js +6 -0
  15. package/build/src/jobs/prune_job_runs.d.ts +20 -0
  16. package/build/src/jobs/prune_job_runs.js +27 -0
  17. package/build/src/logo.d.ts +4 -0
  18. package/build/src/logo.js +12 -0
  19. package/build/src/middleware/dashboard_auth.d.ts +10 -0
  20. package/build/src/middleware/dashboard_auth.js +16 -0
  21. package/build/src/models/job_run.d.ts +35 -0
  22. package/build/src/models/job_run.js +71 -0
  23. package/build/src/models/silenced_job.d.ts +14 -0
  24. package/build/src/models/silenced_job.js +27 -0
  25. package/build/src/recorder.d.ts +14 -0
  26. package/build/src/recorder.js +102 -0
  27. package/build/src/retry.d.ts +9 -0
  28. package/build/src/retry.js +18 -0
  29. package/build/src/routes.d.ts +8 -0
  30. package/build/src/routes.js +20 -0
  31. package/build/src/sinks/database_sink.d.ts +16 -0
  32. package/build/src/sinks/database_sink.js +88 -0
  33. package/build/src/types.d.ts +92 -0
  34. package/build/src/types.js +1 -0
  35. package/build/stubs/config/orbit.stub +31 -0
  36. package/build/stubs/main.d.ts +1 -0
  37. package/build/stubs/main.js +1 -0
  38. package/build/stubs/migrations/create_job_runs_table.stub +62 -0
  39. package/build/stubs/migrations/create_silenced_jobs_table.stub +29 -0
  40. package/build/templates/components/layout.edge +174 -0
  41. package/build/templates/index.edge +111 -0
  42. package/build/templates/metrics.edge +59 -0
  43. package/build/templates/orbit.svg +35 -0
  44. package/build/templates/silenced.edge +35 -0
  45. package/package.json +130 -0
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 Artian Techs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ 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.
package/README.md ADDED
@@ -0,0 +1,275 @@
1
+ <p align="center">
2
+ <img src="templates/orbit.svg" width="96" alt="Orbit">
3
+ </p>
4
+
5
+ # adonis-orbit
6
+
7
+ What the queue of AdonisJS did with each job, kept in a table, read on a page,
8
+ and asked again when it failed.
9
+
10
+ The queue holds its jobs in Redis and lets them go when they end. This package
11
+ listens to the channels that the queue publishes, writes one row for each job,
12
+ and serves a page that reads the table. A job that failed keeps the values it
13
+ carried, so a person can ask for it again — from the page, or from a command.
14
+
15
+ It works with AdonisJS 6 and 7.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add @artian-techs/adonis-orbit
21
+ node ace configure @artian-techs/adonis-orbit
22
+ node ace migration:run
23
+ ```
24
+
25
+ The configure step writes the provider in `adonisrc.ts`, publishes
26
+ `config/orbit.ts`, publishes the two migrations — the table of the jobs, and
27
+ the one that names the silenced classes — and declares the two environment
28
+ variables that guard the page.
29
+
30
+ ## The page
31
+
32
+ ```
33
+ http://localhost:3333/queue
34
+ ```
35
+
36
+ It asks for a name and a word, in Basic auth. **While one of the two is
37
+ missing, the page answers "not found"**: a machine that carries neither shows
38
+ nothing.
39
+
40
+ The page holds no session on purpose: it belongs to the team that runs the
41
+ machine, not to the users of the application. It speaks English only.
42
+
43
+ A menu on the left leads to each view, and carries the count of each status.
44
+ Under it, one button reads the page in the light or in the dark. The page reads
45
+ the mode of the machine while nobody chose; a choice is held in a cookie. The
46
+ page holds no JavaScript.
47
+
48
+ | Entry | What it shows |
49
+ | --- | --- |
50
+ | Overview | four numbers, then the last jobs of every status |
51
+ | Pending, Running, Retrying, Completed, Failed | the same list, of one status |
52
+ | Silenced | the classes that the lists leave out |
53
+ | Metrics | what each class and each queue cost over the last 24 hours |
54
+
55
+ The four numbers of the overview:
56
+
57
+ | Number | What it counts |
58
+ | --- | --- |
59
+ | Jobs per minute | the jobs that ended in the last minute |
60
+ | Jobs past hour | the jobs that ended in the last hour |
61
+ | Failed jobs past 7 days | the jobs whose last try fell |
62
+ | Jobs running now | the jobs a worker holds at this moment |
63
+
64
+ Metrics reads the same table: for each class and for each queue, how many jobs
65
+ ended, how many fell, and the average and the longest of their durations.
66
+
67
+ ## Silence a job
68
+
69
+ A job that runs every minute fills the list and hides the rest. The button
70
+ **Silence** on any row takes its class out of the lists, and out of the counts
71
+ of the menu.
72
+
73
+ Nothing else changes: the worker keeps its work, and the table keeps its trace.
74
+ The page **Silenced** shows what is left out, and **Show again** brings the
75
+ class back — with everything it did while it was silent.
76
+
77
+ ## Configuration
78
+
79
+ ```ts
80
+ import { DatabaseSink, basicAuth, defineConfig } from '@artian-techs/adonis-orbit'
81
+
82
+ import env from '#start/env'
83
+
84
+ export default defineConfig({
85
+ path: '/queue',
86
+
87
+ authorize: basicAuth({
88
+ user: env.get('ORBIT_DASHBOARD_USER'),
89
+ password: env.get('ORBIT_DASHBOARD_PASSWORD'),
90
+ }),
91
+
92
+ sinks: [new DatabaseSink()],
93
+ })
94
+ ```
95
+
96
+ ## Who may read the page
97
+
98
+ `authorize` reads the request and answers `true` or `false`. It answers `false`,
99
+ and the page says "not found": a machine that guards nothing shows nothing.
100
+
101
+ `basicAuth` asks for a name and a word, with no session. It is what the
102
+ configure step writes, and it answers "not found" while one of the two is
103
+ missing.
104
+
105
+ An application that already knows who is asking writes its own guard:
106
+
107
+ ```ts
108
+ const TEAM = ['someone@example.com', 'someone-else@example.com']
109
+
110
+ export default defineConfig({
111
+ path: '/queue',
112
+
113
+ authorize: async ({ auth }) => {
114
+ await auth.check()
115
+
116
+ return TEAM.includes(auth.user?.email ?? '')
117
+ },
118
+
119
+ sinks: [new DatabaseSink()],
120
+ })
121
+ ```
122
+
123
+ The package imports no authentication of its own: the guard reads the context
124
+ of the request, and the application says what it reads there.
125
+
126
+ A guard that wants to say something other than "not found" writes its answer
127
+ before it says `false`:
128
+
129
+ ```ts
130
+ authorize: async (ctx) => {
131
+ if (await ctx.auth.check()) {
132
+ return true
133
+ }
134
+
135
+ ctx.response.redirect('/login')
136
+
137
+ return false
138
+ }
139
+ ```
140
+
141
+ ## Where the trace goes
142
+
143
+ **Nothing is kept that the configuration does not name.** The table of
144
+ `job_runs` is one sink among others, and the configure step writes it in
145
+ `config/orbit.ts`:
146
+
147
+ ```ts
148
+ import { DatabaseSink, defineConfig } from '@artian-techs/adonis-orbit'
149
+
150
+ export default defineConfig({
151
+ path: '/queue',
152
+ auth: { user: env.get('ORBIT_DASHBOARD_USER'), password: env.get('ORBIT_DASHBOARD_PASSWORD') },
153
+
154
+ sinks: [new DatabaseSink(), new MySink()],
155
+ })
156
+ ```
157
+
158
+ Take `DatabaseSink` away, and the table stays empty and the page shows nothing:
159
+ the page reads the table, and reads nothing else. A machine that sends its
160
+ trace somewhere else thus keeps none of it.
161
+
162
+ A sink reads one step of one job, and gives nothing back:
163
+
164
+ ```ts
165
+ import type { JobEvent, Sink } from '@artian-techs/adonis-orbit'
166
+
167
+ class MySink implements Sink {
168
+ async write(events: JobEvent[]) {
169
+ // { type: 'dispatched' | 'started' | 'finished', at, job: { id, name, queue, payload, attempts } }
170
+ }
171
+ }
172
+ ```
173
+
174
+ The events of one step arrive together: a dispatch of ten jobs calls `write`
175
+ once, with ten events. A sink that fails is written in the log, and the job
176
+ that runs goes on.
177
+
178
+ ## What of the payload is kept
179
+
180
+ A payload holds what a person wrote: an address, a name, a number. The
181
+ configuration says how much of it the trace keeps.
182
+
183
+ ```ts
184
+ export default defineConfig({
185
+ // ...
186
+
187
+ /** 'full' by default. 'none' keeps nothing. */
188
+ payload: (payload, job) => ({ ...payload, password: undefined }),
189
+ })
190
+ ```
191
+
192
+ The rule is applied before the first sink reads the event, thus what it takes
193
+ out reaches nothing and nobody.
194
+
195
+ ## What the table holds
196
+
197
+ One row for one job, completed at each step.
198
+
199
+ | Status | What it says |
200
+ | --- | --- |
201
+ | `pending` | the job waits for a worker |
202
+ | `running` | a worker holds it |
203
+ | `retrying` | a try failed, the next one is set |
204
+ | `completed` | done |
205
+ | `failed` | every try fell |
206
+
207
+ The row that the dispatch writes and the row that the worker writes are the
208
+ same row. Either of the two can arrive first, thus the first one writes and the
209
+ second one completes.
210
+
211
+ ## Ask a job again
212
+
213
+ From the page, with the button on a row that failed. Or with the job that the
214
+ package carries:
215
+
216
+ ```ts
217
+ import { JobRun, JobRunStatus, retryRun } from '@artian-techs/adonis-orbit'
218
+
219
+ const runs = await JobRun.query().where('status', JobRunStatus.FAILED)
220
+
221
+ for (const run of runs) {
222
+ await retryRun(run)
223
+ }
224
+ ```
225
+
226
+ The row is never replaced: the new job takes a new identifier and opens its own
227
+ row, and the old one names it.
228
+
229
+ ## Tests
230
+
231
+ ```bash
232
+ pnpm test
233
+ ```
234
+
235
+ The tests read a PostgreSQL server, as the package does. The environment names
236
+ it, and without it the tests read `127.0.0.1:5432` and the database
237
+ `orbit_test`:
238
+
239
+ | Variable | What it names |
240
+ | --- | --- |
241
+ | `PG_HOST` | the machine, `127.0.0.1` |
242
+ | `PG_PORT` | the port, `5432` |
243
+ | `PG_USER` | the name, `postgres` |
244
+ | `PG_PASSWORD` | the word, `postgres` |
245
+ | `PG_DATABASE` | the database, `orbit_test` |
246
+
247
+ A server for the tests alone:
248
+
249
+ ```bash
250
+ docker run -d --name orbit-test-pg -p 55432:5432 \
251
+ -e POSTGRES_USER=orbit -e POSTGRES_PASSWORD=orbit -e POSTGRES_DB=orbit_test \
252
+ postgres:16
253
+
254
+ PG_PORT=55432 PG_USER=orbit PG_PASSWORD=orbit pnpm test
255
+ ```
256
+
257
+ The tests create the two tables from the stubs of `stubs/migrations`, and take
258
+ them away at the end.
259
+
260
+ `pnpm preview` opens the page on `http://localhost:3333/queue`, with jobs of
261
+ every status to read and the name `orbit` and the word `orbit`. It reads a
262
+ database of its own, `orbit_preview`, and makes it if it is not there.
263
+
264
+ ## Take out the traces that ended well
265
+
266
+ The package carries `PruneJobRuns` and names it to the queue itself. Give it a
267
+ place in the schedule of the application:
268
+
269
+ ```ts
270
+ import { PruneJobRuns } from '@artian-techs/adonis-orbit'
271
+
272
+ await PruneJobRuns.schedule({ days: 7 }).cron('10 0 * * *').run()
273
+ ```
274
+
275
+ A row that failed is never taken out.
@@ -0,0 +1,8 @@
1
+ import type Configure from '@adonisjs/core/commands/configure';
2
+ /**
3
+ * Put Orbit in an application.
4
+ *
5
+ * The table belongs to the application, thus the migration is published there
6
+ * and read like the others.
7
+ */
8
+ export declare function configure(command: Configure): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { stubsRoot } from './stubs/main.js';
2
+ /**
3
+ * Put Orbit in an application.
4
+ *
5
+ * The table belongs to the application, thus the migration is published there
6
+ * and read like the others.
7
+ */
8
+ export async function configure(command) {
9
+ const codemods = await command.createCodemods();
10
+ await codemods.updateRcFile((rcFile) => {
11
+ rcFile.addProvider('@artian-techs/adonis-orbit/orbit_provider');
12
+ });
13
+ await codemods.makeUsingStub(stubsRoot, 'config/orbit.stub', {});
14
+ const now = new Date().getTime();
15
+ await codemods.makeUsingStub(stubsRoot, 'migrations/create_job_runs_table.stub', {
16
+ migration: {
17
+ folder: 'database/migrations',
18
+ fileName: `${now}_create_job_runs_table.ts`,
19
+ },
20
+ });
21
+ await codemods.makeUsingStub(stubsRoot, 'migrations/create_silenced_jobs_table.stub', {
22
+ migration: {
23
+ folder: 'database/migrations',
24
+ fileName: `${now + 1}_create_silenced_jobs_table.ts`,
25
+ },
26
+ });
27
+ await codemods.defineEnvVariables({
28
+ ORBIT_DASHBOARD_USER: '',
29
+ ORBIT_DASHBOARD_PASSWORD: '',
30
+ });
31
+ await codemods.defineEnvValidations({
32
+ variables: {
33
+ ORBIT_DASHBOARD_USER: 'Env.schema.string.optional()',
34
+ ORBIT_DASHBOARD_PASSWORD: 'Env.schema.string.optional()',
35
+ },
36
+ leadingComment: 'Variables for the page of the queue',
37
+ });
38
+ }
@@ -0,0 +1,10 @@
1
+ export { defineConfig } from './src/define_config.js';
2
+ export { basicAuth } from './src/auth/basic_auth.js';
3
+ export { default as DatabaseSink } from './src/sinks/database_sink.js';
4
+ export type { Authorize, JobEvent, JobIdentity, JobOutcome, OrbitConfig, PayloadRule, Sink, } from './src/types.js';
5
+ export { default as JobRun, JobRunStatus, type JobRunStatusValue } from './src/models/job_run.js';
6
+ export { default as SilencedJob } from './src/models/silenced_job.js';
7
+ export { default as PruneJobRuns } from './src/jobs/prune_job_runs.js';
8
+ export { retryRun } from './src/retry.js';
9
+ export { stubsRoot } from './stubs/main.js';
10
+ export { configure } from './configure.js';
package/build/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { defineConfig } from './src/define_config.js';
2
+ export { basicAuth } from './src/auth/basic_auth.js';
3
+ export { default as DatabaseSink } from './src/sinks/database_sink.js';
4
+ export { default as JobRun, JobRunStatus } from './src/models/job_run.js';
5
+ export { default as SilencedJob } from './src/models/silenced_job.js';
6
+ export { default as PruneJobRuns } from './src/jobs/prune_job_runs.js';
7
+ export { retryRun } from './src/retry.js';
8
+ export { stubsRoot } from './stubs/main.js';
9
+ export { configure } from './configure.js';
@@ -0,0 +1,13 @@
1
+ import type { ApplicationService } from '@adonisjs/core/types';
2
+ /**
3
+ * Watch what the queue does, and keep it.
4
+ *
5
+ * The provider listens to the channels of the queue, writes each job in the
6
+ * table, and opens the page that reads it. The application gives it a path and
7
+ * the two words that guard the page, and nothing else.
8
+ */
9
+ export default class OrbitProvider {
10
+ protected app: ApplicationService;
11
+ constructor(app: ApplicationService);
12
+ boot(): Promise<void>;
13
+ }
@@ -0,0 +1,35 @@
1
+ import { Locator } from '@adonisjs/queue';
2
+ import edge from 'edge.js';
3
+ import PruneJobRuns from '../src/jobs/prune_job_runs.js';
4
+ const DEFAULTS = { path: '/queue', authorize: () => false, sinks: [] };
5
+ /**
6
+ * Watch what the queue does, and keep it.
7
+ *
8
+ * The provider listens to the channels of the queue, writes each job in the
9
+ * table, and opens the page that reads it. The application gives it a path and
10
+ * the two words that guard the page, and nothing else.
11
+ */
12
+ export default class OrbitProvider {
13
+ app;
14
+ constructor(app) {
15
+ this.app = app;
16
+ }
17
+ async boot() {
18
+ const config = this.app.config.get('orbit', DEFAULTS);
19
+ /**
20
+ * The page of Orbit lives with Orbit, thus the application carries no
21
+ * view for it.
22
+ */
23
+ edge.mount('orbit', new URL('../templates/', import.meta.url));
24
+ /**
25
+ * The application looks for its jobs in its own folders, and this one
26
+ * lives here. It names itself to the queue.
27
+ */
28
+ Locator.register(PruneJobRuns.name, PruneJobRuns);
29
+ const { recordJobRuns } = await import('../src/recorder.js');
30
+ recordJobRuns(config);
31
+ const router = await this.app.container.make('router');
32
+ const { registerRoutes } = await import('../src/routes.js');
33
+ registerRoutes(router, config);
34
+ }
35
+ }
@@ -0,0 +1,12 @@
1
+ import type { Authorize } from '../types.js';
2
+ /**
3
+ * Read the page with a name and a word, and no session.
4
+ *
5
+ * The page belongs to the team that runs the machine, not to the users of the
6
+ * application. It shows nothing at all while one of the two is missing, thus a
7
+ * machine that carries neither shows nothing.
8
+ */
9
+ export declare function basicAuth(credentials: {
10
+ user?: string;
11
+ password?: string;
12
+ }): Authorize;
@@ -0,0 +1,36 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * Compare the two words in a time that does not change with the number of
4
+ * letters that agree.
5
+ */
6
+ function matches(given, expected) {
7
+ if (!given) {
8
+ return false;
9
+ }
10
+ const a = Buffer.from(given);
11
+ const b = Buffer.from(expected);
12
+ return a.length === b.length && timingSafeEqual(a, b);
13
+ }
14
+ /**
15
+ * Read the page with a name and a word, and no session.
16
+ *
17
+ * The page belongs to the team that runs the machine, not to the users of the
18
+ * application. It shows nothing at all while one of the two is missing, thus a
19
+ * machine that carries neither shows nothing.
20
+ */
21
+ export function basicAuth(credentials) {
22
+ return ({ request, response }) => {
23
+ const { user, password } = credentials;
24
+ if (!user || !password) {
25
+ return false;
26
+ }
27
+ const [scheme, given] = (request.header('authorization') ?? '').split(' ');
28
+ const expected = Buffer.from(`${user}:${password}`).toString('base64');
29
+ if (scheme !== 'Basic' || !matches(given, expected)) {
30
+ response.header('www-authenticate', 'Basic realm="Queue"');
31
+ response.unauthorized({ success: false, error: 'Authentication is required' });
32
+ return false;
33
+ }
34
+ return true;
35
+ };
36
+ }
@@ -0,0 +1,51 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * The page that shows what the queue does.
4
+ *
5
+ * It reads the table, not Redis: a job that ended left Redis, and the table
6
+ * keeps it. The page is served by the API itself, outside the front, thus it
7
+ * answers even when the front is down.
8
+ */
9
+ export default class DashboardController {
10
+ #private;
11
+ /**
12
+ * Everything the queue did, and the four numbers that say how it goes.
13
+ *
14
+ * The side menu leads here with a status. A row that was asked again leads
15
+ * here with the identifier of the job that answered. Without one and without
16
+ * the other, the page shows the numbers and the last jobs of every status.
17
+ */
18
+ index(ctx: HttpContext): Promise<void>;
19
+ /**
20
+ * The classes that the lists leave out.
21
+ *
22
+ * Their work goes on and their trace is written. Take a name away from here
23
+ * and everything it did comes back, including what it did while it was
24
+ * silent.
25
+ */
26
+ silenced(ctx: HttpContext): Promise<void>;
27
+ /**
28
+ * What each class and each queue cost in the last day.
29
+ *
30
+ * A job that takes long, or that fails often, is read here before it is read
31
+ * in the list.
32
+ */
33
+ metrics(ctx: HttpContext): Promise<void>;
34
+ /**
35
+ * Ask the queue for one job again.
36
+ *
37
+ * The row that answers stays where it is. The new job opens a row of its
38
+ * own, thus the page shows the two.
39
+ */
40
+ retry({ params, response }: HttpContext): Promise<void>;
41
+ /**
42
+ * Read the page in the light, or in the dark.
43
+ *
44
+ * The choice is held in a cookie, thus the page carries it on the next
45
+ * request and no script reads it. A person who chose nothing reads the mode
46
+ * of their machine.
47
+ */
48
+ theme({ params, response }: HttpContext): Promise<void>;
49
+ /** Take a class out of the lists, or put it back. */
50
+ silence({ params, response }: HttpContext): Promise<void>;
51
+ }