@cosmicdrift/kumiko-framework 0.393.0 → 0.395.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.
- package/AGENTS.md +1 -1
- package/agents/examples/cross-feature-events.md +87 -0
- package/agents/examples/dashboard-stats.md +141 -2
- package/agents/examples/env-composition.md +7 -7
- package/agents/examples/inbound-mail-triage.md +12 -0
- package/agents/examples/pdf-audit-export.md +103 -50
- package/agents/features.md +17 -15
- package/agents/widgets.md +1 -1
- package/dist/api/entity-read-access.d.ts +1 -0
- package/dist/api/entity-read-access.js +20 -5
- package/dist/api/server-event-dispatcher.js +23 -2
- package/dist/compliance/profiles.js +4 -4
- package/dist/crypto/index.d.ts +1 -0
- package/dist/crypto/index.js +1 -0
- package/dist/crypto/kms-outage.d.ts +8 -0
- package/dist/crypto/kms-outage.js +33 -0
- package/dist/crypto/pii-field-encryption.js +4 -3
- package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
- package/dist/engine/boot-validator/entity-handler.js +2 -16
- package/dist/engine/boot-validator/query-output-columns.js +7 -0
- package/dist/engine/boot-validator/query-refs.js +9 -0
- package/dist/engine/boot-validator/screens-actions.js +19 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
- package/dist/engine/boot-validator/screens-dashboard.js +13 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
- package/dist/engine/boot-validator/screens-lists.js +3 -0
- package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
- package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
- package/dist/engine/boot-validator/transaction-phases.js +28 -0
- package/dist/engine/define-handler.d.ts +3 -2
- package/dist/engine/define-handler.js +3 -0
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +1 -17
- package/dist/engine/handler-definition-slots.d.ts +17 -0
- package/dist/engine/handler-definition-slots.js +20 -0
- package/dist/engine/qualified-name.d.ts +2 -1
- package/dist/engine/qualified-name.js +2 -14
- package/dist/engine/registry-facade.js +6 -0
- package/dist/engine/registry-read-models.d.ts +2 -0
- package/dist/engine/registry-read-models.js +24 -0
- package/dist/engine/registry-state.d.ts +4 -0
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/registry-validate.js +12 -1
- package/dist/engine/registry.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
- package/dist/events-cli.d.ts +19 -0
- package/dist/events-cli.js +121 -0
- package/dist/files/bind-pending-file-refs.js +2 -1
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/can-caller-read-file-ref.d.ts +10 -0
- package/dist/files/can-caller-read-file-ref.js +12 -0
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +7 -2
- package/dist/files/create-file-ref.d.ts +4 -12
- package/dist/files/create-file-ref.js +2 -45
- package/dist/files/default-file-guard.d.ts +4 -0
- package/dist/files/default-file-guard.js +41 -0
- package/dist/files/delete-record-file-refs.d.ts +8 -0
- package/dist/files/delete-record-file-refs.js +20 -0
- package/dist/files/file-routes.d.ts +1 -0
- package/dist/files/file-routes.js +19 -38
- package/dist/files/index.d.ts +6 -2
- package/dist/files/index.js +4 -1
- package/dist/files/record-only-file-entity-types.d.ts +2 -0
- package/dist/files/record-only-file-entity-types.js +22 -0
- package/dist/files/record-read-check.js +11 -0
- package/dist/files/types.d.ts +2 -1
- package/dist/files/types.js +1 -1
- package/dist/files/upload-field-resolution.d.ts +24 -0
- package/dist/files/upload-field-resolution.js +51 -0
- package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
- package/dist/jobs/job-runner-event-triggers.js +64 -0
- package/dist/jobs/job-runner-helpers.d.ts +5 -0
- package/dist/jobs/job-runner-helpers.js +10 -0
- package/dist/jobs/job-runner-types.d.ts +1 -0
- package/dist/jobs/job-runner.js +3 -51
- package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
- package/dist/pipeline/aggregate-type-removal.js +146 -0
- package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
- package/dist/pipeline/catch-up-job-consumer.js +38 -0
- package/dist/pipeline/dispatch-write.js +4 -9
- package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
- package/dist/pipeline/job-trigger-consumer.js +48 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
- package/dist/pipeline/pre-transaction-reservations.js +16 -0
- package/dist/pipeline/system-hooks.d.ts +0 -3
- package/dist/pipeline/system-hooks.js +0 -48
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +11 -3
- package/src/changes.json +48 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { AnyFileFieldDef } from "@cosmicdrift/kumiko-types/fields";
|
|
2
1
|
import type { TenantDb } from "../db/tenant-db.ts";
|
|
3
2
|
import type { Registry, SessionUser } from "../engine/types/index.ts";
|
|
4
3
|
import { type WriteFailure } from "../errors/index.ts";
|
|
4
|
+
import { type AttachTargets } from "./upload-field-resolution.ts";
|
|
5
5
|
export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
6
6
|
declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<{
|
|
7
7
|
readonly id: string;
|
|
@@ -23,15 +23,6 @@ declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event
|
|
|
23
23
|
readonly storageKey: string;
|
|
24
24
|
}>;
|
|
25
25
|
export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
|
|
26
|
-
type AttachedFieldResolution = {
|
|
27
|
-
readonly kind: "unattached";
|
|
28
|
-
} | {
|
|
29
|
-
readonly kind: "resolved";
|
|
30
|
-
readonly fieldDef: AnyFileFieldDef;
|
|
31
|
-
} | {
|
|
32
|
-
readonly kind: "unresolvable";
|
|
33
|
-
};
|
|
34
|
-
export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined): AttachedFieldResolution;
|
|
35
26
|
export type StoreFileInput = {
|
|
36
27
|
readonly fileName: string;
|
|
37
28
|
readonly declaredMimeType: string;
|
|
@@ -48,7 +39,8 @@ export type StoreFileDeps = {
|
|
|
48
39
|
readonly tenantDb: TenantDb;
|
|
49
40
|
readonly rowInOwnTransaction?: boolean;
|
|
50
41
|
readonly registry: Registry | undefined;
|
|
51
|
-
readonly
|
|
42
|
+
readonly attachTargets?: AttachTargets;
|
|
43
|
+
readonly defaultMaxUploadSize: string | number;
|
|
52
44
|
readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
|
|
53
45
|
readonly deleteBytes: (storageKey: string) => Promise<void>;
|
|
54
46
|
readonly isAttachTargetLocked?: (rowDb: TenantDb) => Promise<boolean>;
|
|
@@ -88,7 +80,7 @@ export type StoreFileOutcome = {
|
|
|
88
80
|
readonly kind: "failed";
|
|
89
81
|
readonly failure: WriteFailure;
|
|
90
82
|
};
|
|
91
|
-
export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
|
|
83
|
+
export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "attachTargets" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
|
|
92
84
|
export declare function createFileRefRow(deps: Pick<StoreFileDeps, "user" | "tenantDb">, uploaded: UploadedFileBytes): Promise<{
|
|
93
85
|
readonly fileRef: CreatedFileRef;
|
|
94
86
|
} | {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
2
2
|
import { runInOwnTransaction } from "../db/tenant-db-scoped-rebind.js";
|
|
3
|
-
import { isFileField } from "../engine/field-helpers.js";
|
|
4
3
|
import { InternalError } from "../errors/index.js";
|
|
5
4
|
import { generateId } from "../utils/index.js";
|
|
6
|
-
import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
|
|
7
5
|
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
8
6
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
9
7
|
import { resolveContentType } from "./resolve-content-type.js";
|
|
10
8
|
import { buildStorageKey, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
|
|
9
|
+
import { resolveUploadField } from "./upload-field-resolution.js";
|
|
11
10
|
import { takeUploadLedgerEntry } from "./upload-ledger.js";
|
|
12
11
|
export const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
13
12
|
// Standard entity executor for fileRef: create/delete emit fileRef.created /
|
|
@@ -26,50 +25,8 @@ export function getFileRefExecutor() {
|
|
|
26
25
|
}
|
|
27
26
|
const ACTION_FORM_STORAGE_SEGMENT = "action-form";
|
|
28
27
|
const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
|
|
29
|
-
// entityType/fieldName drive the GDPR-forget decision downstream
|
|
30
|
-
// (fileRefDeleteHook resolves the field's PII annotation from exactly this
|
|
31
|
-
// pairing) — attaching to a field nobody can resolve would leave that
|
|
32
|
-
// decision hanging on an unverified client string. Either both are omitted
|
|
33
|
-
// (unattached upload, allowed) or both must resolve to a real registered
|
|
34
|
-
// field.
|
|
35
|
-
export function resolveAttachedField(registry, entityType, fieldName) {
|
|
36
|
-
if (entityType === undefined && fieldName === undefined)
|
|
37
|
-
return { kind: "unattached" };
|
|
38
|
-
const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
|
|
39
|
-
const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
|
|
40
|
-
if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
|
|
41
|
-
return { kind: "unresolvable" };
|
|
42
|
-
return { kind: "resolved", fieldDef };
|
|
43
|
-
}
|
|
44
28
|
class AttachTargetLockedError extends Error {
|
|
45
29
|
}
|
|
46
|
-
function resolveUploadField(registry, input) {
|
|
47
|
-
const { entityType, entityId, fieldName, actionForm } = input;
|
|
48
|
-
if (actionForm === undefined)
|
|
49
|
-
return resolveAttachedField(registry, entityType, fieldName);
|
|
50
|
-
if (entityType !== undefined || entityId !== undefined) {
|
|
51
|
-
return {
|
|
52
|
-
kind: "rejected",
|
|
53
|
-
error: "invalid_action_form: actionForm cannot be combined with entityType/entityId",
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
if (!isActionFormScreenQn(actionForm)) {
|
|
57
|
-
return {
|
|
58
|
-
kind: "rejected",
|
|
59
|
-
error: "invalid_action_form: expected a screen QN like <feature>:screen:<id>",
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
const fieldDef = fieldName !== undefined
|
|
63
|
-
? resolveActionFormFileField(registry, actionForm, fieldName)
|
|
64
|
-
: undefined;
|
|
65
|
-
if (fieldDef === undefined) {
|
|
66
|
-
return {
|
|
67
|
-
kind: "rejected",
|
|
68
|
-
error: "unresolvable_field: actionForm and fieldName must name a files/image field of a registered actionForm screen",
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
return { kind: "resolved", fieldDef };
|
|
72
|
-
}
|
|
73
30
|
function acceptFile(deps, input) {
|
|
74
31
|
const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
|
|
75
32
|
const rejected = (error) => ({ kind: "rejected", error });
|
|
@@ -85,7 +42,7 @@ function acceptFile(deps, input) {
|
|
|
85
42
|
return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
|
|
86
43
|
}
|
|
87
44
|
}
|
|
88
|
-
const field = resolveUploadField(deps.registry, input);
|
|
45
|
+
const field = resolveUploadField(deps.registry, input, deps.attachTargets);
|
|
89
46
|
if (field.kind === "rejected")
|
|
90
47
|
return field;
|
|
91
48
|
if (field.kind === "unresolvable") {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FileAccessGuard } from "./file-routes.ts";
|
|
2
|
+
import type { RecordReadCheck } from "./record-read-check.ts";
|
|
3
|
+
export declare const DEFAULT_PRIVILEGED_ROLES: readonly ["Admin", "SystemAdmin"];
|
|
4
|
+
export declare function createDefaultGuard(privilegedRoles: readonly string[], canReadRecord: RecordReadCheck | undefined, unclaimedUploadReaders: readonly string[], recordOnlyEntityTypes: ReadonlySet<string>): FileAccessGuard;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
2
|
+
export const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
3
|
+
// Default guard: on attached files, allow the uploader or a privileged role,
|
|
4
|
+
// and for reads also everyone who may read the record. Delete stays with the
|
|
5
|
+
// uploader and privileged roles: reading a record is no right to remove its
|
|
6
|
+
// files. Pending uploads (entityType set, record not yet created) are the
|
|
7
|
+
// uploader's own until bound; so are uploads without an entityType, which no
|
|
8
|
+
// record can ever claim.
|
|
9
|
+
export function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders, recordOnlyEntityTypes) {
|
|
10
|
+
return async ({ fileRef, user, operation }) => {
|
|
11
|
+
if (fileRef.insertedById === user.id)
|
|
12
|
+
return "allow";
|
|
13
|
+
const isRecordOnly = fileRef.entityType !== null && recordOnlyEntityTypes.has(fileRef.entityType);
|
|
14
|
+
if (!isRecordOnly && hasAnyRole(user, privilegedRoles))
|
|
15
|
+
return "allow";
|
|
16
|
+
if (isUnclaimedUpload(fileRef) &&
|
|
17
|
+
operation === "read" &&
|
|
18
|
+
hasAnyRole(user, unclaimedUploadReaders)) {
|
|
19
|
+
return "allow";
|
|
20
|
+
}
|
|
21
|
+
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
22
|
+
return "deny";
|
|
23
|
+
if (operation !== "read" || canReadRecord === undefined)
|
|
24
|
+
return "deny";
|
|
25
|
+
const recordReadable = await canReadRecord({
|
|
26
|
+
entityType: fileRef.entityType,
|
|
27
|
+
entityId: fileRef.entityId,
|
|
28
|
+
user,
|
|
29
|
+
});
|
|
30
|
+
return recordReadable ? "allow" : "deny";
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function hasAnyRole(user, roles) {
|
|
34
|
+
return roles.some((role) => user.roles.includes(role));
|
|
35
|
+
}
|
|
36
|
+
// System-generated documents are created without attachTo too, but belong to a person's data, not to an inbox.
|
|
37
|
+
function isUnclaimedUpload(fileRef) {
|
|
38
|
+
return (fileRef.entityType === null &&
|
|
39
|
+
fileRef.entityId === null &&
|
|
40
|
+
fileRef.insertedById !== SYSTEM_USER_ID);
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.ts";
|
|
2
|
+
import type { SessionUser } from "../engine/types/index.ts";
|
|
3
|
+
export declare function deleteFileRefsBoundToRecord(args: {
|
|
4
|
+
readonly db: TenantDb;
|
|
5
|
+
readonly user: SessionUser;
|
|
6
|
+
readonly entityType: string;
|
|
7
|
+
readonly entityId: string;
|
|
8
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
2
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
3
|
+
import { fileRefsTable } from "./file-ref-table.js";
|
|
4
|
+
// Same soft delete as DELETE /files/:id for every fileRef bound to one record, for owners that
|
|
5
|
+
// remove the record without an r.entity delete (a retention sweep over a projection read model).
|
|
6
|
+
export async function deleteFileRefsBoundToRecord(args) {
|
|
7
|
+
const { db, user, entityType, entityId } = args;
|
|
8
|
+
const rows = await db.selectMany(fileRefsTable, {
|
|
9
|
+
entityType,
|
|
10
|
+
entityId,
|
|
11
|
+
isDeleted: false,
|
|
12
|
+
});
|
|
13
|
+
const executor = getFileRefExecutor();
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
const removed = await executor.delete({ id: row.id }, user, db);
|
|
16
|
+
if (!removed.isSuccess)
|
|
17
|
+
throw reraiseAsKumikoError(removed.error);
|
|
18
|
+
}
|
|
19
|
+
return rows.length;
|
|
20
|
+
}
|
|
@@ -43,5 +43,6 @@ export type FileRoutesOptions = {
|
|
|
43
43
|
};
|
|
44
44
|
export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
|
|
45
45
|
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "unclaimedUploadReaders" | "maxUploadSize">;
|
|
46
|
+
export declare function configuredMaxUploadBytes(registry: Registry): number | undefined;
|
|
46
47
|
export declare function createFileRoutes(options: FileRoutesOptions): Hono;
|
|
47
48
|
export {};
|
|
@@ -11,14 +11,16 @@ import { createProjectingTenantDb } from "../db/tenant-db.js";
|
|
|
11
11
|
import { createDerivativesContext, resolveRenderer } from "../derivatives/derivatives-context.js";
|
|
12
12
|
import { resolveFieldVariant } from "../derivatives/index.js";
|
|
13
13
|
import { isFileField } from "../engine/field-helpers.js";
|
|
14
|
-
import { isAnonymousSessionUser
|
|
14
|
+
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
15
15
|
import { createFailOpenReporter, respondRateLimited } from "../rate-limit/middleware.js";
|
|
16
16
|
import { listActionFormFileFields, mayUseActionForm } from "./action-form-upload.js";
|
|
17
17
|
import { buildContentDispositionHeader } from "./content-disposition.js";
|
|
18
18
|
import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
|
|
19
|
+
import { createDefaultGuard, DEFAULT_PRIVILEGED_ROLES } from "./default-file-guard.js";
|
|
19
20
|
import { createFileContext, createTenantFileContext } from "./file-handle.js";
|
|
20
21
|
import { fileRefEntity } from "./file-ref-entity.js";
|
|
21
22
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
23
|
+
import { collectRecordOnlyFileEntityTypes } from "./record-only-file-entity-types.js";
|
|
22
24
|
import { isStoredRecordLocked, RecordWriteDecisions, } from "./record-write-check.js";
|
|
23
25
|
import { neutralFileNameForMimeType } from "./resolve-content-type.js";
|
|
24
26
|
import { parseMaxSize, resolveServedContentType } from "./types.js";
|
|
@@ -29,7 +31,10 @@ export function deleteFailureResponseFor(error) {
|
|
|
29
31
|
const status = isOneOf(ERROR_STATUSES, error.httpStatus) ? error.httpStatus : 500;
|
|
30
32
|
return { status, code: status === 404 ? "not_found" : error.code };
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
// 401 (not 403) lets the client tell a revoked or expired session from a missing permission.
|
|
35
|
+
function unauthenticatedUploadResponse(c) {
|
|
36
|
+
return c.json({ error: "unauthenticated: sign in to upload files", code: "unauthenticated" }, 401);
|
|
37
|
+
}
|
|
33
38
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
34
39
|
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
35
40
|
// credential (see "Signed-URL default expiry" in core-files.md).
|
|
@@ -39,39 +44,6 @@ const META_BATCH_IDS_PER_WINDOW = 1_000;
|
|
|
39
44
|
const META_BATCH_WINDOW_SECONDS = 60;
|
|
40
45
|
// Each detail() read decrypts through the KMS pool; a bounded chunk keeps one batch from hogging it.
|
|
41
46
|
const BATCH_META_CONCURRENCY = 10;
|
|
42
|
-
// Default guard: on attached files, allow the uploader or a privileged role,
|
|
43
|
-
// and for reads also everyone who may read the record. Delete stays with the
|
|
44
|
-
// uploader and privileged roles: reading a record is no right to remove its
|
|
45
|
-
// files. Pending uploads (entityType set, record not yet created) are the
|
|
46
|
-
// uploader's own until bound; so are uploads without an entityType, which no
|
|
47
|
-
// record can ever claim.
|
|
48
|
-
function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders) {
|
|
49
|
-
return async ({ fileRef, user, operation }) => {
|
|
50
|
-
if (fileRef.insertedById === user.id)
|
|
51
|
-
return "allow";
|
|
52
|
-
if (privilegedRoles.some((role) => user.roles.includes(role)))
|
|
53
|
-
return "allow";
|
|
54
|
-
// System-generated documents are created without attachTo too, but belong to a person's data, not to an inbox.
|
|
55
|
-
const isUnclaimedUpload = fileRef.entityType === null &&
|
|
56
|
-
fileRef.entityId === null &&
|
|
57
|
-
fileRef.insertedById !== SYSTEM_USER_ID;
|
|
58
|
-
if (isUnclaimedUpload &&
|
|
59
|
-
operation === "read" &&
|
|
60
|
-
unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
|
|
61
|
-
return "allow";
|
|
62
|
-
}
|
|
63
|
-
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
64
|
-
return "deny";
|
|
65
|
-
if (operation !== "read" || canReadRecord === undefined)
|
|
66
|
-
return "deny";
|
|
67
|
-
const recordReadable = await canReadRecord({
|
|
68
|
-
entityType: fileRef.entityType,
|
|
69
|
-
entityId: fileRef.entityId,
|
|
70
|
-
user,
|
|
71
|
-
});
|
|
72
|
-
return recordReadable ? "allow" : "deny";
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
47
|
// Multipart framing plus the small entityType/entityId/fieldName parts
|
|
76
48
|
// riding alongside the file.
|
|
77
49
|
const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
|
|
@@ -106,6 +78,11 @@ export function readFilesRouteOptions(registry) {
|
|
|
106
78
|
}
|
|
107
79
|
return {};
|
|
108
80
|
}
|
|
81
|
+
// Explicitly configured global upload limit in bytes; undefined when createFilesFeature set none.
|
|
82
|
+
export function configuredMaxUploadBytes(registry) {
|
|
83
|
+
const configured = readFilesRouteOptions(registry).maxUploadSize;
|
|
84
|
+
return configured === undefined ? undefined : parseMaxSize(configured);
|
|
85
|
+
}
|
|
109
86
|
export function createFileRoutes(options) {
|
|
110
87
|
const { db, metaBatchRateLimitResolver } = options;
|
|
111
88
|
const reportMetaBatchLimiterFailOpen = createFailOpenReporter("files-meta-batch");
|
|
@@ -115,7 +92,7 @@ export function createFileRoutes(options) {
|
|
|
115
92
|
: withUploadLedger(options.resolveProvider, db);
|
|
116
93
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
117
94
|
const guard = options.accessGuard ??
|
|
118
|
-
createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? []);
|
|
95
|
+
createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? [], options.registry ? collectRecordOnlyFileEntityTypes(options.registry) : new Set());
|
|
119
96
|
const executor = getFileRefExecutor();
|
|
120
97
|
const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
|
|
121
98
|
const api = new Hono();
|
|
@@ -171,6 +148,10 @@ export function createFileRoutes(options) {
|
|
|
171
148
|
// POST /files — multipart upload.
|
|
172
149
|
api.post("/files", uploadBodyLimit, async (c) => {
|
|
173
150
|
const user = getUser(c);
|
|
151
|
+
// Anonymous visitors share one user id and cannot bind pending uploads (bind-pending-file-refs),
|
|
152
|
+
// so an anonymous upload could never be used; 401 lets the client tell an ended session apart.
|
|
153
|
+
if (isAnonymousSessionUser(user))
|
|
154
|
+
return unauthenticatedUploadResponse(c);
|
|
174
155
|
const body = await c.req.parseBody();
|
|
175
156
|
const file = body["file"];
|
|
176
157
|
if (!file || !(file instanceof File)) {
|
|
@@ -188,11 +169,11 @@ export function createFileRoutes(options) {
|
|
|
188
169
|
}
|
|
189
170
|
// Before any bytes are written, like the record check below. An unresolvable screen falls
|
|
190
171
|
// through to storeFileAsFileRef, which answers 400; a known one must be submittable by the
|
|
191
|
-
// caller.
|
|
172
|
+
// caller.
|
|
192
173
|
if (actionForm !== undefined &&
|
|
193
174
|
options.registry !== undefined &&
|
|
194
175
|
options.registry.getScreen(actionForm)?.type === "actionForm" &&
|
|
195
|
-
|
|
176
|
+
!mayUseActionForm(options.registry, actionForm, user)) {
|
|
196
177
|
return c.json({ error: "forbidden: no access to this form" }, 403);
|
|
197
178
|
}
|
|
198
179
|
if (entityId !== undefined) {
|
package/dist/files/index.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
export { listActionFormFileFields, resolveActionFormFileField } from "./action-form-upload.ts";
|
|
2
2
|
export type { PendingFileRef } from "./bind-pending-file-refs.ts";
|
|
3
3
|
export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
|
|
4
|
+
export type { CanReadFileRefContext } from "./can-caller-read-file-ref.ts";
|
|
5
|
+
export { canCallerReadFileRef } from "./can-caller-read-file-ref.ts";
|
|
4
6
|
export type { CreatedFileRef, CreateFileRefInput, UploadedFileBytes, } from "./create-file-ref-from-bytes.ts";
|
|
5
7
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
|
|
8
|
+
export { createDefaultGuard } from "./default-file-guard.ts";
|
|
9
|
+
export { deleteFileRefsBoundToRecord } from "./delete-record-file-refs.ts";
|
|
6
10
|
export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
|
|
7
11
|
export type { FileContext } from "./file-handle.ts";
|
|
8
12
|
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
|
|
9
13
|
export { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.ts";
|
|
10
14
|
export { fileRefsTable } from "./file-ref-table.ts";
|
|
11
|
-
export type { FileAccessGuard } from "./file-routes.ts";
|
|
12
|
-
export { readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.ts";
|
|
15
|
+
export type { FileAccessGuard, FileRef } from "./file-routes.ts";
|
|
16
|
+
export { configuredMaxUploadBytes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.ts";
|
|
13
17
|
export type { InMemoryFileProvider } from "./in-memory-provider.ts";
|
|
14
18
|
export { createInMemoryFileProvider } from "./in-memory-provider.ts";
|
|
15
19
|
export { createLocalProvider } from "./local-provider.ts";
|
package/dist/files/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
export { listActionFormFileFields, resolveActionFormFileField } from "./action-form-upload.js";
|
|
2
2
|
export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
|
|
3
|
+
export { canCallerReadFileRef } from "./can-caller-read-file-ref.js";
|
|
3
4
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
|
|
5
|
+
export { createDefaultGuard } from "./default-file-guard.js";
|
|
6
|
+
export { deleteFileRefsBoundToRecord } from "./delete-record-file-refs.js";
|
|
4
7
|
export { createFilesFeature, FILES_FEATURE } from "./feature.js";
|
|
5
8
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
6
9
|
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
7
10
|
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
|
|
8
11
|
export { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
9
12
|
export { fileRefsTable } from "./file-ref-table.js";
|
|
10
|
-
export { readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
|
|
13
|
+
export { configuredMaxUploadBytes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
|
|
11
14
|
export { createInMemoryFileProvider } from "./in-memory-provider.js";
|
|
12
15
|
export { createLocalProvider } from "./local-provider.js";
|
|
13
16
|
export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const cache = new WeakMap();
|
|
2
|
+
// Event-sourced read models (projection entities) are not in getAllEntities, so both sources count.
|
|
3
|
+
export function collectRecordOnlyFileEntityTypes(registry) {
|
|
4
|
+
const cached = cache.get(registry);
|
|
5
|
+
if (cached)
|
|
6
|
+
return cached;
|
|
7
|
+
const entityTypes = new Set();
|
|
8
|
+
for (const [name, entity] of registry.getAllEntities()) {
|
|
9
|
+
if (entity.attachedFileAccess === "record-only")
|
|
10
|
+
entityTypes.add(name);
|
|
11
|
+
}
|
|
12
|
+
for (const projection of registry.getAllProjections().values()) {
|
|
13
|
+
const name = projection.source;
|
|
14
|
+
if (typeof name !== "string")
|
|
15
|
+
continue;
|
|
16
|
+
if (registry.getReadModelEntity(name)?.attachedFileAccess === "record-only") {
|
|
17
|
+
entityTypes.add(name);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
cache.set(registry, entityTypes);
|
|
21
|
+
return entityTypes;
|
|
22
|
+
}
|
|
@@ -17,6 +17,17 @@ function buildDetailQueryIndex(registry) {
|
|
|
17
17
|
detailQueryByEntity.set(entityName, qn);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
for (const projection of registry.getAllProjections().values()) {
|
|
21
|
+
const entityName = projection.source;
|
|
22
|
+
if (typeof entityName !== "string")
|
|
23
|
+
continue;
|
|
24
|
+
const owner = registry.getReadModelEntityOwner(entityName);
|
|
25
|
+
if (owner === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
const qn = `${owner}:query:${toKebab(entityName)}:detail`;
|
|
28
|
+
if (registry.getAllQueryHandlers().has(qn))
|
|
29
|
+
detailQueryByEntity.set(entityName, qn);
|
|
30
|
+
}
|
|
20
31
|
return detailQueryByEntity;
|
|
21
32
|
}
|
|
22
33
|
// "May this user read the record?" is answered by the entity's own detail
|
package/dist/files/types.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export type FileMetadata = {
|
|
|
7
7
|
};
|
|
8
8
|
export type { FileStorageProvider, SignedUrlOptions, WriteStreamOptions, } from "@cosmicdrift/kumiko-types/file-storage-provider-types";
|
|
9
9
|
export type FileValidationOptions = {
|
|
10
|
-
|
|
10
|
+
/** "10mb"-style string or a byte count. */
|
|
11
|
+
readonly maxSize?: string | number | undefined;
|
|
11
12
|
readonly accept?: readonly string[] | undefined;
|
|
12
13
|
};
|
|
13
14
|
export declare function parseMaxSize(maxSize: string): number;
|
package/dist/files/types.js
CHANGED
|
@@ -175,7 +175,7 @@ export function resolveServedContentType(bytes, declaredMimeType) {
|
|
|
175
175
|
}
|
|
176
176
|
export function validateFile(metadata, options) {
|
|
177
177
|
if (options.maxSize) {
|
|
178
|
-
const maxBytes = parseMaxSize(options.maxSize);
|
|
178
|
+
const maxBytes = typeof options.maxSize === "number" ? options.maxSize : parseMaxSize(options.maxSize);
|
|
179
179
|
if (metadata.size > maxBytes) {
|
|
180
180
|
return `file_too_large: ${metadata.size} bytes exceeds ${options.maxSize}`;
|
|
181
181
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnyFileFieldDef } from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
+
import type { Registry } from "../engine/types/index.ts";
|
|
3
|
+
type UploadTarget = {
|
|
4
|
+
readonly entityType?: string | undefined;
|
|
5
|
+
readonly entityId?: string | undefined;
|
|
6
|
+
readonly fieldName?: string | undefined;
|
|
7
|
+
readonly actionForm?: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
export type AttachTargets = "entities" | "entities-and-read-models";
|
|
10
|
+
type AttachedFieldResolution = {
|
|
11
|
+
readonly kind: "unattached";
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "resolved";
|
|
14
|
+
readonly fieldDef: AnyFileFieldDef;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "unresolvable";
|
|
17
|
+
};
|
|
18
|
+
export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets): AttachedFieldResolution;
|
|
19
|
+
type UploadFieldResolution = AttachedFieldResolution | {
|
|
20
|
+
readonly kind: "rejected";
|
|
21
|
+
readonly error: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function resolveUploadField(registry: Registry | undefined, input: UploadTarget, attachTargets: AttachTargets | undefined): UploadFieldResolution;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isFileField } from "../engine/field-helpers.js";
|
|
2
|
+
import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
|
|
3
|
+
// entityType/fieldName drive the GDPR-forget decision downstream
|
|
4
|
+
// (fileRefDeleteHook resolves the field's PII annotation from exactly this
|
|
5
|
+
// pairing) — attaching to a field nobody can resolve would leave that
|
|
6
|
+
// decision hanging on an unverified client string. Either both are omitted
|
|
7
|
+
// (unattached upload, allowed) or both must resolve to a real registered
|
|
8
|
+
// field. "entities-and-read-models" is for server-side callers only: a client upload must
|
|
9
|
+
// never name a projection entity, whose rows nobody can write-check.
|
|
10
|
+
export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities") {
|
|
11
|
+
if (entityType === undefined && fieldName === undefined)
|
|
12
|
+
return { kind: "unattached" };
|
|
13
|
+
const entity = entityType !== undefined
|
|
14
|
+
? (registry?.getEntity(entityType) ??
|
|
15
|
+
(attachTargets === "entities-and-read-models"
|
|
16
|
+
? registry?.getReadModelEntity(entityType)
|
|
17
|
+
: undefined))
|
|
18
|
+
: undefined;
|
|
19
|
+
const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
|
|
20
|
+
if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
|
|
21
|
+
return { kind: "unresolvable" };
|
|
22
|
+
return { kind: "resolved", fieldDef };
|
|
23
|
+
}
|
|
24
|
+
export function resolveUploadField(registry, input, attachTargets) {
|
|
25
|
+
const { entityType, entityId, fieldName, actionForm } = input;
|
|
26
|
+
if (actionForm === undefined) {
|
|
27
|
+
return resolveAttachedField(registry, entityType, fieldName, attachTargets);
|
|
28
|
+
}
|
|
29
|
+
if (entityType !== undefined || entityId !== undefined) {
|
|
30
|
+
return {
|
|
31
|
+
kind: "rejected",
|
|
32
|
+
error: "invalid_action_form: actionForm cannot be combined with entityType/entityId",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (!isActionFormScreenQn(actionForm)) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "rejected",
|
|
38
|
+
error: "invalid_action_form: expected a screen QN like <feature>:screen:<id>",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const fieldDef = fieldName !== undefined
|
|
42
|
+
? resolveActionFormFileField(registry, actionForm, fieldName)
|
|
43
|
+
: undefined;
|
|
44
|
+
if (fieldDef === undefined) {
|
|
45
|
+
return {
|
|
46
|
+
kind: "rejected",
|
|
47
|
+
error: "unresolvable_field: actionForm and fieldName must name a files/image field of a registered actionForm screen",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { kind: "resolved", fieldDef };
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Tracer } from "@cosmicdrift/kumiko-types/observability";
|
|
2
|
+
import type { Queue } from "bullmq";
|
|
3
|
+
import type { JobRunIn, Registry } from "../engine/types/index.ts";
|
|
4
|
+
import { type JobRunnerScope } from "./job-runner-scope.ts";
|
|
5
|
+
import type { JobRunner } from "./job-runner-types.ts";
|
|
6
|
+
export type JobEventTriggerDeps = {
|
|
7
|
+
readonly scope: JobRunnerScope;
|
|
8
|
+
readonly queues: Readonly<Record<JobRunIn, Queue>>;
|
|
9
|
+
readonly tracer: Tracer;
|
|
10
|
+
readonly allJobs: ReturnType<Registry["getAllJobs"]>;
|
|
11
|
+
};
|
|
12
|
+
export declare function createJobEventTriggers(deps: JobEventTriggerDeps): Pick<JobRunner, "handleEvent" | "handleCatchUpEvent">;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context.js";
|
|
2
|
+
import { buildRetryBullOpts, captureTraceContext, catchUpJobId, eventTriggerMatches, stampGatedWriteOrigin, TRACE_CONTEXT_KEY, } from "./job-runner-helpers.js";
|
|
3
|
+
import { announceQueued, isOverPerTenantLimit, laneForJob, } from "./job-runner-scope.js";
|
|
4
|
+
import { resolveMaxPerTenant } from "./max-per-tenant.js";
|
|
5
|
+
export function createJobEventTriggers(deps) {
|
|
6
|
+
const { scope, queues, tracer, allJobs } = deps;
|
|
7
|
+
async function enqueueEventTriggeredJob(name, jobDef, eventName, payload, user, triggerEvent, jobId) {
|
|
8
|
+
// Same correlation propagation as dispatch(): the job keeps the causal chain.
|
|
9
|
+
const traceCtx = captureTraceContext(tracer);
|
|
10
|
+
const reqCtx = requestContext.get();
|
|
11
|
+
const data = { ...payload };
|
|
12
|
+
if (user) {
|
|
13
|
+
data["_tenantId"] = user.tenantId;
|
|
14
|
+
data["_triggeredById"] = user.id;
|
|
15
|
+
}
|
|
16
|
+
// _triggerName tells a multi-trigger handler which trigger fired; set for single triggers too.
|
|
17
|
+
data["_triggerName"] = eventName;
|
|
18
|
+
if (triggerEvent)
|
|
19
|
+
data["_triggerEvent"] = triggerEvent;
|
|
20
|
+
if (traceCtx)
|
|
21
|
+
data[TRACE_CONTEXT_KEY] = traceCtx;
|
|
22
|
+
if (reqCtx?.correlationId)
|
|
23
|
+
data["_correlationId"] = reqCtx.correlationId;
|
|
24
|
+
// Same maxPerTenant guard as dispatch — events that fan into many
|
|
25
|
+
// jobs must respect the per-tenant cap or the limit is one-sided.
|
|
26
|
+
const maxPerTenant = resolveMaxPerTenant(jobDef);
|
|
27
|
+
if (maxPerTenant?.onLimit === "skip" && user?.tenantId !== undefined) {
|
|
28
|
+
if (await isOverPerTenantLimit(scope, name, String(user.tenantId), maxPerTenant.limit)) {
|
|
29
|
+
// skip: onLimit "skip" drops this trigger on purpose while the tenant is at its cap.
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
stampGatedWriteOrigin(data);
|
|
34
|
+
const bullOpts = { ...buildRetryBullOpts(jobDef) };
|
|
35
|
+
if (jobId !== undefined)
|
|
36
|
+
bullOpts.jobId = jobId;
|
|
37
|
+
// Route to the job's declared lane, not a fixed queue.
|
|
38
|
+
const job = await queues[laneForJob(jobDef)].add(name, data, bullOpts);
|
|
39
|
+
await announceQueued(scope, name, jobDef, job, data);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
async handleEvent(eventName, payload, user, triggerEvent) {
|
|
43
|
+
for (const [name, jobDef] of allJobs) {
|
|
44
|
+
if (!("on" in jobDef.trigger) || jobDef.trigger.catchUp)
|
|
45
|
+
continue;
|
|
46
|
+
// skip: other trigger types (cron, manual) do not respond to events;
|
|
47
|
+
// catchUp jobs are fed by their own consumer (handleCatchUpEvent).
|
|
48
|
+
if (!eventTriggerMatches(jobDef.trigger, eventName, payload))
|
|
49
|
+
continue;
|
|
50
|
+
await enqueueEventTriggeredJob(name, jobDef, eventName, payload, user, triggerEvent);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async handleCatchUpEvent(jobName, eventName, payload, user, triggerEvent) {
|
|
54
|
+
const jobDef = allJobs.get(jobName);
|
|
55
|
+
// A stale consumer for a removed or changed job, or an event the trigger filters out, runs nothing.
|
|
56
|
+
if (jobDef &&
|
|
57
|
+
"on" in jobDef.trigger &&
|
|
58
|
+
jobDef.trigger.catchUp &&
|
|
59
|
+
eventTriggerMatches(jobDef.trigger, eventName, payload)) {
|
|
60
|
+
await enqueueEventTriggeredJob(jobName, jobDef, eventName, payload, user, triggerEvent, catchUpJobId(jobName, triggerEvent.id));
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -46,6 +46,11 @@ export declare function timeoutReject(ms: number, message: string): {
|
|
|
46
46
|
};
|
|
47
47
|
export declare function buildRetryBullOpts(jobDef: JobDefinition, priorAttempts?: number): Pick<JobsOptions, "attempts" | "backoff">;
|
|
48
48
|
export declare function payloadMatchesTriggerFilter(where: Readonly<Record<string, string | number | boolean>> | undefined, payload: Record<string, unknown>): boolean;
|
|
49
|
+
type EventJobTrigger = Extract<JobDefinition["trigger"], {
|
|
50
|
+
readonly on: unknown;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function eventTriggerMatches(trigger: EventJobTrigger, eventName: string, payload: Record<string, unknown>): boolean;
|
|
53
|
+
export declare function catchUpJobId(jobName: string, eventId: string): string;
|
|
49
54
|
export declare const COMPLETED_JOB_RETENTION_AGE_SEC = 86400;
|
|
50
55
|
export declare const FAILED_JOB_RETENTION_AGE_SEC = 604800;
|
|
51
56
|
export declare function maxRetryWindowMs(jobDef: JobDefinition): number;
|
|
@@ -272,6 +272,16 @@ export function payloadMatchesTriggerFilter(where, payload) {
|
|
|
272
272
|
return true;
|
|
273
273
|
return Object.entries(where).every(([key, value]) => payload[key] === value);
|
|
274
274
|
}
|
|
275
|
+
export function eventTriggerMatches(trigger, eventName, payload) {
|
|
276
|
+
const matches = Array.isArray(trigger.on)
|
|
277
|
+
? trigger.on.includes(eventName)
|
|
278
|
+
: trigger.on === eventName;
|
|
279
|
+
return matches && payloadMatchesTriggerFilter(trigger.where, payload);
|
|
280
|
+
}
|
|
281
|
+
// BullMQ rejects ":" in custom ids; job QNs and event ids contain them or dashes.
|
|
282
|
+
export function catchUpJobId(jobName, eventId) {
|
|
283
|
+
return `catchup.${jobName.replaceAll(":", ".")}.${eventId}`;
|
|
284
|
+
}
|
|
275
285
|
// BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
|
|
276
286
|
// OTHER jobs in the same completed/failed zset (moveToFinished lua,
|
|
277
287
|
// removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
|
|
@@ -42,6 +42,7 @@ export type JobRunner = {
|
|
|
42
42
|
stop(): Promise<void>;
|
|
43
43
|
dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
|
|
44
44
|
handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
|
|
45
|
+
handleCatchUpEvent(jobName: string, eventName: string, payload: Record<string, unknown>, user: SessionUser, triggerEvent: JobTriggerEvent): Promise<void>;
|
|
45
46
|
attachDispatcher(ref: DispatchWriteRef): void;
|
|
46
47
|
countPendingJobs(): Promise<number>;
|
|
47
48
|
};
|