@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1

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 (163) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/webhook-step.md +11 -1
  22. package/agents/examples/workflow-engine.md +2 -2
  23. package/agents/examples.md +2 -1
  24. package/agents/features.md +4 -4
  25. package/dist/api/auth-routes.d.ts +0 -1
  26. package/dist/api/auth-routes.js +44 -43
  27. package/dist/api/index.d.ts +1 -1
  28. package/dist/api/index.js +1 -1
  29. package/dist/api/server.d.ts +2 -2
  30. package/dist/api/server.js +29 -4
  31. package/dist/bun-db/connection.js +3 -16
  32. package/dist/bun-db/index.d.ts +1 -1
  33. package/dist/bun-db/index.js +1 -1
  34. package/dist/bun-db/query.d.ts +7 -0
  35. package/dist/bun-db/query.js +50 -18
  36. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  37. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  38. package/dist/crypto/owner-transition-rekey.js +7 -3
  39. package/dist/crypto/pg-kms-adapter.js +10 -6
  40. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  41. package/dist/crypto/pii-field-encryption.js +9 -2
  42. package/dist/crypto/subject-resolver.d.ts +1 -0
  43. package/dist/crypto/subject-resolver.js +7 -0
  44. package/dist/db/api.d.ts +4 -3
  45. package/dist/db/apply-entity-event.js +26 -2
  46. package/dist/db/blind-index-cleanup.js +2 -1
  47. package/dist/db/bun-provider.js +9 -18
  48. package/dist/db/dialect.d.ts +1 -1
  49. package/dist/db/dialect.js +2 -0
  50. package/dist/db/eagerload.js +2 -1
  51. package/dist/db/event-store-executor-context.d.ts +1 -0
  52. package/dist/db/event-store-executor-context.js +1 -0
  53. package/dist/db/event-store-executor-write.d.ts +1 -1
  54. package/dist/db/event-store-executor-write.js +85 -58
  55. package/dist/db/index.d.ts +1 -0
  56. package/dist/db/index.js +1 -0
  57. package/dist/db/migrate-runner.d.ts +1 -0
  58. package/dist/db/migrate-runner.js +56 -31
  59. package/dist/db/queries/backfill-pii.js +63 -25
  60. package/dist/db/queries/event-consumer.js +2 -1
  61. package/dist/db/queries/event-store-transfer.js +15 -4
  62. package/dist/db/queries/event-store.js +11 -10
  63. package/dist/db/queries/pii-backfill-state.js +3 -2
  64. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  65. package/dist/db/queries/postgres-array-literal.js +6 -0
  66. package/dist/db/queries/projection-rebuild.js +3 -2
  67. package/dist/db/queries/shadow-swap.d.ts +1 -1
  68. package/dist/db/queries/shadow-swap.js +12 -8
  69. package/dist/db/query.d.ts +1 -1
  70. package/dist/db/query.js +1 -1
  71. package/dist/db/retired-framework-tables.js +22 -4
  72. package/dist/db/table-builder.js +6 -2
  73. package/dist/db/tenant-db.d.ts +2 -0
  74. package/dist/db/tenant-db.js +20 -1
  75. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  76. package/dist/derivatives/delete-stored-file.js +9 -0
  77. package/dist/derivatives/index.d.ts +1 -1
  78. package/dist/derivatives/index.js +1 -1
  79. package/dist/engine/boot-validator/entity-handler.js +3 -0
  80. package/dist/engine/boot-validator/query-refs.js +14 -2
  81. package/dist/engine/dedupe-features.js +31 -4
  82. package/dist/engine/entity-handlers.js +14 -7
  83. package/dist/engine/index.d.ts +2 -2
  84. package/dist/engine/index.js +1 -1
  85. package/dist/engine/registry-validate.d.ts +1 -0
  86. package/dist/engine/registry-validate.js +21 -0
  87. package/dist/engine/registry.js +2 -1
  88. package/dist/engine/screen-helpers.d.ts +1 -0
  89. package/dist/engine/screen-helpers.js +2 -2
  90. package/dist/engine/steps/_read-source.js +5 -2
  91. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  92. package/dist/engine/tier-resolver-extension.js +6 -0
  93. package/dist/engine/types/index.d.ts +1 -1
  94. package/dist/entrypoint/index.d.ts +2 -2
  95. package/dist/errors/docs-url.js +6 -6
  96. package/dist/errors/index.d.ts +1 -1
  97. package/dist/errors/index.js +1 -1
  98. package/dist/errors/subject-erased.d.ts +1 -0
  99. package/dist/errors/subject-erased.js +6 -0
  100. package/dist/event-store/admin-api.js +3 -2
  101. package/dist/event-store/errors.d.ts +9 -0
  102. package/dist/event-store/errors.js +20 -0
  103. package/dist/event-store/event-store.d.ts +6 -0
  104. package/dist/event-store/event-store.js +28 -6
  105. package/dist/event-store/index.d.ts +1 -1
  106. package/dist/event-store/index.js +1 -1
  107. package/dist/files/create-file-ref.d.ts +2 -1
  108. package/dist/files/create-file-ref.js +16 -2
  109. package/dist/files/dispose-removed-file-refs.js +21 -6
  110. package/dist/files/feature.d.ts +1 -0
  111. package/dist/files/feature.js +2 -1
  112. package/dist/files/file-handle.js +4 -0
  113. package/dist/files/file-routes.js +9 -5
  114. package/dist/files/index.d.ts +1 -1
  115. package/dist/files/index.js +1 -1
  116. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  117. package/dist/files/resolve-content-type.js +40 -4
  118. package/dist/files/storage-tracking.js +5 -4
  119. package/dist/files/types.js +5 -8
  120. package/dist/files/zip-entry-names.d.ts +1 -0
  121. package/dist/files/zip-entry-names.js +46 -0
  122. package/dist/jobs/job-runner.d.ts +2 -0
  123. package/dist/jobs/job-runner.js +183 -48
  124. package/dist/jobs/max-per-tenant.d.ts +8 -0
  125. package/dist/jobs/max-per-tenant.js +14 -0
  126. package/dist/observability/escape-hatch-report.d.ts +1 -0
  127. package/dist/observability/escape-hatch-report.js +31 -2
  128. package/dist/observability/index.d.ts +1 -1
  129. package/dist/observability/index.js +1 -1
  130. package/dist/observability/metrics-handle.d.ts +1 -0
  131. package/dist/observability/metrics-handle.js +7 -0
  132. package/dist/observability/standard-metrics.js +4 -4
  133. package/dist/pipeline/dispatch-query.d.ts +2 -1
  134. package/dist/pipeline/dispatch-query.js +6 -6
  135. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  136. package/dist/pipeline/dispatch-shared.js +41 -30
  137. package/dist/pipeline/dispatch-stream.js +10 -4
  138. package/dist/pipeline/dispatcher.js +3 -1
  139. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  140. package/dist/pipeline/event-dispatcher.js +11 -9
  141. package/dist/pipeline/event-retention.d.ts +1 -0
  142. package/dist/pipeline/event-retention.js +7 -1
  143. package/dist/pipeline/index.d.ts +2 -0
  144. package/dist/pipeline/index.js +1 -0
  145. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  146. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  147. package/dist/pipeline/notify-scope.js +3 -1
  148. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  149. package/dist/schema-cli.js +12 -1
  150. package/dist/stack/db.d.ts +2 -2
  151. package/dist/stack/test-stack.js +2 -1
  152. package/dist/testing/index.d.ts +1 -0
  153. package/dist/testing/index.js +1 -0
  154. package/dist/testing/multipart-helper.d.ts +6 -0
  155. package/dist/testing/multipart-helper.js +1 -1
  156. package/dist/testing/zip-fixture.d.ts +1 -0
  157. package/dist/testing/zip-fixture.js +33 -0
  158. package/dist/ui-types/card-column-roles.js +4 -2
  159. package/dist/ui-types/index.d.ts +1 -1
  160. package/dist/ui-types/index.js +1 -1
  161. package/dist/upgrade-cli.js +46 -13
  162. package/package.json +4 -4
  163. package/src/changes.json +323 -0
