@cosmicdrift/kumiko-framework 0.361.0 → 0.363.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/api/auth-middleware.js +7 -5
  2. package/dist/api/redis-sse-broker.js +10 -8
  3. package/dist/api/routes.js +2 -2
  4. package/dist/api/server.js +3 -0
  5. package/dist/bun-db/query.d.ts +6 -4
  6. package/dist/bun-db/query.js +66 -92
  7. package/dist/crypto/kek-source.js +5 -3
  8. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  9. package/dist/crypto/pii-field-encryption.js +2 -2
  10. package/dist/db/apply-entity-event.js +2 -2
  11. package/dist/db/cursor.js +2 -1
  12. package/dist/db/dialect.d.ts +1 -0
  13. package/dist/db/dialect.js +28 -13
  14. package/dist/db/entity-table-from-registry.js +1 -1
  15. package/dist/db/entity-table-meta.js +5 -13
  16. package/dist/db/event-store-executor-context.js +6 -6
  17. package/dist/db/event-store-executor-read.js +9 -9
  18. package/dist/db/migrate-generator.js +5 -3
  19. package/dist/db/migrate-runner.js +31 -35
  20. package/dist/db/parent-ref-clause.js +2 -2
  21. package/dist/db/queries/backfill-pii.js +11 -9
  22. package/dist/db/queries/es-ops.js +1 -1
  23. package/dist/db/queries/event-consumer.d.ts +6 -6
  24. package/dist/db/queries/event-consumer.js +17 -17
  25. package/dist/db/queries/event-store-admin.js +6 -6
  26. package/dist/db/queries/event-store-transfer.js +2 -2
  27. package/dist/db/queries/event-store.d.ts +1 -1
  28. package/dist/db/queries/event-store.js +2 -2
  29. package/dist/db/queries/pii-backfill-state.js +2 -2
  30. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  31. package/dist/db/queries/raw-sql.js +1 -1
  32. package/dist/db/queries/test-stack.js +1 -1
  33. package/dist/db/reference-data.js +3 -5
  34. package/dist/db/table-builder.js +6 -4
  35. package/dist/db/table-introspection.d.ts +16 -0
  36. package/dist/db/table-introspection.js +76 -0
  37. package/dist/db/tenant-db.d.ts +3 -2
  38. package/dist/db/tenant-db.js +4 -10
  39. package/dist/derivatives/variant-key.js +2 -1
  40. package/dist/engine/boot-validator/access-declarations.js +29 -18
  41. package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
  42. package/dist/engine/boot-validator/entity-list-screens.js +1 -1
  43. package/dist/engine/boot-validator/pii-retention.js +1 -1
  44. package/dist/engine/boot-validator/screens.js +2 -1
  45. package/dist/engine/build-app-schema.js +1 -0
  46. package/dist/engine/changeset-changes.js +3 -3
  47. package/dist/engine/define-handler.js +3 -6
  48. package/dist/engine/embedded-derived.js +10 -6
  49. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  50. package/dist/engine/factories.js +5 -6
  51. package/dist/engine/feature-ast/extractors/events.js +43 -33
  52. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
  53. package/dist/engine/feature-ast/extractors/round3.js +4 -4
  54. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  55. package/dist/engine/feature-ast/patcher.js +3 -3
  56. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
  57. package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
  58. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  59. package/dist/engine/feature-ast/render.js +6 -3
  60. package/dist/engine/feature-changelog.d.ts +2 -1
  61. package/dist/engine/feature-changelog.js +8 -6
  62. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  63. package/dist/engine/feature-config-events-jobs.js +20 -5
  64. package/dist/engine/feature-ui-extensions.js +2 -3
  65. package/dist/engine/index.d.ts +2 -1
  66. package/dist/engine/index.js +1 -0
  67. package/dist/engine/ownership.js +7 -20
  68. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  69. package/dist/engine/registry-ingest.d.ts +2 -1
  70. package/dist/engine/registry-ingest.js +85 -2
  71. package/dist/engine/registry-validate.d.ts +1 -0
  72. package/dist/engine/registry-validate.js +38 -8
  73. package/dist/engine/registry.js +2 -1
  74. package/dist/engine/schema-builder.js +1 -3
  75. package/dist/engine/soft-delete-cleanup.js +2 -1
  76. package/dist/engine/steps/call-feature.js +1 -3
  77. package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
  78. package/dist/engine/template-variables.d.ts +29 -0
  79. package/dist/engine/template-variables.js +77 -0
  80. package/dist/engine/tier-resolver-extension.js +2 -3
  81. package/dist/engine/types/index.d.ts +2 -2
  82. package/dist/engine/validate-projection-allowlist.js +12 -13
  83. package/dist/engine/where-rule-lint.js +8 -7
  84. package/dist/errors/reasons.d.ts +1 -0
  85. package/dist/errors/reasons.js +2 -0
  86. package/dist/files/bind-referenced-files.write.d.ts +16 -0
  87. package/dist/files/bind-referenced-files.write.js +109 -0
  88. package/dist/files/feature.d.ts +1 -0
  89. package/dist/files/feature.js +2 -0
  90. package/dist/files/file-routes.d.ts +2 -1
  91. package/dist/files/file-routes.js +10 -3
  92. package/dist/files/local-provider.js +2 -1
  93. package/dist/files/record-write-check.d.ts +1 -0
  94. package/dist/files/record-write-check.js +9 -9
  95. package/dist/i18n/index.d.ts +1 -1
  96. package/dist/i18n/index.js +1 -1
  97. package/dist/i18n/mail-registry.d.ts +2 -0
  98. package/dist/i18n/mail-registry.js +7 -0
  99. package/dist/jobs/job-runner.js +5 -5
  100. package/dist/pipeline/dispatch-query.js +2 -3
  101. package/dist/pipeline/dispatch-shared.js +45 -14
  102. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  103. package/dist/pipeline/index.d.ts +1 -0
  104. package/dist/pipeline/index.js +1 -0
  105. package/dist/pipeline/member-read-only-transaction.js +1 -1
  106. package/dist/pipeline/notify-scope.d.ts +13 -0
  107. package/dist/pipeline/notify-scope.js +32 -0
  108. package/dist/pipeline/system-hooks.js +2 -1
  109. package/dist/pipeline/system-identity-switch.js +2 -2
  110. package/dist/pipeline/write-origin.js +2 -2
  111. package/dist/search/meilisearch-adapter.js +3 -4
  112. package/dist/secrets/stored-envelope.js +7 -7
  113. package/dist/stack/push-entity-projection-tables.js +2 -3
  114. package/dist/stack/table-helpers.js +10 -26
  115. package/dist/stack/test-stack.js +2 -5
  116. package/dist/testing/multipart-helper.d.ts +0 -6
  117. package/dist/testing/multipart-helper.js +5 -5
  118. package/dist/testing/without-ambient-temporal.js +4 -4
  119. package/dist/ui-types/card-column-roles.d.ts +5 -0
  120. package/dist/ui-types/card-column-roles.js +22 -4
  121. package/dist/ui-types/index.d.ts +1 -1
  122. package/dist/ui-types/index.js +1 -1
  123. package/dist/utils/is-plain-object.d.ts +1 -2
  124. package/dist/utils/is-plain-object.js +1 -4
  125. package/package.json +4 -4
  126. package/src/changes.json +88 -0
