alepha 0.20.4 → 0.20.6
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.
- package/dist/api/audits/index.d.ts +391 -359
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +23 -1
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.d.ts +18 -0
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +51 -0
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.browser.js +33 -14
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +452 -155
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +474 -159
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +32 -4
- package/dist/api/keys/index.d.ts.map +1 -1
- package/dist/api/keys/index.js +53 -0
- package/dist/api/keys/index.js.map +1 -1
- package/dist/api/notifications/index.d.ts +29 -1
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +55 -13
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/organizations/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +15 -0
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +37 -0
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/payments/index.js.map +1 -1
- package/dist/api/users/index.d.ts +150 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +237 -28
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +3 -3
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/bin/index.js +0 -0
- package/dist/bucket/index.d.ts +18 -0
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +47 -0
- package/dist/bucket/index.js.map +1 -1
- package/dist/bucket/index.workerd.js +24 -0
- package/dist/bucket/index.workerd.js.map +1 -1
- package/dist/cache/core/index.d.ts +20 -3
- package/dist/cache/core/index.d.ts.map +1 -1
- package/dist/cache/core/index.js.map +1 -1
- package/dist/cache/core/index.workerd.js.map +1 -1
- package/dist/cache/database/index.d.ts +155 -0
- package/dist/cache/database/index.d.ts.map +1 -0
- package/dist/cache/database/index.js +266 -0
- package/dist/cache/database/index.js.map +1 -0
- package/dist/cache/redis/index.js.map +1 -1
- package/dist/captcha/index.js.map +1 -1
- package/dist/cli/config/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +35 -5
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +85 -6
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/platform/index.js +1 -1
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/crypto/index.browser.js.map +1 -1
- package/dist/crypto/index.js.map +1 -1
- package/dist/datetime/index.js.map +1 -1
- package/dist/email/brevo/index.js.map +1 -1
- package/dist/email/core/index.js.map +1 -1
- package/dist/email/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/fake/index.js.map +1 -1
- package/dist/lock/core/index.js.map +1 -1
- package/dist/lock/redis/index.js.map +1 -1
- package/dist/logger/index.js.map +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/queue/core/index.js.map +1 -1
- package/dist/queue/core/index.workerd.js.map +1 -1
- package/dist/queue/redis/index.js.map +1 -1
- package/dist/react/auth/index.browser.js.map +1 -1
- package/dist/react/auth/index.js.map +1 -1
- package/dist/react/core/index.js.map +1 -1
- package/dist/react/form/index.js +2 -0
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/head/index.browser.js.map +1 -1
- package/dist/react/head/index.js.map +1 -1
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/intro/index.js.map +1 -1
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/testing/index.js.map +1 -1
- package/dist/react/ui/index.js.map +1 -1
- package/dist/react/websocket/index.js.map +1 -1
- package/dist/redis/index.bun.js.map +1 -1
- package/dist/redis/index.js.map +1 -1
- package/dist/retry/index.js.map +1 -1
- package/dist/router/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +22 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/index.workerd.js +12 -0
- package/dist/scheduler/index.workerd.js.map +1 -1
- package/dist/security/index.browser.js.map +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.browser.js.map +1 -1
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.browser.js.map +1 -1
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/cors/index.js.map +1 -1
- package/dist/server/etag/index.js.map +1 -1
- package/dist/server/health/index.js.map +1 -1
- package/dist/server/links/index.browser.js.map +1 -1
- package/dist/server/links/index.js.map +1 -1
- package/dist/server/metrics/index.js.map +1 -1
- package/dist/server/proxy/index.js.map +1 -1
- package/dist/server/rate-limit/index.js.map +1 -1
- package/dist/server/static/index.js.map +1 -1
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/sms/index.js.map +1 -1
- package/dist/system/index.browser.js.map +1 -1
- package/dist/system/index.js.map +1 -1
- package/dist/system/index.workerd.js.map +1 -1
- package/dist/topic/core/index.js.map +1 -1
- package/dist/topic/redis/index.js.map +1 -1
- package/dist/websocket/index.browser.js +4 -0
- package/dist/websocket/index.browser.js.map +1 -1
- package/dist/websocket/index.js +10 -0
- package/dist/websocket/index.js.map +1 -1
- package/package.json +282 -272
- package/src/api/audits/controllers/AdminAuditController.ts +29 -0
- package/src/api/files/controllers/FileController.ts +24 -0
- package/src/api/files/services/FileService.ts +41 -0
- package/src/api/jobs/__tests__/$job.spec.ts +427 -2
- package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
- package/src/api/jobs/index.ts +47 -10
- package/src/api/jobs/primitives/$job.ts +22 -9
- package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
- package/src/api/jobs/providers/JobDispatcher.ts +49 -0
- package/src/api/jobs/providers/JobProvider.ts +365 -142
- package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
- package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
- package/src/api/jobs/services/JobService.ts +21 -11
- package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
- package/src/api/keys/services/ApiKeyService.ts +42 -0
- package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
- package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
- package/src/api/notifications/index.ts +13 -3
- package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
- package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
- package/src/api/parameters/services/ParameterProvider.ts +18 -0
- package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
- package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
- package/src/api/users/controllers/AdminSessionController.ts +29 -0
- package/src/api/users/controllers/AdminUserController.ts +32 -0
- package/src/api/users/index.ts +3 -0
- package/src/api/users/services/CredentialService.ts +5 -0
- package/src/api/users/services/RegistrationService.ts +49 -1
- package/src/api/users/services/SessionCrudService.ts +16 -0
- package/src/api/users/services/SessionService.ts +17 -59
- package/src/api/users/services/UsernameSlugger.ts +195 -0
- package/src/bucket/primitives/$bucket.ts +21 -0
- package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
- package/src/bucket/providers/FileStorageProvider.ts +9 -0
- package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
- package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
- package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
- package/src/cache/core/primitives/$cache.ts +20 -3
- package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
- package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
- package/src/cache/database/entities/cacheEntries.ts +55 -0
- package/src/cache/database/index.ts +36 -0
- package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
- package/src/cli/core/services/ProjectScaffolder.ts +0 -2
- package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
- package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
- package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
- package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
- package/src/cli/platform/commands/platform.ts +7 -1
- package/src/scheduler/index.ts +14 -0
- package/src/scheduler/providers/CronProvider.ts +13 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { $inject, t } from "alepha";
|
|
2
|
+
import { $repository } from "alepha/orm";
|
|
2
3
|
import { $secure } from "alepha/security";
|
|
3
4
|
import { $action } from "alepha/server";
|
|
5
|
+
import { audits } from "../entities/audits.ts";
|
|
4
6
|
import { auditQuerySchema } from "../schemas/auditQuerySchema.ts";
|
|
5
7
|
import { auditResourceSchema } from "../schemas/auditResourceSchema.ts";
|
|
6
8
|
import { createAuditSchema } from "../schemas/createAuditSchema.ts";
|
|
@@ -19,6 +21,7 @@ export class AdminAuditController {
|
|
|
19
21
|
protected readonly url = "/audits";
|
|
20
22
|
protected readonly group = "admin:audits";
|
|
21
23
|
protected readonly auditService = $inject(AuditService);
|
|
24
|
+
protected readonly repo = $repository(audits);
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Find audit entries with filtering and pagination.
|
|
@@ -52,6 +55,32 @@ export class AdminAuditController {
|
|
|
52
55
|
handler: ({ params }) => this.auditService.getById(params.id),
|
|
53
56
|
});
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Delete many audit entries by id in one repository call. Use with care —
|
|
60
|
+
* audit logs are usually retained for compliance reasons.
|
|
61
|
+
*/
|
|
62
|
+
public readonly deleteAudits = $action({
|
|
63
|
+
method: "POST",
|
|
64
|
+
path: `${this.url}/delete`,
|
|
65
|
+
group: this.group,
|
|
66
|
+
use: [$secure({ permissions: ["admin:audit:delete"] })],
|
|
67
|
+
description: "Delete many audit entries",
|
|
68
|
+
schema: {
|
|
69
|
+
body: t.object({
|
|
70
|
+
ids: t.array(t.text(), { minItems: 1, maxItems: 1000 }),
|
|
71
|
+
}),
|
|
72
|
+
response: t.object({
|
|
73
|
+
deleted: t.array(t.text()),
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
handler: async ({ body }) => {
|
|
77
|
+
const deleted = await this.repo.deleteMany({
|
|
78
|
+
id: { inArray: body.ids },
|
|
79
|
+
});
|
|
80
|
+
return { deleted: deleted.map(String) };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
55
84
|
/**
|
|
56
85
|
* Create a new audit entry.
|
|
57
86
|
* System-only — this permission should never be assigned to human roles.
|
|
@@ -50,6 +50,30 @@ export class FileController {
|
|
|
50
50
|
handler: ({ params }) => this.fileService.deleteFile(params.id),
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* POST /files/delete - Delete many files in one request, batching the
|
|
55
|
+
* underlying bucket calls per bucket (R2/S3 batch where supported).
|
|
56
|
+
*/
|
|
57
|
+
public readonly deleteFiles = $action({
|
|
58
|
+
method: "POST",
|
|
59
|
+
path: `${this.url}/delete`,
|
|
60
|
+
group: `admin:${this.group}`,
|
|
61
|
+
use: [$secure({ permissions: ["admin:file:delete"] })],
|
|
62
|
+
description: "Delete many files",
|
|
63
|
+
schema: {
|
|
64
|
+
body: t.object({
|
|
65
|
+
ids: t.array(t.uuid(), { minItems: 1, maxItems: 1000 }),
|
|
66
|
+
}),
|
|
67
|
+
response: t.object({
|
|
68
|
+
deleted: t.array(t.string()),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
handler: async ({ body }) => {
|
|
72
|
+
const deleted = await this.fileService.deleteFiles(body.ids);
|
|
73
|
+
return { deleted };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
53
77
|
/**
|
|
54
78
|
* POST /files - Uploads a new file to storage.
|
|
55
79
|
* Creates a database record with metadata and calculates checksum.
|
|
@@ -324,6 +324,47 @@ export class FileService {
|
|
|
324
324
|
return { ok: true, id: String(file.id) };
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Delete many files in one round-trip per bucket. The database rows are
|
|
329
|
+
* removed in a single `deleteMany`, and each affected bucket gets a single
|
|
330
|
+
* `bucket.deleteMany` call (R2/S3 batch where supported).
|
|
331
|
+
*/
|
|
332
|
+
public async deleteFiles(ids: string[]): Promise<string[]> {
|
|
333
|
+
if (ids.length === 0) return [];
|
|
334
|
+
|
|
335
|
+
const files = await this.fileRepository.findMany({
|
|
336
|
+
where: { id: { inArray: ids } },
|
|
337
|
+
columns: ["id", "bucket", "blobId"],
|
|
338
|
+
});
|
|
339
|
+
if (files.length === 0) return [];
|
|
340
|
+
|
|
341
|
+
const dbDeleted = await this.fileRepository.deleteMany({
|
|
342
|
+
id: { inArray: files.map((f) => f.id) },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const blobsByBucket = new Map<string, string[]>();
|
|
346
|
+
for (const f of files) {
|
|
347
|
+
const list = blobsByBucket.get(f.bucket) ?? [];
|
|
348
|
+
list.push(f.blobId);
|
|
349
|
+
blobsByBucket.set(f.bucket, list);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const [bucketName, blobIds] of blobsByBucket) {
|
|
353
|
+
try {
|
|
354
|
+
await this.bucket(bucketName).deleteMany(blobIds, true);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
// DB rows already gone — log and continue. Orphaned blobs are
|
|
357
|
+
// recoverable; orphaned DB rows would be worse.
|
|
358
|
+
this.log.warn(
|
|
359
|
+
`Failed to bulk-delete ${blobIds.length} files from bucket ${bucketName}`,
|
|
360
|
+
e,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return dbDeleted.map(String);
|
|
366
|
+
}
|
|
367
|
+
|
|
327
368
|
/**
|
|
328
369
|
* Retrieves a file entity by its ID.
|
|
329
370
|
* If already an entity object, returns it as-is (convenience method).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Alepha, AlephaError, t } from "alepha";
|
|
2
|
+
import { LockProvider, MemoryLockProvider } from "alepha/lock";
|
|
2
3
|
import { $repository } from "alepha/orm";
|
|
3
4
|
import { AlephaOrmPostgres } from "alepha/orm/postgres";
|
|
4
5
|
import { describe, it } from "vitest";
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
$job,
|
|
7
8
|
AlephaApiJobs,
|
|
8
9
|
AlephaApiJobsQueue,
|
|
10
|
+
JobProvider,
|
|
9
11
|
jobExecutionEntity,
|
|
10
12
|
} from "../index.ts";
|
|
11
13
|
|
|
@@ -15,6 +17,10 @@ const makeApp = () =>
|
|
|
15
17
|
.with(AlephaApiJobs)
|
|
16
18
|
.with(AlephaApiJobsQueue);
|
|
17
19
|
|
|
20
|
+
/** App without `AlephaApiJobsQueue` — exercises *direct* mode. */
|
|
21
|
+
const makeAppDirect = () =>
|
|
22
|
+
Alepha.create().with(AlephaOrmPostgres).with(AlephaApiJobs);
|
|
23
|
+
|
|
18
24
|
describe("$job — registration validation", () => {
|
|
19
25
|
it("rejects jobs declaring both cron and schema", async ({ expect }) => {
|
|
20
26
|
const alepha = makeApp();
|
|
@@ -240,7 +246,7 @@ describe("$job — queue mode (outbox)", () => {
|
|
|
240
246
|
expect(seen.sort()).toEqual([1, 2, 3]);
|
|
241
247
|
});
|
|
242
248
|
|
|
243
|
-
it("retry: failed queue job is
|
|
249
|
+
it("retry: failed queue job is rescheduled for the next sweep tick", async ({
|
|
244
250
|
expect,
|
|
245
251
|
}) => {
|
|
246
252
|
const alepha = makeApp();
|
|
@@ -249,7 +255,7 @@ describe("$job — queue mode (outbox)", () => {
|
|
|
249
255
|
executions = $repository(jobExecutionEntity);
|
|
250
256
|
work = $job({
|
|
251
257
|
schema: t.object({ v: t.integer() }),
|
|
252
|
-
retry: { retries: 2
|
|
258
|
+
retry: { retries: 2 },
|
|
253
259
|
handler: async () => {
|
|
254
260
|
attempts++;
|
|
255
261
|
throw new Error("fail");
|
|
@@ -267,6 +273,11 @@ describe("$job — queue mode (outbox)", () => {
|
|
|
267
273
|
expect(rows[0].status).toBe("scheduled");
|
|
268
274
|
expect(rows[0].attempt).toBe(1);
|
|
269
275
|
expect(attempts).toBe(1);
|
|
276
|
+
// scheduledAt is "now" (no backoff), proving we no longer wait for an
|
|
277
|
+
// exponential delay — the row is immediately eligible at the next sweep.
|
|
278
|
+
expect(rows[0].scheduledAt).toBeTruthy();
|
|
279
|
+
const sched = new Date(rows[0].scheduledAt!).getTime();
|
|
280
|
+
expect(Math.abs(sched - Date.now())).toBeLessThan(2_000);
|
|
270
281
|
});
|
|
271
282
|
|
|
272
283
|
it("retry: terminal error after all retries exhausted", async ({
|
|
@@ -349,4 +360,418 @@ describe("$job — admin service", () => {
|
|
|
349
360
|
expect(byName.get("App.queueB")?.type).toBe("queue");
|
|
350
361
|
expect(byName.get("App.cronA")?.recent.ok).toBe(0);
|
|
351
362
|
});
|
|
363
|
+
|
|
364
|
+
it("listJobs reports 'direct' when AlephaApiJobsQueue is not loaded", async ({
|
|
365
|
+
expect,
|
|
366
|
+
}) => {
|
|
367
|
+
const alepha = makeAppDirect();
|
|
368
|
+
class App {
|
|
369
|
+
worker = $job({
|
|
370
|
+
schema: t.object({ v: t.integer() }),
|
|
371
|
+
handler: async () => {},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
alepha.inject(App);
|
|
375
|
+
await alepha.start();
|
|
376
|
+
|
|
377
|
+
const { JobService } = await import("../services/JobService.ts");
|
|
378
|
+
const svc = alepha.inject(JobService);
|
|
379
|
+
const list = await svc.listJobs();
|
|
380
|
+
|
|
381
|
+
expect(list.find((j) => j.name === "App.worker")?.type).toBe("direct");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
describe("$job — direct mode (no AlephaApiJobsQueue)", () => {
|
|
388
|
+
it("push without a queue dispatcher processes the row in-process", async ({
|
|
389
|
+
expect,
|
|
390
|
+
}) => {
|
|
391
|
+
const alepha = makeAppDirect();
|
|
392
|
+
let received: { n: number } | undefined;
|
|
393
|
+
class App {
|
|
394
|
+
executions = $repository(jobExecutionEntity);
|
|
395
|
+
work = $job({
|
|
396
|
+
schema: t.object({ n: t.integer() }),
|
|
397
|
+
handler: async ({ payload }) => {
|
|
398
|
+
received = payload;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
const app = alepha.inject(App);
|
|
403
|
+
await alepha.start();
|
|
404
|
+
|
|
405
|
+
expect(alepha.inject(JobProvider).effectiveMode("App.work")).toBe("direct");
|
|
406
|
+
|
|
407
|
+
await app.work.push({ n: 7 });
|
|
408
|
+
|
|
409
|
+
// Direct mode is fire-and-track; give the microtask queue a moment.
|
|
410
|
+
const deadline = Date.now() + 1500;
|
|
411
|
+
while (received === undefined && Date.now() < deadline) {
|
|
412
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
expect(received).toEqual({ n: 7 });
|
|
416
|
+
// Default record: 'error' → success deletes the row.
|
|
417
|
+
const rows = await app.executions.findMany({
|
|
418
|
+
where: { jobName: { eq: "App.work" } },
|
|
419
|
+
});
|
|
420
|
+
expect(rows).toHaveLength(0);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("direct mode failure schedules the row for the next sweep", async ({
|
|
424
|
+
expect,
|
|
425
|
+
}) => {
|
|
426
|
+
const alepha = makeAppDirect();
|
|
427
|
+
class App {
|
|
428
|
+
executions = $repository(jobExecutionEntity);
|
|
429
|
+
work = $job({
|
|
430
|
+
schema: t.object({ n: t.integer() }),
|
|
431
|
+
retry: { retries: 2 },
|
|
432
|
+
handler: async () => {
|
|
433
|
+
throw new Error("nope");
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const app = alepha.inject(App);
|
|
438
|
+
await alepha.start();
|
|
439
|
+
|
|
440
|
+
await app.work.push({ n: 1 });
|
|
441
|
+
|
|
442
|
+
// Wait for the in-process attempt to fail.
|
|
443
|
+
const deadline = Date.now() + 2000;
|
|
444
|
+
let row: any;
|
|
445
|
+
while (Date.now() < deadline) {
|
|
446
|
+
const rows = await app.executions.findMany({
|
|
447
|
+
where: { jobName: { eq: "App.work" } },
|
|
448
|
+
});
|
|
449
|
+
if (rows[0]?.status === "scheduled") {
|
|
450
|
+
row = rows[0];
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
expect(row).toBeTruthy();
|
|
457
|
+
expect(row.status).toBe("scheduled");
|
|
458
|
+
expect(row.attempt).toBe(1);
|
|
459
|
+
expect(row.error).toBe("nope");
|
|
460
|
+
// scheduledAt should be "now" (no backoff).
|
|
461
|
+
const sched = new Date(row.scheduledAt).getTime();
|
|
462
|
+
expect(Math.abs(sched - Date.now())).toBeLessThan(2_000);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Shared in-process key/value to simulate a cross-instance lock store
|
|
470
|
+
* (mirrors the pattern used in scheduler tests).
|
|
471
|
+
*/
|
|
472
|
+
const sharedLockStore: Record<string, string> = {};
|
|
473
|
+
class SharedMemoryLockProvider extends MemoryLockProvider {
|
|
474
|
+
override store = sharedLockStore;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
describe("$job — cron lock (multi-instance)", () => {
|
|
478
|
+
it("only one instance fires the cron handler when sharing a LockProvider", async ({
|
|
479
|
+
expect,
|
|
480
|
+
}) => {
|
|
481
|
+
// Reset store between tests.
|
|
482
|
+
for (const k of Object.keys(sharedLockStore)) delete sharedLockStore[k];
|
|
483
|
+
|
|
484
|
+
let fired = 0;
|
|
485
|
+
class App {
|
|
486
|
+
tick = $job({
|
|
487
|
+
cron: "0 0 * * *",
|
|
488
|
+
handler: async () => {
|
|
489
|
+
fired++;
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const make = () =>
|
|
495
|
+
Alepha.create()
|
|
496
|
+
// Substitute the LockProvider BEFORE AlephaApiJobs imports AlephaLock,
|
|
497
|
+
// otherwise the module's default `optional` MemoryLockProvider wins.
|
|
498
|
+
.with({ provide: LockProvider, use: SharedMemoryLockProvider })
|
|
499
|
+
.with(AlephaOrmPostgres)
|
|
500
|
+
.with(AlephaApiJobs);
|
|
501
|
+
|
|
502
|
+
const a = make();
|
|
503
|
+
const b = make();
|
|
504
|
+
a.inject(App);
|
|
505
|
+
b.inject(App);
|
|
506
|
+
await a.start();
|
|
507
|
+
await b.start();
|
|
508
|
+
|
|
509
|
+
// Trigger the tick from both instances: only one should win the lock.
|
|
510
|
+
await Promise.all([
|
|
511
|
+
a.inject(App).tick.trigger(),
|
|
512
|
+
b.inject(App).tick.trigger(),
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
expect(fired).toBe(1);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("lock: false lets every instance fire (opt-out behavior)", async ({
|
|
519
|
+
expect,
|
|
520
|
+
}) => {
|
|
521
|
+
for (const k of Object.keys(sharedLockStore)) delete sharedLockStore[k];
|
|
522
|
+
|
|
523
|
+
let fired = 0;
|
|
524
|
+
class App {
|
|
525
|
+
tick = $job({
|
|
526
|
+
cron: "0 0 * * *",
|
|
527
|
+
lock: false,
|
|
528
|
+
handler: async () => {
|
|
529
|
+
fired++;
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const make = () =>
|
|
535
|
+
Alepha.create()
|
|
536
|
+
// Substitute the LockProvider BEFORE AlephaApiJobs imports AlephaLock,
|
|
537
|
+
// otherwise the module's default `optional` MemoryLockProvider wins.
|
|
538
|
+
.with({ provide: LockProvider, use: SharedMemoryLockProvider })
|
|
539
|
+
.with(AlephaOrmPostgres)
|
|
540
|
+
.with(AlephaApiJobs);
|
|
541
|
+
|
|
542
|
+
const a = make();
|
|
543
|
+
const b = make();
|
|
544
|
+
a.inject(App);
|
|
545
|
+
b.inject(App);
|
|
546
|
+
await a.start();
|
|
547
|
+
await b.start();
|
|
548
|
+
|
|
549
|
+
await Promise.all([
|
|
550
|
+
a.inject(App).tick.trigger(),
|
|
551
|
+
b.inject(App).tick.trigger(),
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
expect(fired).toBe(2);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
describe("$job — retry semantics", () => {
|
|
561
|
+
it("retries: 2 runs the handler 3 times before the row is terminal", async ({
|
|
562
|
+
expect,
|
|
563
|
+
}) => {
|
|
564
|
+
const alepha = makeApp();
|
|
565
|
+
let attempts = 0;
|
|
566
|
+
class App {
|
|
567
|
+
executions = $repository(jobExecutionEntity);
|
|
568
|
+
work = $job({
|
|
569
|
+
schema: t.object({ v: t.integer() }),
|
|
570
|
+
retry: { retries: 2 },
|
|
571
|
+
handler: async () => {
|
|
572
|
+
attempts++;
|
|
573
|
+
throw new Error("boom");
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
const app = alepha.inject(App);
|
|
578
|
+
await alepha.start();
|
|
579
|
+
|
|
580
|
+
await app.work.push({ v: 1 });
|
|
581
|
+
|
|
582
|
+
// First attempt runs immediately. Subsequent retries are sweep-driven.
|
|
583
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
584
|
+
expect(attempts).toBe(1);
|
|
585
|
+
|
|
586
|
+
const provider = alepha.inject(JobProvider);
|
|
587
|
+
// Trigger sweeps manually — each one should claim and run the next attempt.
|
|
588
|
+
await (provider as any).sweep();
|
|
589
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
590
|
+
expect(attempts).toBe(2);
|
|
591
|
+
|
|
592
|
+
await (provider as any).sweep();
|
|
593
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
594
|
+
expect(attempts).toBe(3);
|
|
595
|
+
|
|
596
|
+
// After 3 attempts the row is terminal — no more retries.
|
|
597
|
+
const finalRow = (
|
|
598
|
+
await app.executions.findMany({ where: { jobName: { eq: "App.work" } } })
|
|
599
|
+
)[0];
|
|
600
|
+
expect(finalRow.status).toBe("error");
|
|
601
|
+
expect(finalRow.attempt).toBe(3);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
describe("$job — cancel race", () => {
|
|
608
|
+
it("cancel during a running handler keeps the row 'cancelled' (not 'error')", async ({
|
|
609
|
+
expect,
|
|
610
|
+
}) => {
|
|
611
|
+
const alepha = makeApp();
|
|
612
|
+
class App {
|
|
613
|
+
executions = $repository(jobExecutionEntity);
|
|
614
|
+
slow = $job({
|
|
615
|
+
schema: t.object({ v: t.integer() }),
|
|
616
|
+
retry: { retries: 0 },
|
|
617
|
+
handler: async ({ signal }) => {
|
|
618
|
+
// Wait for cancellation, then throw — without the guard this throw
|
|
619
|
+
// would write `status: error` after `status: cancelled`.
|
|
620
|
+
await new Promise<void>((_, reject) => {
|
|
621
|
+
signal.addEventListener("abort", () => {
|
|
622
|
+
reject(new Error("aborted"));
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const app = alepha.inject(App);
|
|
629
|
+
await alepha.start();
|
|
630
|
+
|
|
631
|
+
const id = await app.slow.push({ v: 1 });
|
|
632
|
+
|
|
633
|
+
// Wait for the handler to be `running`.
|
|
634
|
+
const deadline = Date.now() + 1500;
|
|
635
|
+
while (Date.now() < deadline) {
|
|
636
|
+
const row = await app.executions.findById(id);
|
|
637
|
+
if (row?.status === "running") break;
|
|
638
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
await app.slow.cancel(id);
|
|
642
|
+
|
|
643
|
+
// Give the handler's abort path a moment to execute and (without the
|
|
644
|
+
// guard) try to overwrite the row.
|
|
645
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
646
|
+
|
|
647
|
+
const final = await app.executions.findById(id);
|
|
648
|
+
expect(final?.status).toBe("cancelled");
|
|
649
|
+
expect(final?.error).toBeFalsy();
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
describe("$job — cron + retry (outbox path)", () => {
|
|
656
|
+
it("a failing cron job with retry is rescheduled by the sweep, not next tick", async ({
|
|
657
|
+
expect,
|
|
658
|
+
}) => {
|
|
659
|
+
const alepha = makeAppDirect();
|
|
660
|
+
let attempts = 0;
|
|
661
|
+
class App {
|
|
662
|
+
executions = $repository(jobExecutionEntity);
|
|
663
|
+
tick = $job({
|
|
664
|
+
cron: "0 0 * * *",
|
|
665
|
+
retry: { retries: 1 },
|
|
666
|
+
handler: async () => {
|
|
667
|
+
attempts++;
|
|
668
|
+
throw new Error("transient");
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const app = alepha.inject(App);
|
|
673
|
+
await alepha.start();
|
|
674
|
+
|
|
675
|
+
// Manually fire the cron tick (admin trigger path goes through the
|
|
676
|
+
// same lock-aware runner as the scheduled tick).
|
|
677
|
+
await app.tick.trigger();
|
|
678
|
+
|
|
679
|
+
// Direct-mode dispatch is fire-and-track. Wait for the first attempt
|
|
680
|
+
// to land in the DB.
|
|
681
|
+
const deadline = Date.now() + 1500;
|
|
682
|
+
while (attempts < 1 && Date.now() < deadline) {
|
|
683
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
684
|
+
}
|
|
685
|
+
expect(attempts).toBe(1);
|
|
686
|
+
|
|
687
|
+
// The row exists in the outbox after the cron tick (unlike inline crons,
|
|
688
|
+
// which only persist on error).
|
|
689
|
+
const provider = alepha.inject(JobProvider);
|
|
690
|
+
const rows1 = await app.executions.findMany({
|
|
691
|
+
where: { jobName: { eq: "App.tick" } },
|
|
692
|
+
});
|
|
693
|
+
expect(rows1).toHaveLength(1);
|
|
694
|
+
expect(rows1[0].status).toBe("scheduled");
|
|
695
|
+
|
|
696
|
+
// Sweep picks it up and runs attempt 2 → terminal.
|
|
697
|
+
await (provider as any).sweep();
|
|
698
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
699
|
+
expect(attempts).toBe(2);
|
|
700
|
+
const rows2 = await app.executions.findMany({
|
|
701
|
+
where: { jobName: { eq: "App.tick" } },
|
|
702
|
+
});
|
|
703
|
+
expect(rows2[0].status).toBe("error");
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
describe("$job — dispatchMany (queue mode)", () => {
|
|
710
|
+
it("pushMany hands the dispatcher a single batch", async ({ expect }) => {
|
|
711
|
+
const alepha = makeApp();
|
|
712
|
+
class App {
|
|
713
|
+
bulk = $job({
|
|
714
|
+
schema: t.object({ n: t.integer() }),
|
|
715
|
+
handler: async () => {},
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const app = alepha.inject(App);
|
|
719
|
+
await alepha.start();
|
|
720
|
+
|
|
721
|
+
const provider = alepha.inject(JobProvider);
|
|
722
|
+
const dispatcher = (provider as any).dispatcher as {
|
|
723
|
+
dispatchMany: (
|
|
724
|
+
items: Array<{ jobName: string; executionId: string }>,
|
|
725
|
+
) => Promise<void>;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
let batched: Array<{ jobName: string; executionId: string }> = [];
|
|
729
|
+
const original = dispatcher.dispatchMany.bind(dispatcher);
|
|
730
|
+
dispatcher.dispatchMany = async (items) => {
|
|
731
|
+
batched = items;
|
|
732
|
+
await original(items);
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
await app.bulk.pushMany([
|
|
736
|
+
{ payload: { n: 1 } },
|
|
737
|
+
{ payload: { n: 2 } },
|
|
738
|
+
{ payload: { n: 3 } },
|
|
739
|
+
]);
|
|
740
|
+
|
|
741
|
+
expect(batched).toHaveLength(3);
|
|
742
|
+
for (const it of batched) {
|
|
743
|
+
expect(it.jobName).toBe("App.bulk");
|
|
744
|
+
expect(it.executionId).toBeTruthy();
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
describe("$job — admin resource shape", () => {
|
|
752
|
+
it("execution resource exposes priority as the enum string", async ({
|
|
753
|
+
expect,
|
|
754
|
+
}) => {
|
|
755
|
+
const alepha = makeApp();
|
|
756
|
+
class App {
|
|
757
|
+
work = $job({
|
|
758
|
+
schema: t.object({ v: t.integer() }),
|
|
759
|
+
priority: "high",
|
|
760
|
+
record: "all",
|
|
761
|
+
handler: async () => {},
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
const app = alepha.inject(App);
|
|
765
|
+
await alepha.start();
|
|
766
|
+
|
|
767
|
+
const id = await app.work.push({ v: 1 });
|
|
768
|
+
// Wait for the handler to finish so the row is `ok` (record: all keeps it).
|
|
769
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
770
|
+
|
|
771
|
+
const { JobService } = await import("../services/JobService.ts");
|
|
772
|
+
const svc = alepha.inject(JobService);
|
|
773
|
+
const resource = await svc.getExecution(id);
|
|
774
|
+
expect(resource.priority).toBe("high");
|
|
775
|
+
expect(typeof resource.priority).toBe("string");
|
|
776
|
+
});
|
|
352
777
|
});
|
|
@@ -10,11 +10,11 @@ import { $entity, db } from "alepha/orm";
|
|
|
10
10
|
* the last N rows per job (configurable via `jobConfig.keepLastSuccess`).
|
|
11
11
|
*
|
|
12
12
|
* Status transitions:
|
|
13
|
-
* - queue push → pending
|
|
13
|
+
* - queue push → pending (or `scheduled` if `delay`/`scheduledAt` was given)
|
|
14
14
|
* - worker claim → running
|
|
15
|
-
* - success → ok
|
|
15
|
+
* - success → ok (or row deleted, depending on `record` and `keepLastSuccess`)
|
|
16
16
|
* - terminal failure → error
|
|
17
|
-
* -
|
|
17
|
+
* - retryable failure → scheduled (with scheduledAt = now; sweep picks it up)
|
|
18
18
|
* - delay → scheduled (with scheduledAt = now + delay)
|
|
19
19
|
* - sweep picks due ones → pending
|
|
20
20
|
* - cancel → cancelled
|