simple-module-records 0.0.8__py3-none-any.whl

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 (427) hide show
  1. simple_module_records-0.0.8.dist-info/METADATA +1658 -0
  2. simple_module_records-0.0.8.dist-info/RECORD +427 -0
  3. simple_module_records-0.0.8.dist-info/WHEEL +4 -0
  4. simple_module_records-0.0.8.dist-info/entry_points.txt +2 -0
  5. sm_records/__init__.py +6 -0
  6. sm_records/_body_limit.py +160 -0
  7. sm_records/_cross_tenant.py +101 -0
  8. sm_records/_grammar.py +286 -0
  9. sm_records/_health_counts.py +124 -0
  10. sm_records/_menu_middleware.py +61 -0
  11. sm_records/_tenancy_mode.py +111 -0
  12. sm_records/_tenant_guard.py +100 -0
  13. sm_records/_text.py +53 -0
  14. sm_records/boot.py +117 -0
  15. sm_records/cli.py +196 -0
  16. sm_records/cli_common.py +110 -0
  17. sm_records/cli_io.py +208 -0
  18. sm_records/cli_reindex.py +131 -0
  19. sm_records/cli_verify.py +91 -0
  20. sm_records/collections.py +85 -0
  21. sm_records/components/BulkRefusalReport.test.tsx +122 -0
  22. sm_records/components/BulkRefusalReport.tsx +95 -0
  23. sm_records/components/ColumnChooser.tsx +86 -0
  24. sm_records/components/ColumnChooserPanel.test.tsx +253 -0
  25. sm_records/components/ColumnChooserPanel.tsx +191 -0
  26. sm_records/components/ColumnOptionRow.tsx +170 -0
  27. sm_records/components/ConfirmDialog.test.tsx +121 -0
  28. sm_records/components/ConfirmDialog.tsx +142 -0
  29. sm_records/components/ConflictPanel.tsx +87 -0
  30. sm_records/components/EmptyTrashButton.test.tsx +132 -0
  31. sm_records/components/EmptyTrashButton.tsx +151 -0
  32. sm_records/components/FilterBar.test.tsx +202 -0
  33. sm_records/components/FilterBar.tsx +223 -0
  34. sm_records/components/FilterValueInput.test.tsx +98 -0
  35. sm_records/components/FilterValueInput.tsx +210 -0
  36. sm_records/components/ImportReportSummary.tsx +109 -0
  37. sm_records/components/InvalidBadge.test.tsx +112 -0
  38. sm_records/components/InvalidFilter.test.tsx +79 -0
  39. sm_records/components/InvalidNotice.tsx +47 -0
  40. sm_records/components/JsonField.tsx +48 -0
  41. sm_records/components/PageSizeSelect.tsx +42 -0
  42. sm_records/components/PagerButton.test.tsx +109 -0
  43. sm_records/components/PagerButton.tsx +54 -0
  44. sm_records/components/RecordActions.test.tsx +108 -0
  45. sm_records/components/RecordActions.tsx +111 -0
  46. sm_records/components/RecordBulkToolbar.test.tsx +89 -0
  47. sm_records/components/RecordBulkToolbar.tsx +77 -0
  48. sm_records/components/RecordCardList.test.tsx +136 -0
  49. sm_records/components/RecordCardList.tsx +170 -0
  50. sm_records/components/RecordCell.test.tsx +229 -0
  51. sm_records/components/RecordCell.tsx +228 -0
  52. sm_records/components/RecordColumnCells.tsx +169 -0
  53. sm_records/components/RecordCursorPager.tsx +46 -0
  54. sm_records/components/RecordDeleteDialog.test.tsx +111 -0
  55. sm_records/components/RecordDeleteDialog.tsx +215 -0
  56. sm_records/components/RecordEditorActionsRow.tsx +85 -0
  57. sm_records/components/RecordEditorTypeLink.test.tsx +22 -0
  58. sm_records/components/RecordEditorTypeLink.tsx +19 -0
  59. sm_records/components/RecordEnvelopeFields.tsx +194 -0
  60. sm_records/components/RecordForm.tsx +55 -0
  61. sm_records/components/RecordImportOptions.tsx +152 -0
  62. sm_records/components/RecordIoMenu.test.tsx +102 -0
  63. sm_records/components/RecordIoMenu.tsx +252 -0
  64. sm_records/components/RecordListActions.test.tsx +128 -0
  65. sm_records/components/RecordListActions.tsx +118 -0
  66. sm_records/components/RecordListBulk.tsx +109 -0
  67. sm_records/components/RecordListEmpty.test.tsx +90 -0
  68. sm_records/components/RecordListEmpty.tsx +127 -0
  69. sm_records/components/RecordListFooter.tsx +66 -0
  70. sm_records/components/RecordListPublicUrl.test.tsx +23 -0
  71. sm_records/components/RecordListPublicUrl.tsx +65 -0
  72. sm_records/components/RecordPagination.capped.test.tsx +50 -0
  73. sm_records/components/RecordPagination.test.tsx +166 -0
  74. sm_records/components/RecordPagination.tsx +236 -0
  75. sm_records/components/RecordReferrers.tsx +201 -0
  76. sm_records/components/RecordRevisions.test.tsx +23 -0
  77. sm_records/components/RecordRevisions.tsx +245 -0
  78. sm_records/components/RecordRowAction.test.tsx +132 -0
  79. sm_records/components/RecordRowAction.tsx +123 -0
  80. sm_records/components/RecordSelectCell.tsx +66 -0
  81. sm_records/components/RecordStatusBadge.test.tsx +29 -0
  82. sm_records/components/RecordStatusBadge.tsx +124 -0
  83. sm_records/components/RecordTable.columns.test.tsx +254 -0
  84. sm_records/components/RecordTable.test.tsx +196 -0
  85. sm_records/components/RecordTable.tsx +232 -0
  86. sm_records/components/RecordTranslations.test.tsx +57 -0
  87. sm_records/components/RecordTranslations.tsx +144 -0
  88. sm_records/components/RecordsLayout.tsx +15 -0
  89. sm_records/components/RecordsToaster.tsx +10 -0
  90. sm_records/components/RelationChips.tsx +73 -0
  91. sm_records/components/RelationPicker.tsx +279 -0
  92. sm_records/components/SortableHeader.tsx +42 -0
  93. sm_records/components/TenantBadge.test.tsx +39 -0
  94. sm_records/components/TenantBadge.tsx +33 -0
  95. sm_records/components/TypesCardList.tsx +182 -0
  96. sm_records/components/fields/BooleanField.test.tsx +80 -0
  97. sm_records/components/fields/BooleanField.tsx +85 -0
  98. sm_records/components/fields/ChoiceFields.test.tsx +40 -0
  99. sm_records/components/fields/ChoiceFields.tsx +104 -0
  100. sm_records/components/fields/FieldShell.tsx +110 -0
  101. sm_records/components/fields/MediaField.test.tsx +195 -0
  102. sm_records/components/fields/MediaField.tsx +115 -0
  103. sm_records/components/fields/RelationField.tsx +32 -0
  104. sm_records/components/fields/StructuredFields.tsx +37 -0
  105. sm_records/components/fields/TextFields.test.tsx +45 -0
  106. sm_records/components/fields/TextFields.tsx +146 -0
  107. sm_records/components/fields/index.test.ts +41 -0
  108. sm_records/components/fields/index.ts +54 -0
  109. sm_records/components/filterBarFields.ts +100 -0
  110. sm_records/components/media/MediaApiContext.tsx +34 -0
  111. sm_records/components/media/MediaCell.test.tsx +283 -0
  112. sm_records/components/media/MediaCell.tsx +45 -0
  113. sm_records/components/media/MediaGrid.tsx +130 -0
  114. sm_records/components/media/MediaPickerDialog.test.tsx +270 -0
  115. sm_records/components/media/MediaPickerDialog.tsx +281 -0
  116. sm_records/components/media/MediaPreview.tsx +221 -0
  117. sm_records/components/media/MediaUpload.tsx +129 -0
  118. sm_records/components/media/catalog.test.ts +49 -0
  119. sm_records/components/trashToast.test.tsx +56 -0
  120. sm_records/components/trashToast.tsx +61 -0
  121. sm_records/components/typeeditor/ChoicesEditor.test.ts +29 -0
  122. sm_records/components/typeeditor/ChoicesEditor.tsx +189 -0
  123. sm_records/components/typeeditor/CollectionField.tsx +75 -0
  124. sm_records/components/typeeditor/DefaultInput.tsx +137 -0
  125. sm_records/components/typeeditor/DeleteTypeSection.test.tsx +88 -0
  126. sm_records/components/typeeditor/DeleteTypeSection.tsx +142 -0
  127. sm_records/components/typeeditor/DryRunReportView.tsx +46 -0
  128. sm_records/components/typeeditor/FieldList.test.tsx +94 -0
  129. sm_records/components/typeeditor/FieldList.tsx +163 -0
  130. sm_records/components/typeeditor/FieldOptions.tsx +160 -0
  131. sm_records/components/typeeditor/FieldRow.tsx +103 -0
  132. sm_records/components/typeeditor/FieldRowBody.test.tsx +85 -0
  133. sm_records/components/typeeditor/FieldRowBody.tsx +235 -0
  134. sm_records/components/typeeditor/FieldRowSummary.tsx +130 -0
  135. sm_records/components/typeeditor/FieldsCard.tsx +90 -0
  136. sm_records/components/typeeditor/IconField.tsx +77 -0
  137. sm_records/components/typeeditor/LastAppliedReport.tsx +94 -0
  138. sm_records/components/typeeditor/OrphanedConflictsList.tsx +26 -0
  139. sm_records/components/typeeditor/PointerFields.tsx +140 -0
  140. sm_records/components/typeeditor/PublicField.tsx +99 -0
  141. sm_records/components/typeeditor/ReindexStatus.tsx +98 -0
  142. sm_records/components/typeeditor/RelationOptions.tsx +101 -0
  143. sm_records/components/typeeditor/RolesMultiSelect.tsx +47 -0
  144. sm_records/components/typeeditor/SaveBar.test.tsx +37 -0
  145. sm_records/components/typeeditor/SaveBar.tsx +119 -0
  146. sm_records/components/typeeditor/SchemaChangeList.tsx +63 -0
  147. sm_records/components/typeeditor/SchemaConflictPanel.test.tsx +59 -0
  148. sm_records/components/typeeditor/SchemaConflictPanel.tsx +134 -0
  149. sm_records/components/typeeditor/SchemaPreviewPanel.test.tsx +121 -0
  150. sm_records/components/typeeditor/SchemaPreviewPanel.tsx +289 -0
  151. sm_records/components/typeeditor/SidebarField.test.tsx +47 -0
  152. sm_records/components/typeeditor/SidebarField.tsx +82 -0
  153. sm_records/components/typeeditor/TypeConflictNotice.tsx +51 -0
  154. sm_records/components/typeeditor/TypeImportDialog.test.tsx +154 -0
  155. sm_records/components/typeeditor/TypeImportDialog.tsx +149 -0
  156. sm_records/components/typeeditor/TypeIoMenu.test.tsx +37 -0
  157. sm_records/components/typeeditor/TypeIoMenu.tsx +135 -0
  158. sm_records/components/typeeditor/TypeMetadataForm.tsx +275 -0
  159. sm_records/components/typeeditor/TypeRevisions.tsx +211 -0
  160. sm_records/components/typeeditor/changeLabels.test.ts +92 -0
  161. sm_records/components/typeeditor/changeLabels.ts +61 -0
  162. sm_records/components/typeeditor/errors.ts +98 -0
  163. sm_records/components/typeeditor/focusInvalid.ts +83 -0
  164. sm_records/components/typeeditor/formHelpers.test.ts +190 -0
  165. sm_records/components/typeeditor/formHelpers.ts +218 -0
  166. sm_records/components/typeeditor/navIcons.ts +114 -0
  167. sm_records/components/typeeditor/reindexPending.test.ts +51 -0
  168. sm_records/components/typeeditor/reindexPending.ts +35 -0
  169. sm_records/components/typeeditor/rules.test.ts +257 -0
  170. sm_records/components/typeeditor/rules.ts +200 -0
  171. sm_records/components/typeeditor/types.ts +150 -0
  172. sm_records/components/widget/FieldValues.test.tsx +88 -0
  173. sm_records/components/widget/FieldValues.tsx +54 -0
  174. sm_records/components/widget/FieldsPicker.test.tsx +67 -0
  175. sm_records/components/widget/FieldsPicker.tsx +96 -0
  176. sm_records/components/widget/PublicMediaImage.tsx +21 -0
  177. sm_records/components/widget/RecordsListBlock.tsx +241 -0
  178. sm_records/components/widget/RecordsListRender.tsx +260 -0
  179. sm_records/components/widget/format.test.ts +156 -0
  180. sm_records/components/widget/format.ts +148 -0
  181. sm_records/components/widget/types.ts +60 -0
  182. sm_records/constants.py +221 -0
  183. sm_records/contracts/__init__.py +1 -0
  184. sm_records/contracts/_types.py +164 -0
  185. sm_records/contracts/aggregate.py +52 -0
  186. sm_records/contracts/bulk.py +163 -0
  187. sm_records/contracts/errors.py +114 -0
  188. sm_records/contracts/events.py +179 -0
  189. sm_records/contracts/i18n.py +76 -0
  190. sm_records/contracts/io.py +172 -0
  191. sm_records/contracts/public.py +123 -0
  192. sm_records/contracts/relations.py +117 -0
  193. sm_records/contracts/revisions.py +68 -0
  194. sm_records/contracts/schema_change.py +207 -0
  195. sm_records/contracts/schemas.py +269 -0
  196. sm_records/deferred.py +142 -0
  197. sm_records/deps.py +265 -0
  198. sm_records/endpoints/__init__.py +1 -0
  199. sm_records/endpoints/_list_view.py +120 -0
  200. sm_records/endpoints/api/__init__.py +72 -0
  201. sm_records/endpoints/api/_error_bodies.py +151 -0
  202. sm_records/endpoints/api/_error_table.py +255 -0
  203. sm_records/endpoints/api/_errors.py +248 -0
  204. sm_records/endpoints/api/_io_upload.py +155 -0
  205. sm_records/endpoints/api/_public_cache.py +175 -0
  206. sm_records/endpoints/api/_responses.py +107 -0
  207. sm_records/endpoints/api/aggregate.py +113 -0
  208. sm_records/endpoints/api/bulk.py +148 -0
  209. sm_records/endpoints/api/io.py +189 -0
  210. sm_records/endpoints/api/io_types.py +130 -0
  211. sm_records/endpoints/api/lifecycle.py +92 -0
  212. sm_records/endpoints/api/preview.py +186 -0
  213. sm_records/endpoints/api/public.py +230 -0
  214. sm_records/endpoints/api/records.py +248 -0
  215. sm_records/endpoints/api/referrers.py +121 -0
  216. sm_records/endpoints/api/revisions.py +221 -0
  217. sm_records/endpoints/api/translations.py +115 -0
  218. sm_records/endpoints/api/types.py +291 -0
  219. sm_records/endpoints/views.py +275 -0
  220. sm_records/endpoints/views_types.py +142 -0
  221. sm_records/events.py +220 -0
  222. sm_records/health.py +261 -0
  223. sm_records/hooks/useBulkActions.test.tsx +148 -0
  224. sm_records/hooks/useBulkActions.ts +170 -0
  225. sm_records/hooks/useIsNarrow.test.tsx +56 -0
  226. sm_records/hooks/useIsNarrow.ts +34 -0
  227. sm_records/hooks/useListColumns.ts +104 -0
  228. sm_records/hooks/useMediaFile.ts +72 -0
  229. sm_records/hooks/useRecordEditor.ts +297 -0
  230. sm_records/hooks/useRecordForm.ts +269 -0
  231. sm_records/hooks/useRecordImport.test.tsx +117 -0
  232. sm_records/hooks/useRecordImport.ts +125 -0
  233. sm_records/hooks/useRecordListMutations.test.tsx +85 -0
  234. sm_records/hooks/useRecordListMutations.ts +44 -0
  235. sm_records/hooks/useRecordListNav.ts +124 -0
  236. sm_records/hooks/useRecordSelection.test.tsx +121 -0
  237. sm_records/hooks/useRecordSelection.ts +78 -0
  238. sm_records/hooks/useRelationLabels.ts +104 -0
  239. sm_records/hooks/useSchemaApply.ts +90 -0
  240. sm_records/hooks/useTypeEditorSave.test.tsx +94 -0
  241. sm_records/hooks/useTypeEditorSave.ts +103 -0
  242. sm_records/hooks/useTypeImport.test.tsx +85 -0
  243. sm_records/hooks/useTypeImport.ts +50 -0
  244. sm_records/hooks/useTypeSync.test.tsx +115 -0
  245. sm_records/hooks/useTypeSync.ts +65 -0
  246. sm_records/hooks/useUnsavedGuard.ts +56 -0
  247. sm_records/index/__init__.py +65 -0
  248. sm_records/index/_analyze.py +83 -0
  249. sm_records/index/_batch.py +79 -0
  250. sm_records/index/_coerce.py +150 -0
  251. sm_records/index/_cursor.py +218 -0
  252. sm_records/index/_drift.py +78 -0
  253. sm_records/index/_fields.py +90 -0
  254. sm_records/index/_filters.py +158 -0
  255. sm_records/index/_fixed.py +279 -0
  256. sm_records/index/_predicates.py +228 -0
  257. sm_records/index/_prefix.py +89 -0
  258. sm_records/index/_reduce_write.py +183 -0
  259. sm_records/index/_registry.py +208 -0
  260. sm_records/index/_sorting.py +297 -0
  261. sm_records/index/aggregate.py +210 -0
  262. sm_records/index/providers.py +243 -0
  263. sm_records/index/query.py +292 -0
  264. sm_records/index/reduce.py +223 -0
  265. sm_records/index/reduce_rebuild.py +191 -0
  266. sm_records/index/reindex.py +262 -0
  267. sm_records/index/writer.py +273 -0
  268. sm_records/locales/en.json +700 -0
  269. sm_records/locales.py +106 -0
  270. sm_records/media.py +249 -0
  271. sm_records/menu.py +273 -0
  272. sm_records/models/__init__.py +73 -0
  273. sm_records/models/_base.py +58 -0
  274. sm_records/models/_factory.py +52 -0
  275. sm_records/models/_index.py +202 -0
  276. sm_records/models/_record.py +264 -0
  277. sm_records/models/_record_args.py +248 -0
  278. sm_records/models/_record_tables.py +56 -0
  279. sm_records/models/_reduce.py +96 -0
  280. sm_records/models/_tables.py +228 -0
  281. sm_records/models/_type.py +194 -0
  282. sm_records/module.py +286 -0
  283. sm_records/package.json +19 -0
  284. sm_records/page-tests/InvalidWorklistLink.test.tsx +79 -0
  285. sm_records/page-tests/RecordEditor.duplicate.test.tsx +174 -0
  286. sm_records/page-tests/RecordEditor.inflight.test.tsx +148 -0
  287. sm_records/page-tests/RecordEditor.invalid.test.tsx +111 -0
  288. sm_records/page-tests/RecordEditor.tenancy.test.tsx +102 -0
  289. sm_records/page-tests/RecordEditor.test.tsx +300 -0
  290. sm_records/page-tests/RecordList.columns.test.tsx +278 -0
  291. sm_records/page-tests/RecordList.cursor.test.tsx +190 -0
  292. sm_records/page-tests/RecordList.repeated.test.tsx +160 -0
  293. sm_records/page-tests/RecordList.test.tsx +158 -0
  294. sm_records/page-tests/Types.test.tsx +140 -0
  295. sm_records/pages/RecordEditor.tsx +285 -0
  296. sm_records/pages/RecordList.tsx +293 -0
  297. sm_records/pages/TypeEditor.tsx +272 -0
  298. sm_records/pages/Types.tsx +155 -0
  299. sm_records/puck-blocks.ts +20 -0
  300. sm_records/schema/__init__.py +1 -0
  301. sm_records/schema/_builders.py +260 -0
  302. sm_records/schema/_diff_rules.py +185 -0
  303. sm_records/schema/_keys.py +103 -0
  304. sm_records/schema/_scalars.py +216 -0
  305. sm_records/schema/changes.py +96 -0
  306. sm_records/schema/compile.py +272 -0
  307. sm_records/schema/diff.py +192 -0
  308. sm_records/schema/fields.py +274 -0
  309. sm_records/schema/types.py +103 -0
  310. sm_records/seed/__init__.py +25 -0
  311. sm_records/seed/_i18n.py +75 -0
  312. sm_records/seed/data.py +166 -0
  313. sm_records/seed/generate.py +179 -0
  314. sm_records/seed/log.py +18 -0
  315. sm_records/seed/plan.py +76 -0
  316. sm_records/seed/runner.py +268 -0
  317. sm_records/seed/types.py +294 -0
  318. sm_records/services/__init__.py +41 -0
  319. sm_records/services/_bulk_turn.py +188 -0
  320. sm_records/services/_claims.py +291 -0
  321. sm_records/services/_common.py +279 -0
  322. sm_records/services/_delete_plan.py +190 -0
  323. sm_records/services/_dry_run.py +269 -0
  324. sm_records/services/_duplicates.py +263 -0
  325. sm_records/services/_empty_trash.py +136 -0
  326. sm_records/services/_export_cells.py +182 -0
  327. sm_records/services/_import_checks.py +158 -0
  328. sm_records/services/_import_csv.py +191 -0
  329. sm_records/services/_import_match.py +167 -0
  330. sm_records/services/_import_parse.py +172 -0
  331. sm_records/services/_import_plan.py +238 -0
  332. sm_records/services/_import_rows.py +296 -0
  333. sm_records/services/_invalid.py +177 -0
  334. sm_records/services/_lifecycle.py +245 -0
  335. sm_records/services/_listing.py +182 -0
  336. sm_records/services/_lock.py +68 -0
  337. sm_records/services/_orphaned.py +120 -0
  338. sm_records/services/_payload.py +237 -0
  339. sm_records/services/_prepare.py +85 -0
  340. sm_records/services/_preview.py +89 -0
  341. sm_records/services/_referrer_page.py +170 -0
  342. sm_records/services/_referrer_sets.py +108 -0
  343. sm_records/services/_referrers.py +259 -0
  344. sm_records/services/_relations.py +140 -0
  345. sm_records/services/_rollback.py +73 -0
  346. sm_records/services/_schema.py +216 -0
  347. sm_records/services/_titles.py +57 -0
  348. sm_records/services/_translations.py +277 -0
  349. sm_records/services/_type_update.py +218 -0
  350. sm_records/services/_uuids.py +151 -0
  351. sm_records/services/aggregate.py +157 -0
  352. sm_records/services/bulk.py +185 -0
  353. sm_records/services/errors.py +225 -0
  354. sm_records/services/expand.py +252 -0
  355. sm_records/services/export.py +228 -0
  356. sm_records/services/import_.py +280 -0
  357. sm_records/services/preview_jobs.py +256 -0
  358. sm_records/services/preview_runner.py +99 -0
  359. sm_records/services/public.py +246 -0
  360. sm_records/services/records.py +278 -0
  361. sm_records/services/reindex_runner.py +275 -0
  362. sm_records/services/revisions.py +245 -0
  363. sm_records/services/schema_change.py +293 -0
  364. sm_records/services/types.py +259 -0
  365. sm_records/settings.py +264 -0
  366. sm_records/settings_boot.py +133 -0
  367. sm_records/settings_checks.py +172 -0
  368. sm_records/tenancy.py +233 -0
  369. sm_records/test-dom.tsx +121 -0
  370. sm_records/utils/api-history.ts +106 -0
  371. sm_records/utils/api-net.test.ts +34 -0
  372. sm_records/utils/api-net.ts +137 -0
  373. sm_records/utils/api-records.test.ts +68 -0
  374. sm_records/utils/api-records.ts +158 -0
  375. sm_records/utils/api.test.ts +158 -0
  376. sm_records/utils/api.ts +188 -0
  377. sm_records/utils/bulk-copy.test.ts +99 -0
  378. sm_records/utils/bulk-copy.ts +139 -0
  379. sm_records/utils/column-storage.test.ts +42 -0
  380. sm_records/utils/column-storage.ts +42 -0
  381. sm_records/utils/conflicts.test.ts +60 -0
  382. sm_records/utils/conflicts.ts +79 -0
  383. sm_records/utils/created-flag.ts +37 -0
  384. sm_records/utils/dry-run-messages.test.ts +35 -0
  385. sm_records/utils/dry-run-messages.ts +46 -0
  386. sm_records/utils/duplicate.test.ts +90 -0
  387. sm_records/utils/duplicate.ts +86 -0
  388. sm_records/utils/errors-display.test.ts +19 -0
  389. sm_records/utils/errors-display.ts +15 -0
  390. sm_records/utils/export-scope.ts +64 -0
  391. sm_records/utils/field-type-name.ts +34 -0
  392. sm_records/utils/filters.test.ts +137 -0
  393. sm_records/utils/filters.ts +169 -0
  394. sm_records/utils/focus-invalid.test.ts +34 -0
  395. sm_records/utils/focus-invalid.ts +78 -0
  396. sm_records/utils/format-bytes.ts +44 -0
  397. sm_records/utils/io.test.ts +234 -0
  398. sm_records/utils/io.ts +265 -0
  399. sm_records/utils/list-columns.ts +226 -0
  400. sm_records/utils/list-errors.ts +143 -0
  401. sm_records/utils/listing-cursor.test.ts +174 -0
  402. sm_records/utils/listing.columns.test.ts +279 -0
  403. sm_records/utils/listing.test.ts +186 -0
  404. sm_records/utils/listing.ts +227 -0
  405. sm_records/utils/locale.ts +21 -0
  406. sm_records/utils/media-api.test.ts +175 -0
  407. sm_records/utils/media-api.ts +299 -0
  408. sm_records/utils/media-cache.test.ts +35 -0
  409. sm_records/utils/media-test-support.ts +140 -0
  410. sm_records/utils/public-api.test.ts +168 -0
  411. sm_records/utils/public-api.ts +141 -0
  412. sm_records/utils/record-form-helpers.ts +51 -0
  413. sm_records/utils/record-title.test.ts +29 -0
  414. sm_records/utils/record-title.ts +31 -0
  415. sm_records/utils/record-types.test.ts +54 -0
  416. sm_records/utils/record-types.ts +249 -0
  417. sm_records/utils/relation-search.ts +55 -0
  418. sm_records/utils/selection.test.ts +76 -0
  419. sm_records/utils/selection.ts +83 -0
  420. sm_records/utils/translate.ts +13 -0
  421. sm_records/utils/type-io.test.ts +82 -0
  422. sm_records/utils/type-io.ts +130 -0
  423. sm_records/utils/types.ts +163 -0
  424. sm_records/utils/validation.test.ts +204 -0
  425. sm_records/utils/validation.ts +277 -0
  426. sm_records/utils/values.test.ts +243 -0
  427. sm_records/utils/values.ts +300 -0
