@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/files-post-processing. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# files-post-processing
|
|
4
|
+
|
|
5
|
+
The three ways to generate an image variant: declarative field variants, imperative ctx.derivatives.variant() with region-blur, and the public route behind a default-deny predicate.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Files Post Processing
|
|
10
|
+
|
|
11
|
+
File upload plus post-processing hooks.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Declarative `variants` on `createImageField` (file-derivatives)
|
|
16
|
+
- Imperative `ctx.derivatives.variant()` path and the public variant route (`EXT_DERIVATIVE_PUBLIC_PREDICATE`)
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd samples/recipes/files-post-processing
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Files Post-Processing Sample — Integration Test
|
|
37
|
+
//
|
|
38
|
+
// Proves each of the three variant entry points end-to-end, through real
|
|
39
|
+
// HTTP calls and a real sharp render (no identity-transform, no mocks):
|
|
40
|
+
//
|
|
41
|
+
// 1. declarative — GET /api/files/:id/variant/:name resolves a field's
|
|
42
|
+
// declared variant.
|
|
43
|
+
// 2. imperative — the `set-cover` write-handler calls
|
|
44
|
+
// ctx.derivatives.variant() directly, with a region-blur.
|
|
45
|
+
// 3. public route — GET /media/:fileRefId/:variant, default-deny until a
|
|
46
|
+
// photo is explicitly published.
|
|
47
|
+
|
|
48
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
49
|
+
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
50
|
+
import { derivativesSharpFeature } from "@cosmicdrift/kumiko-bundled-features/derivatives-sharp";
|
|
51
|
+
import { createFileDerivativesFeature } from "@cosmicdrift/kumiko-bundled-features/file-derivatives";
|
|
52
|
+
import { fileFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
53
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
54
|
+
import { entityEventName } from "@cosmicdrift/kumiko-framework/db";
|
|
55
|
+
import {
|
|
56
|
+
createFilesFeature,
|
|
57
|
+
createInMemoryFileProvider,
|
|
58
|
+
type InMemoryFileProvider,
|
|
59
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
60
|
+
import {
|
|
61
|
+
createTestUser,
|
|
62
|
+
setupTestStack,
|
|
63
|
+
type TestStack,
|
|
64
|
+
testTenantId,
|
|
65
|
+
unsafeCreateEntityTable,
|
|
66
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
67
|
+
import {
|
|
68
|
+
buildMultipartBody,
|
|
69
|
+
patchFileInstanceofForBunTest,
|
|
70
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
71
|
+
import sharp from "sharp";
|
|
72
|
+
import { coverOrder, filesPostProcessingFeature, photoEntity, publishedPhotoIds } from "../feature";
|
|
73
|
+
|
|
74
|
+
const HOST = "photos.example.com";
|
|
75
|
+
const HOST_B = "photos-b.example.com";
|
|
76
|
+
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
77
|
+
|
|
78
|
+
let stack: TestStack;
|
|
79
|
+
let provider: InMemoryFileProvider;
|
|
80
|
+
|
|
81
|
+
const tenantId = testTenantId(1);
|
|
82
|
+
const tenantIdB = testTenantId(2);
|
|
83
|
+
const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
|
|
84
|
+
const userB = createTestUser({ id: 2, tenantId: tenantIdB, roles: ["Admin"] });
|
|
85
|
+
|
|
86
|
+
async function jpegBytes(width: number, height: number): Promise<Uint8Array> {
|
|
87
|
+
return sharp({
|
|
88
|
+
create: { width, height, channels: 3, background: { r: 90, g: 140, b: 200 } },
|
|
89
|
+
})
|
|
90
|
+
.jpeg()
|
|
91
|
+
.toBuffer();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
patchFileInstanceofForBunTest();
|
|
96
|
+
provider = createInMemoryFileProvider();
|
|
97
|
+
stack = await setupTestStack({
|
|
98
|
+
features: [
|
|
99
|
+
createConfigFeature(),
|
|
100
|
+
fileFoundationFeature,
|
|
101
|
+
createFilesFeature(),
|
|
102
|
+
createFileDerivativesFeature({
|
|
103
|
+
resolveApexTenant: (host) => {
|
|
104
|
+
if (host === HOST) return tenantId;
|
|
105
|
+
if (host === HOST_B) return tenantIdB;
|
|
106
|
+
return null;
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
derivativesSharpFeature,
|
|
110
|
+
filesPostProcessingFeature,
|
|
111
|
+
],
|
|
112
|
+
files: { storageProvider: provider },
|
|
113
|
+
});
|
|
114
|
+
await unsafeCreateEntityTable(stack.db, photoEntity, "photo");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
async function createPhoto(asUser: typeof user = user): Promise<string> {
|
|
118
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
119
|
+
"files-post-processing:write:photo:create",
|
|
120
|
+
{},
|
|
121
|
+
asUser,
|
|
122
|
+
);
|
|
123
|
+
return created.id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await stack.cleanup();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
provider.clear();
|
|
132
|
+
publishedPhotoIds.clear();
|
|
133
|
+
coverOrder.length = 0;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
async function uploadPhoto(
|
|
137
|
+
attach: { entityType: string; entityId: string; fieldName: string } | null,
|
|
138
|
+
asUser: typeof user = user,
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
const token = await stack.jwt.sign(asUser);
|
|
141
|
+
const bytes = await jpegBytes(400, 300);
|
|
142
|
+
const fd = new FormData();
|
|
143
|
+
fd.append("file", new File([Buffer.from(bytes)], "photo.jpg", { type: "image/jpeg" }));
|
|
144
|
+
if (attach) {
|
|
145
|
+
fd.append("entityType", attach.entityType);
|
|
146
|
+
fd.append("entityId", attach.entityId);
|
|
147
|
+
fd.append("fieldName", attach.fieldName);
|
|
148
|
+
}
|
|
149
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
150
|
+
const res = await stack.app.request("/api/files", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
153
|
+
body,
|
|
154
|
+
});
|
|
155
|
+
expect(res.status).toBe(201);
|
|
156
|
+
const json = (await res.json()) as { id: string };
|
|
157
|
+
return json.id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe("entry point 1 — declarative field variant", () => {
|
|
161
|
+
test("GET /api/files/:id/variant/:name renders a real resized webp", async () => {
|
|
162
|
+
const photoId = await createPhoto();
|
|
163
|
+
const fileId = await uploadPhoto({
|
|
164
|
+
entityType: "photo",
|
|
165
|
+
entityId: photoId,
|
|
166
|
+
fieldName: "original",
|
|
167
|
+
});
|
|
168
|
+
const token = await stack.jwt.sign(user);
|
|
169
|
+
|
|
170
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
171
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
176
|
+
const meta = await sharp(Buffer.from(await res.arrayBuffer())).metadata();
|
|
177
|
+
expect(meta.format).toBe("webp");
|
|
178
|
+
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(200);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("entry point 2 — imperative ctx.derivatives.variant() with region-blur", () => {
|
|
183
|
+
test("set-cover derives a variant with no field declaration involved, and tracks order", async () => {
|
|
184
|
+
const fileId = await uploadPhoto(null);
|
|
185
|
+
|
|
186
|
+
const result = await stack.http.writeOk<{ fileRefId: string; storageKey: string }>(
|
|
187
|
+
"files-post-processing:write:set-cover",
|
|
188
|
+
{ fileRefId: fileId, blurRegion: { x: 0, y: 0, width: 50, height: 50 } },
|
|
189
|
+
user,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(coverOrder).toEqual([fileId]);
|
|
193
|
+
const bytes = await provider.read(result.storageKey);
|
|
194
|
+
const meta = await sharp(Buffer.from(bytes)).metadata();
|
|
195
|
+
expect(meta.format).toBe("webp");
|
|
196
|
+
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(800);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("entry point 3 — public route, default-deny", () => {
|
|
201
|
+
test("unpublished photo → 404", async () => {
|
|
202
|
+
const photoId = await createPhoto();
|
|
203
|
+
const fileId = await uploadPhoto({
|
|
204
|
+
entityType: "photo",
|
|
205
|
+
entityId: photoId,
|
|
206
|
+
fieldName: "original",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const res = await stack.app.request(`http://${HOST}/media/${fileId}/thumb`);
|
|
210
|
+
|
|
211
|
+
expect(res.status).toBe(404);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("photo published via the write-handler → 200 with rendered bytes", async () => {
|
|
215
|
+
const photoId = await createPhoto();
|
|
216
|
+
const fileId = await uploadPhoto({
|
|
217
|
+
entityType: "photo",
|
|
218
|
+
entityId: photoId,
|
|
219
|
+
fieldName: "original",
|
|
220
|
+
});
|
|
221
|
+
await stack.http.writeOk("files-post-processing:write:publish", { entityId: photoId }, user);
|
|
222
|
+
|
|
223
|
+
const res = await stack.app.request(`http://${HOST}/media/${fileId}/thumb`);
|
|
224
|
+
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("publishing tenant A's photo does not publish tenant B's photo (#1983/1)", async () => {
|
|
230
|
+
const photoIdA = await createPhoto(user);
|
|
231
|
+
const photoIdB = await createPhoto(userB);
|
|
232
|
+
const fileIdA = await uploadPhoto(
|
|
233
|
+
{ entityType: "photo", entityId: photoIdA, fieldName: "original" },
|
|
234
|
+
user,
|
|
235
|
+
);
|
|
236
|
+
const fileIdB = await uploadPhoto(
|
|
237
|
+
{ entityType: "photo", entityId: photoIdB, fieldName: "original" },
|
|
238
|
+
userB,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// publish accepts any entityId: tenant A names B's photo too, which must not publish it.
|
|
242
|
+
await stack.http.writeOk("files-post-processing:write:publish", { entityId: photoIdB }, user);
|
|
243
|
+
await stack.http.writeOk("files-post-processing:write:publish", { entityId: photoIdA }, user);
|
|
244
|
+
|
|
245
|
+
const resA = await stack.app.request(`http://${HOST}/media/${fileIdA}/thumb`);
|
|
246
|
+
const resB = await stack.app.request(`http://${HOST_B}/media/${fileIdB}/thumb`);
|
|
247
|
+
|
|
248
|
+
expect(resA.status).toBe(200);
|
|
249
|
+
expect(resB.status).toBe(404);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("event emission contract", () => {
|
|
254
|
+
test("fileRef.created event payload carries storageKey, not the binary", async () => {
|
|
255
|
+
const photoId = await createPhoto();
|
|
256
|
+
const fileId = await uploadPhoto({
|
|
257
|
+
entityType: "photo",
|
|
258
|
+
entityId: photoId,
|
|
259
|
+
fieldName: "original",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Inspect the raw event row. The derivatives path already proves the
|
|
263
|
+
// payload's pointer-shape by reading through it, but this test guards
|
|
264
|
+
// the contract directly: no one accidentally adds a binary field later
|
|
265
|
+
// and explodes the events table.
|
|
266
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
267
|
+
`SELECT payload FROM kumiko_events WHERE aggregate_id = $1 AND type = $2`,
|
|
268
|
+
[fileId, FILE_REF_CREATED],
|
|
269
|
+
);
|
|
270
|
+
expect(rows.length).toBe(1);
|
|
271
|
+
const row = rows[0] as { payload: Record<string, unknown> };
|
|
272
|
+
const payload = row.payload;
|
|
273
|
+
expect(typeof payload["storageKey"]).toBe("string");
|
|
274
|
+
expect(payload["mimeType"]).toBe("image/jpeg");
|
|
275
|
+
expect(payload["data"]).toBeUndefined();
|
|
276
|
+
expect(payload["binary"]).toBeUndefined();
|
|
277
|
+
expect(payload["bytes"]).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### src/feature.ts
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
// Files Post-Processing Sample
|
|
286
|
+
//
|
|
287
|
+
// The three ways to get an image variant out of a FileRef — see
|
|
288
|
+
// docs/reference/core-files.md for the full write-up of the contract and
|
|
289
|
+
// security model behind each.
|
|
290
|
+
//
|
|
291
|
+
// 1. Declarative — `original`'s `variants` map on the `photo` entity. The
|
|
292
|
+
// normal case (profile picture, hero image, floor plan): the framework
|
|
293
|
+
// resolves + caches the variant behind the auth-gated
|
|
294
|
+
// `GET /api/files/:id/variant/:name` route, no handler code needed.
|
|
295
|
+
// 2. Imperative — the `set-cover` write-handler calls
|
|
296
|
+
// `ctx.derivatives.variant()` directly. This is for apps that track
|
|
297
|
+
// images as their own records (ordering, a chosen cover image) and for
|
|
298
|
+
// region-blur, whose coordinates are runtime data that can't live in a
|
|
299
|
+
// static field declaration.
|
|
300
|
+
// 3. The public route with a predicate — the part people get wrong. Only
|
|
301
|
+
// entityIds explicitly marked published resolve; everything else 404s,
|
|
302
|
+
// indistinguishable from "doesn't exist".
|
|
303
|
+
|
|
304
|
+
import type { DerivativePublicPredicateArgs } from "@cosmicdrift/kumiko-bundled-features/file-derivatives";
|
|
305
|
+
import {
|
|
306
|
+
createEntity,
|
|
307
|
+
createImageField,
|
|
308
|
+
defineEntityCreateHandler,
|
|
309
|
+
defineEntityDetailHandler,
|
|
310
|
+
defineEntityUpdateHandler,
|
|
311
|
+
defineFeature,
|
|
312
|
+
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
313
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
314
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
315
|
+
import { z } from "zod";
|
|
316
|
+
|
|
317
|
+
// The `variants` map IS the whitelist the auth-gated variant route resolves
|
|
318
|
+
// against — no separate registration step. Named `thumb`/`hero` on purpose:
|
|
319
|
+
// the public route's fixed presets (thumb/card/hero/full, entry point 3)
|
|
320
|
+
// share the name `thumb` but are a completely separate spec — same label,
|
|
321
|
+
// different whitelist, different actual pixels.
|
|
322
|
+
export const photoEntity = createEntity({
|
|
323
|
+
table: "files_post_processing_photos",
|
|
324
|
+
fields: {
|
|
325
|
+
original: createImageField({
|
|
326
|
+
variants: {
|
|
327
|
+
thumb: { maxEdge: 200, fit: "cover", format: "webp" },
|
|
328
|
+
hero: { maxEdge: 1200, fit: "inside", format: "webp" },
|
|
329
|
+
},
|
|
330
|
+
}),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// A real app would check a `published` column via the entity's read model.
|
|
335
|
+
// This recipe tracks it in memory to stay a single file — the predicate
|
|
336
|
+
// below is what matters, not where the flag is stored. Keyed by
|
|
337
|
+
// `${tenantId}:${entityId}`, not entityId alone — two tenants can each have
|
|
338
|
+
// their own entityId "p3", and an entityId-only key would leak tenant A's
|
|
339
|
+
// publish state onto tenant B's photo.
|
|
340
|
+
export const publishedPhotoIds = new Set<string>();
|
|
341
|
+
|
|
342
|
+
// Exported so the integration test can assert the imperative path ran.
|
|
343
|
+
export const coverOrder: string[] = [];
|
|
344
|
+
|
|
345
|
+
export const filesPostProcessingFeature = defineFeature("files-post-processing", (r) => {
|
|
346
|
+
r.requires("file-derivatives");
|
|
347
|
+
r.entity("photo", photoEntity);
|
|
348
|
+
|
|
349
|
+
// Uploading a file to a photo's field needs the same rights as saving the
|
|
350
|
+
// photo, so the photo must exist and the uploader needs update access.
|
|
351
|
+
const photoAccess = { access: { roles: ["Admin"] } } as const;
|
|
352
|
+
r.writeHandler(defineEntityCreateHandler("photo", photoEntity, photoAccess));
|
|
353
|
+
r.writeHandler(defineEntityUpdateHandler("photo", photoEntity, photoAccess));
|
|
354
|
+
r.queryHandler(defineEntityDetailHandler("photo", photoEntity, photoAccess));
|
|
355
|
+
|
|
356
|
+
// Entry point 2 — imperative. A real app would gate this behind real
|
|
357
|
+
// roles; `openToAll` keeps the recipe's HTTP wiring to one line.
|
|
358
|
+
r.writeHandler(
|
|
359
|
+
"set-cover",
|
|
360
|
+
z.object({
|
|
361
|
+
fileRefId: z.string(),
|
|
362
|
+
// Relative to the ORIGINAL image's pixels, clamped before resize —
|
|
363
|
+
// runtime data (e.g. a detected face box), which is exactly why this
|
|
364
|
+
// can only be expressed imperatively, not in a static field def.
|
|
365
|
+
blurRegion: z
|
|
366
|
+
.object({
|
|
367
|
+
x: z.number().nonnegative(),
|
|
368
|
+
y: z.number().nonnegative(),
|
|
369
|
+
width: z.number().positive(),
|
|
370
|
+
height: z.number().positive(),
|
|
371
|
+
})
|
|
372
|
+
.optional(),
|
|
373
|
+
}),
|
|
374
|
+
async (event, ctx) => {
|
|
375
|
+
if (!ctx.derivatives) {
|
|
376
|
+
return writeFailure(
|
|
377
|
+
new InternalError({ message: "ctx.derivatives missing — is file-derivatives mounted?" }),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const { fileRefId, blurRegion } = event.payload;
|
|
381
|
+
const result = await ctx.derivatives.variant(
|
|
382
|
+
fileRefId,
|
|
383
|
+
{
|
|
384
|
+
maxEdge: 800,
|
|
385
|
+
fit: "cover",
|
|
386
|
+
format: "webp",
|
|
387
|
+
...(blurRegion ? { blurRegions: [blurRegion] } : {}),
|
|
388
|
+
},
|
|
389
|
+
"cover",
|
|
390
|
+
);
|
|
391
|
+
coverOrder.push(fileRefId);
|
|
392
|
+
return { isSuccess: true as const, data: { fileRefId, storageKey: result.storageKey } };
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
access: {
|
|
396
|
+
openToAll: {
|
|
397
|
+
reason:
|
|
398
|
+
"demo recipe: any signed-in user may generate a cover-image derivative for " +
|
|
399
|
+
"a file; there is no per-user file ownership check in this sample",
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// A real app would gate this behind real roles; `openToAll` keeps the
|
|
406
|
+
// recipe's HTTP wiring to one line.
|
|
407
|
+
r.writeHandler(
|
|
408
|
+
"publish",
|
|
409
|
+
z.object({ entityId: z.string() }),
|
|
410
|
+
async (event) => {
|
|
411
|
+
publishedPhotoIds.add(`${event.user.tenantId}:${event.payload.entityId}`);
|
|
412
|
+
return { isSuccess: true as const, data: { entityId: event.payload.entityId } };
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
access: {
|
|
416
|
+
openToAll: {
|
|
417
|
+
reason:
|
|
418
|
+
"demo recipe: any signed-in user may mark a photo entity published; a real " +
|
|
419
|
+
"app would replace this with a role check as noted above",
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
// Entry point 3 — default-deny. No registration here means every entityId
|
|
426
|
+
// 404s; a registration that returns false does the same. Only an explicit
|
|
427
|
+
// `true` serves bytes.
|
|
428
|
+
r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "photo", {
|
|
429
|
+
isPublic: ({ entityId, tenantId }: DerivativePublicPredicateArgs) =>
|
|
430
|
+
publishedPhotoIds.has(`${tenantId}:${entityId}`),
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
```
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/i18n. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# i18n
|
|
4
|
+
|
|
5
|
+
Feature-scoped translations. 'Hallo' and 'Hello' in the same app.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# i18n
|
|
10
|
+
|
|
11
|
+
Feature-scoped translations — 'Hallo' and 'Hello' in the same app.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- `r.translations()` for multi-language feature keys
|
|
16
|
+
- Plurals
|
|
17
|
+
|
|
18
|
+
## Plurals
|
|
19
|
+
|
|
20
|
+
A translation value can be a CLDR plural-forms object instead of a plain
|
|
21
|
+
string. `other` is required; the rest (`zero`/`one`/`two`/`few`/`many`) only
|
|
22
|
+
exist where the locale's grammar needs them:
|
|
23
|
+
|
|
24
|
+
```ts illustration
|
|
25
|
+
"greeting.unread_count": {
|
|
26
|
+
de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
|
|
27
|
+
en: { one: "{count} unread message", other: "{count} unread messages" },
|
|
28
|
+
},
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`t(key, locale, { count })` picks the CLDR category for `count` via
|
|
32
|
+
`Intl.PluralRules(locale)`, then interpolates `{count}` (and any other
|
|
33
|
+
`{name}` placeholder) into the chosen form.
|
|
34
|
+
|
|
35
|
+
## Source
|
|
36
|
+
|
|
37
|
+
Feature entry point: `src/feature.ts`.
|
|
38
|
+
|
|
39
|
+
## Tests
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cd samples/recipes/i18n
|
|
43
|
+
bun test
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Source files
|
|
47
|
+
|
|
48
|
+
### src/__tests__/feature.test.ts
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// i18n Sample — Unit Test (no DB needed)
|
|
52
|
+
// Proves: translations registered, locale resolution, fallback to default
|
|
53
|
+
// Note: keys are prefixed with feature name → "featureName:key"
|
|
54
|
+
|
|
55
|
+
import { describe, expect, test } from "bun:test";
|
|
56
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
57
|
+
import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
|
|
58
|
+
import { errorFeature, greetingFeature } from "../feature";
|
|
59
|
+
|
|
60
|
+
const registry = createRegistry([greetingFeature, errorFeature]);
|
|
61
|
+
const i18n = createI18n(registry, { defaultLocale: "de" });
|
|
62
|
+
|
|
63
|
+
describe("translation lookup", () => {
|
|
64
|
+
test("returns translation for default locale", () => {
|
|
65
|
+
expect(i18n.t("greeting:greeting.welcome")).toBe("Willkommen");
|
|
66
|
+
expect(i18n.t("greeting:greeting.goodbye")).toBe("Auf Wiedersehen");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns translation for specific locale", () => {
|
|
70
|
+
expect(i18n.t("greeting:greeting.welcome", "en")).toBe("Welcome");
|
|
71
|
+
expect(i18n.t("greeting:greeting.welcome", "fr")).toBe("Bienvenue");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("falls back to default locale for unknown locale", () => {
|
|
75
|
+
expect(i18n.t("greeting:greeting.welcome", "ja")).toBe("Willkommen");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns key itself for unknown translation key", () => {
|
|
79
|
+
expect(i18n.t("nonexistent.key")).toBe("nonexistent.key");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("plural forms", () => {
|
|
84
|
+
test("selects the CLDR category for the count and interpolates {count}", () => {
|
|
85
|
+
expect(i18n.t("greeting:greeting.unread_count", "en", { count: 1 })).toBe("1 unread message");
|
|
86
|
+
expect(i18n.t("greeting:greeting.unread_count", "en", { count: 5 })).toBe("5 unread messages");
|
|
87
|
+
expect(i18n.t("greeting:greeting.unread_count", "de", { count: 1 })).toBe(
|
|
88
|
+
"1 ungelesene Nachricht",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("multiple features merge translations", () => {
|
|
94
|
+
test("keys from both features available", () => {
|
|
95
|
+
expect(i18n.t("greeting:greeting.welcome")).toBe("Willkommen");
|
|
96
|
+
expect(i18n.t("errors:errors.not_found")).toBe("Nicht gefunden");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("getAllKeys returns all registered keys", () => {
|
|
100
|
+
const keys = i18n.getAllKeys();
|
|
101
|
+
expect(keys).toContain("greeting:greeting.welcome");
|
|
102
|
+
expect(keys).toContain("greeting:greeting.goodbye");
|
|
103
|
+
expect(keys).toContain("greeting:greeting.hello_name");
|
|
104
|
+
expect(keys).toContain("errors:errors.not_found");
|
|
105
|
+
expect(keys).toContain("errors:errors.access_denied");
|
|
106
|
+
expect(keys).toHaveLength(6);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### src/feature.ts
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// i18n Sample
|
|
115
|
+
// Shows: r.translations() for multi-language feature keys, and plural forms
|
|
116
|
+
// (CLDR categories) for a count-dependent key
|
|
117
|
+
|
|
118
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
119
|
+
|
|
120
|
+
export const greetingFeature = defineFeature("greeting", (r) => {
|
|
121
|
+
r.translations({
|
|
122
|
+
keys: {
|
|
123
|
+
"greeting.welcome": {
|
|
124
|
+
de: "Willkommen",
|
|
125
|
+
en: "Welcome",
|
|
126
|
+
fr: "Bienvenue",
|
|
127
|
+
},
|
|
128
|
+
"greeting.goodbye": {
|
|
129
|
+
de: "Auf Wiedersehen",
|
|
130
|
+
en: "Goodbye",
|
|
131
|
+
fr: "Au revoir",
|
|
132
|
+
},
|
|
133
|
+
"greeting.hello_name": {
|
|
134
|
+
de: "Hallo, {name}!",
|
|
135
|
+
en: "Hello, {name}!",
|
|
136
|
+
fr: "Bonjour, {name}!",
|
|
137
|
+
},
|
|
138
|
+
"greeting.unread_count": {
|
|
139
|
+
de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
|
|
140
|
+
en: { one: "{count} unread message", other: "{count} unread messages" },
|
|
141
|
+
pl: {
|
|
142
|
+
one: "{count} nieprzeczytana wiadomość",
|
|
143
|
+
few: "{count} nieprzeczytane wiadomości",
|
|
144
|
+
many: "{count} nieprzeczytanych wiadomości",
|
|
145
|
+
other: "{count} nieprzeczytanej wiadomości",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const errorFeature = defineFeature("errors", (r) => {
|
|
153
|
+
r.translations({
|
|
154
|
+
keys: {
|
|
155
|
+
"errors.not_found": {
|
|
156
|
+
de: "Nicht gefunden",
|
|
157
|
+
en: "Not found",
|
|
158
|
+
},
|
|
159
|
+
"errors.access_denied": {
|
|
160
|
+
de: "Zugriff verweigert",
|
|
161
|
+
en: "Access denied",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
```
|