@cosmicdrift/kumiko-framework 0.365.1 → 0.367.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 +2 -2
- package/agents/examples/access-control.md +32 -30
- package/agents/examples/anonymous-access-multitenant.md +14 -16
- package/agents/examples/anonymous-access.md +36 -43
- package/agents/examples/auth-foundation-providers.md +1 -1
- package/agents/examples/basic-entity.md +1 -1
- package/agents/examples/boot-check.md +3 -3
- package/agents/examples/chat-channels.md +1 -1
- package/agents/examples/cross-feature-events.md +7 -7
- package/agents/examples/currencies-global.md +3 -3
- package/agents/examples/currencies-per-tenant.md +2 -2
- package/agents/examples/custom-fields-basic.md +4 -3
- package/agents/examples/custom-handlers.md +40 -44
- package/agents/examples/dashboard-stats.md +6 -6
- package/agents/examples/delivery-notifications.md +9 -9
- package/agents/examples/designer-demo.md +2 -1
- package/agents/examples/embedded-entity-form.md +13 -13
- package/agents/examples/embedded.md +1 -1
- package/agents/examples/encrypted-tenant-config.md +1 -1
- package/agents/examples/error-contract.md +25 -25
- package/agents/examples/event-sourcing.md +96 -76
- package/agents/examples/feature-toggles.md +29 -21
- package/agents/examples/files-post-processing.md +22 -26
- package/agents/examples/idempotency.md +17 -19
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +10 -12
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +59 -39
- package/agents/examples/managed-config.md +9 -9
- package/agents/examples/managed-pages.md +1 -1
- package/agents/examples/notes-history-basic.md +3 -3
- package/agents/examples/pdf-audit-export.md +7 -7
- package/agents/examples/pipeline-basics.md +8 -8
- package/agents/examples/public-share-token.md +1 -1
- package/agents/examples/rate-limiting.md +7 -9
- package/agents/examples/record-detail-layout.md +26 -20
- package/agents/examples/reference-data.md +1 -1
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +2 -2
- package/agents/examples/search.md +17 -19
- package/agents/examples/security-baseline.md +1 -1
- package/agents/examples/state-machine.md +1 -1
- package/agents/examples/tags-basic.md +3 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/timezones.md +12 -12
- package/agents/examples/user-data-rights.md +14 -29
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/webhook-step.md +1 -1
- package/agents/examples/writeform-section.md +21 -15
- package/agents/features.md +4 -4
- package/agents/widgets.md +5 -0
- package/dist/api/server.js +12 -4
- package/dist/bun-db/query.js +16 -1
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/queries/seed-context.js +3 -3
- package/dist/engine/boot-validator/api-ext.js +2 -2
- package/dist/engine/boot-validator/config-deps.js +1 -1
- package/dist/engine/boot-validator/handler-field-references.js +3 -10
- package/dist/engine/boot-validator/screens.js +1 -1
- package/dist/engine/build-app-schema.js +3 -6
- package/dist/engine/define-feature.js +5 -5
- package/dist/engine/entity-handlers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +5 -5
- package/dist/engine/factories.d.ts +11 -11
- package/dist/engine/factories.js +11 -11
- package/dist/engine/feature-ast/extractors/round2.js +16 -2
- package/dist/engine/feature-ast/render.js +4 -7
- package/dist/engine/feature-config-events-jobs.d.ts +6 -0
- package/dist/engine/feature-config-events-jobs.js +5 -0
- package/dist/engine/feature-entity-handlers.d.ts +4 -23
- package/dist/engine/feature-entity-handlers.js +77 -100
- package/dist/engine/feature-ui-extensions.d.ts +9 -14
- package/dist/engine/feature-ui-extensions.js +22 -14
- package/dist/engine/handler-field-references.d.ts +25 -0
- package/dist/engine/handler-field-references.js +80 -6
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +2 -2
- package/dist/engine/object-form.d.ts +2 -0
- package/dist/engine/object-form.js +13 -10
- package/dist/engine/registry-ingest.js +3 -0
- package/dist/engine/registry-state.js +2 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/files/create-file-ref-from-bytes.d.ts +1 -0
- package/dist/files/create-file-ref-from-bytes.js +7 -2
- package/dist/files/feature.js +1 -1
- package/dist/jobs/job-runner.js +16 -2
- package/dist/pipeline/dispatch-query.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +2 -1
- package/dist/pipeline/dispatch-shared.js +3 -6
- package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
- package/dist/pipeline/tenant-timezone-config-key.js +6 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/manual-clock.d.ts +14 -0
- package/dist/testing/manual-clock.js +12 -0
- package/dist/time/index.d.ts +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +9 -4
- package/dist/upgrade-cli.js +13 -2
- package/package.json +4 -4
- package/src/changes.json +52 -0
- package/src/scripts/codemod/README.md +23 -0
- package/src/scripts/codemod/object-form.ts +709 -0
|
@@ -567,7 +567,7 @@ import type { InvoiceStatus } from "./reducer";
|
|
|
567
567
|
export { invoiceEntity } from "./entities/invoice";
|
|
568
568
|
|
|
569
569
|
export const stateMachineFeature = defineFeature("billing", (r) => {
|
|
570
|
-
r.entity("invoice", invoiceEntity);
|
|
570
|
+
r.entity({ name: "invoice", ...invoiceEntity });
|
|
571
571
|
|
|
572
572
|
// Domain events — one per intent. Names live in events.ts so the reducer
|
|
573
573
|
// and the projection stay in sync. The auto "invoice.created" event from
|
|
@@ -85,7 +85,7 @@ See the [tags feature reference](/en/bundled-features/tags/) for the full set.
|
|
|
85
85
|
tags → core bundle: tag + tag-assignment entities,
|
|
86
86
|
create/assign/remove handlers, list queries
|
|
87
87
|
note-management → our feature: a plain `note` entity. Declares
|
|
88
|
-
r.requires("tags") only so the bundle is mounted —
|
|
88
|
+
r.requires({ features: ["tags"] }) only so the bundle is mounted —
|
|
89
89
|
the note itself is completely tag-agnostic.
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -321,9 +321,9 @@ export const noteFeature = defineFeature("note-management", (r) => {
|
|
|
321
321
|
// tags is non-optional for this recipe: the demo tags notes. The note
|
|
322
322
|
// feature itself stays completely tag-agnostic — it only declares the
|
|
323
323
|
// dependency so the bundle is mounted.
|
|
324
|
-
r.requires("tags");
|
|
324
|
+
r.requires({ features: ["tags"] });
|
|
325
325
|
|
|
326
|
-
r.entity("note", noteEntity);
|
|
326
|
+
r.entity({ name: "note", ...noteEntity });
|
|
327
327
|
|
|
328
328
|
r.writeHandler({
|
|
329
329
|
name: "note:create",
|
|
@@ -35,7 +35,7 @@ export const invoiceEntity = createEntity({
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
export const invoiceFeature = defineFeature("invoice", (r) => {
|
|
38
|
-
r.entity("invoice", invoiceEntity);
|
|
38
|
+
r.entity({ name: "invoice", ...invoiceEntity });
|
|
39
39
|
r.writeHandler(
|
|
40
40
|
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
41
41
|
access: { roles: ["Admin"] },
|
|
@@ -263,7 +263,7 @@ export const invoiceEntity = createEntity({
|
|
|
263
263
|
const ACCESS = { roles: ["Admin"] } as const;
|
|
264
264
|
|
|
265
265
|
export const invoiceFeature = defineFeature("invoice", (r) => {
|
|
266
|
-
r.entity("invoice", invoiceEntity);
|
|
266
|
+
r.entity({ name: "invoice", ...invoiceEntity });
|
|
267
267
|
r.writeHandler(
|
|
268
268
|
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
269
269
|
access: ACCESS,
|
|
@@ -336,12 +336,12 @@ export const timezonesFeature = defineFeature("timezones", (r) => {
|
|
|
336
336
|
});
|
|
337
337
|
r.writeHandler(defineEntityDeleteHandler("delivery", deliveryEntity, adminWrite));
|
|
338
338
|
|
|
339
|
-
r.queryHandler(
|
|
340
|
-
"day-window",
|
|
341
|
-
z.object({
|
|
339
|
+
r.queryHandler({
|
|
340
|
+
name: "day-window",
|
|
341
|
+
schema: z.object({
|
|
342
342
|
zone: z.string().refine(isValidIanaTimeZone, { message: "Invalid IANA timezone" }),
|
|
343
343
|
}),
|
|
344
|
-
async (query, ctx) => {
|
|
344
|
+
handler: async (query, ctx) => {
|
|
345
345
|
const { start, end } = ctx.tz.todayRange(query.payload.zone);
|
|
346
346
|
return {
|
|
347
347
|
zone: query.payload.zone,
|
|
@@ -350,17 +350,17 @@ export const timezonesFeature = defineFeature("timezones", (r) => {
|
|
|
350
350
|
end: end.toString(),
|
|
351
351
|
};
|
|
352
352
|
},
|
|
353
|
-
openRead,
|
|
354
|
-
);
|
|
353
|
+
...openRead,
|
|
354
|
+
});
|
|
355
355
|
|
|
356
|
-
r.queryHandler(
|
|
357
|
-
"zone-at",
|
|
358
|
-
z.object({ latitude: z.number(), longitude: z.number() }),
|
|
359
|
-
async (query, ctx) => {
|
|
356
|
+
r.queryHandler({
|
|
357
|
+
name: "zone-at",
|
|
358
|
+
schema: z.object({ latitude: z.number(), longitude: z.number() }),
|
|
359
|
+
handler: async (query, ctx) => {
|
|
360
360
|
const zone = await ctx.tz.fromCoordinates(query.payload);
|
|
361
361
|
return { zone };
|
|
362
362
|
},
|
|
363
|
-
openRead,
|
|
364
|
-
);
|
|
363
|
+
...openRead,
|
|
364
|
+
});
|
|
365
365
|
});
|
|
366
366
|
```
|
|
@@ -19,8 +19,8 @@ forget logic:
|
|
|
19
19
|
|
|
20
20
|
```ts illustration
|
|
21
21
|
defineFeature("notes", (r) => {
|
|
22
|
-
r.requires("user-data-rights");
|
|
23
|
-
r.entity("note", noteEntity);
|
|
22
|
+
r.requires({ features: ["user-data-rights"] });
|
|
23
|
+
r.entity({ name: "note", ...noteEntity });
|
|
24
24
|
|
|
25
25
|
r.useExtension(EXT_USER_DATA, "note", {
|
|
26
26
|
export: async (ctx) => ({
|
|
@@ -133,6 +133,10 @@ import {
|
|
|
133
133
|
tenantRetentionOverrideEntity,
|
|
134
134
|
} from "@cosmicdrift/kumiko-bundled-features/data-retention";
|
|
135
135
|
import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
136
|
+
import {
|
|
137
|
+
tenantMembershipEntity,
|
|
138
|
+
tenantMembershipsTable,
|
|
139
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
136
140
|
import {
|
|
137
141
|
createUserFeature,
|
|
138
142
|
USER_STATUS,
|
|
@@ -144,7 +148,7 @@ import {
|
|
|
144
148
|
runForgetCleanup,
|
|
145
149
|
runUserExport,
|
|
146
150
|
} from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
|
|
147
|
-
import {
|
|
151
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
148
152
|
import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
149
153
|
import {
|
|
150
154
|
setupTestStack,
|
|
@@ -185,23 +189,7 @@ beforeAll(async () => {
|
|
|
185
189
|
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
186
190
|
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
187
191
|
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
188
|
-
await
|
|
189
|
-
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
190
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
191
|
-
tenant_id UUID NOT NULL,
|
|
192
|
-
user_id TEXT NOT NULL,
|
|
193
|
-
version INTEGER NOT NULL DEFAULT 0,
|
|
194
|
-
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
195
|
-
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
196
|
-
inserted_by_id TEXT,
|
|
197
|
-
modified_by_id TEXT,
|
|
198
|
-
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
199
|
-
deleted_at TIMESTAMPTZ,
|
|
200
|
-
deleted_by_id TEXT,
|
|
201
|
-
roles TEXT NOT NULL DEFAULT '[]',
|
|
202
|
-
UNIQUE(user_id, tenant_id)
|
|
203
|
-
)
|
|
204
|
-
`);
|
|
192
|
+
await unsafeCreateEntityTable(stack.db, tenantMembershipEntity);
|
|
205
193
|
});
|
|
206
194
|
|
|
207
195
|
afterAll(async () => {
|
|
@@ -233,14 +221,11 @@ async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {
|
|
|
233
221
|
status: opts.status ?? USER_STATUS.Active,
|
|
234
222
|
gracePeriodEnd: opts.gracePeriodEnd ?? null,
|
|
235
223
|
});
|
|
236
|
-
await
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
`,
|
|
242
|
-
[TENANT_A, ALICE_ID],
|
|
243
|
-
);
|
|
224
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
225
|
+
tenantId: TENANT_A,
|
|
226
|
+
userId: ALICE_ID,
|
|
227
|
+
roles: '["Member"]',
|
|
228
|
+
});
|
|
244
229
|
}
|
|
245
230
|
|
|
246
231
|
async function seedNote(id: string, title: string): Promise<void> {
|
|
@@ -375,7 +360,7 @@ export const noteEntity = createEntity({
|
|
|
375
360
|
export const notesTable = deriveEntityTableMeta("note", noteEntity, { source: "unmanaged" });
|
|
376
361
|
|
|
377
362
|
export const notesFeature = defineFeature("notes", (r) => {
|
|
378
|
-
r.requires("user-data-rights");
|
|
363
|
+
r.requires({ features: ["user-data-rights"] });
|
|
379
364
|
// store_notes is a direct-write store: the forget hook below `updateMany`/
|
|
380
365
|
// `deleteMany`s rows WITHOUT emitting lifecycle events. r.entity would make
|
|
381
366
|
// it a rebuildable implicit projection whose replay finds zero note events
|
|
@@ -187,7 +187,7 @@ export function createAccountFeature(): FeatureDefinition {
|
|
|
187
187
|
"entry — user-profile registers the `profile` screen itself " +
|
|
188
188
|
"(fw#2312 declarative projectionDetail).",
|
|
189
189
|
);
|
|
190
|
-
r.requires("user-profile");
|
|
190
|
+
r.requires({ features: ["user-profile"] });
|
|
191
191
|
|
|
192
192
|
r.nav({
|
|
193
193
|
id: "profile",
|
|
@@ -436,7 +436,7 @@ const incidentExecutor = createEventStoreExecutor(incidentTable, incidentEntity,
|
|
|
436
436
|
const INCIDENT_HOOK_URL = "https://hooks.example/incident";
|
|
437
437
|
|
|
438
438
|
export const webhookDemoFeature = defineFeature("webhook-demo", (r) => {
|
|
439
|
-
r.entity("incident", incidentEntity);
|
|
439
|
+
r.entity({ name: "incident", ...incidentEntity });
|
|
440
440
|
r.requires.step("webhook.send");
|
|
441
441
|
r.requires.step("mail.send");
|
|
442
442
|
r.requires.step("callFeature");
|
|
@@ -127,10 +127,16 @@ describe("writeform-section sample — boot-validator catches author mistakes",
|
|
|
127
127
|
test("a writeForm field without its pseudo-entity translation key fails boot", () => {
|
|
128
128
|
const broken = defineFeature("broken-note-desk", (r) => {
|
|
129
129
|
r.translations({ keys: { "screen:x.title": { en: "X" } } });
|
|
130
|
-
r.queryHandler(
|
|
130
|
+
r.queryHandler({
|
|
131
|
+
name: "foo:detail",
|
|
132
|
+
schema: z.object({ id: z.string() }),
|
|
133
|
+
handler: async () => ({}),
|
|
131
134
|
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
132
135
|
});
|
|
133
|
-
r.writeHandler(
|
|
136
|
+
r.writeHandler({
|
|
137
|
+
name: "save",
|
|
138
|
+
schema: z.object({}),
|
|
139
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
134
140
|
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
135
141
|
});
|
|
136
142
|
r.screen({
|
|
@@ -249,19 +255,19 @@ export const noteDeskFeature = defineFeature("note-desk", (r) => {
|
|
|
249
255
|
access: open.access,
|
|
250
256
|
});
|
|
251
257
|
|
|
252
|
-
r.queryHandler(
|
|
253
|
-
"note:summary",
|
|
254
|
-
z.object({ id: z.string() }),
|
|
255
|
-
async (query) => ({ id: query.payload.id, ...NOTE_DETAIL_FIELDS }),
|
|
256
|
-
open,
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
r.writeHandler(
|
|
260
|
-
"note:comment",
|
|
261
|
-
commentPayloadSchema,
|
|
262
|
-
async () => ({ isSuccess: true as const, data: null }),
|
|
263
|
-
open,
|
|
264
|
-
);
|
|
258
|
+
r.queryHandler({
|
|
259
|
+
name: "note:summary",
|
|
260
|
+
schema: z.object({ id: z.string() }),
|
|
261
|
+
handler: async (query) => ({ id: query.payload.id, ...NOTE_DETAIL_FIELDS }),
|
|
262
|
+
...open,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
r.writeHandler({
|
|
266
|
+
name: "note:comment",
|
|
267
|
+
schema: commentPayloadSchema,
|
|
268
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
269
|
+
...open,
|
|
270
|
+
});
|
|
265
271
|
|
|
266
272
|
r.screen({
|
|
267
273
|
id: "note-detail",
|
package/agents/features.md
CHANGED
|
@@ -106,7 +106,7 @@ Requires: `delivery`, `secrets`
|
|
|
106
106
|
|
|
107
107
|
## channel-email
|
|
108
108
|
|
|
109
|
-
Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.
|
|
109
|
+
Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
|
|
110
110
|
|
|
111
111
|
Category: notifications
|
|
112
112
|
|
|
@@ -206,7 +206,7 @@ Exposes APIs: `retention.policyFor`
|
|
|
206
206
|
|
|
207
207
|
## delivery
|
|
208
208
|
|
|
209
|
-
The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.
|
|
209
|
+
The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
|
|
210
210
|
|
|
211
211
|
Category: notifications
|
|
212
212
|
|
|
@@ -413,7 +413,7 @@ German UI and mail copy for framework screens. Opt-in; not included by includeBu
|
|
|
413
413
|
|
|
414
414
|
## mail-foundation
|
|
415
415
|
|
|
416
|
-
Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences.
|
|
416
|
+
Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.
|
|
417
417
|
|
|
418
418
|
Category: notifications
|
|
419
419
|
|
|
@@ -538,7 +538,7 @@ Requires: `template-resolver`
|
|
|
538
538
|
|
|
539
539
|
## renderer-mail-html
|
|
540
540
|
|
|
541
|
-
A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout,
|
|
541
|
+
A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource="logo"` images use the branding logo upload, or the branding logo https address when no file is uploaded. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
|
|
542
542
|
|
|
543
543
|
Requires: `renderer-foundation`, `template-resolver`
|
|
544
544
|
|
package/agents/widgets.md
CHANGED
|
@@ -147,6 +147,9 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
147
147
|
| `createKumikoApp` | |
|
|
148
148
|
| `createPublicSurface` | |
|
|
149
149
|
| `createStaticLocaleResolver` | Default-Resolver für Apps ohne eigene i18n-Schicht. |
|
|
150
|
+
| `downloadFileRef` | |
|
|
151
|
+
| `fetchFileRefBlob` | |
|
|
152
|
+
| `fileNameFromContentDisposition` | |
|
|
150
153
|
| `filterAppSchemaNavsByAllowlist` | |
|
|
151
154
|
| `filterByAccess` | |
|
|
152
155
|
| `formatMoney` | |
|
|
@@ -158,6 +161,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
158
161
|
| `readLoadedBuild` | |
|
|
159
162
|
| `reparentAppSchemaNavs` | |
|
|
160
163
|
| `resolveDefaultId` | |
|
|
164
|
+
| `saveBlobAsFile` | |
|
|
161
165
|
| `smoothPath` | Quadratic-durch-Mittelpunkte-Trick: glättet die Zick-Zack-Linie ohne Overshoot (~5 Zeilen statt Catmull-Rom/Bezier-Fit). |
|
|
162
166
|
| `synthesizeActionFormEntity` | Baut eine minimale EntityDefinition aus den Inline-Fields eines ActionForm- oder SecretMint-Screens. |
|
|
163
167
|
| `synthesizeActionFormScreen` | Wandelt ein ActionFormScreenDefinition oder SecretMintScreenDefinition in die EntityEditScreen-Shape die RenderEdit erwartet. |
|
|
@@ -178,4 +182,5 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
|
|
|
178
182
|
|
|
179
183
|
| Name | Description |
|
|
180
184
|
| --- | --- |
|
|
185
|
+
| `FileDownloadFailedError` | |
|
|
181
186
|
| `FileUploadFailedError` | Carries the server's own `error` message when the upload endpoint sent one. |
|
package/dist/api/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildAppSchema } from "../engine/build-app-schema.js";
|
|
|
6
6
|
import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
|
|
7
7
|
import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
|
|
8
8
|
import { runsInLane } from "../engine/run-in.js";
|
|
9
|
+
import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from "../engine/strip-disabled-features-from-app-schema.js";
|
|
9
10
|
import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
|
|
10
11
|
import { isFileField, } from "../engine/types/index.js";
|
|
11
12
|
import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
|
|
@@ -673,15 +674,22 @@ export function buildServer(options) {
|
|
|
673
674
|
// not the whole app's schema) and cached per role key. Tenant is
|
|
674
675
|
// deliberately NOT part of the key: the projection depends only on
|
|
675
676
|
// roles, and per-tenant caching would grow unboundedly across tenants
|
|
676
|
-
// that share the same role set.
|
|
677
|
+
// that share the same role set. What IS part of the key: the sorted set of
|
|
678
|
+
// features the caller's tenant has switched off (r.toggleable) — few
|
|
679
|
+
// distinct sets, so the cache stays small. The resolver is read per request
|
|
680
|
+
// because run{Dev,Prod}App fill it in after the server is built.
|
|
677
681
|
let fullSchema;
|
|
678
682
|
const projectedSchemaCache = new Map();
|
|
679
|
-
app.get(`/api${Routes.schema}`, (c) => {
|
|
683
|
+
app.get(`/api${Routes.schema}`, async (c) => {
|
|
680
684
|
const user = getUser(c);
|
|
681
685
|
if (isMissingOrAnonymousUser(user)) {
|
|
682
686
|
return unauthenticatedResponse(c);
|
|
683
687
|
}
|
|
684
|
-
const
|
|
688
|
+
const effectiveFeatures = options.dispatcherOptions?.effectiveFeatures;
|
|
689
|
+
const disabledFeatureNames = effectiveFeatures === undefined
|
|
690
|
+
? []
|
|
691
|
+
: await resolveDisabledFeatureNames(options.registry, effectiveFeatures, user.tenantId);
|
|
692
|
+
const roleKey = JSON.stringify([[...new Set(user?.roles ?? [])].sort(), disabledFeatureNames]);
|
|
685
693
|
let cached = projectedSchemaCache.get(roleKey);
|
|
686
694
|
if (cached === undefined) {
|
|
687
695
|
if (fullSchema === undefined) {
|
|
@@ -689,7 +697,7 @@ export function buildServer(options) {
|
|
|
689
697
|
searchAdapterMissing: !options.context.searchAdapter,
|
|
690
698
|
});
|
|
691
699
|
}
|
|
692
|
-
const projected = projectAppSchemaForRoles(fullSchema, user?.roles ?? []);
|
|
700
|
+
const projected = projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), user?.roles ?? []);
|
|
693
701
|
const json = JSON.stringify(projected);
|
|
694
702
|
cached = { json, etag: computeStrongEtag(json) };
|
|
695
703
|
projectedSchemaCache.set(roleKey, cached);
|
package/dist/bun-db/query.js
CHANGED
|
@@ -861,6 +861,21 @@ export async function unsafeReadRetrying(db, sqlText, params) {
|
|
|
861
861
|
throw lastErr;
|
|
862
862
|
}
|
|
863
863
|
}
|
|
864
|
+
function selectColumnList(info, fields) {
|
|
865
|
+
if (fields === undefined)
|
|
866
|
+
return "*";
|
|
867
|
+
if (fields.length === 0) {
|
|
868
|
+
throw new Error(`selectMany: fields must not be empty for "${info.name}"`);
|
|
869
|
+
}
|
|
870
|
+
return fields
|
|
871
|
+
.map((field) => {
|
|
872
|
+
if (!info.hasColumn(field)) {
|
|
873
|
+
throw new Error(`selectMany: fields entry "${field}" is not a column of "${info.name}"`);
|
|
874
|
+
}
|
|
875
|
+
return quoteIdent(info.columnOf(field));
|
|
876
|
+
})
|
|
877
|
+
.join(", ");
|
|
878
|
+
}
|
|
864
879
|
// biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
|
|
865
880
|
export async function selectMany(db, table, where, options) {
|
|
866
881
|
const scoped = tenantDbDelegate(db);
|
|
@@ -868,7 +883,7 @@ export async function selectMany(db, table, where, options) {
|
|
|
868
883
|
return scoped.selectMany(table, where, options);
|
|
869
884
|
}
|
|
870
885
|
const info = extractTableInfo(table);
|
|
871
|
-
let sqlText = `SELECT
|
|
886
|
+
let sqlText = `SELECT ${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
|
|
872
887
|
let values = [];
|
|
873
888
|
if (where && Object.keys(where).length > 0) {
|
|
874
889
|
const w = buildWhereClause(info, where, 1);
|
|
@@ -358,8 +358,8 @@ export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
|
358
358
|
function sqlExpressionText(where) {
|
|
359
359
|
return isSqlExpression(where) ? where.text : undefined;
|
|
360
360
|
}
|
|
361
|
-
// Validates that a backing Drizzle table (declared via `r.entity(name, def,
|
|
362
|
-
//
|
|
361
|
+
// Validates that a backing Drizzle table (declared via `r.entity({ name, ...def,
|
|
362
|
+
// backingTable })`) is a SUPERSET of the field-derived meta: every column the
|
|
363
363
|
// entity fields produce must exist on the table with the same pgType +
|
|
364
364
|
// notNull. Ride-along columns/indexes the table adds on top (envelope,
|
|
365
365
|
// uniqueIndex, …) are exactly the point — they pass. A field with no matching
|
|
@@ -371,13 +371,13 @@ export function assertBackingTableSuperset(entityName, fieldMeta, tableMeta) {
|
|
|
371
371
|
for (const fieldCol of fieldMeta.columns) {
|
|
372
372
|
const tableCol = tableCols.get(fieldCol.name);
|
|
373
373
|
if (!tableCol) {
|
|
374
|
-
throw new Error(`r.entity("${entityName}", …,
|
|
374
|
+
throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): the backing table ` +
|
|
375
375
|
`"${tableMeta.tableName}" is missing column "${fieldCol.name}" that the ` +
|
|
376
376
|
"entity field declares. The table must be a superset of the entity's " +
|
|
377
377
|
"fields — add the column to the table or remove the field.");
|
|
378
378
|
}
|
|
379
379
|
if (tableCol.pgType !== fieldCol.pgType || tableCol.notNull !== fieldCol.notNull) {
|
|
380
|
-
throw new Error(`r.entity("${entityName}", …,
|
|
380
|
+
throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): column "${fieldCol.name}" ` +
|
|
381
381
|
`disagrees between entity field (${fieldCol.pgType}, ` +
|
|
382
382
|
`notNull=${fieldCol.notNull}) and backing table "${tableMeta.tableName}" ` +
|
|
383
383
|
`(${tableCol.pgType}, notNull=${tableCol.notNull}). Align them.`);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "../query.js";
|
|
2
2
|
import { tableExists } from "../schema-inspection.js";
|
|
3
|
-
import { SOFT_DELETE_LIVE_ROW_PREDICATE } from "../table-builder.js";
|
|
4
3
|
export async function selectUserByEmail(db, email) {
|
|
5
4
|
const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
|
|
6
5
|
FROM read_users
|
|
@@ -27,11 +26,12 @@ export async function selectAllTenants(db) {
|
|
|
27
26
|
}
|
|
28
27
|
// Column names are fixed fragments; every filter value is a bound parameter.
|
|
29
28
|
// template-resolver is an optional feature — without its table the answer is
|
|
30
|
-
// "no templates", not an error.
|
|
29
|
+
// "no templates", not an error. The template entity has no soft delete, so the
|
|
30
|
+
// table has no is_deleted column to filter on.
|
|
31
31
|
export async function selectTemplateResources(db, filter) {
|
|
32
32
|
if (!(await tableExists(db, "read_template_resources")))
|
|
33
33
|
return [];
|
|
34
|
-
const conditions = ["tenant_id = $1"
|
|
34
|
+
const conditions = ["tenant_id = $1"];
|
|
35
35
|
const params = [filter.tenantId];
|
|
36
36
|
for (const [column, value] of [
|
|
37
37
|
["slug", filter.slug],
|
|
@@ -25,7 +25,7 @@ export function validateApiExposureMatching(feature, allExposedApis, featureMap)
|
|
|
25
25
|
}
|
|
26
26
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
27
27
|
if (!allDeps.includes(providerFeature)) {
|
|
28
|
-
throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires("${providerFeature}").`);
|
|
28
|
+
throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`);
|
|
29
29
|
}
|
|
30
30
|
// Sanity: provider feature actually exists in this app's feature set.
|
|
31
31
|
// Should always be true if allExposedApis was built from `features`,
|
|
@@ -48,7 +48,7 @@ export function validateExtensionUsages(feature, extensionProviders) {
|
|
|
48
48
|
}
|
|
49
49
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
50
50
|
if (!allDeps.includes(providerFeature)) {
|
|
51
|
-
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires("${providerFeature}").`);
|
|
51
|
+
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -2,7 +2,7 @@ import { optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
|
2
2
|
import { isEncryptedAtRest } from "../config-helpers.js";
|
|
3
3
|
// --- Toggleable-dependency warnings ---
|
|
4
4
|
//
|
|
5
|
-
// When feature A declares r.requires("B") and B is toggleable with
|
|
5
|
+
// When feature A declares r.requires({ features: ["B"] }) and B is toggleable with
|
|
6
6
|
// default=false, A is effectively disabled out-of-the-box until someone
|
|
7
7
|
// flips B on globally. That's usually an oversight — the dev either meant
|
|
8
8
|
// optionalRequires, or forgot to ship B with default=true. We warn (not
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readHandlerFieldReferences } from "../handler-field-references.js";
|
|
2
2
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
3
|
-
import { getZodObjectShape } from "./zod-shape.js";
|
|
4
3
|
function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
|
|
5
4
|
for (const [handlerName, def] of Object.entries(handlers)) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
continue;
|
|
9
|
-
for (const fieldName of Object.keys(shape)) {
|
|
10
|
-
const where = `Input field "${fieldName}" of ${handlerNoun} "${handlerName}"`;
|
|
11
|
-
const reading = readHandlerFieldReference(def.schema, fieldName);
|
|
12
|
-
if (reading.kind === "none")
|
|
13
|
-
continue;
|
|
5
|
+
for (const { path, reading } of readHandlerFieldReferences(def.schema)) {
|
|
6
|
+
const where = `Input field "${path}" of ${handlerNoun} "${handlerName}"`;
|
|
14
7
|
if (reading.kind === "invalid") {
|
|
15
8
|
throw new Error(`[Feature ${feature.name}] ${where} has an invalid meta references value ${JSON.stringify(reading.raw)}. ` +
|
|
16
9
|
`Use "<entity>", "<feature>:<entity>" or { entity, by? }.`);
|
|
@@ -2216,7 +2216,7 @@ function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureNam
|
|
|
2216
2216
|
const targetFeature = featureMap.get(target.featureName);
|
|
2217
2217
|
if (targetFeature === undefined) {
|
|
2218
2218
|
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
|
|
2219
|
-
`mounted — mount it and declare r.requires("${target.featureName}").`);
|
|
2219
|
+
`mounted — mount it and declare r.requires({ features: ["${target.featureName}"] }).`);
|
|
2220
2220
|
}
|
|
2221
2221
|
if (targetFeature.entities?.[target.entityName] === undefined) {
|
|
2222
2222
|
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
@@ -20,12 +20,9 @@
|
|
|
20
20
|
// Translations: verbatim feature.translations (raw r.translations keys,
|
|
21
21
|
// unfiltered — see FeatureSchema.translations doc)
|
|
22
22
|
//
|
|
23
|
-
// Feature
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// dispatcher, nicht in der Registry, und wir haben hier keinen TenantDb-
|
|
27
|
-
// Kontext um sie zu lesen. TODO wenn das ein realer Use-Case wird:
|
|
28
|
-
// `effectiveFeatures` Argument annehmen und über alle iterations filtern.
|
|
23
|
+
// Feature toggles: this schema is tenant-independent and lists every feature.
|
|
24
|
+
// The schema route strips features the caller's tenant has switched off
|
|
25
|
+
// (r.toggleable) via stripDisabledFeaturesFromAppSchema.
|
|
29
26
|
import { ZodObject } from "zod";
|
|
30
27
|
import { collectUrlPrefillFieldsByScreenQn } from "./boot-validator/url-prefill-fields.js";
|
|
31
28
|
import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "./build-config-feature-schema.js";
|
|
@@ -2,7 +2,7 @@ import { createInitialFeatureBuilderState } from "./feature-builder-state.js";
|
|
|
2
2
|
import { buildConfigEventsJobsMethods } from "./feature-config-events-jobs.js";
|
|
3
3
|
import { buildEntityHandlerMethods } from "./feature-entity-handlers.js";
|
|
4
4
|
import { buildUiExtensionsMethods } from "./feature-ui-extensions.js";
|
|
5
|
-
import { unwrapArrayForm } from "./object-form.js";
|
|
5
|
+
import { isObjectArgument, throwPositionalFormRemoved, unwrapArrayForm } from "./object-form.js";
|
|
6
6
|
// `TExports` lets the setup callback hand back a typed object that
|
|
7
7
|
// downstream features can import (e.g. `tenantFeature.exports.config`). The
|
|
8
8
|
// runtime always packs whatever setup returns into `featureDef.exports` —
|
|
@@ -15,10 +15,8 @@ import { unwrapArrayForm } from "./object-form.js";
|
|
|
15
15
|
// for `ctx.appendEvent({ type: eventDef.name, ... })` lights up. Apps
|
|
16
16
|
// that don't care can keep the default-string and use the wrapper-based
|
|
17
17
|
// strict-mode (string-literal types per call-site) like before.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// canonical Object-Form for Designer/AI-generated code) — see the matching
|
|
21
|
-
// overloads on RequiresApi / FeatureRegistrar['optionalRequires'].
|
|
18
|
+
// optionalRequires accepts either variadic strings or a single `{ features }` object; requires
|
|
19
|
+
// takes only the object (see RequiresApi).
|
|
22
20
|
function resolveFeatureNamesArgs(args) {
|
|
23
21
|
return unwrapArrayForm(args, "features");
|
|
24
22
|
}
|
|
@@ -50,6 +48,8 @@ export function defineFeature(name, setup, settings) {
|
|
|
50
48
|
},
|
|
51
49
|
requires: (() => {
|
|
52
50
|
const fn = (...args) => {
|
|
51
|
+
if (!args.every(isObjectArgument))
|
|
52
|
+
throwPositionalFormRemoved(name, "requires");
|
|
53
53
|
state.requires.push(...resolveFeatureNamesArgs(args));
|
|
54
54
|
};
|
|
55
55
|
fn.projection = (tableName) => {
|
|
@@ -63,5 +63,5 @@ export declare function defineProjectionQueryHandler(name: string, projectionQua
|
|
|
63
63
|
};
|
|
64
64
|
escapeHatch?: EscapeHatchDeclaration;
|
|
65
65
|
}): QueryHandlerDef;
|
|
66
|
-
/**
|
|
67
|
-
export declare function
|
|
66
|
+
/** Backs `r.crud`: registers the standard entity CRUD handlers. Access stays explicit — no openToAll default. */
|
|
67
|
+
export declare function registerCrudHandlers(r: EntityCrudRegistrar, entityName: string, entity: EntityDefinition, options?: RegisterEntityCrudOptions): void;
|
|
@@ -551,21 +551,21 @@ function defaultCrudVerbs(entity) {
|
|
|
551
551
|
detail: true,
|
|
552
552
|
};
|
|
553
553
|
}
|
|
554
|
-
/**
|
|
555
|
-
export function
|
|
554
|
+
/** Backs `r.crud`: registers the standard entity CRUD handlers. Access stays explicit — no openToAll default. */
|
|
555
|
+
export function registerCrudHandlers(r, entityName, entity, options) {
|
|
556
556
|
const verbs = { ...defaultCrudVerbs(entity), ...options?.verbs };
|
|
557
557
|
if (verbs.restore && entity.softDelete !== true) {
|
|
558
558
|
throw new Error(`restore requested but entity "${entityName}" has no softDelete: true`);
|
|
559
559
|
}
|
|
560
560
|
if (options?.registerEntity !== false) {
|
|
561
|
-
r.entity(entityName, entity);
|
|
561
|
+
r.entity({ name: entityName, ...entity });
|
|
562
562
|
}
|
|
563
563
|
const writeOpts = options?.write;
|
|
564
564
|
const readOpts = options?.read;
|
|
565
565
|
const requireAccess = (verb, access) => {
|
|
566
566
|
if (!access) {
|
|
567
|
-
throw new Error(`
|
|
568
|
-
`Set write.access/read.access
|
|
567
|
+
throw new Error(`r.crud("${entityName}", …): no access rule resolved for verb "${verb}". ` +
|
|
568
|
+
`Set write.access/read.access in r.crud's options, or verbAccess.${verb}.`);
|
|
569
569
|
}
|
|
570
570
|
return access;
|
|
571
571
|
};
|