@@ -0,0 +1,109 @@
1
+ import * as z from "zod";
2
+ import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/index.js";
3
+ import { isFileField } from "../engine/types/index.js";
4
+ import { ValidationError } from "../errors/index.js";
5
+ import { fileRefIdsOf } from "./bind-pending-file-refs.js";
6
+ import { getFileRefExecutor } from "./create-file-ref.js";
7
+ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
8
+ import { findImplicitEntityTable } from "./record-write-check.js";
9
+ const MAX_PAGE_SIZE = 500;
10
+ const DEFAULT_PAGE_SIZE = 200;
11
+ const payloadSchema = z.object({
12
+ entityType: z.string().min(1),
13
+ entityIds: z.array(z.uuid()).min(1).max(MAX_PAGE_SIZE).optional(),
14
+ afterId: z.uuid().optional(),
15
+ limit: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
16
+ });
17
+ function invalidEntityType() {
18
+ return new ValidationError({
19
+ fields: [
20
+ {
21
+ path: "entityType",
22
+ code: "invalid_value",
23
+ i18nKey: "errors.validation.invalid_value",
24
+ },
25
+ ],
26
+ });
27
+ }
28
+ // The entity must have an implicit table and declared file fields, and the files
29
+ // feature must be mounted; anything else is a caller error, not an empty page.
30
+ function resolveFileBearingEntity(registry, entityType) {
31
+ const entity = registry.getEntity(entityType);
32
+ const table = findImplicitEntityTable(registry, entityType);
33
+ if (entity === undefined || table === undefined)
34
+ throw invalidEntityType();
35
+ if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
36
+ throw invalidEntityType();
37
+ const fileFieldNames = Object.entries(entity.fields)
38
+ .filter(([, fieldDef]) => isFileField(fieldDef))
39
+ .map(([fieldName]) => fieldName);
40
+ if (fileFieldNames.length === 0)
41
+ throw invalidEntityType();
42
+ return { entity, table, fileFieldNames };
43
+ }
44
+ // Either the named records or one id-ordered page after afterId.
45
+ function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
46
+ return db.selectMany(table, {
47
+ ...(entity.softDelete === true && { isDeleted: false }),
48
+ ...(entityIds !== undefined
49
+ ? { id: entityIds }
50
+ : afterId !== undefined && { id: { gt: afterId } }),
51
+ }, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
52
+ }
53
+ // Unbound, same tenant, uploaded by the record's creator (or the system), and either
54
+ // unclaimed or pending for exactly this entity field.
55
+ function isBindableToRecord(fileRef, target) {
56
+ const uploader = fileRef["insertedById"];
57
+ return (typeof uploader === "string" &&
58
+ (uploader === target.record["insertedById"] || uploader === SYSTEM_USER_ID) &&
59
+ fileRef["tenantId"] === target.tenantId &&
60
+ fileRef["entityId"] === null &&
61
+ (fileRef["entityType"] === null ||
62
+ (fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName)));
63
+ }
64
+ // The binding follows only the record's declared file fields; the caller names
65
+ // no file ids. The record's creator must have uploaded the file themselves (or the
66
+ // system generated it), otherwise a foreign reference would pull someone else's
67
+ // private upload into the referencing user's record and make it readable there.
68
+ export const bindReferencedFilesWrite = defineWriteHandler({
69
+ name: "bindReferencedFiles",
70
+ schema: payloadSchema,
71
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
72
+ // Maintenance backfill run by ops or system jobs, not a task an assistant performs.
73
+ agent: { expose: false },
74
+ description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
75
+ handler: async (event, ctx) => {
76
+ const { entityType, entityIds, limit } = event.payload;
77
+ const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
78
+ const records = await loadRecordPage(ctx.db, source, event.payload);
79
+ const executor = getFileRefExecutor();
80
+ let bound = 0;
81
+ let skipped = 0;
82
+ for (const record of records) {
83
+ for (const fieldName of fileFieldNames) {
84
+ for (const fileRefId of fileRefIdsOf(record[fieldName])) {
85
+ const row = await executor.detail({ id: fileRefId }, event.user, ctx.db);
86
+ if (row === null ||
87
+ !isBindableToRecord(row, {
88
+ record,
89
+ entityType,
90
+ fieldName,
91
+ tenantId: event.user.tenantId,
92
+ })) {
93
+ skipped += 1;
94
+ continue;
95
+ }
96
+ const version = typeof row["version"] === "number" ? row["version"] : undefined;
97
+ const result = await executor.update({ id: fileRefId, version, changes: { entityType, entityId: record.id, fieldName } }, event.user, ctx.db);
98
+ if (!result.isSuccess)
99
+ return result;
100
+ bound += 1;
101
+ }
102
+ }
103
+ }
104
+ const lastRecord = records[records.length - 1];
105
+ const pageIsFull = entityIds === undefined && records.length === limit;
106
+ const nextAfterId = pageIsFull ? (lastRecord?.id ?? null) : null;
107
+ return { isSuccess: true, data: { bound, skipped, nextAfterId } };
108
+ },
109
+ });
@@ -4,6 +4,7 @@ export { fileRefEntity } from "./file-ref-entity.js";
4
4
  export type FilesFeatureOptions = {
5
5
  readonly accessGuard?: FileAccessGuard;
6
6
  readonly privilegedRoles?: readonly string[];
7
+ readonly unclaimedUploadReaders?: readonly string[];
7
8
  readonly maxUploadSize?: string;
8
9
  };
