@cosmicdrift/kumiko-framework 0.402.0 → 0.404.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/api/framework-engine.md +8 -1
- package/agents/api/framework-ui-types.md +4 -0
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/types.md +56 -0
- package/agents/api.md +1 -1
- package/agents/boot-rules.md +12 -0
- package/agents/examples/dashboard-stats.md +58 -3
- package/agents/examples/lane-routing.md +40 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +5 -3
- package/dist/engine/boot-validator/boot-phases-app.js +5 -0
- package/dist/engine/boot-validator/boot-rules.d.ts +36 -0
- package/dist/engine/boot-validator/boot-rules.js +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
- package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +5 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +5 -0
- package/dist/engine/boot-validator/entity-list-screens.js +12 -0
- package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
- package/dist/engine/boot-validator/named-filter-rules.js +32 -0
- package/dist/engine/boot-validator/projection-list-screens.js +13 -0
- package/dist/engine/entity-handlers.d.ts +1 -0
- package/dist/engine/entity-handlers.js +13 -1
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +11 -7
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/resolve-named-list-filters.d.ts +3 -0
- package/dist/engine/resolve-named-list-filters.js +30 -0
- package/dist/engine/soft-delete-cleanup.js +2 -2
- package/dist/entrypoint/index.d.ts +5 -2
- package/dist/entrypoint/index.js +15 -6
- package/dist/entrypoint/internals.d.ts +2 -0
- package/dist/files/feature.js +1 -1
- package/dist/jobs/cron-repeat-options.d.ts +9 -0
- package/dist/jobs/cron-repeat-options.js +9 -0
- package/dist/jobs/index.d.ts +1 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-runner-context.js +4 -1
- package/dist/jobs/job-runner-types.d.ts +5 -0
- package/dist/jobs/job-runner.js +14 -5
- package/dist/jobs/maintenance-window.d.ts +7 -0
- package/dist/jobs/maintenance-window.js +14 -0
- package/dist/redis/cache-sync-bus.d.ts +1 -0
- package/dist/redis/cache-sync-bus.js +15 -6
- package/dist/redis/client.d.ts +12 -0
- package/dist/redis/client.js +30 -5
- package/dist/redis/index.d.ts +2 -2
- package/dist/redis/index.js +1 -1
- package/package.json +3 -3
- package/src/changes.json +18 -0
|
@@ -24,6 +24,11 @@ export const ENTITY_LIST_RULES = {
|
|
|
24
24
|
summary: "A list whose entity has a detail screen offers a navigate row action.",
|
|
25
25
|
fix: 'Add a navigate rowAction with id "view" or "edit".',
|
|
26
26
|
},
|
|
27
|
+
unknownNamedFilter: {
|
|
28
|
+
id: "entity-list/unknown-named-filter",
|
|
29
|
+
summary: "Every screen namedFilters id is declared in the entity's namedFilters.",
|
|
30
|
+
fix: "Declare the rule in EntityDefinition.namedFilters or remove the id from the screen.",
|
|
31
|
+
},
|
|
27
32
|
unknownColumn: {
|
|
28
33
|
id: "entity-list/unknown-column",
|
|
29
34
|
summary: "Every column without a label names an entity field, derived field or row-meta column.",
|
|
@@ -95,12 +95,24 @@ function checkColumns(feature, screen) {
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
|
+
function checkNamedFilters(feature, screen) {
|
|
99
|
+
const declared = feature.entities?.[screen.entity]?.namedFilters ?? {};
|
|
100
|
+
collectBootRuleViolations(screen.namedFilters ?? [], (ref) => {
|
|
101
|
+
// skip: id is declared on the entity
|
|
102
|
+
if (Object.hasOwn(declared, ref.id))
|
|
103
|
+
return;
|
|
104
|
+
throw bootRuleError(ENTITY_LIST_RULES.unknownNamedFilter, {
|
|
105
|
+
message: `${screenPrefix(feature, screen)}: namedFilters id "${ref.id}" is not declared in entity "${screen.entity}" namedFilters. Known: ${Object.keys(declared).sort().join(", ") || "(none)"}.`,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
98
109
|
const ENTITY_LIST_CHECKS = [
|
|
99
110
|
checkSearchable,
|
|
100
111
|
checkDefaultSortRequired,
|
|
101
112
|
checkDefaultSortColumn,
|
|
102
113
|
checkDetailNavigate,
|
|
103
114
|
checkColumns,
|
|
115
|
+
checkNamedFilters,
|
|
104
116
|
];
|
|
105
117
|
export function validateEntityListScreens(features) {
|
|
106
118
|
const screens = features.flatMap((feature) => Object.values(feature.screens).flatMap((screen) => screen.type === "entityList" ? [{ feature, screen }] : []));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const NAMED_FILTER_RULES: {
|
|
2
|
+
readonly projectionQueryAcceptsNamedFilter: {
|
|
3
|
+
readonly id: "named-filter/projection-query-accepts-named-filter";
|
|
4
|
+
readonly summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.";
|
|
5
|
+
readonly fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.";
|
|
6
|
+
};
|
|
7
|
+
readonly statNavigateTarget: {
|
|
8
|
+
readonly id: "named-filter/stat-navigate-target";
|
|
9
|
+
readonly summary: "A stat navigate.screen resolves to an entityList or projectionList screen.";
|
|
10
|
+
readonly fix: "Point navigate.screen at a registered list screen.";
|
|
11
|
+
};
|
|
12
|
+
readonly statNavigateFilterDeclared: {
|
|
13
|
+
readonly id: "named-filter/stat-navigate-filter-declared";
|
|
14
|
+
readonly summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.";
|
|
15
|
+
readonly fix: "Add { id, label } to namedFilters of the target screen.";
|
|
16
|
+
};
|
|
17
|
+
readonly statNavigateSameQuery: {
|
|
18
|
+
readonly id: "named-filter/stat-navigate-same-query";
|
|
19
|
+
readonly summary: "A stat with navigate counts with the target list's own query.";
|
|
20
|
+
readonly fix: "Set the stat query to the target list's query.";
|
|
21
|
+
};
|
|
22
|
+
readonly statNavigateSameNamedFilter: {
|
|
23
|
+
readonly id: "named-filter/stat-navigate-same-named-filter";
|
|
24
|
+
readonly summary: "A stat's params.namedFilter equals navigate.namedFilter.";
|
|
25
|
+
readonly fix: "Set params.namedFilter to the same id as navigate.namedFilter.";
|
|
26
|
+
};
|
|
27
|
+
readonly statNavigateStaticFilter: {
|
|
28
|
+
readonly id: "named-filter/stat-navigate-static-filter";
|
|
29
|
+
readonly summary: "A stat repeats the target screen's static filter in params.filter.";
|
|
30
|
+
readonly fix: "Set params.filter to the target screen's filter.";
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const NAMED_FILTER_RULES = {
|
|
2
|
+
projectionQueryAcceptsNamedFilter: {
|
|
3
|
+
id: "named-filter/projection-query-accepts-named-filter",
|
|
4
|
+
summary: "A projectionList with namedFilters binds a query whose schema has a namedFilter key.",
|
|
5
|
+
fix: "Add namedFilter: z.string().optional() to the query schema, resolve it with resolveNamedListFilters and pass the conditions into executor.list filters, the only path that applies field-read and PII guards.",
|
|
6
|
+
},
|
|
7
|
+
statNavigateTarget: {
|
|
8
|
+
id: "named-filter/stat-navigate-target",
|
|
9
|
+
summary: "A stat navigate.screen resolves to an entityList or projectionList screen.",
|
|
10
|
+
fix: "Point navigate.screen at a registered list screen.",
|
|
11
|
+
},
|
|
12
|
+
statNavigateFilterDeclared: {
|
|
13
|
+
id: "named-filter/stat-navigate-filter-declared",
|
|
14
|
+
summary: "A stat navigate.namedFilter is declared in the target screen's namedFilters.",
|
|
15
|
+
fix: "Add { id, label } to namedFilters of the target screen.",
|
|
16
|
+
},
|
|
17
|
+
statNavigateSameQuery: {
|
|
18
|
+
id: "named-filter/stat-navigate-same-query",
|
|
19
|
+
summary: "A stat with navigate counts with the target list's own query.",
|
|
20
|
+
fix: "Set the stat query to the target list's query.",
|
|
21
|
+
},
|
|
22
|
+
statNavigateSameNamedFilter: {
|
|
23
|
+
id: "named-filter/stat-navigate-same-named-filter",
|
|
24
|
+
summary: "A stat's params.namedFilter equals navigate.namedFilter.",
|
|
25
|
+
fix: "Set params.namedFilter to the same id as navigate.namedFilter.",
|
|
26
|
+
},
|
|
27
|
+
statNavigateStaticFilter: {
|
|
28
|
+
id: "named-filter/stat-navigate-static-filter",
|
|
29
|
+
summary: "A stat repeats the target screen's static filter in params.filter.",
|
|
30
|
+
fix: "Set params.filter to the target screen's filter.",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name.js";
|
|
3
3
|
import { isEditLayoutScreen } from "../screen-helpers.js";
|
|
4
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
4
5
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
|
|
5
6
|
import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
|
|
7
|
+
import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
|
|
6
8
|
import { findEntity } from "./parent-ref.js";
|
|
7
9
|
import { getZodObjectShape } from "./zod-shape.js";
|
|
8
10
|
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
@@ -66,6 +68,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
|
|
|
66
68
|
}
|
|
67
69
|
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
68
70
|
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
71
|
+
validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema);
|
|
69
72
|
validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
|
|
70
73
|
}
|
|
71
74
|
// filter sends `payload.filter` — same 422 footgun as facets below
|
|
@@ -78,6 +81,16 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
|
|
|
78
81
|
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
79
82
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
|
|
80
83
|
}
|
|
84
|
+
// namedFilters send `payload.namedFilter` — same 422 footgun as filter/facets.
|
|
85
|
+
function validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema) {
|
|
86
|
+
// skip: no named filters declared, or the schema already accepts the key — nothing to reject.
|
|
87
|
+
if (screen.namedFilters === undefined || schemaAccepts(schema, "namedFilter"))
|
|
88
|
+
return;
|
|
89
|
+
throw bootRuleError(NAMED_FILTER_RULES.projectionQueryAcceptsNamedFilter, {
|
|
90
|
+
message: `${prefix}: declares namedFilters but query "${screen.query}" has no "namedFilter" parameter in its ` +
|
|
91
|
+
`Zod schema — add namedFilter: z.string().optional() to the handler's schema and resolve it with resolveNamedListFilters.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
81
94
|
// facets send `payload.filters` — same failure mode as search/sort:
|
|
82
95
|
// definePagedQueryHandler doesn't auto-merge params into the
|
|
83
96
|
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
@@ -45,6 +45,7 @@ export declare const entityListSchema: z.ZodObject<{
|
|
|
45
45
|
}>;
|
|
46
46
|
value: z.ZodUnknown;
|
|
47
47
|
}, z.core.$strip>>>;
|
|
48
|
+
namedFilter: z.ZodOptional<z.ZodString>;
|
|
48
49
|
aggregate: z.ZodOptional<z.ZodObject<{
|
|
49
50
|
groupBy: z.ZodOptional<z.ZodString>;
|
|
50
51
|
measures: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -15,6 +15,7 @@ import { entityListRuntime, parentVisibilityFor } from "./entity-list-runtime-op
|
|
|
15
15
|
import { assertReason } from "./escape-hatch-reason.js";
|
|
16
16
|
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
17
17
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
18
|
+
import { resolveNamedListFilters } from "./resolve-named-list-filters.js";
|
|
18
19
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
19
20
|
import { parseTenantId } from "./types/identifiers.js";
|
|
20
21
|
// Convention-based handler factories for event-sourced aggregates.
|
|
@@ -109,6 +110,8 @@ export const entityListSchema = z.object({
|
|
|
109
110
|
value: z.unknown(),
|
|
110
111
|
}))
|
|
111
112
|
.optional(),
|
|
113
|
+
// Entity-defined work-queue rule (EntityDefinition.namedFilters); its conditions are appended to `filters`.
|
|
114
|
+
namedFilter: z.string().min(1).max(100).optional(),
|
|
112
115
|
aggregate: listAggregatePayloadSchema.optional(),
|
|
113
116
|
});
|
|
114
117
|
function parseHandlerName(name, validVerbs) {
|
|
@@ -398,7 +401,16 @@ export function defineEntityQueryHandler(name, entity, options) {
|
|
|
398
401
|
// against Meilisearch/InMem at runtime (remote combobox search). The
|
|
399
402
|
// executor is built at definition time and cannot know the adapter, so the
|
|
400
403
|
// runtime override supplies it.
|
|
401
|
-
const
|
|
404
|
+
const requestPayload = query.payload; // @cast-boundary engine-payload
|
|
405
|
+
const listPayload = requestPayload.namedFilter === undefined
|
|
406
|
+
? requestPayload
|
|
407
|
+
: {
|
|
408
|
+
...requestPayload,
|
|
409
|
+
filters: [
|
|
410
|
+
...(requestPayload.filters ?? []),
|
|
411
|
+
...(await resolveNamedListFilters(ctx, query.user, entityName, requestPayload.namedFilter)),
|
|
412
|
+
],
|
|
413
|
+
};
|
|
402
414
|
const db = dbFor(ctx);
|
|
403
415
|
const { runtimeOptions, eagerLoadViewer, resolveEntity } = entityListRuntime(ctx, entityName, query.user);
|
|
404
416
|
if (listPayload.aggregate !== undefined) {
|
|
@@ -208,6 +208,8 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
|
|
|
208
208
|
readonly readGate?: EntityDefinition["readGate"];
|
|
209
209
|
readonly retention?: RetentionDef;
|
|
210
210
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
211
|
+
/** See EntityDefinition.namedFilters. */
|
|
212
|
+
readonly namedFilters?: EntityDefinition["namedFilters"];
|
|
211
213
|
readonly tenancy?: T;
|
|
212
214
|
/** See EntityDefinition.transferable. */
|
|
213
215
|
readonly transferable?: boolean;
|
|
@@ -6,6 +6,16 @@ import { createEventDef } from "./event-def.js";
|
|
|
6
6
|
import { resolveName } from "./handler-helpers.js";
|
|
7
7
|
import { requireArgument, splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
|
|
8
8
|
import { QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
9
|
+
function assertScheduleTrigger(featureName, jobName, trigger) {
|
|
10
|
+
if ("maintenance" in trigger && trigger.maintenance !== true) {
|
|
11
|
+
throw new Error(`[Feature ${featureName}] r.job("${jobName}") declares trigger { maintenance: ${JSON.stringify(trigger.maintenance)} } — ` +
|
|
12
|
+
"the only valid value is true; the run time comes from KUMIKO_MAINTENANCE_WINDOW.");
|
|
13
|
+
}
|
|
14
|
+
if ("cron" in trigger && trigger.tz !== undefined && !isValidIanaTimeZone(trigger.tz)) {
|
|
15
|
+
throw new Error(`[Feature ${featureName}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(trigger.tz)} } — ` +
|
|
16
|
+
'tz must be an IANA time zone name such as "Europe/Berlin".');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
9
19
|
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
10
20
|
export function buildConfigEventsJobsMethods(state, name) {
|
|
11
21
|
function config(arg1, arg2) {
|
|
@@ -90,13 +100,7 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
90
100
|
if (jobGrantsProblem) {
|
|
91
101
|
throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
|
|
92
102
|
}
|
|
93
|
-
|
|
94
|
-
const tz = jobOptions.trigger.tz;
|
|
95
|
-
if (!isValidIanaTimeZone(tz)) {
|
|
96
|
-
throw new Error(`[Feature ${name}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(tz)} } — ` +
|
|
97
|
-
'tz must be an IANA time zone name such as "Europe/Berlin".');
|
|
98
|
-
}
|
|
99
|
-
}
|
|
103
|
+
assertScheduleTrigger(name, jobName, jobOptions.trigger);
|
|
100
104
|
// Resolve NameOrRef(s) in trigger.on. For the multi-trigger form, arrays
|
|
101
105
|
// become arrays of resolved strings while a single value remains a string;
|
|
102
106
|
// the job runner distinguishes them with Array.isArray.
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -80,6 +80,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.ts"
|
|
|
80
80
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.ts";
|
|
81
81
|
export { readClaim } from "./read-claim.ts";
|
|
82
82
|
export { createRegistry } from "./registry.ts";
|
|
83
|
+
export { resolveNamedListFilters } from "./resolve-named-list-filters.ts";
|
|
83
84
|
export type { AssignableAppRoles } from "./role-assignment.ts";
|
|
84
85
|
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.ts";
|
|
85
86
|
export { runPipeline, runStepList } from "./run-pipeline.ts";
|
package/dist/engine/index.js
CHANGED
|
@@ -64,6 +64,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.js"
|
|
|
64
64
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
65
65
|
export { readClaim } from "./read-claim.js";
|
|
66
66
|
export { createRegistry } from "./registry.js";
|
|
67
|
+
export { resolveNamedListFilters } from "./resolve-named-list-filters.js";
|
|
67
68
|
export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
|
|
68
69
|
export { runPipeline, runStepList } from "./run-pipeline.js";
|
|
69
70
|
export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { NamedListFilterCondition } from "@cosmicdrift/kumiko-types/named-list-filters";
|
|
2
|
+
import type { HandlerContext, SessionUser } from "./types/index.ts";
|
|
3
|
+
export declare function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<readonly NamedListFilterCondition[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AccessDeniedError, ValidationError } from "../errors/index.js";
|
|
2
|
+
import { hasAccess } from "./access.js";
|
|
3
|
+
export async function resolveNamedListFilters(ctx, user, entityName, name) {
|
|
4
|
+
const registered = ctx.registry.getEntity(entityName)?.namedFilters ?? {};
|
|
5
|
+
const def = Object.hasOwn(registered, name) ? registered[name] : undefined;
|
|
6
|
+
if (def === undefined) {
|
|
7
|
+
throw new ValidationError({
|
|
8
|
+
fields: [
|
|
9
|
+
{
|
|
10
|
+
path: "namedFilter",
|
|
11
|
+
code: "unknown_named_filter",
|
|
12
|
+
i18nKey: "errors.validation.invalid_value",
|
|
13
|
+
params: { entity: entityName, namedFilter: name },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (def.access !== undefined && !hasAccess(user, def.access)) {
|
|
19
|
+
throw new AccessDeniedError();
|
|
20
|
+
}
|
|
21
|
+
const conditions = await def.filters({ ctx, user, now: ctx.tz.now() });
|
|
22
|
+
const entityFields = ctx.registry.getEntity(entityName)?.fields ?? {};
|
|
23
|
+
for (const condition of conditions) {
|
|
24
|
+
// Fail closed: a condition on a non-column is dropped by the list filter and would widen the queue.
|
|
25
|
+
if (condition.field !== "id" && !Object.hasOwn(entityFields, condition.field)) {
|
|
26
|
+
throw new Error(`Named filter "${name}" of entity "${entityName}" filters on unknown field "${condition.field}".`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return conditions;
|
|
30
|
+
}
|
|
@@ -161,7 +161,7 @@ export function buildSoftDeleteCleanupJob() {
|
|
|
161
161
|
return {
|
|
162
162
|
name: SOFT_DELETE_CLEANUP_JOB,
|
|
163
163
|
handler: softDeleteCleanupJob,
|
|
164
|
-
trigger: {
|
|
164
|
+
trigger: { maintenance: true },
|
|
165
165
|
perTenant: true,
|
|
166
166
|
concurrency: "skip",
|
|
167
167
|
runIn: "worker",
|
|
@@ -171,7 +171,7 @@ export function buildSoftDeleteCleanupSystemJob() {
|
|
|
171
171
|
return {
|
|
172
172
|
name: SOFT_DELETE_CLEANUP_SYSTEM_JOB,
|
|
173
173
|
handler: softDeleteCleanupSystemJob,
|
|
174
|
-
trigger: {
|
|
174
|
+
trigger: { maintenance: true },
|
|
175
175
|
concurrency: "skip",
|
|
176
176
|
runIn: "worker",
|
|
177
177
|
escapeHatch: {
|
|
@@ -11,6 +11,9 @@ import type { Dispatcher } from "../pipeline/dispatcher.ts";
|
|
|
11
11
|
import type { EventDispatcher } from "../pipeline/index.ts";
|
|
12
12
|
import type { LifecycleHooks } from "../pipeline/lifecycle-pipeline.ts";
|
|
13
13
|
import type { AllInOneEntrypointOptions, BaseEntrypointOptions, JobsBlock, WorkerEntrypointOptions } from "./internals.ts";
|
|
14
|
+
export type EntrypointStopOptions = {
|
|
15
|
+
readonly drainTimeoutMs?: number;
|
|
16
|
+
};
|
|
14
17
|
export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
15
18
|
readonly auth?: AuthRoutesConfig;
|
|
16
19
|
readonly anonymousAccess?: ServerOptions["anonymousAccess"];
|
|
@@ -41,7 +44,7 @@ export type ApiEntrypoint = {
|
|
|
41
44
|
readonly eventDispatcher?: EventDispatcher;
|
|
42
45
|
readonly mode: "api";
|
|
43
46
|
start(): Promise<void>;
|
|
44
|
-
stop(): Promise<void>;
|
|
47
|
+
stop(options?: EntrypointStopOptions): Promise<void>;
|
|
45
48
|
};
|
|
46
49
|
export type WorkerEntrypoint = {
|
|
47
50
|
readonly lifecycle: Lifecycle;
|
|
@@ -52,7 +55,7 @@ export type WorkerEntrypoint = {
|
|
|
52
55
|
readonly dispatcher: Dispatcher;
|
|
53
56
|
readonly mode: "worker";
|
|
54
57
|
start(): Promise<void>;
|
|
55
|
-
stop(): Promise<void>;
|
|
58
|
+
stop(options?: EntrypointStopOptions): Promise<void>;
|
|
56
59
|
};
|
|
57
60
|
export type AllInOneEntrypoint = Omit<ApiEntrypoint, "mode"> & Omit<WorkerEntrypoint, "mode"> & {
|
|
58
61
|
readonly mode: "all-in-one";
|
package/dist/entrypoint/index.js
CHANGED
|
@@ -176,6 +176,8 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
|
|
|
176
176
|
redisUrl: jobs.redisUrl,
|
|
177
177
|
consumerLane,
|
|
178
178
|
queueNamePrefix: jobs.queueNamePrefix,
|
|
179
|
+
redisNamespace: jobs.redisNamespace,
|
|
180
|
+
maintenanceWindow: jobs.maintenanceWindow,
|
|
179
181
|
getActiveTenantIds: jobs.getActiveTenantIds,
|
|
180
182
|
onJobStart: jobs.onJobStart,
|
|
181
183
|
onJobComplete: jobs.onJobComplete,
|
|
@@ -189,6 +191,13 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
|
|
|
189
191
|
});
|
|
190
192
|
return jobRunner;
|
|
191
193
|
}
|
|
194
|
+
// Before the lifecycle drain, not as a hook: runner hooks drain one after another, and every other
|
|
195
|
+
// hook (cache sync, brokers) would otherwise run first, leaving a window in which a lane still
|
|
196
|
+
// fetches new jobs after SIGTERM.
|
|
197
|
+
async function drainEntrypoint(lifecycle, runners, stopOptions) {
|
|
198
|
+
await Promise.all(runners.map((runner) => runner.stopConsuming?.()));
|
|
199
|
+
await lifecycle.drain(stopOptions?.drainTimeoutMs !== undefined ? { timeoutMs: stopOptions.drainTimeoutMs } : {});
|
|
200
|
+
}
|
|
192
201
|
// A worker-shaped process with no consumers (no SSE, no search adapter,
|
|
193
202
|
// no MSPs) has nothing to drain — that's a caller-config bug, not a
|
|
194
203
|
// usable process shape. Fail loud so ops sees it before prod takes
|
|
@@ -263,8 +272,8 @@ export function createApiEntrypoint(options) {
|
|
|
263
272
|
if (server.eventDispatcher)
|
|
264
273
|
await server.eventDispatcher.start();
|
|
265
274
|
},
|
|
266
|
-
async stop() {
|
|
267
|
-
await lifecycle
|
|
275
|
+
async stop(stopOptions) {
|
|
276
|
+
await drainEntrypoint(lifecycle, apiJobRunner ? [apiJobRunner] : [], stopOptions);
|
|
268
277
|
},
|
|
269
278
|
};
|
|
270
279
|
}
|
|
@@ -290,8 +299,8 @@ export function createWorkerEntrypoint(options) {
|
|
|
290
299
|
await eventDispatcher.start();
|
|
291
300
|
await jobRunner.start();
|
|
292
301
|
},
|
|
293
|
-
async stop() {
|
|
294
|
-
await lifecycle
|
|
302
|
+
async stop(stopOptions) {
|
|
303
|
+
await drainEntrypoint(lifecycle, [jobRunner], stopOptions);
|
|
295
304
|
},
|
|
296
305
|
};
|
|
297
306
|
}
|
|
@@ -339,8 +348,8 @@ export function createAllInOneEntrypoint(options) {
|
|
|
339
348
|
await workerJobRunner.start();
|
|
340
349
|
await apiJobRunner.start();
|
|
341
350
|
},
|
|
342
|
-
async stop() {
|
|
343
|
-
await lifecycle
|
|
351
|
+
async stop(stopOptions) {
|
|
352
|
+
await drainEntrypoint(lifecycle, [workerJobRunner, apiJobRunner], stopOptions);
|
|
344
353
|
},
|
|
345
354
|
};
|
|
346
355
|
}
|
|
@@ -27,6 +27,8 @@ export type BaseEntrypointOptions = {
|
|
|
27
27
|
export type JobsBlock = {
|
|
28
28
|
readonly redisUrl: string;
|
|
29
29
|
readonly queueNamePrefix?: string;
|
|
30
|
+
readonly redisNamespace?: JobRunnerOptions["redisNamespace"];
|
|
31
|
+
readonly maintenanceWindow?: JobRunnerOptions["maintenanceWindow"];
|
|
30
32
|
readonly getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
|
|
31
33
|
readonly onJobStart?: JobRunnerOptions["onJobStart"];
|
|
32
34
|
readonly onJobComplete?: JobRunnerOptions["onJobComplete"];
|
package/dist/files/feature.js
CHANGED
|
@@ -40,7 +40,7 @@ export function createFilesFeature(options = {}) {
|
|
|
40
40
|
r.storeTable(fileUploadLedgerTable, { reason: "coordination.file_upload_ledger" });
|
|
41
41
|
r.job({
|
|
42
42
|
name: "sweep-orphaned-uploads",
|
|
43
|
-
trigger: {
|
|
43
|
+
trigger: { maintenance: true },
|
|
44
44
|
perTenant: true,
|
|
45
45
|
concurrency: "skip",
|
|
46
46
|
handler: async (_payload, ctx) => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { JobTrigger } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
import { type MaintenanceWindow } from "./maintenance-window.ts";
|
|
1
3
|
export type CronTrigger = {
|
|
2
4
|
readonly cron: string;
|
|
3
5
|
readonly tz?: string | undefined;
|
|
@@ -6,4 +8,11 @@ export type CronRepeatOptions = {
|
|
|
6
8
|
readonly pattern: string;
|
|
7
9
|
readonly tz?: string;
|
|
8
10
|
};
|
|
11
|
+
export type ScheduledTrigger = Extract<JobTrigger, {
|
|
12
|
+
readonly cron: string;
|
|
13
|
+
}> | Extract<JobTrigger, {
|
|
14
|
+
readonly maintenance: true;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function isScheduledTrigger(trigger: JobTrigger): trigger is ScheduledTrigger;
|
|
9
17
|
export declare function cronRepeatOptions(trigger: CronTrigger): CronRepeatOptions;
|
|
18
|
+
export declare function scheduleRepeatOptions(trigger: ScheduledTrigger, window?: MaintenanceWindow): CronRepeatOptions;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { DEFAULT_MAINTENANCE_WINDOW } from "./maintenance-window.js";
|
|
2
|
+
export function isScheduledTrigger(trigger) {
|
|
3
|
+
return "cron" in trigger || "maintenance" in trigger;
|
|
4
|
+
}
|
|
1
5
|
export function cronRepeatOptions(trigger) {
|
|
2
6
|
return trigger.tz === undefined
|
|
3
7
|
? { pattern: trigger.cron }
|
|
4
8
|
: { pattern: trigger.cron, tz: trigger.tz };
|
|
5
9
|
}
|
|
10
|
+
export function scheduleRepeatOptions(trigger, window = DEFAULT_MAINTENANCE_WINDOW) {
|
|
11
|
+
return "cron" in trigger
|
|
12
|
+
? cronRepeatOptions(trigger)
|
|
13
|
+
: { pattern: `${window.minute} ${window.hour} * * *`, tz: "UTC" };
|
|
14
|
+
}
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, } from "./job-runner.ts";
|
|
2
2
|
export { createJobRunner, serializeJobSubject } from "./job-runner.ts";
|
|
3
|
+
export { DEFAULT_MAINTENANCE_WINDOW, MAINTENANCE_WINDOW_ENV, type MaintenanceWindow, maintenanceWindowFromEnv, } from "./maintenance-window.ts";
|
package/dist/jobs/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js"
|
|
|
12
12
|
import { buildContextServices } from "../pipeline/context-services.js";
|
|
13
13
|
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
14
14
|
import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
|
|
15
|
+
import { isScheduledTrigger } from "./cron-repeat-options.js";
|
|
15
16
|
import { createJobAppendEvent } from "./job-append-event.js";
|
|
16
17
|
import { attemptNumberOf, createJobLogger, handlerPayloadOf, hasDeferBudgetLeft, isFinalAttempt, isJobTriggerEvent, jobSubjectKey, missingDbProxy, tenantIdOfJobData, } from "./job-runner-helpers.js";
|
|
17
18
|
// Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
|
|
@@ -56,7 +57,9 @@ function parseJobWriteOrigin(rawData, jobName) {
|
|
|
56
57
|
function createJobEscapeHatchReporter(scope, input, systemUser) {
|
|
57
58
|
const { jobName, jobDef, tenantId, triggeredById } = input;
|
|
58
59
|
// One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
|
|
59
|
-
const isSystemCronRun = triggeredById === null &&
|
|
60
|
+
const isSystemCronRun = triggeredById === null &&
|
|
61
|
+
jobDef.escapeHatch !== undefined &&
|
|
62
|
+
isScheduledTrigger(jobDef.trigger);
|
|
60
63
|
return createEscapeHatchReporter({
|
|
61
64
|
handler: jobName,
|
|
62
65
|
tenantId,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
2
|
import type { DispatchWriteRef, JobTriggerEvent } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import type { AppContext, JobRunIn, Registry, SessionUser, TenantId } from "../engine/types/index.ts";
|
|
4
|
+
import type { RedisNamespace } from "../redis/client.ts";
|
|
5
|
+
import type { MaintenanceWindow } from "./maintenance-window.ts";
|
|
4
6
|
export type JobLogEntry = {
|
|
5
7
|
level: "info" | "warn" | "error";
|
|
6
8
|
message: string;
|
|
@@ -40,6 +42,7 @@ export type JobSubjectValue = string | number | boolean | null;
|
|
|
40
42
|
export type JobRunner = {
|
|
41
43
|
start(): Promise<void>;
|
|
42
44
|
stop(): Promise<void>;
|
|
45
|
+
stopConsuming?(): Promise<void>;
|
|
43
46
|
dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
|
|
44
47
|
handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
|
|
45
48
|
handleCatchUpEvent(jobName: string, eventName: string, payload: Record<string, unknown>, user: SessionUser, triggerEvent: JobTriggerEvent): Promise<void>;
|
|
@@ -52,6 +55,8 @@ export type JobRunnerOptions = {
|
|
|
52
55
|
redisUrl: string;
|
|
53
56
|
consumerLane?: JobRunIn | undefined;
|
|
54
57
|
queueNamePrefix?: string | undefined;
|
|
58
|
+
redisNamespace?: Pick<RedisNamespace, "keyPrefix" | "bullmqPrefix"> | undefined;
|
|
59
|
+
maintenanceWindow?: MaintenanceWindow | undefined;
|
|
55
60
|
bootRedisTimeoutMs?: number | undefined;
|
|
56
61
|
schedulerSeenRefreshMs?: number | undefined;
|
|
57
62
|
jobRetention?: {
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -14,7 +14,7 @@ import { registerStandardMetrics } from "../observability/standard-metrics.js";
|
|
|
14
14
|
import { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.js";
|
|
15
15
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
16
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
|
-
import {
|
|
17
|
+
import { isScheduledTrigger, scheduleRepeatOptions } from "./cron-repeat-options.js";
|
|
18
18
|
import { createJobEventTriggers } from "./job-runner-event-triggers.js";
|
|
19
19
|
import { applyHandlerDeferral, executeJobInSpan } from "./job-runner-execution.js";
|
|
20
20
|
import { claimSequentialSlot, deferOverTenantLimit, fanOutPerTenantJob, PER_TENANT_PREFIX, perTenantTargetOf, releaseSequentialSlot, resolveJobDefinitionOrFail, startJobRun, } from "./job-runner-gates.js";
|
|
@@ -24,12 +24,15 @@ import { announceDropped, announceQueued, isOverPerTenantLimit, laneForJob, } fr
|
|
|
24
24
|
import { resolveMaxPerTenant } from "./max-per-tenant.js";
|
|
25
25
|
export { serializeJobSubject } from "./job-runner-helpers.js";
|
|
26
26
|
export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
|
|
27
|
+
const DEFAULT_REDIS_NAMESPACE = { keyPrefix: "", bullmqPrefix: "bull" };
|
|
27
28
|
export function createJobRunner(options) {
|
|
28
29
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
29
30
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
30
31
|
const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
|
|
31
32
|
const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
|
|
32
33
|
const redisOpts = parseRedisOpts(redisUrl);
|
|
34
|
+
// BullMQ rejects an ioredis keyPrefix on its connection, so it gets its own `prefix`; only lockRedis uses keyPrefix.
|
|
35
|
+
const { keyPrefix, bullmqPrefix } = options.redisNamespace ?? DEFAULT_REDIS_NAMESPACE;
|
|
33
36
|
// Use the context's tracer when present (observability-provider injected at
|
|
34
37
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
35
38
|
const tracer = context.tracer ?? getFallbackTracer();
|
|
@@ -133,7 +136,7 @@ export function createJobRunner(options) {
|
|
|
133
136
|
let lockRedis = null;
|
|
134
137
|
let sequentialLock = null;
|
|
135
138
|
if (hasSequential) {
|
|
136
|
-
lockRedis = new Redis(redisOpts);
|
|
139
|
+
lockRedis = new Redis({ ...redisOpts, keyPrefix });
|
|
137
140
|
// Without a listener, a post-close 'error' (e.g. a teardown-race
|
|
138
141
|
// "Connection is closed") is unhandled and crashes the process — in
|
|
139
142
|
// bun:test it gets attributed to whichever test happens to run next.
|
|
@@ -161,10 +164,12 @@ export function createJobRunner(options) {
|
|
|
161
164
|
const queues = {
|
|
162
165
|
api: new Queue(queueNameFor(queueNamePrefix, "api"), {
|
|
163
166
|
connection: redisOpts,
|
|
167
|
+
prefix: bullmqPrefix,
|
|
164
168
|
defaultJobOptions: jobRetentionOpts,
|
|
165
169
|
}),
|
|
166
170
|
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), {
|
|
167
171
|
connection: redisOpts,
|
|
172
|
+
prefix: bullmqPrefix,
|
|
168
173
|
defaultJobOptions: jobRetentionOpts,
|
|
169
174
|
}),
|
|
170
175
|
};
|
|
@@ -292,7 +297,7 @@ export function createJobRunner(options) {
|
|
|
292
297
|
function cronSchedulerIdsForLane(lane) {
|
|
293
298
|
const ids = new Set();
|
|
294
299
|
for (const [name, jobDef] of allJobs) {
|
|
295
|
-
if (laneForJob(jobDef) === lane &&
|
|
300
|
+
if (laneForJob(jobDef) === lane && isScheduledTrigger(jobDef.trigger)) {
|
|
296
301
|
ids.add(schedulerIdForJobName(name));
|
|
297
302
|
}
|
|
298
303
|
}
|
|
@@ -302,8 +307,8 @@ export function createJobRunner(options) {
|
|
|
302
307
|
for (const [name, jobDef] of allJobs) {
|
|
303
308
|
if (laneForJob(jobDef) !== lane)
|
|
304
309
|
continue;
|
|
305
|
-
if (
|
|
306
|
-
await queue.upsertJobScheduler(schedulerIdForJobName(name),
|
|
310
|
+
if (isScheduledTrigger(jobDef.trigger)) {
|
|
311
|
+
await queue.upsertJobScheduler(schedulerIdForJobName(name), scheduleRepeatOptions(jobDef.trigger, options.maintenanceWindow), {
|
|
307
312
|
name: jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name,
|
|
308
313
|
data: {},
|
|
309
314
|
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
@@ -370,6 +375,7 @@ export function createJobRunner(options) {
|
|
|
370
375
|
const consumerQueue = queues[consumerLane];
|
|
371
376
|
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), (job, token) => handleJob(job, token), {
|
|
372
377
|
connection: redisOpts,
|
|
378
|
+
prefix: bullmqPrefix,
|
|
373
379
|
concurrency: 5,
|
|
374
380
|
// Not consuming until the boot gates passed: a job already queued from a
|
|
375
381
|
// previous run must not execute against an unmet boot precondition.
|
|
@@ -460,6 +466,9 @@ export function createJobRunner(options) {
|
|
|
460
466
|
// Last on purpose: a start() that fails earlier must not leave a timer stamping for a runner that is not running.
|
|
461
467
|
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
462
468
|
},
|
|
469
|
+
async stopConsuming() {
|
|
470
|
+
await worker?.pause(true);
|
|
471
|
+
},
|
|
463
472
|
async stop() {
|
|
464
473
|
stopping = true;
|
|
465
474
|
if (queueDepthTimer) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type MaintenanceWindow = {
|
|
2
|
+
readonly hour: number;
|
|
3
|
+
readonly minute: number;
|
|
4
|
+
};
|
|
5
|
+
export declare const MAINTENANCE_WINDOW_ENV = "KUMIKO_MAINTENANCE_WINDOW";
|
|
6
|
+
export declare const DEFAULT_MAINTENANCE_WINDOW: MaintenanceWindow;
|
|
7
|
+
export declare function maintenanceWindowFromEnv(env?: Record<string, string | undefined>): MaintenanceWindow;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const MAINTENANCE_WINDOW_ENV = "KUMIKO_MAINTENANCE_WINDOW";
|
|
2
|
+
export const DEFAULT_MAINTENANCE_WINDOW = { hour: 3, minute: 0 };
|
|
3
|
+
const MAINTENANCE_WINDOW_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
4
|
+
// UTC wall clock, so the window does not move with the server time zone or DST.
|
|
5
|
+
export function maintenanceWindowFromEnv(env = process.env) {
|
|
6
|
+
const raw = env[MAINTENANCE_WINDOW_ENV]?.trim();
|
|
7
|
+
if (raw === undefined || raw === "")
|
|
8
|
+
return DEFAULT_MAINTENANCE_WINDOW;
|
|
9
|
+
const match = MAINTENANCE_WINDOW_PATTERN.exec(raw);
|
|
10
|
+
if (match === null) {
|
|
11
|
+
throw new Error(`${MAINTENANCE_WINDOW_ENV}="${raw}" is invalid: expected HH:MM in UTC, e.g. "03:00" or "22:45".`);
|
|
12
|
+
}
|
|
13
|
+
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
14
|
+
}
|
|
@@ -14,6 +14,7 @@ export type RedisCacheSyncBusOptions = {
|
|
|
14
14
|
readonly redisUrl: string;
|
|
15
15
|
readonly clientOptions?: RedisClientOptions;
|
|
16
16
|
readonly channelPrefix?: string;
|
|
17
|
+
readonly channelSubscribePattern?: string;
|
|
17
18
|
};
|
|
18
19
|
export declare function createRedisCacheSyncBus(opts: RedisCacheSyncBusOptions): ClosableCacheSyncBus;
|
|
19
20
|
export declare function createDefaultCacheSyncBus(envVars?: Record<string, string | undefined>): ClosableCacheSyncBus;
|