@@ -0,0 +1,1658 @@
1
+ Metadata-Version: 2.5
2
+ Name: simple_module_records
3
+ Version: 0.0.8
4
+ Summary: Generic structured content: admin-defined record types stored as JSON documents with typed SQL indexes (YesSql-style document/index split)
5
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python_modules
6
+ Author-email: Anto Subash <antosubash@live.com>
7
+ License-Expression: MIT
8
+ Keywords: cms,content,headless,json,records,simple-module
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pydantic-settings>=2.0
11
+ Requires-Dist: simple-module-core<0.1,>=0.0.35
12
+ Requires-Dist: simple-module-db<0.1,>=0.0.35
13
+ Requires-Dist: simple-module-hosting<0.1,>=0.0.35
14
+ Requires-Dist: simple-module-inertia<0.1,>=0.0.35
15
+ Requires-Dist: simple-module-settings<0.1,>=0.0.35
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Requires-Dist: simple-module-test<0.1,>=0.0.35; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # simple_module_records
23
+
24
+ Generic structured content for SimpleModule hosts: an administrator defines
25
+ **Record Types** — arbitrary named schemas, each an ordered list of typed
26
+ field definitions — from the admin UI, and performs full CRUD on their
27
+ **Records**, without a developer writing a module, a SQLModel table, or an
28
+ Alembic migration for each one.
29
+
30
+ The storage shape follows [YesSql](https://github.com/sebastienros/yessql) and
31
+ the content layer Orchard Core builds on it: every record of every type is a
32
+ row in one document table with its field values in a `JSON` column, and a
33
+ declared field is projected into a separate, typed, real SQL index table so
34
+ it stays genuinely queryable. See `docs/plans/2026-09-19-records-module-design.md`
35
+ in the source repo for the full design.
36
+
37
+ ## Documentation
38
+
39
+ The rest of this file is the long-form reference. Start with
40
+ [`docs/index.md`](docs/index.md), which says who should read what.
41
+
42
+ - [`docs/user-guide.md`](docs/user-guide.md) — for administrators: every admin
43
+ screen in the order you meet it, with the exact UI labels.
44
+ - [`docs/api-reference.md`](docs/api-reference.md) — every endpoint, the query
45
+ grammar, the wire shapes, the error table and the file formats.
46
+ - [`docs/operations.md`](docs/operations.md) — install, migrations, settings,
47
+ the CLI, health checks and the deployment constraints.
48
+ - [`docs/architecture.md`](docs/architecture.md) — for contributors: the
49
+ document/index split, the schema-change pipeline and the extension points.
50
+ - [`docs/performance.md`](docs/performance.md) — what it costs at scale, and
51
+ how to measure it yourself.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install simple_module_records
57
+ ```
58
+
59
+ Add `simple_module_records` to your host's dependencies; for an in-repo
60
+ checkout, resolve it from the workspace:
61
+
62
+ ```toml
63
+ dependencies = ["simple_module_records"]
64
+
65
+ [tool.uv.sources.simple_module_records]
66
+ workspace = true
67
+ ```
68
+
69
+ Then run `make migration msg="add records"` and apply it with `make migrate`.
70
+ The module's first revision is labelled `records`. Note that
71
+ `alembic downgrade records@base` does **not** remove only this module: the
72
+ revision chains off your host's current head, so that command rolls back
73
+ every revision beneath it as well. To drop this module's tables alone,
74
+ downgrade to the revision *before* the records one, or drop the eleven
75
+ `records_*` tables directly (plus the eight `records_c_<name>_*` tables of
76
+ each collection your host declares).
77
+
78
+ ## Usage
79
+
80
+ Go to **Records** in the admin sidebar (`/admin/records`) to define a
81
+ type and its fields; a type that opts in gets its own entry there too. Each type gets a generic, schema-driven list screen and
82
+ form at `/admin/records/{key}` and `/admin/records/{key}/{uuid}` — one pair of
83
+ screens serves every type; nothing is generated per type.
84
+
85
+ The same operations are available as a JSON API under `/api/records`. Every
86
+ route on it requires a session and one of the three permissions below. A type
87
+ marked **`is_public`** additionally serves its *published* records to callers
88
+ with no session at all, under `public_route_prefix` — see
89
+ [Public read API](#public-read-api).
90
+
91
+ **If a field is not indexed, it is not queryable.** `data` is opaque storage;
92
+ no endpoint filters, sorts, or searches by extracting from it. A field must
93
+ be marked `indexed: true` on its type to be usable as a filter or sort key —
94
+ this is a deliberate hard rule, not a v1 limitation, and it is what keeps
95
+ performance a property of the schema rather than a cliff discovered under
96
+ load.
97
+
98
+ ### Sidebar entries
99
+
100
+ Every type is reachable through the **Record Types** entry above. A type can
101
+ also ask for an entry of its own, next to it: turn on **Show in sidebar** in
102
+ the type editor (`show_in_menu` on the API) and the admin sidebar gains an
103
+ item in its own *Records* group — right after *Content*, where the hub entry
104
+ itself lives — labelled with the type's plural, using the type's icon and
105
+ linking to its record list. The type gets its own group rather than joining
106
+ *Content* as a peer of pagebuilder's "Pages" or news' "Articles": nothing
107
+ would otherwise say the entry is a record type, and a type named "Pages"
108
+ would be ambiguous with pagebuilder's own. Off by default — a sidebar with an
109
+ item per type is unusable on an install with thirty of them — and the hub
110
+ entry stays whatever you do.
111
+
112
+ It is **not a schema change**: no classification, no dry run, no revision and
113
+ no `schema_version` bump, exactly like `is_public`. It round-trips through
114
+ export and import with the rest of the definition.
115
+
116
+ A type's `allowed_roles` narrow who sees the entry, deliberately: role
117
+ filtering in the menu is a plain intersection with no admin bypass, and so is
118
+ the check on the record list behind the link, so a caller the type excludes
119
+ would get a 403 from a link only they could see. An empty `allowed_roles` —
120
+ the default — means everyone with `records.view` sees it.
121
+
122
+ **The entry can lag by `menu_refresh_seconds` in a multi-worker host.** The
123
+ framework's menu registry is built once per process, at boot, and types are
124
+ created long after that; the worker that served your save re-reads the types
125
+ on its next page request, and the others within the window (5 seconds by
126
+ default, `0` to re-read on every page request). Nothing about the record data
127
+ is affected — only which links the sidebar is showing.
128
+
129
+ ### Choosing the list's columns
130
+
131
+ The record list shows, by default, Status, the type's first four indexed
132
+ fields (the display field is not repeated) plus the first media field,
133
+ Position, Published on and Updated. Its **Columns** menu picks any other set, up to eight declared fields
134
+ plus the record's own columns, in any order. A non-indexed field can be shown
135
+ too — every list row already carries its full `data` — but its header does
136
+ not sort, since only an indexed field is queryable.
137
+
138
+ The choice is written into the link as `?columns=key1,key2,...` (so a view is
139
+ shareable) and remembered per type in the browser's `localStorage` as that
140
+ viewer's default; a link that names its own columns wins, and **Reset to
141
+ default** clears both. `columns` is read by the list screen only — no JSON
142
+ endpoint takes it, and an export is always the full row.
143
+
144
+ ### Acting on many records at once
145
+
146
+ `POST /api/records/types/{key}/records/bulk` takes
147
+ `{"action": "trash"|"restore"|"purge"|"publish"|"unpublish", "uuids": [...]}`
148
+ and an optional `expected_versions` map, and the record list exposes it as a
149
+ tick box per row plus a toolbar. Every record goes through the same service
150
+ call the single-record endpoint makes, so `allowed_roles`, the `on_delete`
151
+ cascade, slug and `unique` claims, revisions and events all behave exactly as
152
+ they do for one record.
153
+
154
+ **It is all-or-nothing.** If even one record refuses — a `restrict` referrer,
155
+ a stale `expected_version`, a type the caller may not write, a payload the
156
+ current schema no longer accepts — nothing is written and the `409` carries a
157
+ report naming each failing uuid with the status that record alone would have
158
+ answered. The admin UI renders that as a list with "deselect the ones that
159
+ failed", which is the point: a batch you cannot half-apply needs a refusal you
160
+ can act on. One request may name up to `max_bulk_records` (500) records.
161
+
162
+ `POST /api/records/types/{key}/records/trash/empty` purges the type's trash,
163
+ or — with the listing grammar's `?filter=` — the part of it a screen is
164
+ showing. It names nothing, so it is not bounded by `max_bulk_records` and is
165
+ set-based rather than a pass over records; it still publishes one
166
+ `RecordPurged` per record. In the UI it is the **Empty trash** button in the
167
+ Trash view, with filter-aware copy and the same typed-count guard the type
168
+ delete uses.
169
+
170
+ ### Paging a large type
171
+
172
+ `GET /api/records/types/{key}/records` takes `?page=` and `?page_size=` as it
173
+ always has, and three parameters that exist because none of the above scales
174
+ past a type of a few thousand records. The same three are available on the
175
+ public read API.
176
+
177
+ | parameter | does |
178
+ |---|---|
179
+ | `?after=<cursor>` | returns the page *after* the row a previous page's `next_cursor` names, with no `OFFSET` |
180
+ | `?total=false` | skips the count query entirely: `total` comes back `null` |
181
+ | — | `total` is exact up to `max_count`; beyond it `total` is `max_count` and `total_capped` is `true` |
182
+
183
+ A page reads back as
184
+ `{items, total, total_capped, page, page_size, next_cursor}`.
185
+
186
+ **The grammar is bounded too.** `?filter=` is capped at `max_filter_terms`
187
+ (20) and an `in:` list at `max_in_values` (200); `?sort=` is deduplicated by
188
+ field — a repeated term cannot change an order the first one fixed — and then
189
+ capped at `max_sort_terms` (5) distinct fields. Over any of them is a `400`
190
+ naming the parameter, on the admin listing, the export and the anonymous API
191
+ alike: every term is another `EXISTS` or another `LEFT JOIN`, and unbounded
192
+ they were a `500` rather than a slow page. `?page=` is bounded at 1,000,000 —
193
+ an `OFFSET` past `max_count` has nothing left to find, and `?after=` is what
194
+ walks a type that large.
195
+
196
+ **The count is bounded.** A page can stop after `page_size` matches; the
197
+ `COUNT` behind `total` never can, so a filter matching most of a large type
198
+ paid for all of it on every page of it. `total` is now exact up to `max_count`
199
+ (10,000 by default) and reported as that ceiling with `total_capped: true`
200
+ beyond it — the admin list renders that as "10,000+". A caller that does not
201
+ render the number should send `?total=false` and skip the statement.
202
+
203
+ **`?after=` is keyset pagination.** Read `next_cursor` off a page and send it
204
+ as `?after=` to get the next one; `null` means there are no more. The cost of
205
+ page 200 is then the cost of page 1, which `?page=200` is not — `OFFSET`
206
+ produces and discards everything before the page it wants. Walking a whole
207
+ type looks like:
208
+
209
+ ```python
210
+ url = "/api/records/types/order/records?page_size=200&sort=-placed_at&total=false"
211
+ while url:
212
+ page = client.get(url).json()
213
+ handle(page["items"])
214
+ cursor = page["next_cursor"]
215
+ url = f"...&after={cursor}" if cursor else None
216
+ ```
217
+
218
+ The cursor is opaque (base64 of the row's sort values and its id) but not
219
+ secret, and it carries a digest of the sort it was produced under. That digest
220
+ covers everything that changes what the value tuple *means*: the type, the
221
+ ordered `(field, direction)` list, whether the listing was the trash, **the
222
+ index kind behind each sort field** (so a field retyped from `number` to `text`
223
+ mid-walk invalidates the cursor instead of comparing a decimal against a
224
+ string), on the public API **the `?locale=` the listing was narrowed to**, and
225
+ **the tenant it was minted in**. Three things are a `400`: a cursor that does
226
+ not decode, a cursor replayed under a different sort — in any of those senses
227
+ — or against the trash or in another tenant, and
228
+ `?page=` and `?after=` sent together, which are two ways of asking for a page
229
+ and the server will not guess which one you meant. `?page=` stays for the admin
230
+ UI, which shows numbered pages up to the capped count and follows the same
231
+ cursor past it (`?after=` on `/admin/records/{key}`).
232
+
233
+ A full final page still returns a `next_cursor`; the request after it comes
234
+ back empty with `next_cursor: null`. That is one extra round trip at the end
235
+ of a walk and is the ordinary contract of cursor pagination — "fewer rows than
236
+ asked for" is the only end-of-data signal that survives a capped `total`.
237
+
238
+ ### Counting records by group
239
+
240
+ `GET /api/records/types/{key}/records/aggregate` answers "how many records per
241
+ X" without pulling the records. It is a `GROUP BY` over the same index tables
242
+ the list reads, through the same filter grammar, so the numbers it returns are
243
+ the numbers the list would show.
244
+
245
+ | parameter | does |
246
+ |---|---|
247
+ | `?group_by=` | an indexed field, a virtual field an index provider projects, or a fixed column (`status`, `locale`, `slug`, `position`, `published_at`, `created_at`, `updated_at`, `display_title`, `invalid`) |
248
+ | `?metric=` | `count` (the default), `sum:<number field>`, or `min:<field>` / `max:<field>` over a number, date, datetime or text field |
249
+ | `?filter=` | repeats, and means exactly what it means on the list |
250
+ | `?locale=` | shorthand for `filter=locale:eq:<tag>` |
251
+ | `?reduce=` | read a maintained aggregate instead — see **Maintained aggregates** below |
252
+
253
+ ```
254
+ GET /api/records/types/order/records/aggregate?group_by=state&metric=sum:total&filter=status:eq:published
255
+
256
+ {"group_by": "state", "metric": "sum:total", "stored": false, "updated_at": null,
257
+ "total_groups": 2, "truncated": false,
258
+ "groups": [{"value": "CA", "count": 2, "sum": "15.00000", "min": null, "max": null},
259
+ {"value": "NY", "count": 1, "sum": "7.00000", "min": null, "max": null}]}
260
+ ```
261
+
262
+ Worth knowing:
263
+
264
+ - Every value comes back as a **string**, `count` excepted. A group read from
265
+ a maintained aggregate is text by construction, and the two readings have to
266
+ be comparable without per-field coercion.
267
+ - Groups are ordered by count descending, then by value, and capped at
268
+ `max_aggregate_groups` (1,000 by default) with `truncated: true` when the
269
+ cap was hit. What is dropped is always the long tail.
270
+ - **A multi-valued `group_by` counts a record once per value it holds**, so
271
+ the counts sum to more than the number of records. That is the same reading
272
+ `filter=tags:eq:red` has, and the only honest one for "records per tag".
273
+ - The trash is never counted, and a record with no value for the field is in
274
+ no group at all (it has no index row). A *fixed column* that is `NULL` does
275
+ produce a group whose `value` is `null` — the column exists.
276
+ - Refusals are the filter grammar's: an unknown field is a `400`, a declared
277
+ but unindexed one is a `400`, and one that is mid-rebuild is a `409` — the
278
+ same three you get for filtering on it.
279
+ - **It is not on the anonymous read API, and will not be.** An anonymous
280
+ aggregate is an oracle over rows the caller cannot read: `group_by=status`
281
+ reports how many unpublished drafts a type holds, and a `min`/`max` asked
282
+ repeatedly under different filters reconstructs individual values a row at a
283
+ time. `records.view` plus the type's `allowed_roles` gate it exactly as they
284
+ gate the record list.
285
+
286
+ ### Media fields
287
+
288
+ A `media` field stores one file. When the host also installs the framework's
289
+ `file_storage` module, the editor shows it as a **picker**: the chosen file as
290
+ a thumbnail (images) or a file chip (name, size, type), **Replace…** and
291
+ **Remove**, and a **Choose file…** dialog that browses the media library page
292
+ by page, filters the page by name, and uploads a new file in place (with a
293
+ progress bar; the upload is picked as soon as it lands). The record list shows
294
+ the type's first `media` field as a thumbnail column by default — an ordinary
295
+ column the **Columns** menu can hide or move.
296
+
297
+ What is stored is the file's **id**, the one `file_storage` returns — never a
298
+ URL; the picture's address is derived from it each time it is shown, so the
299
+ library can move without stranding a record. A value saved before the picker
300
+ existed (a pasted `https://` URL) stays valid and renders as a link. Deleting
301
+ a file in the media library does not touch the records that use it: the
302
+ editor and the list then say **File missing** and keep the id until someone
303
+ picks another file or removes it.
304
+
305
+ The browser talks to the media library's own API with the editor's session;
306
+ that module's permissions (`file_storage.upload`, `file_storage.download`)
307
+ decide what a person may list and upload. Records finds the API at startup by
308
+ the shape of its routes — it never imports `file_storage` — or takes it from
309
+ `media_api_prefix` (below). With no media library the field is the plain text
310
+ box it always was: paste an id or a URL.
311
+
312
+ On a public page the **Records list** block shows nothing for a `media` field:
313
+ `file_storage` does not serve files to anonymous visitors, so there is no
314
+ picture a visitor could load — see [Showing records on a page](#showing-records-on-a-page).
315
+
316
+ ### Showing records on a page
317
+
318
+ When `simple_module_pagebuilder` is also installed, this module contributes a
319
+ **Records list** block to its Puck editor palette (category **Data**),
320
+ registered through `puck-blocks.ts` the same way `news`'s `NewsFeed` block is
321
+ — records knows about pagebuilder, pagebuilder does not know about records.
322
+
323
+ Drop the block on a page and pick:
324
+
325
+ | prop | meaning |
326
+ |---|---|
327
+ | **Record type** | any type this account can see; one that isn't public is still selectable, and shows "(not public)" in the list |
328
+ | **Fields to show** | which of the type's declared fields render beyond the title, and in what order — nothing beyond the title by default |
329
+ | **Filter** | one `field:op:value` term, e.g. `status:eq:paid` — indexed fields only, same grammar as the admin list |
330
+ | **Sort** | one field, e.g. `-published_at` for newest first |
331
+ | **How many** | 1–50 |
332
+ | **Layout** | list, cards, or table |
333
+ | **Heading**, **Text shown when there are no records** | freely edited copy |
334
+ | **Link template** | `{slug}` / `{uuid}` placeholders; blank means the title isn't a link |
335
+ | **Public API prefix** | advanced — only touch it if `public_route_prefix` (above) has been changed from its default |
336
+
337
+ **Only a public type's records ever reach a visitor.** The block fetches from
338
+ the anonymous read API (`GET {public_route_prefix}/{type_key}`), so a type
339
+ that is not (yet) marked `is_public` renders the empty state on the live site
340
+ — the editor additionally shows *why*, with a "only public types render on
341
+ the site" hint that a visitor never sees. The `typeKey` picker still lists
342
+ every type on purpose: it is what lets an author build the block ahead of
343
+ flipping the type public, and see the hint rather than a confusing blank
344
+ result.
345
+
346
+ **A `media` field renders nothing on the site** unless the host's media
347
+ library serves files to anonymous visitors, which `file_storage` does not: its
348
+ routes are not exempt from authentication and its download also requires
349
+ `file_storage.download`. The anonymous API says which case it is in
350
+ (`media_url_template`, [Public read API](#public-read-api)); when it is set,
351
+ the field renders as an image. The block's **Fields to show** says so under
352
+ every `media` field, so a ticked field that shows nothing is not a surprise.
353
+
354
+ **The public API never expands a relation** (see [Relations](#relations)), so
355
+ a `relation` field shown in the widget renders as its stored `type:uuid`, not
356
+ a linked title — the block's own field help says so. Everything else formats
357
+ the way the admin list does: booleans as `✓`/`–`, dates and datetimes in the
358
+ visitor's own locale, `select`/`multiselect` through their configured labels.
359
+
360
+ The block renders identically in the editor's live preview and on the
361
+ published page — both call the same anonymous endpoint with the same query,
362
+ so what an author sees while building the page is what a visitor gets.
363
+
364
+ ## Settings
365
+
366
+ DB-backed via the framework's settings module — no `SM_RECORDS_*` environment
367
+ variables are read. Configure on the Settings screen or with
368
+ `scripts/set_setting.py`.
369
+
370
+ | setting | default | restart? |
371
+ |---|---|---|
372
+ | `public_route_prefix` | `/api/records/public` | yes |
373
+ | `content_locales` | `["en"]` | yes |
374
+ | `default_content_locale` | `en` | yes |
375
+ | `media_api_prefix` | `null` (detect) | yes |
376
+ | `default_page_size` | 25 | no |
377
+ | `max_page_size` | 200 | no |
378
+ | `revision_limit` | 50 per record | no |
379
+ | `max_payload_bytes` | 262144 (256 KB) | no |
380
+ | `max_count` | 10000 | no |
381
+ | `max_filter_terms` | 20 | no |
382
+ | `max_sort_terms` | 5 | no |
383
+ | `max_in_values` | 200 | no |
384
+ | `preview_sync_limit` | 5000 | no |
385
+ | `preview_job_ttl_seconds` | 600 (10 min) | no |
386
+ | `max_import_bytes` | 52428800 (50 MB) | no |
387
+ | `max_import_rows` | 20000 | no |
388
+ | `max_bulk_records` | 500 | no |
389
+ | `public_cache_seconds` | 60 | no |
390
+ | `max_fields_per_type` | 100 | no |
391
+ | `max_indexed_fields_per_type` | 25 | no |
392
+ | `reindex_batch_size` | 500 | no |
393
+ | `reindex_stale_after_seconds` | 900 (15 min) | no |
394
+ | `menu_refresh_seconds` | 5 | no |
395
+ | `admin_header_tenant` | `false` | no |
396
+
397
+ `content_locales` and `default_content_locale` are the languages records may
398
+ be authored in — see [Content languages](#content-languages).
399
+
400
+ `media_api_prefix` is where the `media` field picker finds the media library
401
+ — see [Media fields](#media-fields). `null` detects `file_storage` at startup,
402
+ `""` turns the picker off, and a path such as `"/api/file-storage"` uses that
403
+ one; it is edited as JSON because of those three states, and must be a path on
404
+ this host.
405
+
406
+ `menu_refresh_seconds` is the sidebar's refresh window — see
407
+ [Sidebar entries](#sidebar-entries).
408
+
409
+ `max_count` is how far a list page's `total` is counted exactly — see
410
+ [Paging a large type](#paging-a-large-type). `max_bulk_records` is how many
411
+ records one bulk request may name — see
412
+ [Acting on many records at once](#acting-on-many-records-at-once). `preview_sync_limit` is the
413
+ largest type a schema preview will dry-run inside the request, and
414
+ `preview_job_ttl_seconds` is how long a finished preview's report stays
415
+ reusable by the save that follows it — see
416
+ [Changing a schema that already holds records](#changing-a-schema-that-already-holds-records).
417
+
418
+ `public_route_prefix` is one of the four settings above a change to needs a
419
+ restart, and the only one whose reason is about routes it mounts. The routes it
420
+ configures are mounted — and exempted from authentication — while
421
+ the app boots, because the prefix only exists as the operator set it once the
422
+ host has hydrated these settings from the database.
423
+
424
+ It is validated on save: it must start with `/`, must name at least one path
425
+ segment, and may not be `/api`, `/admin`, `/api/records`, `/admin/records` or
426
+ a parent of any of them. The exemption it registers disables `AuthMiddleware`
427
+ for every `GET`/`HEAD` under it — host-wide, not just for this module — so a
428
+ parent path would hand the admin surface to anonymous callers and an empty
429
+ value would hand them the whole site. A value *under* the admin API's prefix
430
+ is harmless; the exemption cannot reach upwards. A stored value that predates
431
+ this rule is logged as an error at boot and replaced by the default rather
432
+ than failing the lifespan: the only screen that could fix it lives in the app
433
+ that would not start.
434
+
435
+ ## Relations
436
+
437
+ A `relation` field stores `{"type": "<type_key>", "uuid": "<record uuid>"}` —
438
+ and a row in the reference index, which is what makes both directions cheap.
439
+
440
+ **Forward: `?expand=`.** `GET /api/records/types/{key}/records` and
441
+ `…/records/{uuid}` take `?expand=field_a,field_b` and resolve those relation
442
+ fields to **depth one**, one batched query per named field for the whole page.
443
+ Each reference comes back under `expanded[field_key]`, in payload order, in
444
+ exactly one of three states:
445
+
446
+ - **resolved** — `display_title`, `slug` and `status` are filled;
447
+ - **dangling** — the target is in the trash, gone, or not a record of the
448
+ type the field declares; `display_title` is `null`. A restorable delete must
449
+ not break what references it, so this is a flag rather than an error or a
450
+ dropped entry. A to-many entry that is not a reference at all (a `null` left
451
+ by a payload written before that was refused) keeps its slot as one of
452
+ these, because `expanded[key]` is rendered positionally against `data[key]`;
453
+ - **restricted** — the target's type narrows `allowed_roles` past the caller.
454
+ Nothing but the uuid the caller already holds in `data` comes back.
455
+
456
+ A key that is not a relation field of the type is a `400` naming it. Depth
457
+ greater than one is not supported; the admin list and record editor always
458
+ expand every relation column they render, so "opt-in" describes the API rather
459
+ than the UI.
460
+
461
+ **Reverse: referrers.**
462
+ `GET /api/records/types/{key}/records/{uuid}/referrers?page=&page_size=`
463
+ answers "what points at this record", from the reference index rather than a
464
+ scan, and each entry carries the referring field's label and its `on_delete`
465
+ so a delete dialog can say *why* a delete would be blocked. Trashed referrers
466
+ are listed and flagged `is_deleted` (the delete path itself still ignores
467
+ them), and a trashed *target* answers too for a caller holding `records.edit`
468
+ — that is the screen where "what still points at this?" decides between
469
+ restore and purge.
470
+
471
+ Three numbers come back, and each means something different:
472
+
473
+ - `total` counts every referring **record**, live, trashed, visible or not.
474
+ One record pointing at the target from two relation fields is one referrer
475
+ here and two `items` (the panel names the field). It is the number a
476
+ `restrict` refusal and the delete dialog speak, and the editor's
477
+ "Referenced by" badge is the same number from the same helper.
478
+ - `hidden` is how many of those the caller may not view, because their type
479
+ narrows `allowed_roles` past them. Stated rather than left to subtraction —
480
+ a panel showing two of four with no explanation reads as a bug.
481
+ - `items` is the visible rows, **paginated over the visible set alone**, so
482
+ walking pages cannot locate the hidden ones. All three are counted and
483
+ windowed in SQL, so a page costs the page rather than the graph.
484
+
485
+ A `restrict` refusal (`409` on a delete) speaks the same three numbers:
486
+ `detail` counts every blocker, `referrers` lists at most 50 uuids of the ones
487
+ this caller may read, `more` says how many visible blockers are not listed,
488
+ and `hidden` counts the blockers whose type narrows `allowed_roles` past them
489
+ — counted, never named, exactly as the panel does.
490
+
491
+ ## Public read API
492
+
493
+ Off by default and per type. Setting `is_public` on a Record Type serves its
494
+ published records anonymously at two routes, under `public_route_prefix`
495
+ (default `/api/records/public`):
496
+
497
+ | route | answers |
498
+ |---|---|
499
+ | `GET`/`HEAD` `{prefix}/{type_key}` | `{items, total, total_capped, page, page_size, next_cursor, media_url_template}` |
500
+ | `GET`/`HEAD` `{prefix}/{type_key}/{uuid}` | one record |
501
+
502
+ A record reads back as `uuid`, `slug`, `locale`, `translations`,
503
+ `display_title`, `published_at` and `data` — nothing else. The audit columns, `version`, `status`, `invalid`,
504
+ `invalid_since` and
505
+ the reserved `_orphaned` sub-key (a deleted field's retained values, which are
506
+ the admin's undo buffer) are removed from the *shape*, not filtered out of the
507
+ query. The public filter grammar does not answer about `invalid` either:
508
+ whether a published record is behind its schema is the admin's problem.
509
+
510
+ The rules worth knowing before you point a site at it:
511
+
512
+ - **A type that is not public is a `404`, identical to one that does not
513
+ exist**, by key and by uuid alike. A draft, a trashed record and an unknown
514
+ uuid answer with that same body, so nothing here can be used to enumerate
515
+ what an install holds.
516
+ - **The filter and sort grammar is the admin one, over indexed fields and the
517
+ public shape's own columns only.** Indexed declared fields, plus `slug`,
518
+ `display_title` and `published_at`. `status`, `position`, `created_at` and
519
+ `updated_at` are removed from the shape and therefore from the grammar —
520
+ otherwise an anonymous caller could binary-search an audit timestamp it
521
+ cannot read. A filter or sort naming one of those, or a field that is
522
+ unindexed, non-existent or mid-reindex, is the same `400` naming the field —
523
+ never the admin API's `409`: "cannot" and "cannot right now" are the same
524
+ answer to a caller who has no business seeing operational state.
525
+ - **`?locale=` names the language the listing is of, and its absence means
526
+ `default_content_locale` — never "all".** An anonymous reader is asking for
527
+ one site, and merging languages into one list is how a German record ends up
528
+ rendered on an English page. A locale that is not configured is a `400`
529
+ naming it and listing the ones that are; that is the site's own front door,
530
+ not an oracle over private content. The by-uuid route is **locale-blind** and
531
+ ignores `?locale=`: a uuid names exactly one record, in exactly one language.
532
+ `translations` lists that record's **published, live** siblings — `locale`,
533
+ `uuid` and `slug` each — so a site can render a language switcher without
534
+ advertising an address that answers `404`. It is resolved in one batched
535
+ query per page, never one per row.
536
+ - **No `?expand=`** — an anonymous caller must not be able to turn one request
537
+ into a batch of joins against other types, some of which may not be public.
538
+ `expand` and `trashed` are simply not parameters here; unknown ones are
539
+ ignored.
540
+ - `page_size` is clamped to `max_page_size` rather than refused.
541
+ - **`media_url_template`** is how a site shows a `media` value: a URL with
542
+ `{id}` in it, when the host's media library lets anonymous visitors download
543
+ files — and `null` otherwise, which with `file_storage` is always. A `media`
544
+ value itself is the file's id; `null` means there is no address a visitor
545
+ could load it from.
546
+ - **`?after=`, `?total=false` and the `max_count` bound apply here too** — see
547
+ [Paging a large type](#paging-a-large-type). A client walking a large public
548
+ type is precisely the caller that should not be paying for an `OFFSET` and a
549
+ count it never reads.
550
+ - **Both reads are cacheable.** They carry a weak `ETag` over the content they
551
+ are about to return and `Cache-Control: public, max-age=N`, where `N` is the
552
+ `public_cache_seconds` setting (60 by default). A conditional GET whose
553
+ `If-None-Match` matches is a `304` with no body. Set it to `0` for
554
+ `Cache-Control: no-store` and no validator, on an install whose "published"
555
+ means "visible the instant it is saved". On a multi-tenant host both also
556
+ carry `Vary: <tenant header>`, and a signed-in reader's answer is `private`;
557
+ every public *error* there (`404`, `400`, `422`, `500`) carries the same
558
+ `Vary` and `Cache-Control: no-store`, since one URL can be a `200` for one
559
+ tenant and a `404` for another.
560
+
561
+ > A *shared* cache still cannot store these on a stock host: every anonymous
562
+ > response also carries `Vary: Cookie` and a fresh `Set-Cookie: session=…`,
563
+ > written on every request by the framework's `InertiaLayoutDataMiddleware`
564
+ > (`simple_module_hosting/_inertia_shared.py:54`). This module writes nothing
565
+ > to the session and does not work around it; until that is fixed upstream,
566
+ > the headers help a browser and a private cache rather than a CDN.
567
+
568
+ ## Permissions
569
+
570
+ Three static permissions, registered at boot and visible in the role editor:
571
+ `records.view`, `records.edit`, `records.manage_types`.
572
+
573
+ Record Types are created at runtime, after the database is open, but
574
+ `register_permissions` runs at app construction, before it — so **per-type
575
+ permissions cannot be framework permissions**. Each `RecordType` instead
576
+ carries its own `allowed_roles`, narrowing which roles may write records of
577
+ that type on top of the static `records.edit`/`records.manage_types`
578
+ permission; an empty list means "any role holding the static permission".
579
+
580
+ `allowed_roles` narrows, and narrowing has no exceptions: a caller holding
581
+ the `admin` wildcard is refused on a type whose `allowed_roles` is non-empty
582
+ unless `admin` is one of the roles listed. So an admin creating a restricted
583
+ type must list a role they actually hold, or else use `records.manage_types`
584
+ to edit `allowed_roles` back before they can work with its records. The same
585
+ list also governs what a delete elsewhere may do to this type's records: a
586
+ `cascade` or `set_null` relation pointing here is refused — reported as a
587
+ `restrict` blocker — for a caller the list excludes, and
588
+ `DELETE /api/records/types/{key}` (which purges every record of the type,
589
+ trash included) and an `orphaned: "discard"` schema edit are refused for them
590
+ too, `records.manage_types` notwithstanding.
591
+
592
+ **It narrows reads exactly as it narrows writes — same rule, same `403`.** A
593
+ caller the list excludes is refused every *record* surface of that type:
594
+
595
+ - `GET /api/records/types/{key}/records`, `…/records/{uuid}`, its
596
+ `…/referrers` and its `…/revisions`;
597
+ - the admin screens over them — `/admin/records/{key}`, `/{key}/new` and
598
+ `/{key}/{uuid}`;
599
+ - `GET /api/records/types` and the Record Types screen *omit* the type
600
+ altogether, rather than listing a card that 403s when opened.
601
+
602
+ One thing stays visible, and it is about the schema rather than the records:
603
+ a **`records.manage_types` holder** reads `GET /api/records/types/{key}` —
604
+ with `…/revisions` and `…/export`, which are the same definition by other
605
+ routes — and opens the type editor at `/admin/records/types/{key}`, whatever
606
+ `allowed_roles` says. A manager locked out of the screen that edits
607
+ `allowed_roles` would be a one-way door. That is the whole exception: a
608
+ caller holding only `records.view` gets the same `403` on those three routes
609
+ as on every record surface of the type, because the definition names every
610
+ field and the `allowed_roles` themselves, and `TypeRead` carries a live
611
+ `record_count` of records they may not list.
612
+
613
+ A relation pointing *at* a narrowed type is a different question and is not a
614
+ refusal: an `?expand=` of it comes back `restricted` (see Relations), because
615
+ the caller is reading a record they are entitled to see that happens to point
616
+ somewhere they are not.
617
+
618
+ **This is an honest limitation, not an oversight: per-type `allowed_roles`
619
+ are invisible in the framework's role editor.** An admin editing roles sees
620
+ only the three coarse permissions above and has no way to discover, from that
621
+ screen, that a given type is further restricted to specific roles.
622
+
623
+ ## Multi-tenancy
624
+
625
+ Every type, record and revision belongs to a tenant, and records decides which
626
+ one a request runs in from the host's middleware stack — there is nothing to
627
+ turn on inside this module itself.
628
+
629
+ **Single-tenant host (the default, and every existing install).** No
630
+ `SM_MULTI_TENANT`, or `SM_MULTI_TENANT=false`. Nothing changes: every row is
631
+ in the tenant `default`, a user's `tenant_id` and an `X-Tenant-ID` header are
632
+ both ignored, and the module behaves exactly as it did before tenancy
633
+ existed. The one thing to check before upgrading past the tenancy migration
634
+ is below.
635
+
636
+ **Multi-tenant host.** Set `SM_MULTI_TENANT=true`, and `SM_TENANT_HEADER` if
637
+ anonymous readers or API clients need to name a tenant, then **restart** —
638
+ the middleware stack is built once at boot, so an edit of the DB-hydrated
639
+ setting alone does not install `TenantMiddleware`. From there:
640
+
641
+ - **Assign every user a tenant by hand.** `UserCreate`/`UserUpdate` have no
642
+ `tenant_id` field and there is no UI for it yet — upstream
643
+ [#360](https://github.com/antosubash/simple_module_python/issues/360) — so
644
+ it is SQL: `UPDATE users_user SET tenant_id = 'acme' WHERE email = '...'`.
645
+ **Give legacy users `tenant_id = 'default'`** to keep them seeing the rows
646
+ that existed before the migration. A user's tenant travels in their session
647
+ cookie, so the change is invisible until they **log out and back in**
648
+ (upstream [#362](https://github.com/antosubash/simple_module_python/issues/362)).
649
+ - A user with no tenant — the bootstrap admin included — gets `403` with
650
+ `"code": "tenant_required"` on every records admin screen, even with a
651
+ header sent: records never guesses. The `admin_header_tenant` setting
652
+ (default off) lets a user holding `admin` with no tenant of their own act
653
+ in the tenant their `X-Tenant-ID` header names — **from an API or headless
654
+ client that can send that header, only**. The browser admin screens never
655
+ send it, so for such a user every screen stays `403` with the setting on;
656
+ the fix for a person working in the browser is to assign them a tenant.
657
+ - Anonymous readers and headless clients name their tenant with that header;
658
+ with none, the public API answers its ordinary `404`, the same one an
659
+ unknown type gets. The pagebuilder `RecordsList` block's **Tenant** field
660
+ always sends the literal header `X-Tenant-ID`, whatever `SM_TENANT_HEADER`
661
+ is set to — so using the widget on a host that renamed the header needs
662
+ `SM_TENANT_HEADER=X-Tenant-ID` to match it, and a cross-origin embed
663
+ sending it triggers a CORS preflight.
664
+ - `/health/ready` needs no login, so records' detail there gives **counts
665
+ only** — no tenant, type, field or language names. The named detail is
666
+ logged at INFO by `sm_records.health`
667
+ ([operations](docs/operations.md#health-checks)).
668
+
669
+ **`tenant_id` is now a reserved field key**, the same way `invalid` is: an
670
+ install whose type already declares one answers `422` to every request that
671
+ touches it once the upgrade lands. Check for it *before* upgrading — see
672
+ [docs/operations.md](docs/operations.md#upgrading-past-4ecb931245dd-records-rows-gain-a-tenant).
673
+
674
+ Type keys and record uuids are unique per tenant, not install-wide. A keyset
675
+ cursor is bound to the tenant it was minted in; a public answer names the
676
+ tenant header in `Vary` (or sends `Cache-Control: private` for a signed-in
677
+ reader, whose tenant no request header can express), and a public error is
678
+ `no-store` with the same `Vary`; a JSON export names its
679
+ tenant, informationally — an import always writes into the importer's tenant,
680
+ which is how a tenant is cloned. The records screens receive the tenant they
681
+ read as a read-only `tenant` prop, alongside `tenancy_mode`.
682
+
683
+ The full runbook — upgrading a populated database, switching modes on a live
684
+ install, the CLI's `--tenant` and `records tenants`, health details, and the
685
+ upstream issues this module works around — is
686
+ [docs/operations.md](docs/operations.md#multi-tenancy-operations). Design:
687
+ [docs/plans/2026-09-23-records-multitenancy.md](../../docs/plans/2026-09-23-records-multitenancy.md).
688
+
689
+ ## Data model notes worth knowing before you rely on them
690
+
691
+ - **`number` fields are stored with five decimal places, and that contract is
692
+ approximate on SQLite.** The index column is `Numeric(19, 5)`; a value the
693
+ index would round is refused on write rather than silently stored with the
694
+ payload and index disagreeing. SQLite has no native decimal type, so
695
+ SQLAlchemy stores `Numeric` there as a floating-point `REAL` — exact on
696
+ Postgres, approximate on SQLite. Callers wanting more precision want a
697
+ `text` or `json` field instead.
698
+ - **Some field keys are reserved.** A field may not be keyed `_orphaned`, nor
699
+ after any column a record already has — `id`, `uuid`, `type_id`, `data`,
700
+ `schema_version`, `version`, `status`, `slug`, `locale`,
701
+ `translation_group`, `display_title`, `position`,
702
+ `published_at`, `created_at`, `updated_at`, `created_by`, `updated_by`,
703
+ `is_deleted`, `deleted_at`, `deleted_by`, `tenant_id`. The query layer resolves those
704
+ names against the record row before the type's own fields, so such a field
705
+ would index correctly and then be filtered and sorted from the wrong data.
706
+ The list is derived from the model, so it cannot drift.
707
+ - **A `relation` field is always indexed**, whatever the checkbox says: the
708
+ flag is normalised on, exactly as `unique` is. `on_delete` is enforced by
709
+ asking the reference index who points at a record, so an unindexed relation
710
+ would accept `restrict`/`set_null`/`cascade` and enforce none of them.
711
+ Indexed relations count against `max_indexed_fields_per_type`.
712
+ - **`allowed_roles` narrows reads and writes the same way: a `403`.** What is
713
+ *redacted* rather than refused is a reference **to** a narrowed type from a
714
+ record the caller may read — an `?expand=` of it comes back `restricted`,
715
+ and a referrer of one is counted in `total`, reported in `hidden` and left
716
+ out of `items`. That is not a weaker rule applied to the same resource: the
717
+ caller is reading a record they are entitled to see that happens to point
718
+ somewhere they are not, and asking that type directly is still a `403`.
719
+ - **`display_field` must point at a `text`, `select`, `email`, `url`,
720
+ `integer`, `number`, `date` or `datetime` field, and `slug_field` at a
721
+ `text`, `select`, `email` or `url` one.** The rest do not stringify into
722
+ anything a title or an address should be.
723
+ - **`required` on a `text`, `longtext`, `email`, `url` or `select` field is
724
+ not satisfied by `""` or whitespace.**
725
+ - **The trash is enumerable**: `GET /api/records/types/{key}/records?trashed=true`
726
+ returns only that type's soft-deleted records, with `total` counted the same
727
+ way and the usual filters and sorts. It needs `records.edit`, not merely
728
+ `records.view` — enumerating the trash is how anything gets restored.
729
+ - **`unique` is enforced by the application, not by a database constraint.**
730
+ The index tables are shared across every field of a kind, so a partial
731
+ unique index naming a runtime-chosen field key isn't possible. A `unique`
732
+ field is checked with a `SELECT ... LIMIT 1` inside the write's transaction —
733
+ among the records that are not translations of the one being written, see
734
+ [Content languages](#what-this-buys-and-what-it-costs) — and writes to the
735
+ type are serialized to close the check-then-act race between two concurrent
736
+ creates: a row lock on the type on Postgres, and a write against the type row
737
+ — which takes SQLite's `RESERVED` lock — on SQLite, where `FOR UPDATE` locks
738
+ nothing. This is "unique enforced at the cost of serializing writes on that
739
+ type," not a database-level uniqueness guarantee. **Both halves are
740
+ measured**: twenty concurrent creates of one value on Postgres and eight on
741
+ SQLite each store exactly one row and refuse the rest with a `409` — see
742
+ [`docs/postgres-2026-09-21.md`](docs/postgres-2026-09-21.md) § 4.
743
+ - **A write costs fewer SQL statements on Postgres than on SQLite, and the
744
+ difference grows with the number of indexed fields.** `write_index` batches
745
+ its index rows into one `INSERT` per *kind table* on Postgres and one per
746
+ *row* on SQLite, so a create is 10 statements against 13 on the demo
747
+ `company` type and 7 against 14 on a type with eight indexed `text` fields.
748
+ Nothing else in the module differs: every other statement count, and every
749
+ plan shape, is the same on both. Any "N statements per write" figure quoted
750
+ elsewhere is SQLite's.
751
+
752
+ ## Collections
753
+
754
+ Off by default, and off for every host that does not write a line of Python to
755
+ turn it on. A **collection** gives one Record Type (or several) its own
756
+ document, revision and index tables instead of sharing the global ones — the
757
+ escape hatch for a type that dwarfs the others.
758
+
759
+ ### Declaring one
760
+
761
+ The tables have to exist in the host's Alembic history, so a collection cannot
762
+ be a setting read from the database at boot. It is declared in code, in a
763
+ module the host imports **before** `create_app`:
764
+
765
+ ```python
766
+ # host/records_collections.py
767
+ from sm_records.collections import declare_collection
768
+
769
+ declare_collection("events")
770
+ ```
771
+
772
+ ```python
773
+ # host/main.py
774
+ import records_collections # noqa: F401 - declares this host's collections
775
+
776
+ app = create_app(settings)
777
+ ```
778
+
779
+ That call builds a full table set on the module's metadata with the prefix
780
+ `records_c_events_`: `record`, `revision` and the six index tables. Each one is
781
+ built by the same factory that builds the global set, so it is identical in
782
+ shape modulo the prefix — same columns, same indexes, same partial unique slug
783
+ index, same foreign keys pointing at the collection's own record table. The
784
+ host then autogenerates **one migration** for the new tables, exactly as it did
785
+ for the module itself:
786
+
787
+ ```
788
+ alembic -c host/alembic.ini revision --autogenerate -m "records events collection tables"
789
+ alembic -c host/alembic.ini upgrade heads
790
+ ```
791
+
792
+ The host's `alembic/env.py` has to import that declaration module, or
793
+ autogenerate cannot see the tables. In this repo `host/alembic.ini` sets
794
+ `prepend_sys_path = %(here)s` and `env.py` imports `records_collections` when
795
+ it exists.
796
+
797
+ A name must match `^[a-z][a-z0-9_]*$`, be at most 22 characters (it is a
798
+ table-name prefix, and the longest index built on it —
799
+ `ix_records_c_<name>_record_type_status_position` — has to fit inside
800
+ Postgres's 63-byte identifier limit, which at 22 it exactly does), and not be one of `default`, `global`,
801
+ `records`, `type`, `index`, `reduce`. Declaring the same name twice is a no-op;
802
+ declaring one **after** the app has been built is a `RuntimeError`, because
803
+ those tables are in no migration and every write to them would be a
804
+ `no such table` found at runtime instead.
805
+
806
+ ### Assigning a type to one
807
+
808
+ `POST /api/records/types` takes `collection`, and that is the only request that
809
+ may set it:
810
+
811
+ ```json
812
+ {"key": "event", "label": "Event", "collection": "events", "fields": [...]}
813
+ ```
814
+
815
+ An undeclared name is a `422` naming what *is* declared. Omitting it — or
816
+ sending `null` — puts the type in the shared tables, which is what every type
817
+ created before this existed carries.
818
+
819
+ **A type cannot be moved between collections.** `PUT /api/records/types/{key}`
820
+ answers a changed `collection` with a `409` and the sentence *"moving a
821
+ populated type between collections is not supported"*. It would mean copying
822
+ the type's records, revisions and index rows into other tables and re-pointing
823
+ every reference at them, with no rollback story; the honest answer is to export
824
+ the type, delete it and re-import under a new one. An echo of the current value
825
+ is accepted, so a client that sends back the whole type it just read still
826
+ saves. The type editor shows the collection read-only once the type exists and
827
+ offers the declared set only on the "new type" form.
828
+
829
+ ### What stays global
830
+
831
+ `records_type`, `records_type_revision`, the reduce table, the settings, the
832
+ permissions, the health check and the CLI. The reduce table in particular:
833
+ a reduce row is a *fold* keyed by `type_id` with no `record_id` to partition
834
+ on, so a collection would gain nothing from its own copy and gain one more
835
+ table to rebuild. The CLI takes a type and follows its collection.
836
+
837
+ ### Relations across a collection
838
+
839
+ They work in both directions, and nothing about declaring a relation mentions
840
+ a collection. A `relation` names its target by `(type, uuid)`, the **target
841
+ type** decides which tables are read, and `on_delete` — `restrict`, `set_null`
842
+ and `cascade` alike — crosses the boundary like any other edge.
843
+
844
+ Two consequences are worth knowing:
845
+
846
+ - **"Who references this record" asks every table set.** A reference row lives
847
+ in the *referrer's* collection tables and names `target_uuid` +
848
+ `target_type_id`, so the rows pointing at one record are scattered across
849
+ every set that holds a type relating to it. The referrers query is a loop —
850
+ one indexed lookup per declared collection plus one for the global set —
851
+ rather than a `UNION`, because the ids a reference table returns are ids in
852
+ *its own* record table and a union would merge them and lose which is which.
853
+ That is a query per declared collection on the delete path and on the
854
+ referrers panel; it is the cost of the partition, and it is why collections
855
+ are a per-type escape hatch rather than a default.
856
+ - **`?expand=` is still one batched query per field.** The target type decides
857
+ the table, so expanding a field that points into a collection reads that
858
+ collection's record table once for the whole page.
859
+
860
+ ### `uuid` across collections
861
+
862
+ **A record's `uuid` is unique across every table set of its tenant**, and both
863
+ halves of that are enforced rather than assumed.
864
+
865
+ The database enforces uniqueness only *within* a table and a tenant — each
866
+ record table carries its own `(tenant_id, uuid)` unique index, and no
867
+ constraint spans tables — so the rule is
868
+ held by the write path: `uuid4` for everything the writer and the seeder
869
+ create, and an explicit check for the one writer that keeps an identifier from
870
+ outside. **The importer preserves a file's `uuid` verbatim** (that is what
871
+ makes a round trip idempotent), so importing a global type's export into a
872
+ collection type used to plant a duplicate deterministically. It now refuses the
873
+ row:
874
+
875
+ ```
876
+ uuid 9d0c… already exists in collection 'events'; a record's uuid is unique
877
+ across every table set, so a file cannot create a record under one that is
878
+ already in use
879
+ ```
880
+
881
+ It is a row error, so `on_error=abort` refuses the whole file before writing
882
+ anything and `on_error=skip` reports the row and imports the rest, and the
883
+ **dry run predicts it** like every other row error. A host that declares no
884
+ collection pays nothing for the check: there is no other table set to ask.
885
+
886
+ **Moving a type's records into a collection is therefore purge-then-import,
887
+ in that order.** Export the global type, delete it (which purges its records),
888
+ create the type again with `collection=`, then import. Importing first and
889
+ deleting afterwards is the sequence the refusal is about.
890
+
891
+ Independently of the data, everything that resolves a record by uuid keys on
892
+ **`(target_type_id, target_uuid)`** and takes its table set from the declared
893
+ type — the referrer query, `referrer_count`, the delete plan's `restrict`,
894
+ `set_null` and `cascade` walks, the relation write check and `?expand=`. So a
895
+ duplicate that somehow exists (a hand-written row, a restore from a backup
896
+ taken before this rule) cannot make a delete cascade into an unrelated record
897
+ or a relation write be refused against a record that is exactly what it claims
898
+ to be.
899
+
900
+ ### Inert when unused
901
+
902
+ With no `declare_collection` call, the module's metadata holds exactly the
903
+ tables it held before collections existed, `alembic check` reports no
904
+ operations beyond the one nullable `records_type.collection` column,
905
+ `tables_for` always returns the global set, and the query layer runs the same
906
+ statements it always did. There is one migration such a host applies (the
907
+ column) and one it can skip (whatever tables a *different* host's collections
908
+ needed).
909
+
910
+ ## Content languages
911
+
912
+ Off by default, per host **and** per type. An install that leaves
913
+ `content_locales` at `["en"]` and never sets `translatable` on a type runs
914
+ exactly the code it ran before this existed: every record is in one language,
915
+ no screen offers another, and the public API behaves identically.
916
+
917
+ ### Configure the host
918
+
919
+ ```
920
+ python scripts/set_setting.py sm_records content_locales '["en","de"]'
921
+ python scripts/set_setting.py sm_records default_content_locale en
922
+ ```
923
+
924
+ Both need a restart. Each tag must be a lowercase language tag (`en`, `de`,
925
+ `pt-br`), and the default must be one of them. Resolution from a query string
926
+ is case-insensitive — `?locale=DE` finds `de` — but the configured list is not,
927
+ so two spellings of one language can never address two different sets.
928
+
929
+ **These are this module's own settings, not `pagebuilder`'s.** `records` is
930
+ published on its own and a host may install either without the other, and the
931
+ two may legitimately publish in different language sets — an English-only
932
+ product catalogue beside a four-language marketing site is a real
933
+ configuration, not a mistake. A host that wants them aligned sets both:
934
+
935
+ ```
936
+ python scripts/set_setting.py pagebuilder content_locales '["en","de"]'
937
+ python scripts/set_setting.py sm_records content_locales '["en","de"]'
938
+ ```
939
+
940
+ Both are distinct again from the host's `SM_I18N_SUPPORTED_LOCALES`, which
941
+ decides what language the *admin console* speaks rather than what the content
942
+ is published in.
943
+
944
+ ### Turn it on for a type
945
+
946
+ `translatable` on a Record Type. Turning it **on** is additive — every
947
+ existing record already carries the default locale. Turning it **off** while
948
+ records in another locale exist is a `409` naming the count: those records
949
+ would otherwise stay in the database, keep their slug claims, keep answering
950
+ `?locale=`, and be unreachable from a UI that no longer offers their language.
951
+
952
+ ### A record's language is fixed for its lifetime
953
+
954
+ There is no `locale` on `PUT /records/{uuid}` — a body carrying one is a `422`,
955
+ refused rather than quietly dropped. `POST /records` takes one (defaulting to
956
+ `default_content_locale`; a type that is not `translatable` accepts only that).
957
+ Everything after that is a **translation**: a whole sibling record, never a
958
+ per-field overlay.
959
+
960
+ | route | does |
961
+ |---|---|
962
+ | `POST /api/records/types/{key}/records/{uuid}/translations` | creates the sibling — `records.edit` plus the type's `allowed_roles` |
963
+ | `GET /api/records/types/{key}/records/{uuid}/translations` | lists the group, the record itself and trashed siblings included |
964
+ | `GET /api/records/types/{key}/records/{uuid}?translations=true` | the same list, on the record — never on the list endpoint, where it would be one query per row |
965
+
966
+ The new record joins the source's `translation_group`, copies its payload and
967
+ `position`, starts as a **draft** whatever the source's status, and gets a slug
968
+ regenerated *in the target locale* — suffixed `-2`, `-3`… if something there
969
+ already holds it. An explicit `slug` is used as given and refused with a `409`
970
+ if it is taken in that locale.
971
+
972
+ Refusals: a type that is not `translatable` (`409`), a locale that is not
973
+ configured (`422`, naming it), a source already in the target locale (`409`),
974
+ and a sibling that already holds that language (`409`) — **including a trashed
975
+ one**, which keeps its claim until it is purged or restored.
976
+
977
+ ### What this buys, and what it costs
978
+
979
+ - **Slugs are unique per `(type, locale)`**, not per type. The same word is the
980
+ address in both languages, because they are two documents at two addresses.
981
+ Trash keeps its claim per locale, as it always did per type.
982
+ - **One record per `(translation_group, locale)`**, enforced by a unique index,
983
+ so a double submit cannot produce two German siblings.
984
+ - **Deleting a record never touches its siblings.** A translation group is a
985
+ grouping, not a cascade.
986
+ - **`locale` is a filterable, sortable fixed column** — `?filter=locale:eq:de`,
987
+ `?sort=locale` — and is therefore a reserved field key. The admin list
988
+ defaults to **all** locales, because an editor's question is "what exists",
989
+ not "what exists in English".
990
+ - **`allowed_roles`, `is_public` and `on_delete` are type-level and
991
+ locale-blind.**
992
+ - **`unique` is enforced among records that are not siblings.** A `unique`
993
+ field is still unique across the whole type and across every language — two
994
+ records in the same locale, or in two locales but in different translation
995
+ groups, collide exactly as they did before. What is exempt is the group
996
+ itself: records sharing a `translation_group` do not claim against each
997
+ other, because a German product legitimately carries the English product's
998
+ SKU. So a translation may copy a `unique` value (it copies the whole
999
+ payload), editing the source afterwards leaves the sibling's now-stale copy
1000
+ alone, and a trashed sibling keeps its exemption while a trashed unrelated
1001
+ record still blocks.
1002
+ - **There is no per-field translation**, no automatic translation, and no
1003
+ fallback on the public API: a missing `de` sibling is a `404` for
1004
+ `?locale=de`, not the `en` record in disguise.
1005
+ - **There are no locale-scoped redirects.** Records have no public *pages* —
1006
+ only a JSON API keyed by uuid and slug — so a rename strands no URL the way
1007
+ a page rename does.
1008
+
1009
+ ### Dropping a content language
1010
+
1011
+ **Removing a tag from `content_locales` is not refused at save**, deliberately
1012
+ — the alternative makes a typo unfixable, and the records written in that
1013
+ language are still perfectly good records. Here is exactly what happens to
1014
+ them:
1015
+
1016
+ - the public listing stops naming the language (`?locale=de` is a `400`
1017
+ listing the languages the site does publish);
1018
+ - the public **by-uuid** read is a `404`, and the language switcher on a
1019
+ sibling stops advertising it. A language the site says it does not publish
1020
+ cannot go on being served;
1021
+ - the **admin** API is untouched: the records list, read, edit, export and
1022
+ delete exactly as before. An operator has to be able to reach them, which is
1023
+ the whole reason the save is not refused;
1024
+ - `/health/ready` degrades with an `orphaned_locales: {default/de: 12}` detail
1025
+ naming each stranded language, per tenant, and how many records are in it.
1026
+
1027
+ The count is taken **once, at startup** (the framework's settings registry
1028
+ offers no post-hydration hook to recompute it from), so it appears after the
1029
+ restart the setting needs anyway. To find them at any time:
1030
+
1031
+ ```
1032
+ GET /api/records/types/{key}/records?filter=locale:eq:de
1033
+ ```
1034
+
1035
+ Then translate them into a language you do publish, or delete them. Adding the
1036
+ tag back restores everything — nothing was rewritten.
1037
+
1038
+ ### Export and import
1039
+
1040
+ The export gains `locale` and `translation_group` columns and the import reads
1041
+ them back: a missing `locale` defaults to `default_content_locale` (a file
1042
+ written before the install spoke more than one language is a file of
1043
+ default-locale records), and a locale that is not configured is that row's
1044
+ error. A row that asks to change an existing record's locale or move it
1045
+ between groups is refused, exactly as `PUT` is.
1046
+
1047
+ **`translation_group` is carried verbatim but not taken on trust.** A group is
1048
+ a uniqueness exemption (see above), so a row that *creates* a record may name
1049
+ only a group it is entitled to: its own `uuid`, a group another row of the
1050
+ same file also carries (a translated pair travelling together — an export of a
1051
+ group always contains the whole group), or a group this type does not have
1052
+ yet. Naming a group this type already holds and the file does not describe is
1053
+ a row error:
1054
+
1055
+ ```
1056
+ translation_group ab03… names a group not in this file; create translations
1057
+ through POST /api/records/types/art/records/{uuid}/translations
1058
+ ```
1059
+
1060
+ Another *type*'s group is not a collision and not a forgery: groups are scoped
1061
+ by type everywhere that reads one, index included.
1062
+
1063
+ ## Changing a schema that already holds records
1064
+
1065
+ Every change to a type's fields is diffed against the current schema and
1066
+ classified before anything is written — **additive**, **index-affecting**,
1067
+ **restrictive** or **destructive** — and `POST /api/records/types/{key}/schema/preview`
1068
+ returns that classification together with a dry run over every existing
1069
+ record (trash included) so you can see what would break before saving.
1070
+
1071
+ - A **restrictive** change (a new required field, a narrowed type, a
1072
+ tightened constraint, a removed choice, a newly unique field) is refused
1073
+ with the report unless every record passes. Send `force: true` to apply it
1074
+ anyway: the failing records are **marked, not rewritten** — they read back
1075
+ with `invalid` naming the fields, and the scan stores the mark on each one
1076
+ as `invalid_since`, so the list badges them and `?filter=invalid:eq:true`
1077
+ is the worklist. Each record's next successful save clears its own mark;
1078
+ `POST .../schema/preview` with `rescan: true` ("Check records") re-derives
1079
+ the whole list, marking what now fails and unmarking what no longer does.
1080
+ - A **destructive** change (removing a field) keeps the value on each
1081
+ record; it moves under the reserved `_orphaned` key on that record's next
1082
+ write. Re-adding a key that still holds orphaned values is refused until
1083
+ you choose `orphaned: "restore"` (the old values read back and index) or
1084
+ `"discard"` (they are dropped — the one bulk write the module ever does,
1085
+ and it touches only that sub-key).
1086
+ - An **index-affecting** change (toggling `indexed`, changing an indexed
1087
+ field's type, re-pointing a `relation` at another type or flipping its
1088
+ `many`, or changing `display_field`) applies immediately and
1089
+ enqueues a rebuild: the field appears in the type's `reindex_pending` and
1090
+ refuses filters and sorts until the rebuild has moved its rows and cleared
1091
+ the entry. The rebuild runs as a background task after the request.
1092
+ - `slug_field` changes never regenerate existing slugs — a slug is an
1093
+ address, and regenerating could break links or collide with a slug handed
1094
+ out since. Only records written after the change use the new pointer.
1095
+
1096
+ **A preview is `records.manage_types`, not `records.view` on the type.**
1097
+ `POST .../schema/preview` deliberately opens to anyone who can open the schema
1098
+ screen (§10), and its report lists up to ten failing records by `uuid` and
1099
+ `display_title`. So a caller holding `manage_types` but outside a type's
1100
+ `allowed_roles` sees those titles. That is pre-existing and intentional —
1101
+ somebody editing a type's schema has to be able to see what their change would
1102
+ break — but it is worth knowing before you use `allowed_roles` to hide titles
1103
+ from an administrator: it hides them from the record API, not from a schema
1104
+ preview of the type.
1105
+
1106
+ **A preview of a big type runs as a job.** The dry run validates every record
1107
+ of the type, trash included, at roughly a thousand records a second — a few
1108
+ seconds on a small type and minutes on a large one, with whatever proxy
1109
+ timeout that implies. `POST .../schema/preview` therefore answers `200` with
1110
+ the report up to `preview_sync_limit` records (5,000 by default) and
1111
+ `202 {"job": "...", "status": "running"}` above it, running the scan after the
1112
+ response. Poll `GET /api/records/types/{key}/schema/preview/{job}` for
1113
+ `{status, checked, total, preview}`; the type editor does this once a second
1114
+ and shows "Checked N of M…". A `404` from the poll means this process no
1115
+ longer holds the job — the registry is in-memory and bounded, by design, since
1116
+ §8.9 refuses to persist a report about records that may have changed — and the
1117
+ answer to one is simply to preview again, which writes nothing.
1118
+
1119
+ Saving straight after a preview does not scan twice. `PUT /types/{key}` reuses
1120
+ a completed job's report when it was taken against the same type, the same
1121
+ proposed fields **and the same `version`** — the value the caller already has
1122
+ to send as `expected_version` and which the save has already checked under the
1123
+ type's row lock, so the schema the report describes is provably the schema
1124
+ being changed. What that does not cover is records written in between, which
1125
+ is what `preview_job_ttl_seconds` bounds (10 minutes by default); set it to
1126
+ `0` to make every save run its own pass. A save with no matching preview, or
1127
+ one resolving orphaned keys with `discard`, always runs its own.
1128
+
1129
+ Field keys are immutable: a "rename" is a remove plus an add, and is
1130
+ treated as one. Every schema change writes a type revision;
1131
+ `POST /api/records/types/{key}/revisions/{version}/restore` rolls back
1132
+ through the same pipeline, so a rollback that would fail records is refused
1133
+ like any other change.
1134
+
1135
+ ## Import and export
1136
+
1137
+ Records and type definitions both move as files. Everything below goes through
1138
+ the same service code the UI and the API use — an import writes with
1139
+ `create_record`/`update_record`, so revisions, index rows, `unique` and slug
1140
+ claims and the per-type lock all behave exactly as they do for a single save.
1141
+
1142
+ | route | permission | notes |
1143
+ |---|---|---|
1144
+ | `GET /api/records/types/{key}/records/export?format=json\|csv` | `records.view` | streaming; takes the list screen's `filter`/`sort` (a refused one is the list's `400`, before any download starts), and `trashed=true` (which costs `records.edit`) |
1145
+ | `POST /api/records/types/{key}/records/import` | `records.edit` | multipart `file=`, or a raw body with `Content-Type: application/json` / `text/csv` |
1146
+ | `GET /api/records/types/{key}/export` | `records.view` | the type definition alone, shaped for the route below; narrowed by the type's `allowed_roles` unless the caller holds `records.manage_types`, exactly as `GET /types/{key}` is |
1147
+ | `POST /api/records/types/import` | `records.manage_types` | `mode=create` (default) or `mode=update` + `expected_version` |
1148
+
1149
+ The record export **streams**. It walks the type keyset-paged by `id` in
1150
+ batches of `reindex_batch_size`, on a session of its own, so a 100k-record
1151
+ type is exported in constant memory rather than assembled in one list. (An
1152
+ export with an explicit `?sort=` cannot be keyset-paged — the sort key lives
1153
+ in an index table — and pages by `OFFSET` instead; it is meant for exporting a
1154
+ *selection*, and the unsorted default is what a round trip should use.)
1155
+
1156
+ **What travels.** Each record carries `uuid`, `slug`, `locale`,
1157
+ `translation_group`, `status`, `position`, `published_at` and `data`; `data`
1158
+ is the lenient read, so defaults are filled in and a record stamped at an
1159
+ older schema version exports under the current one. Relations travel as stored (`{"type": …, "uuid": …}`), never expanded.
1160
+ The reserved `_orphaned` key, the audit columns, `version` and `is_deleted` do
1161
+ not travel: they describe this install's copy of the row. `uuid` does, which
1162
+ is what makes a round trip independent of autoincrement.
1163
+
1164
+ **CSV.** Columns are `uuid, slug, locale, translation_group, status,
1165
+ position, published_at` and then one per declared field in declaration order;
1166
+ import is header-driven, so order and missing columns are fine (a missing column means "leave it alone", an empty
1167
+ cell means null). Values use the module's own wire forms — a `number` is its
1168
+ decimal string, a boolean is `true`/`false`, a date is ISO. `multiselect`,
1169
+ `json` and `media` cells are JSON-encoded; a relation is `type:uuid`, and a
1170
+ to-many relation a JSON list of those. UTF-8 with **no** BOM, `\r\n` line
1171
+ endings per RFC 4180.
1172
+
1173
+ > **CSV formula injection is escaped, losslessly.** A cell beginning `=`, `+`,
1174
+ > `-`, `@`, a tab, a carriage return **or an apostrophe** is written with a
1175
+ > leading apostrophe, which Excel and LibreOffice both read as "the rest of
1176
+ > this cell is literal text" — so a `text` value of `=cmd|' /C calc'!A0`,
1177
+ > chosen by anyone who can create a record, no longer runs in the spreadsheet
1178
+ > of whichever admin clicked Export. Escaping the apostrophe *itself* is what
1179
+ > keeps the round trip exact: the importer strips exactly one leading
1180
+ > apostrophe from every cell, so a value that genuinely starts with one
1181
+ > survives. **A plain negative number is the one exemption** — `-5`, `-5.25`,
1182
+ > a negative `position` — because a spreadsheet evaluates it to itself and
1183
+ > prefixing it turned every column of figures into `'-5`. Anything else that
1184
+ > begins with a hyphen (`-1+1`, `-cmd`) is still escaped. The one cost is a
1185
+ > CSV written **by hand** rather than exported — it carries no doubling, so a
1186
+ > hand-typed `'12` imports as `12`.
1187
+
1188
+ **Import options** (query string, or multipart form fields, which win):
1189
+
1190
+ - `dry_run` — **`true` by default.** A dry run parses, validates and matches
1191
+ every row and returns the full report, writing nothing.
1192
+ - `mode` — `upsert` (default; match, else create), `create`, `update`.
1193
+ - `on_error` — `abort` (default) is all-or-nothing: the request's transaction
1194
+ is rolled back and the 422 carries the report. `skip` writes the valid rows,
1195
+ each under its own savepoint, and reports the rest.
1196
+ - `match_by` — `uuid` (default), `slug`, or the key of a **`unique`** field.
1197
+ Matching on a non-unique field is refused rather than resolved arbitrarily.
1198
+ `slug` matches on the *canonical* slug — the cell is slugified first, the
1199
+ same way a write would store it, so a hand-written `Hello World` matches
1200
+ the record whose slug is `hello-world`. Matching on anything but `uuid`
1201
+ also refuses a row whose `uuid` already exists but whose match key found
1202
+ nothing: the record's slug or unique value has moved since the file was
1203
+ written, and creating it would collide on `uuid`.
1204
+ - `force` — an update whose row carries no `version` is refused, because the
1205
+ export deliberately does not carry one; `force=true` accepts last-write-wins.
1206
+ The refusal is decided in the planning pass, so **a dry run predicts it**:
1207
+ the preview reports the row as failed rather than promising an update the
1208
+ apply would then refuse.
1209
+ - `max_import_bytes` (a setting, 50 MB by default) refuses a larger body with
1210
+ `413` **before** parsing it — `Content-Length` first, and then while the
1211
+ body is read, so a chunked upload that declares no length is stopped at the
1212
+ first byte over the ceiling rather than buffered whole and refused
1213
+ afterwards.
1214
+ - `max_import_rows` (a setting, 20,000 by default) refuses a file with more
1215
+ rows than that, also `413` and also before anything is written. The byte
1216
+ ceiling bounds the *file*; this bounds the *work*, because 50 MB is roughly
1217
+ 1.7 M rows and that is hours inside one HTTP request — long past any reverse
1218
+ proxy's read timeout, at which point the client sees a 504 while the server
1219
+ keeps writing. A JSON file is counted exactly from the parsed document; a CSV
1220
+ is counted as it is read and the parse stops at the first row past the
1221
+ ceiling, because a quoted cell may contain newlines and there is no cheap
1222
+ exact count to check first.
1223
+
1224
+ The report is `{dry_run, mode, total, created, updated, skipped, failed,
1225
+ errors: [{row, uuid, field, message}], errors_truncated, duration_ms}`, with
1226
+ `created + updated + skipped + failed == total` and at most 200 errors listed.
1227
+ **`skipped` is why re-importing an export is a no-op**: a row the record
1228
+ already agrees with is not written at all, so versions do not move.
1229
+
1230
+ A row is refused for carrying `_orphaned`, naming an unknown field, pointing
1231
+ at a relation target that does not exist, repeating a `uuid` already used
1232
+ earlier in the same file, naming a `uuid` that belongs to another type or to
1233
+ [another table set](#uuid-across-collections), matching a record in the trash
1234
+ (restore or purge it first), naming a
1235
+ [translation group it may not join](#export-and-import), or updating a record
1236
+ without a `version`. **Every one of them is decided before anything is
1237
+ written**, so a dry run and the apply that follows it report the same rows: a
1238
+ check the write path could see and the planning pass could not is the one bug
1239
+ this arrangement exists to prevent.
1240
+
1241
+ The handful of refusals that genuinely cannot be predicted — a slug two rows of
1242
+ the same file both derive, which neither row can claim until the other is
1243
+ written — are reported with **the row that failed**, not as row `0`.
1244
+
1245
+ **Type definitions.** `POST /api/records/types/import` with `mode=update` and
1246
+ an `expected_version` routes through the ordinary `update_type` path, so
1247
+ importing a definition onto a populated type is classified, dry-run and
1248
+ refused with the same report — and answered with the same `force` /
1249
+ `orphaned` — as the same change made in the schema editor.
1250
+
1251
+ **It writes what the file contains and nothing else.** Like `PUT
1252
+ /types/{key}`, the update is built with `exclude_unset`: a definition that
1253
+ does not mention `allowed_roles`, `is_public` or `translatable` leaves all
1254
+ three as they are, rather than clearing them to the defaults. A file that
1255
+ *does* name `allowed_roles`, and names a different list from the stored one,
1256
+ costs the caller the type's own narrowing on top of `records.manage_types` —
1257
+ widening a list you are outside of is a thing to ask for in the schema
1258
+ editor, not a side effect of importing a file. An export re-imported onto the
1259
+ install it came from sends the list already stored, so a round trip is
1260
+ unaffected.
1261
+
1262
+ **`collection` travels with the definition.** It is a property of the
1263
+ definition rather than of this install's copy of it — unlike `record_count` or
1264
+ `version`, which is why those do not travel and this does. On a create, a name
1265
+ this host has not declared is the same `422` `POST /types` gives; on
1266
+ `mode=update`, a value that differs from the stored one is the same `409` `PUT
1267
+ /types/{key}` gives, because a collection is assigned at creation and never
1268
+ after. Dropped from the export, as it was, a collection-backed type landed on
1269
+ the next install as a shared-tables type with no warning anywhere.
1270
+
1271
+ **From the command line** (from the repo root, like every entry point here):
1272
+
1273
+ ```bash
1274
+ python -m sm_records.cli export --type order --format json --out order.json
1275
+ python -m sm_records.cli export --type order --format csv # stdout
1276
+ python -m sm_records.cli import --type order order.json # dry run
1277
+ python -m sm_records.cli import --type order order.json --apply
1278
+ ```
1279
+
1280
+ `import` is a dry run unless `--apply`, takes `--mode`, `--on-error`,
1281
+ `--match-by` and `--force`, and exits non-zero with the report printed when an
1282
+ `abort` run is refused.
1283
+
1284
+ ## Development
1285
+
1286
+ ```bash
1287
+ uv sync --extra dev
1288
+ uv run pytest
1289
+ ```
1290
+
1291
+ ### Running the suites against Postgres
1292
+
1293
+ Both suites default to SQLite — in-memory for the unit tests, a reused file
1294
+ for the perf measurements — and both take an opt-in URL instead. CI is
1295
+ unchanged by either; nothing is selected unless the variable is set.
1296
+
1297
+ ```bash
1298
+ # unit suite: a real database, emptied (TRUNCATE ... RESTART IDENTITY) per test
1299
+ cd modules/records
1300
+ SM_TEST_DATABASE_URL=postgresql+asyncpg://postgres@localhost:5432/records_unit \
1301
+ uv run pytest -q
1302
+
1303
+ # perf suite: a seeded database, measured instead of the SQLite file
1304
+ RECORDS_PERF_N=20000 RECORDS_PERF_URL=postgresql+asyncpg://postgres@localhost:5432/records_perf \
1305
+ uv run pytest -q -s -m perf tests/perf
1306
+ ```
1307
+
1308
+ `SM_TEST_DATABASE_URL` is the repo-wide name — `pagebuilder` and `news` read
1309
+ the same variable, through the same `tests/pg_support.py` at the repo root, so
1310
+ one export runs all three suites on one database. **`RECORDS_TEST_URL` still
1311
+ works** and is read as an alias when `SM_TEST_DATABASE_URL` is unset; the
1312
+ Postgres CI job and the 2026-09-21 study both name it.
1313
+
1314
+ Create the databases first; neither variable creates one. The perf suite needs
1315
+ permission to `CREATE DATABASE`, because the measurements that mutate their
1316
+ database run against a `CREATE DATABASE ... TEMPLATE` copy and drop it
1317
+ afterwards — the Postgres equivalent of the SQLite branch copying the file.
1318
+
1319
+ `tests/test_postgres_lock.py` runs **only** when `SM_TEST_DATABASE_URL` (or
1320
+ its `RECORDS_TEST_URL` alias) names a Postgres database: it is the concurrency proof for the `SELECT ... FOR UPDATE`
1321
+ branch of the per-type lock, which cannot be reached on SQLite.
1322
+ `tests/test_unique_concurrency.py` and `tests/test_reindex_runner_locking.py`
1323
+ are its counterparts and stay on a SQLite file whatever the variable says,
1324
+ because what they pin is the other branch.
1325
+
1326
+ The e2e suite picks its database from `SM_DATABASE_URL`, which
1327
+ `playwright.config.ts` passes through when it is set:
1328
+
1329
+ ```bash
1330
+ SM_DATABASE_URL=postgresql+asyncpg://postgres@localhost:5432/records_e2e \
1331
+ npx playwright test tests/e2e/records-*.spec.ts
1332
+ ```
1333
+
1334
+ `tests/e2e/start-test-server.sh` empties whichever database that names —
1335
+ deleting the file for SQLite, dropping and recreating the schema otherwise —
1336
+ before running the migrations.
1337
+
1338
+ Results of the last full Postgres run are in
1339
+ [`docs/postgres-2026-09-21.md`](docs/postgres-2026-09-21.md).
1340
+
1341
+
1342
+ ### Rebuilding the index by hand
1343
+
1344
+ The rebuild normally runs as a background task right after the schema change.
1345
+ If a worker was restarted mid-way, the field stays in `reindex_pending` and
1346
+ `/health/ready` degrades once an entry is older than
1347
+ `reindex_stale_after_seconds`, naming the type (as `tenant/key`) and fields —
1348
+ on a multi-tenant host the detail gives counts only and the names go to the
1349
+ `sm_records.health` log at INFO ([operations](docs/operations.md#health-checks)).
1350
+ Run it yourself from the repo root:
1351
+
1352
+ ```
1353
+ python -m sm_records.cli reindex # every tenant's types with pending keys
1354
+ python -m sm_records.cli reindex --type KEY # one type, in tenant `default`
1355
+ python -m sm_records.cli reindex --type KEY --tenant T # one type, in tenant T
1356
+ ```
1357
+
1358
+ Every subcommand takes `--tenant` (default `default`), and
1359
+ `python -m sm_records.cli tenants` lists the tenants that hold types — see
1360
+ [docs/operations.md § The CLI](docs/operations.md#the-cli).
1361
+
1362
+ It is idempotent and resumable — index rows are derived from the stored
1363
+ payloads, so running it twice converges.
1364
+
1365
+ ### Subscribing to what records do
1366
+
1367
+ The module publishes domain events on the framework's bus, and subscribes to
1368
+ nothing. A host handles them in its own `register_event_handlers`:
1369
+
1370
+ ```python
1371
+ from simple_module_core.events import EventBus
1372
+ from sm_records.contracts.events import RecordTypeChanged, RecordUpdated
1373
+
1374
+
1375
+ class MyModule(ModuleBase):
1376
+ def register_event_handlers(self, bus: EventBus, app=None) -> None:
1377
+ bus.subscribe(RecordUpdated, self.on_record_updated)
1378
+ bus.subscribe(RecordTypeChanged, self.on_type_changed)
1379
+
1380
+ async def on_record_updated(self, event: RecordUpdated) -> None:
1381
+ # event.type_key, event.tenant_id, event.uuid, event.version,
1382
+ # event.status_before/after — the handler runs bound to event.tenant_id
1383
+ ...
1384
+ ```
1385
+
1386
+ The set is `RecordCreated`, `RecordUpdated`, `RecordTrashed`,
1387
+ `RecordRestored`, `RecordPurged`, `RecordTypeChanged` and `RecordTypeDeleted`
1388
+ — see
1389
+ [docs/architecture.md § Extension points](docs/architecture.md#extension-points)
1390
+ for what each carries and why. Five properties are worth knowing before you
1391
+ rely on them:
1392
+
1393
+ - **Every event carries `tenant_id`.** A key and a uuid are unique only
1394
+ within a tenant, so `(tenant_id, type_key, uuid)` names a record. It is
1395
+ `"default"` on a single-tenant host. The handler runs with that tenant
1396
+ bound, so it can read the record back through the ORM as it is.
1397
+ - **They arrive after the commit.** The write is already visible to any
1398
+ session by the time a handler runs, and a request that rolled back publishes
1399
+ nothing.
1400
+ - **Bulk paths speak in records.** An import publishes one event per row that
1401
+ wrote (a dry run publishes none), a delete that cascades publishes one per
1402
+ record it reached — with `cascaded_from` naming the record the operator
1403
+ actually asked about — and deleting a type publishes one `RecordPurged` per
1404
+ record before the `RecordTypeDeleted`.
1405
+ - **They carry identifiers, not payloads.** Read the record if you need its
1406
+ content. The exception is `RecordPurged`, which is the one event whose
1407
+ subject is gone — and even that one carries identity (`uuid`, `locale`,
1408
+ `translation_group`) rather than the payload, because a type delete is
1409
+ set-based and never instantiates the rows it removes.
1410
+ - **Handler failures are the bus's business, not yours to be careful about.**
1411
+ `EventBus.publish` gathers with `return_exceptions=True` and logs; a
1412
+ subscriber that raises does not take the write down, and does not stop the
1413
+ other subscribers.
1414
+
1415
+ `RecordTypeChanged` is the one to reach for if you register an index or reduce
1416
+ provider: `index_affecting_keys` names the fields whose stored shape just
1417
+ moved, which is what the "run the CLI afterwards" note below is asking you to
1418
+ notice by hand.
1419
+
1420
+ ### Extending the index
1421
+
1422
+ The built-in projection writes one index row per field marked `indexed: true`.
1423
+ An **index provider** is the seam for everything a schema cannot express — a
1424
+ computed bucket, a normalised sort key, a value denormalised from a related
1425
+ record. It is a callable `(record, record_type) -> Iterable[IndexEntry]`, and
1426
+ the keys it declares as `VirtualField`s become filterable and sortable on
1427
+ every type's records:
1428
+
1429
+ ```python
1430
+ from sm_records.index import IndexEntry, IndexKind, VirtualField, register_index_provider
1431
+
1432
+
1433
+ def price_bucket(record, rtype):
1434
+ price = (record.data or {}).get("price")
1435
+ if price is not None:
1436
+ yield IndexEntry(IndexKind.NUMBER, "price_bucket", int(float(price)) // 100)
1437
+
1438
+
1439
+ register_index_provider(price_bucket, fields=[VirtualField("price_bucket", IndexKind.NUMBER)])
1440
+ ```
1441
+
1442
+ `?filter=price_bucket:gte:1` and `?sort=-price_bucket` then work like any
1443
+ declared indexed field: same operator matrix per kind, `many=True` for a key
1444
+ that projects several rows per record (`eq` matches any of them, `ne` none of
1445
+ them), and the same truncation re-check on text. Those six names, plus
1446
+ `IndexProvider` (the callable's type, for annotating one) and `virtual_fields`
1447
+ (every provider-projected key, by key), are the whole public surface of
1448
+ `sm_records.index` — its `__all__`; the writer, the query builder and the
1449
+ rebuild are internals.
1450
+
1451
+ Register at import time or from your module's `on_startup` — anywhere before
1452
+ the first record is written. The registry is process-global, so every worker
1453
+ must run the same registrations, and the built-in provider is always first and
1454
+ cannot be removed.
1455
+
1456
+ A few consequences worth knowing before you use it:
1457
+
1458
+ - **A virtual key must be a field key, and one nothing else resolves first.**
1459
+ `register_index_provider` refuses a key that does not match
1460
+ `^[a-z][a-z0-9_]*$`, is over 64 characters, or names a record column or
1461
+ fixed filter column (`status`, `slug`, `position`, `created_at` …) — the
1462
+ query grammar resolves those before it ever looks at the registry, so such
1463
+ rows would be written on every save and never be readable. The refusal is a
1464
+ `ValueError` at registration: your code is what registers it, and there is a
1465
+ person reading the traceback.
1466
+ - **A virtual key is global, so a type may not declare a field of the same
1467
+ key.** Saving a type with one is a 422 naming it. The type editor cannot
1468
+ grey the key out — what a host registered is not knowable to the browser —
1469
+ so a 422 on save is the contract. Registering a provider whose key collides
1470
+ with a field some type *already* declares is not retroactive: that type
1471
+ keeps working, its own definition wins the filter grammar, and the collision
1472
+ is logged once per type. (Taking a stored type offline because of a deploy
1473
+ elsewhere is not a trade this module makes.)
1474
+ - **The rows must match what you declared.** An `IndexEntry` whose `kind`
1475
+ disagrees with the `VirtualField` registered for that key is dropped and
1476
+ logged once per provider and key, because it would land in a table no filter
1477
+ over that key reads — a key answering `is_null: true` for a record that has
1478
+ a value.
1479
+ - **`REF` entries are load-bearing, and cannot be invented.** They are how
1480
+ `on_delete` is enforced and what the "what references this?" panel reads, so
1481
+ a `REF` entry naming a `target_type_id` that is not a record type is dropped
1482
+ too: a provider projects rows, it does not get to make unrelated records
1483
+ undeletable.
1484
+ - **Changing what a provider projects does not mark anything.** A provider is
1485
+ code the host deploys, not a schema edit this module can see, so no
1486
+ `reindex_pending` entry appears and no filter is refused while the rows are
1487
+ stale. Run `python -m sm_records.cli reindex` after deploying a changed
1488
+ provider — or after registering a first one against existing records.
1489
+ - **A provider that raises does not take the write down.** The failure is
1490
+ logged with the provider's qualified name and the record's uuid, that
1491
+ provider contributes no rows for that record, and the other providers and
1492
+ the write itself carry on. A broken host extension must not make every
1493
+ record unsaveable; its rows come back on the next reindex.
1494
+
1495
+ ### Maintained aggregates
1496
+
1497
+ An index provider makes a record *queryable*; a **reduce provider** keeps a
1498
+ running aggregate of the whole type, updated on every write. It is the answer
1499
+ to one problem only — a `GROUP BY` over a type so large that the live
1500
+ `/aggregate` above has become too slow — and it is opt-in per provider,
1501
+ because it buys that speed with a second source of truth.
1502
+
1503
+ ```python
1504
+ from decimal import Decimal
1505
+
1506
+ from sm_records.index import ReduceSpec, register_reduce_provider
1507
+
1508
+ register_reduce_provider(
1509
+ ReduceSpec(
1510
+ key="orders_per_state",
1511
+ group_by=lambda record, rtype: (record.data or {}).get("ship_state"),
1512
+ value=lambda record, rtype: Decimal((record.data or {}).get("total") or 0),
1513
+ value_label="total",
1514
+ )
1515
+ )
1516
+ ```
1517
+
1518
+ Read it back with `GET …/records/aggregate?reduce=orders_per_state`, which
1519
+ returns the same shape as a live aggregate plus `stored: true` and
1520
+ `updated_at` — so a caller can ask the same question both ways and compare.
1521
+
1522
+ **What `metric` says.** A live aggregate's `metric` echoes what you asked for,
1523
+ and `sum:<x>` there names a **field**. A stored one has no field — it has your
1524
+ `value` callable — so it reports the spec's own description: `"count"` when
1525
+ the spec declares no `value`, `"sum:<value_label>"` when it declares one and
1526
+ labels it, and the bare `"sum"` when it does not. `value_label` is free text
1527
+ naming the quantity being folded; with `value_label="total"` above, both
1528
+ readings of the example answer `metric: "sum:total"`, which is what makes the
1529
+ two comparable at a glance.
1530
+
1531
+ **Delta, rebuild, verify.** The three exist together and none of them is
1532
+ optional:
1533
+
1534
+ - **Delta.** `write_index` computes what the record contributed *before* the
1535
+ write and what it contributes after, and applies the difference with
1536
+ `UPDATE … SET count = count + :d` inside the same transaction. The database
1537
+ does the arithmetic, so two writers cannot both read `7` and both store `8`.
1538
+ On SQLite the per-type write lock already serialises them; on Postgres the
1539
+ row `UPDATE` does. An edit that does not move the record costs no statement
1540
+ at all, and an install with no spec registered costs exactly what it did
1541
+ before this existed.
1542
+ - **Rebuild.** `python -m sm_records.cli reindex --type KEY` (and the reindex
1543
+ button, and any schema-triggered rebuild) throws the type's stored groups
1544
+ away and recomputes them from the records. The table is therefore never more
1545
+ than a cache of something the documents already say.
1546
+ - **Verify.** `python -m sm_records.cli reindex --verify [--type KEY]`
1547
+ recomputes every spec and reports each `(type, key, group)` whose stored row
1548
+ disagrees. Exit code `1` on drift, `0` clean, so it works as a deploy gate
1549
+ or a cron check. Design §7.5's objection to a maintained aggregate was that
1550
+ it can drift; the answer is not that it cannot, it is that **drift is
1551
+ detectable**. A verify run *in this process* also degrades `/health/ready`
1552
+ with a `reduce_drift` detail until the next clean verify or rebuild; a CLI
1553
+ verify is a different process and reports on its own stdout instead.
1554
+
1555
+ The transitions, each of which has a test:
1556
+
1557
+ | event | what moves |
1558
+ |---|---|
1559
+ | create | `+1`, `+value` into the new group (insert on the group's first sight) |
1560
+ | update, same group | `+new − old` on `sum` only; no count change |
1561
+ | update, new group | `−1 −old` on the old group, `+1 +new` on the new one |
1562
+ | soft delete (trash) | `−1 −value`; a trashed record is **not** counted |
1563
+ | restore | `+1 +value` |
1564
+ | purge of a trashed record | nothing — it was decremented when it was trashed |
1565
+ | delete the type | every group of every spec on it is removed |
1566
+
1567
+ A group whose count reaches zero is deleted rather than left at zero, so the
1568
+ table is exactly the set of non-empty groups — which is what makes it
1569
+ comparable to a fresh recompute row for row.
1570
+
1571
+ Other things to know before you register one:
1572
+
1573
+ - **A reduce key is a virtual key.** Same rules as a `VirtualField`: it must
1574
+ match `^[a-z][a-z0-9_]*$`, may not name a record column or fixed filter
1575
+ column, has one owner, and is refused as a declared field key on save.
1576
+ - **Registering a spec marks nothing.** A provider is code you deploy, not a
1577
+ schema edit this module can see, so no `reindex_pending` entry appears and
1578
+ the table simply has no rows for the new key. Run
1579
+ `python -m sm_records.cli reindex --type KEY` after deploying one, or after
1580
+ changing what an existing one folds on. Until then the live `/aggregate` is
1581
+ unaffected either way.
1582
+ - **Every worker must run the same registrations.** The registry is
1583
+ process-global; a worker that skipped the registration writes no deltas, and
1584
+ what it writes goes missing from the stored aggregate until a rebuild.
1585
+ - **`group_by` returning `None` means "no group"**, not an "unknown" bucket.
1586
+ `value` returning `None` means zero contribution — the record still counts.
1587
+ - **Groups are stored as text, truncated at 512 characters**, which is the
1588
+ same cut `text` index values take. A spec grouping on free prose will merge
1589
+ groups that differ only after the cut; group on something bounded.
1590
+ - **One row per group is the cost.** A spec whose group is effectively unique
1591
+ per record gives you a stored table as large as the type, maintained on
1592
+ every write, for no benefit. That is a design mistake no implementation here
1593
+ can fix.
1594
+ - **A destructive schema change rebuilds them.** Discarding a deleted field's
1595
+ orphaned values rewrites every payload of the type, which is the one thing
1596
+ other than a record write that can move what a spec folds on — so that pass
1597
+ rebuilds the type's maintained aggregates before it returns.
1598
+ - **A spec that raises contributes nothing and is logged.** The write itself
1599
+ is never taken down by a host's extension — but a failure on the *old* side
1600
+ of a delta leaves a group over-counted until the next rebuild, which is
1601
+ exactly what `--verify` is for.
1602
+
1603
+ ### Demo data
1604
+
1605
+ `sm_records.seed` writes a small US-flavoured business dataset — five Record
1606
+ Types (`company`, `contact`, `product`, `store`, `order`, with relations
1607
+ between them) and however many records you ask for — through the real
1608
+ services, so a seeded install has the same index rows, revisions and
1609
+ relation checks a hand-built one would. Run it from the repo root:
1610
+
1611
+ ```
1612
+ python -m sm_records.cli seed # 5000 records, seed 42
1613
+ python -m sm_records.cli seed --records 2000 --seed 7 # a smaller, different run
1614
+ python -m sm_records.cli seed --reset # purge the demo types first
1615
+ python -m sm_records.cli seed --tenant acme # into tenant acme (default: default)
1616
+ python -m sm_records.cli seed --database-url sqlite+aiosqlite:///path/to.db
1617
+ ```
1618
+
1619
+ On a host that declares the `events` [collection](#collections) the seeder adds
1620
+ a sixth type, `event`, in that collection: a `datetime`, a `select` and a
1621
+ `venue` relation pointing at the **global** `store` type, so the demo dataset
1622
+ exercises a relation across a collection boundary. On a host that declares no
1623
+ collection the type is skipped entirely and the dataset is exactly what it
1624
+ always was.
1625
+
1626
+ `--records` is the *total* across all the types (roughly 5% company / 25%
1627
+ contact / 15% product / 45% order / 10% store, minimum one each; `event` takes
1628
+ 5% proportionally from the five where it exists, so `--records N` still means
1629
+ N). It is
1630
+ deterministic for a given `--seed`; re-running without `--reset` tops up the
1631
+ dataset with more records, and unique fields (`contact.email`, `product.sku`,
1632
+ `order.order_no`) stay collision-free because their values are keyed off each
1633
+ type's current record count, not the seed alone. `--reset` hard-deletes the
1634
+ demo types and everything in them (including the trash) before reseeding.
1635
+
1636
+ On an install with more than one `content_locale`, the seeder also marks the
1637
+ `company` type `translatable` and gives roughly a tenth of the companies a
1638
+ sibling in the second locale, so a multilingual host has something for the
1639
+ Languages panel and for `?locale=` to show. On a single-locale install it does
1640
+ none of that and writes exactly the dataset it always did.
1641
+
1642
+ Every record goes through `services.records.create_record`, so this is the
1643
+ slow path, not a bulk insert — 10,000 records took **1 minute 46 seconds
1644
+ (94 records/s)** on SQLite in testing, and the rate no longer falls away as
1645
+ the dataset grows now that the `unique` check is an existence test rather
1646
+ than a `COUNT` over the whole type (see [docs/performance.md](docs/performance.md)).
1647
+ The run finishes with one `ANALYZE` pass over the module's own tables: a bulk
1648
+ load is exactly the state SQLite has no planner statistics for, and a seeded
1649
+ database's first act is to be queried. For an in-process caller (a test, a
1650
+ perf harness) that already has a `db_state`/`settings` pair, call
1651
+ `sm_records.seed.seed_database(db_state, settings, records=..., seed=...,
1652
+ reset=...)` directly instead of shelling out.
1653
+
1654
+ ## API-version contract
1655
+
1656
+ `ModuleMeta.requires_framework` declares which `simple_module_core` versions
1657
+ this module supports. Update the spec on each framework major bump after
1658
+ verifying compatibility.