@cosmicdrift/kumiko-samples 0.398.0 → 0.400.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  4. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +4 -6
  5. package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
  6. package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
  7. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
  8. package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
  9. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
  10. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
  11. package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
  12. package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
  13. package/packages/bundled-features/src/documents/aggregate-id.ts +20 -0
  14. package/packages/bundled-features/src/documents/capture.ts +125 -0
  15. package/packages/bundled-features/src/documents/constants.ts +31 -0
  16. package/packages/bundled-features/src/documents/ensure-link.ts +35 -0
  17. package/packages/bundled-features/src/documents/entity.ts +103 -0
  18. package/packages/bundled-features/src/documents/executor.ts +13 -0
  19. package/packages/bundled-features/src/documents/expiry.ts +32 -0
  20. package/packages/bundled-features/src/documents/feature.ts +197 -0
  21. package/packages/bundled-features/src/documents/handlers/import.write.ts +157 -0
  22. package/packages/bundled-features/src/documents/handlers/link.write.ts +103 -0
  23. package/packages/bundled-features/src/documents/i18n.ts +35 -0
  24. package/packages/bundled-features/src/documents/index.ts +22 -0
  25. package/packages/bundled-features/src/documents/pending-file-guard.ts +36 -0
  26. package/packages/bundled-features/src/documents/queries/expiry-overview.query.ts +46 -0
  27. package/packages/bundled-features/src/documents/queries/for-record.query.ts +74 -0
  28. package/packages/bundled-features/src/documents/queries/list-page.ts +51 -0
  29. package/packages/bundled-features/src/documents/queries/overview.query.ts +45 -0
  30. package/packages/bundled-features/src/documents/schemas.ts +66 -0
  31. package/packages/bundled-features/src/documents/screens.ts +86 -0
  32. package/packages/bundled-features/src/documents/tab.ts +46 -0
  33. package/packages/bundled-features/src/documents/target-write-check.ts +41 -0
  34. package/packages/bundled-features/src/documents/write-guard.ts +59 -0
  35. package/packages/bundled-features/src/metrics/changes.json +6 -0
  36. package/packages/bundled-features/src/metrics/compute.ts +19 -2
  37. package/packages/bundled-features/src/metrics/feature.ts +14 -1
  38. package/packages/bundled-features/src/metrics/types.ts +3 -1
  39. package/packages/bundled-features/src/metrics/validate.ts +3 -0
  40. package/packages/bundled-features/src/shared/purge-row-file-refs.ts +5 -4
  41. package/samples/apps/styleguide/src/app/seed.ts +102 -1
  42. package/samples/apps/styleguide/src/app/server.ts +2 -2
  43. package/samples/apps/styleguide/src/features/examples/feature.ts +97 -0
  44. package/samples/apps/styleguide/src/features/examples/i18n.ts +47 -0
  45. package/samples/apps/use-all-bundled/feature-manifest.json +31 -2
  46. package/samples/apps/use-all-bundled/kumiko/migrations/0058_documents.rebuild.json +4 -0
  47. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +11 -2
  48. package/samples/apps/use-all-bundled/src/run-config.ts +7 -0
  49. package/samples/recipes/derived-fields-sql/README.md +27 -0
  50. package/samples/recipes/derived-fields-sql/package.json +11 -0
  51. package/samples/recipes/derived-fields-sql/src/feature.ts +98 -0
  52. package/samples/recipes/documents/package.json +12 -0
  53. package/samples/recipes/documents/src/feature.ts +66 -0
  54. package/samples/recipes/inspection-checklist/README.md +27 -0
  55. package/samples/recipes/inspection-checklist/package.json +12 -0
  56. package/samples/recipes/inspection-checklist/src/entities/inspection-plan.ts +24 -0
  57. package/samples/recipes/inspection-checklist/src/entities/inspection-protocol.ts +72 -0
  58. package/samples/recipes/inspection-checklist/src/feature.ts +33 -0
  59. package/samples/recipes/inspection-checklist/src/handlers/create-from-plan.write.ts +38 -0
  60. package/samples/recipes/inspection-checklist/src/screens/inspection-protocol-edit.screen.ts +27 -0
@@ -1,9 +1,14 @@
1
1
  import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
2
2
  import { buildEntityTable, createTenantDb, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
- import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createEntityExecutor, createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createFileRefFromBytes,
6
+ createTenantFileContext,
7
+ } from "@cosmicdrift/kumiko-framework/files";
4
8
  import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