@@ -1,4 +1,4 @@
1
- import type { TenantDb } from "../db/tenant-db.js";
1
+ import { type TenantDb } from "../db/tenant-db.js";
2
2
  import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.js";
3
3
  import type { WriteFailure } from "../errors/index.js";
4
4
  export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
@@ -24,6 +24,7 @@ export type StoreFileInput = {
24
24
  export type StoreFileDeps = {
25
25
  readonly user: SessionUser;
26
26
  readonly tenantDb: TenantDb;
27
+ readonly rowInOwnTransaction?: boolean;
27
28
  readonly registry: Registry | undefined;
28
29
  readonly defaultMaxUploadSize: string;
29
30
  readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
2
+ import { runInOwnTransaction } from "../db/tenant-db.js";
2
3
  import { isFileField, } from "../engine/types/index.js";
3
4
  import { generateId } from "../utils/index.js";
4
5
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
@@ -149,14 +150,27 @@ export async function storeFileAsFileRef(deps, input) {
149
150
  // A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
150
151
  // crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
151
152
  // Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
152
- const row = await createFileRefRow(deps, upload.uploaded);
153
- if ("failure" in row) {
153
+ // A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
154
+ const deleteUploadedBytes = async () => {
154
155
  try {
155
156
  await deps.deleteBytes(upload.uploaded.storageKey);
156
157
  }
157
158
  catch {
158
159
  // skip: the create failure is the error worth reporting; the leftover bytes stay in storage.
159
160
  }
161
+ };
162
+ let row;
163
+ try {
164
+ row = deps.rowInOwnTransaction
165
+ ? await runInOwnTransaction(deps.tenantDb, (txDb) => createFileRefRow({ user: deps.user, tenantDb: txDb }, upload.uploaded))
166
+ : await createFileRefRow(deps, upload.uploaded);
167
+ }
168
+ catch (e) {
169
+ await deleteUploadedBytes();
170
+ throw e;
171
+ }
172
+ if ("failure" in row) {
173
+ await deleteUploadedBytes();
160
174
  return { kind: "failed", failure: row.failure };
161
175
  }
162
176
  return { kind: "created", fileRef: row.fileRef };
@@ -3,16 +3,31 @@ import { reraiseAsKumikoError } from "../errors/index.js";
3
3
  import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
4
  import { getFileRefExecutor } from "./create-file-ref.js";
5
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
- function isBoundTo(row, entityName, entityId, fieldName) {
6
+ // The bound fieldName may differ from the field the id was removed from: an id
7
+ // moved A -> B stays bound to A (binding never re-targets).
8
+ function isBoundToRecord(row, entityName, entityId, fileFields) {
9
+ const boundField = row["fieldName"];
7
10
  return (row["entityType"] === entityName &&
8
11
  row["entityId"] === entityId &&
9
- row["fieldName"] === fieldName);
12
+ typeof boundField === "string" &&
13
+ isFileField(fileFields[boundField]));
14
+ }
15
+ // Across all file fields: an id moved from one field to another is still in use.
16
+ function fileRefIdsStoredInAnyField(fields, data) {
17
+ const ids = new Set();
18
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
19
+ if (!isFileField(fieldDef))
20
+ continue;
21
+ for (const fileRefId of fileRefIdsOf(data[fieldName]))
22
+ ids.add(fileRefId);
23
+ }
24
+ return ids;
10
25
  }
11
26
  // An update that drops or replaces a file id leaves the fileRef readable to
12
27
  // everyone who may read the record. Deleting it goes through the same executor
13
28
  // path as DELETE /files/:id (soft delete, bytes kept until retention), so
14
29
  // record readers get 404 afterwards. Only fileRefs bound to exactly this
15
- // record and field are touched: an id that is pending, shared or bound
30
+ // record are touched: an id that is pending, shared or bound
16
31
  // elsewhere is not this save's to dispose. The authority is the caller's write
17
32
  // right on the record (the fileRef entity declares no write access of its own).
18
33
  export async function disposeRemovedFileRefs(args) {
@@ -33,15 +48,15 @@ export async function disposeRemovedFileRefs(args) {
33
48
  return;
34
49
  const entityId = String(result.id);
35
50
  const executor = getFileRefExecutor();
51
+ const keptAnywhere = fileRefIdsStoredInAnyField(entity.fields, result.data);
36
52
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
37
53
  if (!isFileField(fieldDef) || !(fieldName in result.changes))
38
54
  continue;
39
- const kept = new Set(fileRefIdsOf(result.data[fieldName]));
40
55
  for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
41
- if (kept.has(fileRefId))
56
+ if (keptAnywhere.has(fileRefId))
42
57
  continue;
43
58
  const row = await executor.detail({ id: fileRefId }, user, db);
44
- if (row === null || !isBoundTo(row, entityName, entityId, fieldName))
59
+ if (row === null || !isBoundToRecord(row, entityName, entityId, entity.fields))
45
60
  continue;
46
61
  const removed = await executor.delete({ id: fileRefId }, user, db);
47
62
  if (!removed.isSuccess)
@@ -7,4 +7,5 @@ export type FilesFeatureOptions = {
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
9
  };
10
+ export declare const FILES_FEATURE: "files";
10
11
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -13,8 +13,9 @@ export { fileRefEntity } from "./file-ref-entity.js";
13
13
  // userData/tenantData-Extensions sichtbar und registriert die implizite
14
14
  // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
15
15
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
16
+ export const FILES_FEATURE = "files";
16
17
  export function createFilesFeature(options = {}) {
17
- return defineFeature("files", (r) => {
18
+ return defineFeature(FILES_FEATURE, (r) => {
18
19
  r.describe("Exposes the `fileRef` entity from the framework core so that uploaded files — tracked in the `file_refs` table by `createFileRoutes` — participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.");
19
20
  r.uiHints({
20
21
  displayLabel: "Files · Metadata",
@@ -62,6 +62,10 @@ export function createFileContext(resolve, tenantId) {
62
62
  assertSafeStorageKey(row.storageKey);
63
63
  return createFileHandle(row.storageKey, getProvider);
64
64
  },
65
+ listFromFileRef: async (row) => {
66
+ assertSafeStorageKey(row.storageKey);
67
+ return (await getProvider()).list(storageKeyStemPrefix(row.storageKey));
68
+ },
65
69
  list: async (prefix) => {
66
70
  if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
67
71
  throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
@@ -4,8 +4,9 @@ import { getUser } from "../api/auth-middleware.js";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { selectMany } from "../bun-db/index.js";
6
6
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
7
- import { createTenantDb } from "../db/tenant-db.js";
7
+ import { createProjectingTenantDb } from "../db/tenant-db.js";
8
8
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
9
+ import { SYSTEM_USER_ID } from "../engine/system-user.js";
9
10
  import { isFileField, isUuid, } from "../engine/types/index.js";
10
11
  import { buildContentDispositionHeader } from "./content-disposition.js";
11
12
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
@@ -32,8 +33,11 @@ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReade
32
33
  return "allow";
33
34
  if (privilegedRoles.some((role) => user.roles.includes(role)))
34
35
  return "allow";
35
- const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
36
- if (isUnclaimed &&
36
+ // System-generated documents are created without attachTo too, but belong to a person's data, not to an inbox.
37
+ const isUnclaimedUpload = fileRef.entityType === null &&
38
+ fileRef.entityId === null &&
39
+ fileRef.insertedById !== SYSTEM_USER_ID;
40
+ if (isUnclaimedUpload &&
37
41
  operation === "read" &&
38
42
  unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
39
43
  return "allow";
@@ -88,8 +92,7 @@ export function createFileRoutes(options) {
88
92
  const executor = getFileRefExecutor();
89
93
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
90
94
  const api = new Hono();
91
- const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
92
- const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
95
+ const tenantDbFor = (tenantId) => createProjectingTenantDb(db, tenantId, options.registry);
93
96
  const fileNameIsPersonal = piiSubjectFields.includes("fileName");
94
97
  function kmsContextFor() {
95
98
  return { requestId: requestContext.get()?.requestId ?? "file-routes" };
@@ -171,6 +174,7 @@ export function createFileRoutes(options) {
171
174
  const outcome = await storeFileAsFileRef({
172
175
  user,
173
176
  tenantDb: tenantDbFor(user.tenantId),
177
+ rowInOwnTransaction: true,
174
178
  registry: options.registry,
175
179
  defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
176
180
  writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
@@ -1,6 +1,6 @@
1
1
  export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.js";
2
2
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
3
- export { createFilesFeature } from "./feature.js";
3
+ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
4
4
  export type { FileContext, FileHandle } from "./file-handle.js";
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
6
6
  export { fileRefEntity } from "./file-ref-entity.js";
@@ -1,5 +1,5 @@
1
1
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
2
- export { createFilesFeature } from "./feature.js";
2
+ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
3
3
  // `createFileHandle` is an implementation detail — construct handles via
4
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
@@ -1,5 +1,5 @@
1
1
  import { deleteMany, selectMany } from "../db/query.js";
2
- import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
2
+ import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
4
  const PAGE_ORDER = [
5
5
  { col: "deletedAt", direction: "asc" },
@@ -31,7 +31,6 @@ export async function purgeExpiredFileRefsWithBytes(args) {
31
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
32
32
  return;
33
33
  }
34
- const store = { list: files.list, delete: (key) => files.ref(key).delete() };
35
34
  let cursor;
36
35
  for (;;) {
37
36
  const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
@@ -43,7 +42,7 @@ export async function purgeExpiredFileRefsWithBytes(args) {
43
42
  const purgedIds = [];
44
43
  for (const row of rows) {
45
44
  try {
46
- const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
45
+ const failedKeys = await deleteFileRefBytes(files, row, "soft-delete-cleanup:fileRef");
47
46
  if (failedKeys.length === 0) {
48
47
  purgedIds.push(row.id);
49
48
  continue;
@@ -13,6 +13,41 @@ const UNAMBIGUOUS_SNIFFED_TYPES = new Set([
13
13
  ]);
14
14
  const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04];
15
15
  const XML_PROBE_BYTES = 512;
16
+ // Root elements of markup that starts with `<` but is not a document the XML
17
+ // ingest providers should receive: HTML (documents and fragments), SVG.
18
+ const NON_XML_ROOT_ELEMENTS = new Set([
19
+ "html",
20
+ "head",
21
+ "body",
22
+ "script",
23
+ "style",
24
+ "meta",
25
+ "link",
26
+ "title",
27
+ "svg",
28
+ "div",
29
+ "span",
30
+ "p",
31
+ "a",
32
+ "img",
33
+ "br",
34
+ "hr",
35
+ "table",
36
+ "ul",
37
+ "ol",
38
+ "li",
39
+ "h1",
40
+ "h2",
41
+ "h3",
42
+ "h4",
43
+ "h5",
44
+ "h6",
45
+ "section",
46
+ "article",
47
+ "font",
48
+ "center",
49
+ ]);
50
+ const ROOT_ELEMENT_PATTERN = /^<(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/;
16
51
  const EXTENSION_FALLBACK = {
17
52
  xml: "application/xml",
18
53
  pdf: "application/pdf",
@@ -38,8 +73,8 @@ function hasPrefix(bytes, prefix) {
38
73
  return prefix.every((byte, i) => bytes[i] === byte);
39
74
  }
40
75
  // Textual start only: a NUL in the probe means binary (or UTF-16, which is
41
- // deliberately not detected). HTML also starts with `<` and must not be
42
- // relabelled as XML.
76
+ // deliberately not detected). Without an XML declaration the root element
77
+ // decides: HTML/SVG roots (also namespace-prefixed) are not relabelled as XML.
43
78
  function looksLikeXml(bytes) {
44
79
  const probe = bytes.subarray(0, XML_PROBE_BYTES);
45
80
  if (probe.includes(0))
@@ -47,9 +82,10 @@ function looksLikeXml(bytes) {
47
82
  const text = new TextDecoder().decode(probe).replace(/^/, "").trimStart();
48
83
  if (text.startsWith("<?xml"))
49
84
  return true;
50
- if (!/^<[A-Za-z_]/.test(text))
85
+ const root = ROOT_ELEMENT_PATTERN.exec(text)?.[1];
86
+ if (root === undefined)
51
87
  return false;
52
- return !/^<(html|head|body|script)\b/i.test(text);
88
+ return !NON_XML_ROOT_ELEMENTS.has(root.toLowerCase());
53
89
  }
54
90
  function extensionOf(filename) {
55
91
  const dot = filename.lastIndexOf(".");
@@ -12,6 +12,7 @@
12
12
  // into createApp / setupTestStack alongside their domain features.
13
13
  import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect.js";
14
14
  import { entityEventName, executeRawQueryRead } from "../db/index.js";
15
+ import { toPostgresArrayLiteral } from "../db/queries/postgres-array-literal.js";
15
16
  import { incrementCounter } from "../db/query.js";
16
17
  import { defineFeature, qn, toKebab } from "../engine/index.js";
17
18
  import { SHARED_INSTANCE_SENTINEL, selectConsumerCursorForUpdate, } from "../pipeline/index.js";
@@ -141,12 +142,12 @@ export async function transferTenantStorageUsage(args) {
141
142
  if (!cursor)
142
143
  return;
143
144
  const rows = await executeRawQueryRead(db, `SELECT "id"::text AS id, "type", "payload"::text AS payload FROM kumiko_events
144
- WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3)
145
- AND "type" = ANY($4) AND "id" <= $5`, [
145
+ WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3::uuid[])
146
+ AND "type" = ANY($4::text[]) AND "id" <= $5`, [
146
147
  sourceTenantId,
147
148
  FILE_REF_AGGREGATE_TYPE,
148
- fileRefIds,
149
- TRACKED_FILE_REF_EVENT_TYPES,
149
+ toPostgresArrayLiteral(fileRefIds),
150
+ toPostgresArrayLiteral(TRACKED_FILE_REF_EVENT_TYPES),
150
151
  cursor.lastProcessedEventId,
151
152
  ]);
152
153
  let bytes = 0;
@@ -1,3 +1,4 @@
1
+ import { readZipEntryNames } from "./zip-entry-names.js";
1
2
  // Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
2
3
  // a module that calls createEntity at load time.
3
4
  export const FILE_REF_ENTITY_NAME = "fileRef";
@@ -111,13 +112,9 @@ const MAGIC_BYTE_SIGNATURES = [
111
112
  // so a byte search finds the docx part directory without unzipping. The main
112
113
  // part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
113
114
  const DOCX_PART_PREFIX = "word/";
114
- function containsAscii(bytes, ascii) {
115
- const first = ascii.charCodeAt(0);
116
- for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
117
- if (startsWithAscii(bytes, ascii, i))
118
- return true;
119
- }
120
- return false;
115
+ function hasDocxPart(zipBytes) {
116
+ const names = readZipEntryNames(zipBytes);
117
+ return names?.some((name) => name.startsWith(DOCX_PART_PREFIX)) ?? false;
121
118
  }
122
119
  function startsWithBytes(bytes, signature) {
123
120
  if (bytes.length < signature.length)
@@ -237,7 +234,7 @@ export function validateFileContent(fileName, content, accept) {
237
234
  return { kind: "ok" };
238
235
  const sniffed = sniffMimeType(content);
239
236
  // sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
240
- if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
237
+ if (sniffed === DOCX_MIME_TYPE && !hasDocxPart(content)) {
241
238
  return {
242
239
  kind: "rejected",
243
240
  error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
@@ -0,0 +1 @@
1
+ export declare function readZipEntryNames(bytes: Uint8Array): readonly string[] | null;
@@ -0,0 +1,46 @@
1
+ const EOCD_SIGNATURE = 0x06054b50;
2
+ const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
3
+ const EOCD_MIN_LENGTH = 22;
4
+ const EOCD_MAX_COMMENT_LENGTH = 0xffff;
5
+ const CENTRAL_HEADER_FIXED_LENGTH = 46;
6
+ // Entry names from the central directory, which (unlike local headers) always
7
+ // carries sizes and names even for streamed ZIPs written with data descriptors.
8
+ // Returns null when the container is not a readable (non-ZIP64) ZIP.
9
+ export function readZipEntryNames(bytes) {
10
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
11
+ const eocdOffset = findEndOfCentralDirectory(view);
12
+ if (eocdOffset === null)
13
+ return null;
14
+ const entryCount = view.getUint16(eocdOffset + 10, true);
15
+ let offset = view.getUint32(eocdOffset + 16, true);
16
+ const decoder = new TextDecoder();
17
+ const names = [];
18
+ for (let i = 0; i < entryCount; i++) {
19
+ if (offset + CENTRAL_HEADER_FIXED_LENGTH > view.byteLength)
20
+ return null;
21
+ if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE)
22
+ return null;
23
+ const nameLength = view.getUint16(offset + 28, true);
24
+ const extraLength = view.getUint16(offset + 30, true);
25
+ const commentLength = view.getUint16(offset + 32, true);
26
+ const nameStart = offset + CENTRAL_HEADER_FIXED_LENGTH;
27
+ if (nameStart + nameLength > view.byteLength)
28
+ return null;
29
+ names.push(decoder.decode(bytes.subarray(nameStart, nameStart + nameLength)));
30
+ offset = nameStart + nameLength + extraLength + commentLength;
31
+ }
32
+ return names;
33
+ }
34
+ function findEndOfCentralDirectory(view) {
35
+ const lowest = Math.max(0, view.byteLength - EOCD_MIN_LENGTH - EOCD_MAX_COMMENT_LENGTH);
36
+ for (let offset = view.byteLength - EOCD_MIN_LENGTH; offset >= lowest; offset--) {
37
+ if (view.getUint32(offset, true) !== EOCD_SIGNATURE)
38
+ continue;
39
+ // A signature inside the archive comment is skipped: the real record's
40
+ // comment length reaches exactly to the end of the buffer.
41
+ const commentLength = view.getUint16(offset + 20, true);
42
+ if (offset + EOCD_MIN_LENGTH + commentLength === view.byteLength)
43
+ return offset;
44
+ }
45
+ return null;
46
+ }
@@ -20,6 +20,7 @@ export type JobMeta = {
20
20
  payload?: string | undefined;
21
21
  attempt?: number | undefined;
22
22
  priority?: number | undefined;
23
+ jobId?: string | undefined;
23
24
  tenantVisibleRun?: {
24
25
  readonly tenantId: string;
25
26
  readonly subject: string | null;
@@ -60,6 +61,7 @@ export type JobRunnerOptions = {
60
61
  consumerLane?: JobRunIn | undefined;
61
62
  queueNamePrefix?: string | undefined;
62
63
  bootRedisTimeoutMs?: number | undefined;
64
+ schedulerSeenRefreshMs?: number | undefined;
63
65
  jobRetention?: {
64
66
  completedAgeSec?: number | undefined;
65
67
  failedAgeSec?: number | undefined;