9
10
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -1,4 +1,5 @@
1
1
  import { defineFeature } from "../engine/index.js";
2
+ import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
2
3
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
3
4
  export { fileRefEntity } from "./file-ref-entity.js";
4
5
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
@@ -21,6 +22,7 @@ export function createFilesFeature(options = {}) {
21
22
  recommended: false,
22
23
  });
23
24
  r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
25
+ r.writeHandler(bindReferencedFilesWrite);
24
26
  return { routeOptions: options };
25
27
  });
26
28
  }
@@ -28,10 +28,11 @@ export type FileRoutesOptions = {
28
28
  readonly registry?: Registry;
29
29
  readonly maxUploadSize?: string;
30
30
  readonly privilegedRoles?: readonly string[];
31
+ readonly unclaimedUploadReaders?: readonly string[];
31
32
  readonly accessGuard?: FileAccessGuard;
32
33
  readonly canReadRecord?: RecordReadCheck;
33
34
  readonly canWriteRecord?: RecordWriteCheck;
34
35
  };
35
36
  export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
36
- export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
37
+ export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "unclaimedUploadReaders" | "maxUploadSize">;
37
38
  export declare function createFileRoutes(options: FileRoutesOptions): Hono;
@@ -26,12 +26,18 @@ const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
26
26
  // files. Pending uploads (entityType set, record not yet created) are the