5
9
 
6
10
  import { demoEntity } from "../features/demo/feature";
11
+ import { inspectionEntity } from "../features/examples/feature";
7
12
 
8
13
  // Gives the list view real rows with status/flag variety. price (money)
9
14
  // stays empty — the money input shows in the edit screen, in the seed we leave
@@ -16,6 +21,8 @@ const STATUSES = ["draft", "review", "published", "archived"] as const;
16
21
  // otherwise change every run.
17
22
  export const FIRST_STYLEGUIDE_ITEM_ID = "00000000-0000-4000-8000-0000000000f1";
18
23
 
24
+ export const FIRST_STYLEGUIDE_INSPECTION_ID = "00000000-0000-4000-8000-0000000000f2";
25
+
19
26
  const demoItemTable = buildEntityTable("item", demoEntity);
20
27
 
21
28
  export const seedStyleguideItems: SeedFn = async (stack) => {
@@ -58,3 +65,97 @@ export const seedStyleguideItems: SeedFn = async (stack) => {
58
65
  }
59
66
  }
60
67
  };
68
+
69
+ const inspectionTable = buildEntityTable("inspection", inspectionEntity);
70
+
71
+ // 1x1 PNG, enough for the thumbnail chain; the bytes only need to be a valid image.
72
+ const SEED_PHOTO_PNG = Uint8Array.from(
73
+ Buffer.from(
74
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
75
+ "base64",
76
+ ),
77
+ );
78
+
79
+ // Fixed id for the inspection-checklist screenshot scenarios. Created through the executor like
80
+ // the first demo item. Photos are real file refs attached to checkpoints.photos, created
81
+ // server-side the way POST /files would (a not_ok checkpoint requires a photo).
82
+ export const seedStyleguideInspection: SeedFn = async (stack) => {
83
+ if (await fetchOne(stack.db, inspectionTable, { id: FIRST_STYLEGUIDE_INSPECTION_ID })) return;
84
+ const tenantId = TestUsers.admin.tenantId;
85
+ const tenantDb = createTenantDb({ db: stack.db, tenantId });
86
+ const resolver = stack.context._fileProviderResolver;
87
+ const files = resolver ? createTenantFileContext(resolver, tenantId) : stack.context.files;
88
+
89
+ const photoIds: string[] = [];
90
+ for (const fileName of ["hose.png", "bearing.png"]) {
91
+ const photo = await createFileRefFromBytes(
92
+ {
93
+ db: tenantDb,
94
+ services: { files },
95
+ registry: stack.registry,
96
+ user: createSystemUser(tenantId),
97
+ },
98
+ {
99
+ fileName,
100
+ mimeType: "image/png",
101
+ data: SEED_PHOTO_PNG,
102
+ attachTo: {
103
+ entityType: "inspection",
104
+ fieldName: "checkpoints",
105
+ subField: "photos",
106
+ entityId: FIRST_STYLEGUIDE_INSPECTION_ID,
107
+ },
108
+ },
109
+ );
110
+ if (!photo.isSuccess) {
111
+ // biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
112
+ console.error("[seed] styleguide inspection photo failed:", photo.error);
113
+ return;
114
+ }
115
+ photoIds.push(photo.data.id);
116
+ }
117
+
118
+ const { executor } = createEntityExecutor("inspection", inspectionEntity);
119
+ const res = await executor.create(
120
+ {
121
+ id: FIRST_STYLEGUIDE_INSPECTION_ID,
122
+ measuringDevice: "Torque wrench TW-200",
123
+ checkpoints: [
124
+ { label: "Guard fitted", kind: "check", result: "ok" },
125
+ {
126
+ label: "Hydraulic hose intact",
127
+ kind: "check",
128
+ result: "not_ok",
129
+ finding: "Hose chafed at the clamp, replace before next shift.",
130
+ photos: [photoIds[0]],
131
+ },
132
+ {
133
+ label: "Bolt torque (Nm)",
134
+ kind: "measurement",
135
+ min: 180,
136
+ max: 200,
137
+ measured: 190,
138
+ result: "ok",
139
+ },
140
+ {
141
+ label: "Bearing play (mm)",
142
+ kind: "measurement",
143
+ min: 0.05,
144
+ max: 0.2,
145
+ measured: 0.31,
146
+ result: "not_ok",
147
+ finding: "Play above tolerance, re-measure after re-seating.",
148
+ photos: [photoIds[1]],
149
+ },
150
+ ],
151
+ continuedOperation: "with_condition",
152
+ condition: "Operate at reduced load until the hose is replaced.",
153
+ },
154
+ TestUsers.admin,
155
+ tenantDb,
156
+ );
157
+ if (!res.isSuccess) {
158
+ // biome-ignore lint/suspicious/noConsole: dev-seed surfaces failures in the runner log
159
+ console.error("[seed] styleguide inspection failed:", res.error);
160
+ }
161
+ };
@@ -22,7 +22,7 @@ import { aiTextDemoFeature } from "../features/widgets/ai-text-demo";
22
22
  import { widgetsFeature } from "../features/widgets/feature";
