@cosmicdrift/kumiko-framework 0.364.0 → 0.365.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +3 -3
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +21 -4
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +70 -47
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +116 -33
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +4 -4
- package/src/changes.json +305 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { ConflictError } from "./classes.js";
|
|
2
3
|
import { FrameworkReasons } from "./reasons.js";
|
|
3
4
|
export function subjectErasedConflict(cause) {
|
|
@@ -7,3 +8,8 @@ export function subjectErasedConflict(cause) {
|
|
|
7
8
|
...(cause instanceof Error && { cause }),
|
|
8
9
|
});
|
|
9
10
|
}
|
|
11
|
+
export function isSubjectErasedConflict(e) {
|
|
12
|
+
return (e instanceof ConflictError &&
|
|
13
|
+
isPlainObject(e.details) &&
|
|
14
|
+
e.details["reason"] === FrameworkReasons.subjectErased);
|
|
15
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Guard-Script `scripts/guard-admin-api.ts` blockt Aufrufe aus App-Code —
|
|
8
8
|
// Allowlist: samples/*/migration/, scripts/migrations/, die Definition
|
|
9
9
|
// selbst, das Guard-Script selbst.
|
|
10
|
+
import { instantToTimestamptzParam } from "../bun-db/query.js";
|
|
10
11
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
11
12
|
import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
|
|
12
13
|
import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
|
|
@@ -53,7 +54,7 @@ function rawEventParams(event, newVersion, eventVersion) {
|
|
|
53
54
|
eventVersion,
|
|
54
55
|
payload: event.payload,
|
|
55
56
|
metadata: event.metadata,
|
|
56
|
-
createdAt: event.createdAt
|
|
57
|
+
createdAt: instantToTimestamptzParam(event.createdAt),
|
|
57
58
|
createdBy: event.createdBy,
|
|
58
59
|
};
|
|
59
60
|
}
|
|
@@ -88,7 +89,7 @@ export async function appendRawBatch(runner, events) {
|
|
|
88
89
|
const newVersion = e.expectedVersion + 1;
|
|
89
90
|
const eventVersion = e.eventVersion ?? 1;
|
|
90
91
|
const baseIdx = params.length;
|
|
91
|
-
params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, e.createdAt
|
|
92
|
+
params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, instantToTimestamptzParam(e.createdAt), e.createdBy);
|
|
92
93
|
return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
|
|
93
94
|
});
|
|
94
95
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
1
2
|
export declare class VersionConflictError extends Error {
|
|
2
3
|
readonly aggregateId: string;
|
|
3
4
|
readonly expectedVersion: number;
|
|
@@ -8,6 +9,14 @@ export declare class IdempotentAppendConflictError extends Error {
|
|
|
8
9
|
readonly idempotencyKey: string;
|
|
9
10
|
constructor(tenantId: string, idempotencyKey: string);
|
|
10
11
|
}
|
|
12
|
+
export declare class SeedTimeOrderError extends Error {
|
|
13
|
+
readonly aggregateType: string;
|
|
14
|
+
readonly aggregateId: string;
|
|
15
|
+
readonly version: number;
|
|
16
|
+
readonly seedCreatedAt: Temporal.Instant;
|
|
17
|
+
readonly predecessorCreatedAt: Temporal.Instant;
|
|
18
|
+
constructor(aggregateType: string, aggregateId: string, version: number, seedCreatedAt: Temporal.Instant, predecessorCreatedAt: Temporal.Instant);
|
|
19
|
+
}
|
|
11
20
|
export declare class ArchivedStreamError extends Error {
|
|
12
21
|
readonly tenantId: string;
|
|
13
22
|
readonly aggregateId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
1
2
|
// Failure modes of the event-store's append() path. Surfaced as typed
|
|
2
3
|
// errors so the executor layer can map them to the framework's
|
|
3
4
|
// WriteResult error contract (version_conflict).
|
|
@@ -31,6 +32,25 @@ export class IdempotentAppendConflictError extends Error {
|
|
|
31
32
|
this.idempotencyKey = idempotencyKey;
|
|
32
33
|
}
|
|
33
34
|
}
|
|
35
|
+
// Thrown when a back-dated seed write would put a stream's next version
|
|
36
|
+
// before its predecessor in time. loadAggregateAsOf and projection rebuilds
|
|
37
|
+
// order by created_at, so such a stream would read and replay wrongly.
|
|
38
|
+
export class SeedTimeOrderError extends Error {
|
|
39
|
+
aggregateType;
|
|
40
|
+
aggregateId;
|
|
41
|
+
version;
|
|
42
|
+
seedCreatedAt;
|
|
43
|
+
predecessorCreatedAt;
|
|
44
|
+
constructor(aggregateType, aggregateId, version, seedCreatedAt, predecessorCreatedAt) {
|
|
45
|
+
super(`Seed write for ${aggregateType} ${aggregateId} v${version} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessorCreatedAt.toString()}). Write a stream's events in ascending time.`);
|
|
46
|
+
this.name = "SeedTimeOrderError";
|
|
47
|
+
this.aggregateType = aggregateType;
|
|
48
|
+
this.aggregateId = aggregateId;
|
|
49
|
+
this.version = version;
|
|
50
|
+
this.seedCreatedAt = seedCreatedAt;
|
|
51
|
+
this.predecessorCreatedAt = predecessorCreatedAt;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
34
54
|
// Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
|
|
35
55
|
// are read-only — restoreStream() makes them writable again. The archive
|
|
36
56
|
// state is not carried on the events themselves; it lives on the sparse
|
|
@@ -20,6 +20,12 @@ export declare function loadAggregate(db: DbRunner, aggregateId: string, tenantI
|
|
|
20
20
|
readonly includeArchived?: boolean;
|
|
21
21
|
readonly aggregateType?: string;
|
|
22
22
|
}): Promise<readonly StoredEvent[]>;
|
|
23
|
+
export declare function loadAggregateForWriting(db: DbRunner, aggregateId: string, tenantId: TenantId, options: {
|
|
24
|
+
readonly aggregateType: string;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
readonly events: readonly StoredEvent[];
|
|
27
|
+
readonly streamVersion: number;
|
|
28
|
+
}>;
|
|
23
29
|
export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, tenantId: TenantId, asOf: Temporal.Instant, options?: {
|
|
24
30
|
readonly includeArchived?: boolean;
|
|
25
31
|
}): Promise<readonly StoredEvent[]>;
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
|
+
import { instantToTimestamptzParam } from "../bun-db/query.js";
|
|
5
6
|
import { encryptEventPayloadPii } from "../crypto/event-pii.js";
|
|
6
7
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
7
8
|
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
|
|
8
9
|
import { insertOne, selectMany } from "../db/query.js";
|
|
9
10
|
import { isStreamArchived } from "./archive.js";
|
|
10
|
-
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
11
|
+
import { IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
|
|
11
12
|
import { eventsTable } from "./events-schema.js";
|
|
12
13
|
import { toStoredEvent } from "./row-to-stored-event.js";
|
|
13
14
|
import { currentSeedCreatedAt } from "./seed-clock.js";
|
|
@@ -44,14 +45,16 @@ export async function append(db, event) {
|
|
|
44
45
|
});
|
|
45
46
|
const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
|
|
46
47
|
const seedCreatedAt = currentSeedCreatedAt();
|
|
47
|
-
if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
|
|
48
|
-
await assertSeedTimeNotBeforePredecessor(db, toStore, seedCreatedAt);
|
|
49
|
-
}
|
|
50
48
|
const newVersion = toStore.expectedVersion + 1;
|
|
51
49
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
52
50
|
try {
|
|
53
51
|
const row = await runAtomicEventWrite(db, async (runner) => {
|
|
54
52
|
await claimXactId(runner);
|
|
53
|
+
// Inside the write transaction so a concurrent live append cannot slip
|
|
54
|
+
// between the check and the insert.
|
|
55
|
+
if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
|
|
56
|
+
await assertSeedTimeNotBeforePredecessor(runner, toStore, seedCreatedAt);
|
|
57
|
+
}
|
|
55
58
|
const inserted = toStore.expectedVersion === 0
|
|
56
59
|
? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
|
|
57
60
|
: await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
|
|
@@ -105,7 +108,7 @@ async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
|
|
|
105
108
|
version: event.expectedVersion,
|
|
106
109
|
});
|
|
107
110
|
if (predecessor && predecessor.createdAt.epochMilliseconds > seedCreatedAt.epochMilliseconds) {
|
|
108
|
-
throw new
|
|
111
|
+
throw new SeedTimeOrderError(event.aggregateType, event.aggregateId, event.expectedVersion + 1, seedCreatedAt, predecessor.createdAt);
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
|
|
@@ -141,7 +144,7 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
|
|
|
141
144
|
metadata: event.metadata,
|
|
142
145
|
createdBy: event.metadata.userId,
|
|
143
146
|
expectedVersion: event.expectedVersion,
|
|
144
|
-
createdAt: seedCreatedAt
|
|
147
|
+
createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
|
|
145
148
|
});
|
|
146
149
|
if (!row)
|
|
147
150
|
throw new VersionConflictError(event.aggregateId, event.expectedVersion);
|
|
@@ -201,6 +204,25 @@ export async function loadAggregate(db, aggregateId, tenantId, options) {
|
|
|
201
204
|
: { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
|
|
202
205
|
return rows.map(toStoredEvent);
|
|
203
206
|
}
|
|
207
|
+
// Events and the stream's latest version from ONE read, so both come from the same
|
|
208
|
+
// snapshot: a commit landing between two separate statements would leave the caller
|
|
209
|
+
// pinning a version its loaded events never covered. The aggregateType filter is applied
|
|
210
|
+
// after the read because a foreign type sharing the aggregateId still occupies versions
|
|
211
|
+
// the next append has to stack on. Archived streams load empty but keep their real version.
|
|
212
|
+
export async function loadAggregateForWriting(db, aggregateId, tenantId, options) {
|
|
213
|
+
if (options.aggregateType === "") {
|
|
214
|
+
throw new Error("loadAggregateForWriting: aggregateType must be a non-empty string");
|
|
215
|
+
}
|
|
216
|
+
if (await isStreamArchived(db, tenantId, aggregateId)) {
|
|
217
|
+
return { events: [], streamVersion: await getStreamVersion(db, aggregateId, tenantId) };
|
|
218
|
+
}
|
|
219
|
+
const rows = await selectMany(db, eventsTable, { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
|
|
220
|
+
const all = rows.map(toStoredEvent);
|
|
221
|
+
return {
|
|
222
|
+
events: all.filter((e) => e.aggregateType === options.aggregateType),
|
|
223
|
+
streamVersion: all.at(-1)?.version ?? 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
204
226
|
// Load events up to a point in time. Used for asOf queries that reconstruct
|
|
205
227
|
// historical state. Includes events whose created_at is <= asOf. Same
|
|
206
228
|
// archive semantics as loadAggregate.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
|
|
3
3
|
export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
|
-
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
4
|
+
export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
|
|
5
5
|
export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
|
|
6
6
|
export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
7
7
|
export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
3
3
|
export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
|
-
export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
|
|
4
|
+
export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
|
|
5
5
|
export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
|
|
6
6
|
export { createEventsTable, eventsTable } from "./events-schema.js";
|
|
7
7
|
export { appendProvenanceEvent } from "./provenance-append.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
56
|
+
if (keptAnywhere.has(fileRefId))
|
|
42
57
|
continue;
|
|
43
58
|
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
44
|
-
if (row === null || !
|
|
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)
|
package/dist/files/feature.d.ts
CHANGED
|
@@ -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;
|
package/dist/files/feature.js
CHANGED
|
@@ -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(
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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),
|
package/dist/files/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/files/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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).
|
|
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
|
-
|
|
85
|
+
const root = ROOT_ELEMENT_PATTERN.exec(text)?.[1];
|
|
86
|
+
if (root === undefined)
|
|
51
87
|
return false;
|
|
52
|
-
return
|
|
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;
|
package/dist/files/types.js
CHANGED
|
@@ -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
|
|
115
|
-
const
|
|
116
|
-
|
|
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 && !
|
|
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
|
+
}
|
|
@@ -60,6 +60,7 @@ export type JobRunnerOptions = {
|
|
|
60
60
|
consumerLane?: JobRunIn | undefined;
|
|
61
61
|
queueNamePrefix?: string | undefined;
|
|
62
62
|
bootRedisTimeoutMs?: number | undefined;
|
|
63
|
+
schedulerSeenRefreshMs?: number | undefined;
|
|
63
64
|
jobRetention?: {
|
|
64
65
|
completedAgeSec?: number | undefined;
|
|
65
66
|
failedAgeSec?: number | undefined;
|