27
27
  // uploader's own until bound; so are uploads without an entityType, which no
28
28
  // record can ever claim.
29
- function createDefaultGuard(privilegedRoles, canReadRecord) {
29
+ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders) {
30
30
  return async ({ fileRef, user, operation }) => {
31
31
  if (fileRef.insertedById === user.id)
32
32
  return "allow";
33
33
  if (privilegedRoles.some((role) => user.roles.includes(role)))
34
34
  return "allow";
35
+ const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
36
+ if (isUnclaimed &&
37
+ operation === "read" &&
38
+ unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
39
+ return "allow";
40
+ }
35
41
  if (fileRef.entityType === null || fileRef.entityId === null)
36
42
  return "deny";
37
43
  if (operation !== "read" || canReadRecord === undefined)
@@ -68,7 +74,7 @@ export function readFilesRouteOptions(registry) {
68
74
  if (exp && typeof exp === "object" && "routeOptions" in exp) {
69
75
  const ro = exp.routeOptions;
70
76
  if (ro && typeof ro === "object") {
71
- // @cast-boundary feature-exports: engine-payload (unknown) → known shape
77
+ // @cast-boundary feature-exports — typeof-checked read-back from untyped feature.exports
72
78
  return ro;
73
79
  }
74
80
  }
@@ -77,7 +83,8 @@ export function readFilesRouteOptions(registry) {
77
83
  export function createFileRoutes(options) {
78
84
  const { db } = options;
79
85
  const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
80
- const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles, options.canReadRecord);
86
+ const guard = options.accessGuard ??
87
+ createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? []);
81
88
  const executor = getFileRefExecutor();
82
89
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
83
90
  const api = new Hono();
@@ -2,6 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
2
2
  import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
5
6
  import { assertSafeStorageKey } from "./types.js";
6
7
  // Local-filesystem backend — intended for dev + tests. Production deploys
7
8
  // pick an object-store provider (S3/R2/…). mimeType is ignored here; the
@@ -112,7 +113,7 @@ export function createLocalProvider(basePath) {
112
113
  entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
113
114
  }
114
115
  catch (err) {
115
- const code = err.code;
116
+ const code = errorCodeOf(err);
116
117
  if (code === "ENOENT" || code === "ENOTDIR")
117
118
  return [];
118
119
  throw err;
@@ -14,6 +14,7 @@ export type RecordWriteCheck = (args: {
14
14
  readonly fieldName: string | undefined;
15
15
  readonly user: SessionUser;
16
16
  }) => Promise<RecordWriteDecision>;
17
+ export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
17
18
  export declare function createRecordWriteCheck(args: {
18
19
  readonly registry: Registry;
19
20
  readonly canReadRecord: RecordReadCheck;
@@ -8,6 +8,14 @@ export const RecordWriteDecisions = {
8
8
  forbidden: "forbidden",
9
9
  locked: "locked",
10
10
  };
11
+ export function findImplicitEntityTable(registry, entityType) {
12
+ for (const projection of registry.getAllProjections().values()) {
13
+ if (projection.isImplicit === true && projection.source === entityType) {
14
+ return projection.table;
15
+ }
16
+ }
17
+ return undefined;
18
+ }
11
19
  function isEntityUpdateWrite(qn, entityName) {
12
20
  return qn.endsWith(`:write:${toKebab(entityName)}:update`);
13
21
  }
@@ -25,20 +33,12 @@ export function createRecordWriteCheck(args) {
25
33
  }
26
34
  return undefined;
27
35
  }
28
- function findEntityTable(entityType) {
29
- for (const projection of registry.getAllProjections().values()) {
30
- if (projection.isImplicit === true && projection.source === entityType) {
31
- return projection.table;
32
- }
33
- }
34
- return undefined;
35
- }
36
36
  return async ({ entityType, entityId, fieldName, user }) => {
37
37
  if (!(await canReadRecord({ entityType, entityId, user }))) {
38
38
  return RecordWriteDecisions.notFound;
39
39
  }
40
40
  const entity = registry.getEntity(entityType);
41
- const table = findEntityTable(entityType);
41
+ const table = findImplicitEntityTable(registry, entityType);
42
42
  if (!entity || !table)
43
43
  return RecordWriteDecisions.notFound;
44
44
  // The stored row, not the detail output: the detail query can hide ownership fields.
@@ -1,5 +1,5 @@
1
1
  import type { Registry } from "../engine/types/index.js";
2
- export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
2
+ export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
3
3
  export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
4
4
  export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
5
5
  export type I18nOptions = {
@@ -1,5 +1,5 @@
1
1
  import { resolveTranslationValue } from "../ui-types/plural.js";
2
- export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
2
+ export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
3
3
  export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
4
4
  export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
5
5
  export function createI18n(registry, options) {
@@ -1,5 +1,7 @@
1
1
  import { type TranslationValue } from "../ui-types/plural.js";
2
2
  export declare function registerMailTranslations(locale: string, bundle: Readonly<Record<string, TranslationValue>>): void;
3
+ export declare function listMailLocales(): readonly string[];
4
+ export declare function hasMailTranslationKey(locale: string, key: string): boolean;
3
5
  export declare function hasMailTranslations(locale: string): boolean;
4
6
  export declare function mailT(locale: string, key: string, params?: Readonly<Record<string, string | number>>): string;
5
7
  /** Locale key mailT would actually use (exact → root → en). Use for appUrl
@@ -4,6 +4,13 @@ export function registerMailTranslations(locale, bundle) {
4
4
  const prev = tables.get(locale) ?? {};
5
5
  tables.set(locale, { ...prev, ...bundle });
6
6
  }
7
+ export function listMailLocales() {
8
+ return [...tables.keys()];
9
+ }
10
+ // Own key only, without the root/en fallback mailT applies.
11
+ export function hasMailTranslationKey(locale, key) {
12
+ return tables.get(locale)?.[key] !== undefined;
13
+ }
7
14
  export function hasMailTranslations(locale) {
8
15
  const root = locale.split("-")[0] ?? locale;
9
16
  return tables.has(locale) || tables.has(root);
@@ -1,4 +1,5 @@
1
1
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { Queue, UnrecoverableError, Worker } from "bullmq";
3
4
  import { Redis } from "ioredis";
4
5
  import { requestContext } from "../api/request-context.js";
@@ -24,12 +25,11 @@ import { cronRepeatOptions } from "./cron-repeat-options.js";
24
25
  // run proceeds without it rather than failing over what would be a framework
25
26
  // bug, not untrusted input.
26
27
  function isJobTriggerEvent(value) {
27
- if (typeof value !== "object" || value === null)
28
+ if (!isPlainObject(value))
28
29
  return false;
29
- const candidate = value;
30
- if (typeof candidate["id"] !== "string")
30
+ if (typeof value["id"] !== "string")
31
31
  return false;
32
- const headers = candidate["headers"];
32
+ const headers = value["headers"];
33
33
  if (typeof headers !== "object" || headers === null)
34
34
  return false;
35
35
  return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
@@ -834,7 +834,7 @@ export function createJobRunner(options) {
834
834
  // must resolve for jobs the same way they do for write-handlers, or an
835
835
  // app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
836
836
  // at runtime the write-handler path never would (framework#1532).
837
- const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner);
837
+ const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner, undefined, context._fileProviderResolver);
838
838
  const configDb = context.db; // @cast-boundary db-operator
839
839
  // Shared by the config accessor and ctx.derivatives below — both need the
840
840
  // same tenant-scoped db, and building it twice would let the two calls
@@ -1,3 +1,4 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { hasAccess } from "../engine/access.js";
2
3
  import { tenantOverrideDenied } from "../engine/cross-tenant.js";
3
4
  import { filterReadFields } from "../engine/field-access.js";
@@ -49,9 +50,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
49
50
  // schema (and custom query schemas that opt in) carries `includeDeleted`,
50
51
  // so other handlers never see the flag. Visibility filters still apply
51
52
  // downstream (see HandlerContext.includeDeleted) — safe from raw input.
52
- const includeDeleted = typeof parsed.data === "object" &&
53
- parsed.data !== null &&
54
- parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
53
+ const includeDeleted = propertyOf(parsed.data, "includeDeleted") === true;
55
54
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
56
55
  return isMemberResolutionPrincipal(user)
57
56
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
@@ -27,6 +27,7 @@ import { executeQuery } from "./dispatch-query.js";
27
27
  import { executeWrite } from "./dispatch-write.js";
28
28
  import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
29
29
  import { createMemberReaderFn } from "./member-reader.js";
30
+ import { createDeferringNotify } from "./notify-scope.js";
30
31
  import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
31
32
  import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
32
33
  // Framework/pipeline stays bundled-features-free, so this can't import the
@@ -47,8 +48,10 @@ export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
47
48
  // dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
48
49
  // appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
49
50
  export function resolveDbSource(ctx, tx) {
50
- const { appContext: context } = ctx;
51
- return tx ?? context.db; // @cast-boundary db-operator
51
+ return tx ?? resolveAppPoolDb(ctx);
52
+ }
53
+ function resolveAppPoolDb(ctx) {
54
+ return ctx.appContext.db; // @cast-boundary db-operator
52
55
  }
53
56
  // ctx.appendEvent — append a domain event onto a specific aggregate stream
54
57
  // in the current tx, then fire matching inline projections. Core logic
@@ -248,7 +251,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
248
251
  // that is meant to outlive the request. Computed before `systemDb` below
249
252
  // so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
250
253
  // this same TenantDb instead of a second, independently-built one.
251
- const outsideTxSource = ctx.outsideTransactionDb ?? resolveDbSource(ctx, undefined);
254
+ const outsideTxSource = ctx.outsideTransactionDb ?? resolveAppPoolDb(ctx);
252
255
  const rawDbOutsideTransaction = outsideTxSource
253
256
  ? buildTenantScopedDb(outsideTxSource, undefined)
254
257
  : undefined;
@@ -279,9 +282,40 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
279
282
  userId: user.id,
280
283
  ...(reqCtx && { requestId: reqCtx.requestId }),
281
284
  });
282
- const notify = context._notifyFactory
283
- ? context._notifyFactory(user, user.tenantId, isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined)
285
+ // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
286
+ // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
287
+ const bridgeSink = afterCommitHooks ?? [];
288
+ // Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
289
+ const hasLiveCommitSink = afterCommitHooks !== undefined && isPostCommit !== true;
290
+ const scheduleAfterCommit = (hook) => {
291
+ bridgeSink.push(hook);
292
+ };
293
+ const notifyFactory = context._notifyFactory;
294
+ const notifyJobDispatcher = isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined;
295
+ // Inside the write tx the app pool's slots can all be held by waiting write txs, so the
296
+ // delivery's own DB work (broadcast lookup, attempt log, channel sends) runs on the outside-tx pool.
297
+ const notifyInTransactionFactory = notifyFactory && tx !== undefined
298
+ ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
284
299
  : undefined;
300
+ const notify = buildNotify();
301
+ function buildNotify() {
302
+ if (!notifyFactory)
303
+ return undefined;
304
+ const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
305
+ if (!notifyInTransactionFactory)
306
+ return notifyAfterCommit;
307
+ const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
308
+ // A rolled-back write must leave no notification behind.
309
+ if (!hasLiveCommitSink)
310
+ return notifyInTransaction;
311
+ return createDeferringNotify({
312
+ registry,
313
+ tenantId: user.tenantId,
314
+ notifyAfterCommit,
315
+ notifyNow: notifyInTransaction,
316
+ scheduleAfterCommit,
317
+ });
318
+ }
285
319
  // Mirror notify: only built when the config feature wired its factory.
286
320
  const config = context._configAccessorFactory && db
287
321
  ? context._configAccessorFactory({
@@ -361,9 +395,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
361
395
  const metricsFor = meter
362
396
  ? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
363
397
  : () => createNoopMetricsHandle();
364
- // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
365
- // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
366
- const bridgeSink = afterCommitHooks ?? [];
367
398
  // A failed inner write has already appended its event and projections when a later
368
399
  // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
369
400
  // failure result. The savepoint makes the failure undo exactly the inner write; the
@@ -374,7 +405,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
374
405
  const innerAfterCommitHooks = [];
375
406
  try {
376
407
  const result = await runInSavepoint(tx, async (savepointTx) => {
377
- const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary driver savepoint handle is a transaction handle
408
+ const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary db-runner savepoint handle is a transaction handle
378
409
  innerAfterCommitHooks);
379
410
  if (isFailedWriteResult(innerResult))
380
411
  throw new NestedWriteRollback(innerResult);
@@ -396,9 +427,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
396
427
  const writeAsUser = async (asUser, targetType, payload) => isPostCommit
397
428
  ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
398
429
  : executeNestedWriteInSavepoint(asUser, targetType, payload);
399
- const scheduleAfterCommit = (hook) => {
400
- bridgeSink.push(hook);
401
- };
402
430
  const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
403
431
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
404
432
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
@@ -733,9 +761,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
733
761
  const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
734
762
  // The payload-bucket digest is dispatcher-internal; handlers must not be
735
763
  // able to compute bucket keys.
736
- const { _rateLimitPayloadDigest: _omitPayloadDigest, ...contextForHandler } = context;
764
+ const { _rateLimitPayloadDigest: _omitPayloadDigest,
765
+ // Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
766
+ _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
737
767
  const handlerContext = {
738
768
  ...contextForHandler,
769
+ ...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
739
770
  registry,
740
771
  db: exposedDb,
741
772
  dbOutsideTransaction,
@@ -781,7 +812,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
781
812
  _userId: user.id,
782
813
  _tenantId: user.tenantId,
783
814
  _handlerType: type,
784
- scheduleAfterCommit,
815
+ ...(hasLiveCommitSink && { scheduleAfterCommit }),
785
816
  ...(includeDeleted && { includeDeleted: true }),
786
817
  ...bridge,
787
818
  }; // @cast-boundary engine-bridge
@@ -57,8 +57,7 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
57
57
  const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
58
58
  if (cooldownElapsed && state.rearmCount < maxRearmCount) {
59
59
  const rearmed = await rearmDeadConsumer(tx, name, instanceId);
60
- const rearmedState = rearmed &&
61
- coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
60
+ const rearmedState = rearmed && coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
62
61
  if (rearmedState)
63
62
  return { state: rearmedState, skip: null };
64
63
  }
@@ -149,7 +148,7 @@ export async function markProcessing(tx, name, instanceId) {
149
148
  export async function fetchPendingEvents(tx, cursor, batchSize, pendingRanges = []) {
150
149
  const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
151
150
  const info = extractTableInfo(eventsTable);
152
- return rawRows.map((row) => coerceRow(row, info)); // @cast-boundary db-row
151
+ return rawRows.map((row) => coerceRow(row, info));
153
152
  }
154
153
  // Deliver events to the consumer's handler in events.id order, or — when
155
154
  // the consumer wires a batchHandler — through it in one call first. Halt-
@@ -30,6 +30,7 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
30
30
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
31
31
  export type { MspRebuildDeps } from "./msp-rebuild.js";
32
32
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
33
+ export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
33
34
  export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
34
35
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
35
36
  export type { ProjectionStatus } from "./projection-state.js";
@@ -15,6 +15,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
15
15
  export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
16
16
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
17
17
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
18
+ export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
18
19
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
19
20
  export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
20
21
  export { runProjectionsForEvent } from "./projections-runner.js";
@@ -34,7 +34,7 @@ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, f
34
34
  await runInSavepoint(tx, async (sp) => {
35
35
  // kumiko-lint-ignore raw-sql transaction characteristic, no query helper
36
36
  await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
37
- // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
37
+ // @cast-boundary db-runner savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
38
38
  settled.outcome = { value: await fn(sp) };
39
39
  // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
40
40
  throw new SavepointDiscard();
@@ -0,0 +1,13 @@
1
+ import type { TenantId } from "../engine/types/identifiers.js";
2
+ import type { NotifyFn, NotifyOptions, Registry } from "../engine/types/index.js";
3
+ import type { AfterCommitHook } from "./dispatcher-utils.js";
4
+ export declare function assertNotifyTargetsOwnTenant(options: NotifyOptions, tenantId: TenantId): void;
5
+ type DeferringNotifyDeps = {
6
+ readonly registry: Pick<Registry, "getAllNotifications"> | undefined;
7
+ readonly tenantId: TenantId;
8
+ readonly notifyAfterCommit: NotifyFn;
9
+ readonly notifyNow: NotifyFn;
10
+ readonly scheduleAfterCommit: (hook: AfterCommitHook) => void;
11
+ };
12
+ export declare function createDeferringNotify(deps: DeferringNotifyDeps): NotifyFn;
13
+ export {};
@@ -0,0 +1,32 @@
1
+ import { assertNotificationVariables } from "../engine/template-variables.js";
2
+ import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
3
+ function isTenantBroadcast(to) {
4
+ return typeof to === "object" && to !== null && !Array.isArray(to) && "tenant" in to;
5
+ }
6
+ export function assertNotifyTargetsOwnTenant(options, tenantId) {
7
+ const { to } = options;
8
+ if (isTenantBroadcast(to) && to.tenant !== tenantId) {
9
+ throw new AccessDeniedError({
10
+ message: "ctx.notify can only broadcast to the caller's own tenant",
11
+ details: { reason: FrameworkReasons.notifyForeignTenantBroadcast },
12
+ });
13
+ }
14
+ }
15
+ // Without `immediate`, a notify inside a write transaction waits for the commit so a
16
+ // rolled-back write leaves no notification behind.
17
+ export function createDeferringNotify(deps) {
18
+ const { registry, tenantId, notifyAfterCommit, notifyNow, scheduleAfterCommit } = deps;
19
+ async function notifyDeferringUntilCommit(notificationType, options) {
20
+ assertNotifyTargetsOwnTenant(options, tenantId);
21
+ // Fail the write now: after the commit the problem would only surface in the render job.
22
+ if (registry)
23
+ assertNotificationVariables(registry, notificationType, options.data);
24
+ if (options.immediate === true)
25
+ return notifyNow(notificationType, options);
26
+ scheduleAfterCommit(async () => {
27
+ await notifyAfterCommit(notificationType, options);
28
+ });
29
+ return { kind: "deferred" };
30
+ }
31
+ return notifyDeferringUntilCommit;
32
+ }
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { readNonEmptyStringArray, } from "../api/sse-broker.js";
2
3
  import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
3
4
  import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
@@ -520,7 +521,7 @@ const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
520
521
  const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
521
522
  function readUserIdFromPreviousSnapshot(payload) {
522
523
  const previous = payload["previous"];
523
- if (typeof previous !== "object" || previous === null)
524
+ if (!isPlainObject(previous))
524
525
  return undefined;
525
526
  const userId = previous["userId"];
526
527
  return typeof userId === "string" && userId.length > 0 ? userId : undefined;
@@ -124,14 +124,14 @@ export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audi
124
124
  function readIdentitySwitchFn(context, key) {
125
125
  if (!(key in context))
126
126
  return undefined;
127
- const value = context[key];
127
+ const value = Reflect.get(context, key);
128
128
  return typeof value === "function" ? value : undefined; // @cast-boundary engine-bridge — checked via typeof above
129
129
  }
130
130
  // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
131
131
  function readDbLikeValue(context, key) {
132
132
  if (!(key in context))
133
133
  return undefined;
134
- const value = context[key];
134
+ const value = Reflect.get(context, key);
135
135
  return typeof value === "object" && value !== null ? value : undefined;
136
136
  }
137
137
  function unavailableIdentitySwitchFn(callerLabel, kind) {
@@ -39,11 +39,11 @@ function declaresPublicIntake(access) {
39
39
  // Only the public dispatcher entry points compute a root; every nested call inherits
40
40
  // it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
41
41
  export function rootWriteOrigin(registry, type, user) {
42
- const writeHandler = registry.getWriteHandler(type);
42
+ const rootHandler = registry.getWriteHandler(type) ?? registry.getQueryHandler(type);
43
43
  return {
44
44
  rootHandler: type,
45
45
  anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
46
- publicIntake: writeHandler !== undefined && declaresPublicIntake(writeHandler.access),
46
+ publicIntake: rootHandler !== undefined && declaresPublicIntake(rootHandler.access),
47
47
  };
48
48
  }
49
49
  // Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
@@ -1,3 +1,4 @@
1
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { ErrorStatusCode, Meilisearch } from "meilisearch";
2
3
  // meilisearch's waitTask() RESOLVES (never rejects) once the server task
3
4
  // reaches a terminal state, even "failed" — a rejected doc (e.g. an id over
@@ -23,10 +24,8 @@ const INDEX_LIST_PAGE_SIZE = 100;
23
24
  // What an unconfigured Meilisearch index reports as searchableAttributes.
24
25
  const MEILI_ALL_ATTRIBUTES = "*";
25
26
  function isIndexNotFound(err) {
26
- const cause = typeof err === "object" && err !== null ? err.cause : null;
27
- return (typeof cause === "object" &&
28
- cause !== null &&
29
- cause.code === ErrorStatusCode.INDEX_NOT_FOUND);
27
+ const cause = typeof err === "object" && err !== null && "cause" in err ? err.cause : null;
28
+ return errorCodeOf(cause) === ErrorStatusCode.INDEX_NOT_FOUND;
30
29
  }
31
30
  // Exported for unit tests (index-name / primary-key shape) without a live Meili.
32
31
  export function meilisearchTenantIndex(prefix, tenantId) {