23
23
  import { workboardFeature } from "../features/workboard/feature";
24
24
  import { seedWorkboardTasks } from "../features/workboard/seed";
25
- import { seedStyleguideItems } from "./seed";
25
+ import { seedStyleguideInspection, seedStyleguideItems } from "./seed";
26
26
 
27
27
  const configResolver = createConfigResolver();
28
28
 
@@ -46,7 +46,7 @@ await runDevApp({
46
46
  clientEntry: "./src/app/client.tsx",
47
47
  htmlPath: "./public/index.html",
48
48
  watchDirs: ["./src", "../../../packages/*/src"],
49
- seeds: [seedStyleguideItems, seedAiConnections, seedWorkboardTasks],
49
+ seeds: [seedStyleguideItems, seedStyleguideInspection, seedAiConnections, seedWorkboardTasks],
50
50
  extraContext: ({ registry }) => ({
51
51
  configResolver,
52
52
  configAccessorFactory: createConfigAccessorFactory(registry, configResolver),
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  createBooleanField,
8
8
  createDateField,
9
+ createEmbeddedListField,
9
10
  createEntity,
10
11
  createImageField,
11
12
  createLocatedTimestampField,
@@ -70,6 +71,65 @@ export const deliveryEntity = createEntity({
70
71
  },
71
72
  });
72
73
 
74
+ // Inspection — the inspection-checklist recipe's protocol (sub-fields, derived result,
75
+ // conditions), rendered as the phone checklist in the auto-form.
76
+ const isMeasurement = { field: "kind", eq: "measurement" } as const;
77
+ const isNotOk = { field: "result", eq: "not_ok" } as const;
78
+
79
+ export const inspectionEntity = createEntity({
80
+ table: "read_examples_inspection",
81
+ fields: {
82
+ measuringDevice: createTextField({ personal: false, reason: "is_business_data" }),
83
+ checkpoints: createEmbeddedListField(
84
+ {
85
+ label: { type: "text", required: true },
86
+ kind: { type: "select", options: ["check", "measurement"], required: true },
87
+ min: { type: "number", visibleWhen: isMeasurement },
88
+ max: { type: "number", visibleWhen: isMeasurement },
89
+ measured: { type: "number", visibleWhen: isMeasurement },
90
+ result: {
91
+ type: "select",
92
+ options: ["ok", "not_ok"],
93
+ display: "radio",
94
+ optionTones: { ok: "ok", not_ok: "bad" },
95
+ },
96
+ finding: { type: "text", visibleWhen: isNotOk, requiredWhen: isNotOk },
97
+ photos: {
98
+ type: "images",
99
+ capture: "environment",
100
+ maxCount: 5,
101
+ visibleWhen: isNotOk,
102
+ requiredWhen: isNotOk,
103
+ },
104
+ },
105
+ {
106
+ personal: false,
107
+ reason: "is_business_data",
108
+ derived: {
109
+ result: {
110
+ op: "inRange",
111
+ from: ["measured", "min", "max"],
112
+ inside: "ok",
113
+ outside: "not_ok",
114
+ when: isMeasurement,
115
+ },
116
+ },
117
+ },
118
+ ),
119
+ continuedOperation: createSelectField({
120
+ options: ["yes", "with_condition", "no"],
121
+ display: "radio",
122
+ optionTones: { yes: "ok", with_condition: "warn", no: "bad" },
123
+ }),
124
+ condition: createTextField({
125
+ personal: false,
126
+ reason: "is_business_data",
127
+ visibleWhen: { field: "continuedOperation", eq: "with_condition" },
128
+ requiredWhen: { field: "continuedOperation", eq: "with_condition" },
129
+ }),
130
+ },
131
+ });
132
+
73
133
  const openReason =
74
134
  "demo app: any signed-in user edits the shipping, profile and delivery example " +
75
135
  "forms; there is no per-user ownership in this sample";
@@ -194,6 +254,35 @@ export const examplesFeature = defineFeature("examples", (r) => {
194
254
  },
195
255
  });
196
256
 
257
+ r.crud("inspection", inspectionEntity, editFormOnly);
258
+
259
+ r.screen({
260
+ id: "inspection-edit",
261
+ type: "entityEdit",
262
+ entity: "inspection",
263
+ submitLabel: "examples:inspection:submit",
264
+ layout: {
265
+ width: "full",
266
+ sections: [
267
+ {
268
+ columns: 1,
269
+ fields: [
270
+ "measuringDevice",
271
+ {
272
+ field: "checkpoints",
273
+ embeddedList: {
274
+ readOnlyCells: ["label", "kind", "min", "max"],
275
+ cardHiddenCells: ["kind"],
276
+ fixedRows: true,
277
+ },
278
+ },
279
+ ],
280
+ },
281
+ { columns: 1, fields: ["continuedOperation", "condition"] },
282
+ ],
283
+ },
284
+ });
285
+
197
286
  r.nav({ id: "examples", label: "examples:nav.examples", icon: "folder", order: 30 });
198
287
  r.nav({
199
288
  id: "shipping",
@@ -219,4 +308,12 @@ export const examplesFeature = defineFeature("examples", (r) => {
219
308
  icon: "calendar",
220
309
  order: 30,
221
310
  });
311
+ r.nav({
312
+ id: "inspection",
313
+ label: "examples:nav.inspection",
314
+ parent: "examples:nav:examples",
315
+ screen: "examples:screen:inspection-edit",
316
+ icon: "file",
317
+ order: 40,
318
+ });
222
319
  });
@@ -69,4 +69,51 @@ export const EXAMPLES_I18N: Readonly<Record<string, LocalizedString>> = {
69
69
  en: "Booked at (UTC instant)",
70
70
  },
71
71
  "examples:entity:delivery:field:homeZone": { de: "Heimatzone Kurier", en: "Courier home zone" },
72
+ "examples:nav.inspection": { de: "Prüfprotokoll", en: "Inspection checklist" },
73
+ "screen:inspection-edit.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
74
+ "screen:inspection-edit.create.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
75
+ "screen:inspection-edit.create.subtitle": {
76
+ de: "Prüfpunkte abhaken oder messen, bei Abweichung Befund und Fotos erfassen.",
77
+ en: "Check or measure each point, record a finding and photos on deviation.",
78
+ },
79
+ "screen:inspection-edit.edit.title": { de: "Prüfprotokoll", en: "Inspection checklist" },
80
+ "screen:inspection-edit.edit.subtitle": {
81
+ de: "Prüfpunkte abhaken oder messen, bei Abweichung Befund und Fotos erfassen.",
82
+ en: "Check or measure each point, record a finding and photos on deviation.",
83
+ },
84
+ "examples:inspection:submit": { de: "Protokoll speichern", en: "Save protocol" },
85
+ "examples:entity:inspection:field:measuringDevice": { de: "Messgerät", en: "Measuring device" },
86
+ "examples:entity:inspection:field:checkpoints": { de: "Prüfpunkte", en: "Checkpoints" },
87
+ "examples:entity:inspection:field:checkpoints:cell:label": { de: "Prüfpunkt", en: "Checkpoint" },
88
+ "examples:entity:inspection:field:checkpoints:cell:kind": { de: "Art", en: "Kind" },
89
+ "examples:entity:inspection:field:checkpoints:cell:min": { de: "Min", en: "Min" },
90
+ "examples:entity:inspection:field:checkpoints:cell:max": { de: "Max", en: "Max" },
91
+ "examples:entity:inspection:field:checkpoints:cell:measured": { de: "Messwert", en: "Measured" },
92
+ "examples:entity:inspection:field:checkpoints:cell:result": { de: "Ergebnis", en: "Result" },
93
+ "examples:entity:inspection:field:checkpoints:cell:finding": { de: "Befund", en: "Finding" },
94
+ "examples:entity:inspection:field:checkpoints:cell:photos": { de: "Fotos", en: "Photos" },
95
+ "examples:entity:inspection:field:continuedOperation": {
96
+ de: "Weiterbetrieb",
97
+ en: "Continued operation",
98
+ },
99
+ "examples:entity:inspection:field:condition": { de: "Auflage", en: "Condition" },
100
+ "examples:entity:inspection:field:checkpoints:cell:kind:option:check": {
101
+ de: "Sichtprüfung",
102
+ en: "Check",
103
+ },
104
+ "examples:entity:inspection:field:checkpoints:cell:kind:option:measurement": {
105
+ de: "Messung",
106
+ en: "Measurement",
107
+ },
108
+ "examples:entity:inspection:field:checkpoints:cell:result:option:ok": { de: "i.O.", en: "OK" },
109
+ "examples:entity:inspection:field:checkpoints:cell:result:option:not_ok": {
110
+ de: "n.i.O.",
111
+ en: "Not OK",
112
+ },
113
+ "examples:entity:inspection:field:continuedOperation:option:yes": { de: "Ja", en: "Yes" },
114
+ "examples:entity:inspection:field:continuedOperation:option:with_condition": {
115
+ de: "Mit Auflage",
116
+ en: "With condition",
117
+ },
118
+ "examples:entity:inspection:field:continuedOperation:option:no": { de: "Nein", en: "No" },
72
119
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
3
- "featureCount": 89,
3
+ "featureCount": 90,
4
4
  "features": [
5
5
  {
6
6
  "name": "account-security",
@@ -472,7 +472,7 @@
472
472
  },
473
473
  {
474
474
  "name": "channel-in-app",
475
- "description": "Persists notifications to an in-app inbox table so users can retrieve them via `handlers.inbox` and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. Requires `delivery`; no external service needed — messages are stored in the app's own database.",
475
+ "description": "Persists notifications to an in-app inbox table so users can page through them via `queries.inbox` (cursor, total, parsed `target`) and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. A notification links to a screen when `data.target` is set with `inAppTarget(screenId, entityId?)`. The renderer shell shows a bell with a live unread badge whenever this feature is mounted. Requires `delivery`; no external service needed — messages are stored in the app's own database.",
476
476
  "toggleableDefault": null,
477
477
  "requires": [
478
478
  "delivery"
@@ -914,6 +914,35 @@
914
914
  "recommended": false
915
915
  }
916
916
  },
917
+ {
918
+ "name": "documents",
919
+ "description": "Filed documents with title, category, issue and expiry date and one file, shown on the records they are linked to. A document is visible only through a linked record the viewer can read, and only while the record that owns its file is readable too, so linking never widens access to the file. Documents arrive through capture rules (a file saved into a configured field) or through the system-only import for migrations, which keeps the caller's id, refuses an id another record already uses, and stamps createdAt with the import time (issuedAt carries the business date; the event store does not backdate); users link, unlink, edit and delete them. Read a record's documents with for-record, count expired and expiring ones with expiry-overview, list them with an expiry state and an expiring filter with overview (the documents list screen), and add the documents tab to a record screen with documentsTab().",
920
+ "toggleableDefault": null,
921
+ "adoptsBundledLists": true,
922
+ "requires": [
923
+ "files"
924
+ ],
925
+ "optionalRequires": [],
926
+ "configReads": [],
927
+ "exposesApis": [],
928
+ "usesApis": [],
929
+ "extensionsUsed": [],
930
+ "configKeys": [],
931
+ "secrets": [],
932
+ "writeHandlers": [
933
+ "documents:write:filed-document:create",
934
+ "documents:write:filed-document:delete",
935
+ "documents:write:filed-document:update",
936
+ "documents:write:import",
937
+ "documents:write:link",
938
+ "documents:write:unlink"
939
+ ],
940
+ "uiHints": {
941
+ "displayLabel": "Documents",
942
+ "category": "data",
943
+ "recommended": false
944
+ }
945
+ },
917
946
  {
918
947
  "name": "feature-toggles",
919
948
  "description": "Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: \"shared\"` syncs the snapshot across instances whenever a `toggle-set` event is appended.",
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": 1,
3
+ "tables": ["read_filed_document_links", "read_filed_documents"]
4
+ }
@@ -12,7 +12,11 @@
12
12
 
13
13
  import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
14
14
  import type { RunDevAppOptions } from "@cosmicdrift/kumiko-dev-server";
15
- import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ FILE_STORAGE_PROVIDER_ENV,
17
+ SYSTEM_TENANT_ID,
18
+ type TenantId,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
16
20
  import { BUNDLED_LISTS } from "../bundled-lists";
17
21
  import { AUTH_COMPOSE_OPTIONS, BOOT_FEATURES, VALIDATE_BOOT_OPTIONS } from "../run-config";
18
22
  import { ADMIN_EMAIL, ADMIN_PASSWORD, BETA_TENANT_ID, DEV_TENANT_ID } from "./auth-constants";
@@ -31,7 +35,12 @@ export const devAppOptions = {
31
35
  // it stays out of APP_FEATURES. appScreensFeature places the user-profile screen.
32
36
  features: [...BOOT_FEATURES, appScreensFeature, notesFeature, invoiceReportsFeature],
33
37
  featureToggles: true,
34
- validateBootOptions: VALIDATE_BOOT_OPTIONS,
38
+ // documents has a file field, so the boot gate wants a storage provider; the seeded
39
+ // file-foundation config selects the real one, so only the validation env names it.
40
+ validateBootOptions: {
41
+ ...VALIDATE_BOOT_OPTIONS,
42
+ env: { [FILE_STORAGE_PROVIDER_ENV]: "inmemory", ...process.env },
43
+ },
35
44
  bundledLists: BUNDLED_LISTS,
36
45
  port: Number.parseInt(process.env["PORT"] ?? "4186", 10),
37
46
  clientEntry: "./src/app/client.tsx",
@@ -43,6 +43,7 @@ import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features
43
43
  import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
44
44
  import { createDerivativesSharpFeature } from "@cosmicdrift/kumiko-bundled-features/derivatives-sharp";
45
45
  import { createDocumentIngestFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/document-ingest-foundation";
46
+ import { createDocumentsFeature } from "@cosmicdrift/kumiko-bundled-features/documents";
46
47
  import {
47
48
  createFeatureTogglesFeature,
48
49
  FEATURE_TOGGLES_FEATURE,
@@ -406,6 +407,12 @@ export const APP_FEATURES = [
406
407
  // user-data-rights — both mounted here.
407
408
  createFormDraftUserDataFeature(),
408
409
  createFoldersFeature(),
410
+ // documents: filed files visible through the records they link to; folder stands in for a host.
411
+ createDocumentsFeature({
412
+ categories: ["contract", "other"],
413
+ linkTargets: ["folder"],
414
+ adminRoles: ["TenantAdmin", "SystemAdmin"],
415
+ }),
409
416
  // folders-user-data: GDPR hooks for folder entities. Depends (optionally)
410
417
  // on folders + (hard) on user-data-rights — both mounted above.
411
418
  createFoldersUserDataFeature(),
@@ -0,0 +1,27 @@
1
+ # Derived fields (SQL)
2
+
3
+ A derived field is read-time only: no column, never in a write schema. A `sql` derived field is
4
+ computed inside the list SELECT as the viewer, so it can be sorted on the server and never
5
+ enters the entity cache. A viewer who cannot read a field the value depends on gets NULL.
6
+
7
+ ## What it shows
8
+
9
+ - `coalesce`: the nickname when set, else the name.
10
+ - `related` + `count` with a `where`: open tickets per building.
11
+ - `related` + `first`: the title of the newest ticket.
12
+ - `related` + `first` on a money field with `valueType: "money"`: amount and currency come
13
+ back in the shape of a stored money field (no key when the value is unset).
14
+ - `sortable: true` on a derived field and an `entityList` `defaultSort` on it.
15
+
16
+ ## Source
17
+
18
+ Feature entry point: `src/feature.ts`.
19
+
20
+ ## Tests
21
+
22
+ ```bash
23
+ cd samples/recipes/derived-fields-sql
24
+ bun test
25
+ ```
26
+
27
+ `src/__tests__/feature.integration.test.ts` runs the list query over HTTP.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-sample-derived-fields-sql",
3
+ "description": "SQL derived fields on a list: coalesce, related count with a where, related first (text and money), sortable columns and a defaultSort on a derived value.",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@cosmicdrift/kumiko-framework": "workspace:*"
7
+ },
8
+ "devDependencies": {
9
+ "@cosmicdrift/kumiko-testing": "workspace:*"
10
+ }
11
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ createDateField,
3
+ createDerivedField,
4
+ createEntity,
5
+ createMoneyField,
6
+ createSelectField,
7
+ createTextField,
8
+ defineFeature,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+
11
+ const openAccess = {
12
+ access: {
13
+ openToAll: { reason: "recipe: any signed-in user reads and edits; no per-user ownership" },
14
+ },
15
+ } as const;
16
+
17
+ export const ticketEntity = createEntity({
18
+ table: "read_derived_sql_tickets",
19
+ fields: {
20
+ buildingId: { type: "reference", entity: "building" },
21
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
22
+ status: createSelectField({ options: ["open", "inProgress", "done"] as const }),
23
+ openedOn: createDateField({ required: true }),
24
+ cost: createMoneyField(),
25
+ },
26
+ // The sortable derived columns of the building run a subquery per row over this column.
27
+ indexes: [{ columns: ["buildingId"] }],
28
+ });
29
+
30
+ export const buildingEntity = createEntity({
31
+ table: "read_derived_sql_buildings",
32
+ fields: {
33
+ name: createTextField({
34
+ personal: false,
35
+ reason: "is_business_data",
36
+ required: true,
37
+ searchable: true,
38
+ }),
39
+ nickname: createTextField({ personal: false, reason: "is_business_data" }),
40
+ },
41
+ // Computed inside the list SELECT as the viewer; never stored, never cached.
42
+ derivedFields: {
43
+ // First non-null of the columns, so the list shows the nickname when there is one.
44
+ shownName: createDerivedField({
45
+ valueType: "text",
46
+ sortable: true,
47
+ sql: { kind: "coalesce", fields: ["nickname", "name"] },
48
+ }),
49
+ openTickets: createDerivedField({
50
+ valueType: "number",
51
+ sortable: true,
52
+ sql: {
53
+ kind: "related",
54
+ entity: "ticket",
55
+ on: { field: "buildingId" },
56
+ measure: { kind: "count" },
57
+ where: [{ field: "status", op: "in", value: ["open", "inProgress"] }],
58
+ },
59
+ }),
60
+ latestTicket: createDerivedField({
61
+ valueType: "text",
62
+ sql: {
63
+ kind: "related",
64
+ entity: "ticket",
65
+ on: { field: "buildingId" },
66
+ measure: { kind: "first", field: "title", orderBy: "openedOn", direction: "desc" },
67
+ },
68
+ }),
69
+ // Money reads amount and currency together and has the shape of a stored money field.
70
+ latestCost: createDerivedField({
71
+ valueType: "money",
72
+ sortable: true,
73
+ sql: {
74
+ kind: "related",
75
+ entity: "ticket",
76
+ on: { field: "buildingId" },
77
+ measure: { kind: "first", field: "cost", orderBy: "openedOn", direction: "desc" },
78
+ },
79
+ }),
80
+ },
81
+ });
82
+
83
+ export const buildingListSort = { field: "openTickets", dir: "desc" } as const;
84
+
85
+ export const derivedFieldsSqlFeature = defineFeature("derived-fields-sql", (r) => {
86
+ r.crud("ticket", ticketEntity, { write: openAccess, read: openAccess });
87
+ r.crud("building", buildingEntity, { write: openAccess, read: openAccess });
88
+
89
+ r.screen({
90
+ id: "building-list",
91
+ type: "entityList",
92
+ entity: "building",
93
+ columns: ["shownName", "openTickets", "latestTicket", "latestCost"],
94
+ // A derived column sorts on the server only when it is `sortable: true`.
95
+ defaultSort: buildingListSort,
96
+ access: openAccess.access,
97
+ });
98
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-sample-documents",
3
+ "description": "A document register on top of existing records: captured files, expiry dates and per-record visibility.",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@cosmicdrift/kumiko-bundled-features": "workspace:*",
7
+ "@cosmicdrift/kumiko-framework": "workspace:*"
8
+ },
9
+ "devDependencies": {
10
+ "@cosmicdrift/kumiko-testing": "workspace:*"
11
+ }
12
+ }
@@ -0,0 +1,66 @@
1
+ // kumiko-feature-version: 1
2
+ // Documents Sample
3
+ //
4
+ // A `contract` entity with a file field becomes a document source without knowing about the
5
+ // documents feature: the capture rule below files every saved contract file as a document of
6
+ // category "contract" and links it to the contract and to its property. Who sees the document
7
+ // follows from who may read the linked records and the contract that owns the file.
8
+
9
+ import { createDocumentsFeature } from "@cosmicdrift/kumiko-bundled-features/documents";
10
+ import {
11
+ createEntity,
12
+ createFileField,
13
+ createTextField,
14
+ defineEntityCreateHandler,
15
+ defineEntityDetailHandler,
16
+ defineEntityListHandler,
17
+ defineEntityUpdateHandler,
18
+ defineFeature,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+
21
+ const text = () =>
22
+ createTextField({ personal: false, reason: "is_business_data", required: true, maxLength: 120 });
23
+
24
+ export const propertyEntity = createEntity({
25
+ table: "read_sample_documents_properties",
26
+ fields: { name: text() },
27
+ access: { read: { PropertyReader: "all", TenantAdmin: "all" } },
28
+ });
29
+
30
+ export const contractEntity = createEntity({
31
+ table: "read_sample_documents_contracts",
32
+ fields: { name: text(), propertyId: text(), file: createFileField() },
33
+ access: { read: { ContractReader: "all", TenantAdmin: "all" } },
34
+ });
35
+
36
+ const access = { roles: ["PropertyReader", "ContractReader", "TenantAdmin"] };
37
+
38
+ export const propertyManagement = defineFeature("property-management", (r) => {
39
+ r.requires({ features: ["files", "documents"] });
40
+ r.entity({ name: "property", ...propertyEntity });
41
+ r.entity({ name: "contract", ...contractEntity });
42
+ for (const [name, entity] of [
43
+ ["property", propertyEntity],
44
+ ["contract", contractEntity],
45
+ ] as const) {
46
+ r.writeHandler(defineEntityCreateHandler(name, entity, { access }));
47
+ r.writeHandler(defineEntityUpdateHandler(name, entity, { access: { roles: ["TenantAdmin"] } }));
48
+ r.queryHandler(defineEntityListHandler(name, entity, { access }));
49
+ r.queryHandler(defineEntityDetailHandler(name, entity, { access }));
50
+ }
51
+ });
52
+
53
+ export const documents = createDocumentsFeature({
54
+ categories: ["contract", "insurance"],
55
+ linkTargets: ["property", "contract"],
56
+ access,
57
+ capture: [
58
+ {
59
+ entity: "contract",
60
+ field: "file",
61
+ category: "contract",
62
+ title: (row) => `Contract ${String(row["name"])}`,
63
+ links: (row) => [{ entityType: "property", entityId: String(row["propertyId"]) }],
64
+ },
65
+ ],
66
+ });
@@ -0,0 +1,27 @@
1
+ # Inspection checklist
2
+
3
+ An embedded list as a phone inspection checklist: the inspector walks fixed rows,
4
+ enters a measurement or a result, and documents deviations with a finding and photos.
5
+
6
+ ## What it shows
7
+
8
+ - **Conditional cells** (`src/entities/inspection-protocol.ts`): `visibleWhen` and
9
+ `requiredWhen` on sub-fields and on top-level fields. The server enforces
10
+ `requiredWhen` on create and update and answers with one `required` path per
11
+ missing cell (`checkpoints.0.finding`, `condition`).
12
+ - **Pass/fail by range**: the derived `inRange` cell turns `measured`, `min` and `max`
13
+ into `ok` or `not_ok` (bounds included). The server recomputes it, so a client-sent
14
+ result never survives a value outside the range. `when` limits it to measurement rows;
15
+ a check row keeps the result the inspector picked.
16
+ - **Photos per row**: an `images` sub-field. Upload with `entityType`, `fieldName` of the
17
+ embedded list and `subField` of the photo cell; files are bound, tenant-checked and
18
+ disposed like a top-level images field, always under the embedded list's field name.
19
+ - **Fixed rows**: `inspection-protocol:create-from-plan` copies label, kind and limits from
20
+ a plan; the edit screen keeps those cells read-only and the row count fixed. It sets
21
+ `layout.width: "full"` because the default form width pushes result, finding and photos
22
+ behind horizontal scroll.
23
+
24
+ ## Source
25
+
26
+ `src/feature.ts` wires the two entities, the handler and the screen. The integration test
27
+ covers every behavior above over real HTTP.