includio-cms 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/API.md +1 -1
  2. package/CHANGELOG.md +42 -0
  3. package/DOCS.md +1169 -146
  4. package/ROADMAP.md +5 -330
  5. package/dist/core/server/entries/operations/get.bench.d.ts +1 -0
  6. package/dist/core/server/entries/operations/get.bench.js +68 -0
  7. package/dist/core/server/entries/operations/get.js +17 -7
  8. package/dist/core/server/fields/utils/imageStyles.bench.d.ts +1 -0
  9. package/dist/core/server/fields/utils/imageStyles.bench.js +82 -0
  10. package/dist/core/server/fields/utils/imageStyles.js +49 -53
  11. package/dist/core/server/media/operations/backgroundMaintenance.d.ts +6 -0
  12. package/dist/core/server/media/operations/backgroundMaintenance.js +6 -1
  13. package/dist/core/server/media/styles/operations/getImageStyle.d.ts +7 -0
  14. package/dist/core/server/media/styles/operations/getImageStyle.js +24 -0
  15. package/dist/db-postgres/index.d.ts +1 -1
  16. package/dist/db-postgres/index.js +27 -0
  17. package/dist/paraglide/messages/_index.d.ts +36 -3
  18. package/dist/paraglide/messages/_index.js +71 -3
  19. package/dist/paraglide/messages/en.d.ts +5 -0
  20. package/dist/paraglide/messages/en.js +14 -0
  21. package/dist/paraglide/messages/pl.d.ts +5 -0
  22. package/dist/paraglide/messages/pl.js +14 -0
  23. package/dist/types/adapters/db.d.ts +16 -0
  24. package/dist/types/adapters/db.js +8 -1
  25. package/dist/updates/0.23.0/index.d.ts +2 -0
  26. package/dist/updates/0.23.0/index.js +21 -0
  27. package/dist/updates/0.24.0/index.d.ts +2 -0
  28. package/dist/updates/0.24.0/index.js +20 -0
  29. package/dist/updates/index.js +3 -1
  30. package/package.json +2 -1
  31. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  32. package/dist/paraglide/messages/hello_world.js +0 -33
  33. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  34. package/dist/paraglide/messages/login_hello.js +0 -34
  35. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  36. package/dist/paraglide/messages/login_please_login.js +0 -34
package/DOCS.md CHANGED
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.22.0)
1
+ # Includio CMS Documentation (v0.24.0)
2
2
 
3
3
  > This file is auto-generated from the docs site. For the latest version, update the package.
4
4
 
@@ -789,7 +789,7 @@ Entries are the content records stored in your database. Both collections and si
789
789
 
790
790
  ### Populated Entry (API output)
791
791
 
792
- When fetching entries via `getEntries` / `getEntry`, you receive populated entries with metadata prefixed by `_`:
792
+ When fetching entries via `resolveEntry` / `resolveEntries`, you receive populated entries with metadata prefixed by `_`:
793
793
 
794
794
  ```typescript
795
795
  type Entry = {
@@ -861,87 +861,77 @@ Only one version per status per language can exist at a time.
861
861
  ## Querying
862
862
 
863
863
  ```typescript
864
- import { getEntries, getEntry } from 'includio-cms/admin/remote';
864
+ import { resolveEntry, resolveEntries, countEntries } from 'includio-cms/admin/remote';
865
865
 
866
- // Get published entries for a collection
867
- const posts = await getEntries({
868
- slug: 'posts',
869
- status: 'published',
870
- language: 'en'
871
- });
872
-
873
- // Get a single entry
874
- const homepage = await getEntry({
875
- slug: 'homepage',
866
+ // Published entries for a collection
867
+ const posts = await resolveEntries({
868
+ collection: 'posts',
876
869
  status: 'published',
877
- language: 'en'
878
- });
879
-
880
- // Filter by exact field values
881
- const featured = await getEntries({
882
- slug: 'posts',
883
- dataValues: { featured: true }
870
+ locale: 'en'
884
871
  });
885
872
 
886
- // Search by field content (LIKE query)
887
- const results = await getEntries({
888
- slug: 'posts',
889
- dataLike: { title: 'svelte' }
890
- });
873
+ // Single entry by ID
874
+ const post = await resolveEntry({ id: 'uuid', locale: 'en' });
891
875
 
892
- // Case-insensitive OR search across fields
893
- const search = await getEntries({
894
- slug: 'posts',
895
- dataILikeOr: { title: 'svelte', description: 'svelte' }
876
+ // Singleton (treated as a one-entry collection)
877
+ const homepage = await resolveEntry({
878
+ collection: 'homepage',
879
+ status: 'published',
880
+ locale: 'en'
896
881
  });
897
882
 
898
- // Sort by a data field
899
- const sorted = await getEntries({
900
- slug: 'events',
901
- status: 'published',
902
- language: 'en',
903
- dataOrderBy: { field: 'eventDate', direction: 'asc' }
883
+ // Filter by IDs
884
+ const someOf = await resolveEntries({
885
+ collection: 'posts',
886
+ ids: ['id-1', 'id-2', 'id-3']
904
887
  });
905
888
 
906
889
  // Pagination
907
- const page = await getEntries({
908
- slug: 'posts',
890
+ const page = await resolveEntries({
891
+ collection: 'posts',
909
892
  status: 'published',
910
- language: 'en',
893
+ locale: 'en',
911
894
  limit: 10,
912
895
  offset: 20,
913
896
  orderBy: { column: 'createdAt', direction: 'desc' }
914
897
  });
898
+
899
+ // Count without populate (cheap)
900
+ const total = await countEntries({ collection: 'posts', status: 'published' });
901
+
902
+ // Skip relation populating for one field — return raw IDs
903
+ const slimPosts = await resolveEntries({
904
+ collection: 'posts',
905
+ populate: { fields: { author: false } }
906
+ });
915
907
  ```
916
908
 
917
909
  ## Query Options
918
910
 
919
911
  | Option | Type | Description |
920
912
  |--------|------|-------------|
921
- | `slug` | `string` | Collection/single slug |
922
- | `ids` | `string[]` | Filter by specific entry IDs |
923
- | `status` | `'draft' \| 'published' \| 'scheduled' \| 'archived'` | Version status |
924
- | `language` | `string` | Locale for localized fields |
925
- | `dataValues` | `Record<string, unknown>` | Exact field value matching |
926
- | `dataLike` | `Record<string, unknown>` | Partial text matching (LIKE) |
927
- | `dataILikeOr` | `Record<string, unknown>` | Case-insensitive OR search |
928
- | `orderBy` | `{ column, direction }` | Sort by `'createdAt'`, `'updatedAt'`, or `'sortOrder'` |
929
- | `dataOrderBy` | `{ field, direction }` | Sort by a JSON data field |
930
- | `limit` | `number` | Max entries to return |
931
- | `offset` | `number` | Skip N entries (pagination) |
913
+ | `collection` | `string` | Collection or single slug. Required when `id` not given. |
914
+ | `id` | `string` | Specific entry ID (in `resolveEntry`). |
915
+ | `ids` | `string[]` | Filter by specific entry IDs (in `resolveEntries`). |
916
+ | `status` | `'draft' \| 'published' \| 'scheduled'` | Version status. Default `'published'`. `archived` is **not** a status — use `archive*` operations. |
917
+ | `locale` | `string` | Locale for localized fields. Defaults to `cms.languages[0]`. |
918
+ | `orderBy` | `{ column, direction }` | Sort by `'createdAt'`, `'updatedAt'`, or `'sortOrder'`. |
919
+ | `limit` | `number` | Max entries to return. |
920
+ | `offset` | `number` | Skip N entries (pagination). |
921
+ | `populate` | `{ maxDepth?, fields? }` | Cap recursion depth or opt out of populating specific fields. Default `maxDepth: 5`. |
932
922
 
933
923
  ## Using Entries on Frontend
934
924
 
935
925
  In a SvelteKit `+page.server.ts` load function:
936
926
 
937
927
  ```typescript
938
- import { getEntries, getEntry } from 'includio-cms/admin/remote';
928
+ import { resolveEntries } from 'includio-cms/sveltekit/server';
939
929
 
940
930
  export async function load() {
941
- const posts = await getEntries({
942
- slug: 'posts',
931
+ const posts = await resolveEntries({
932
+ collection: 'posts',
943
933
  status: 'published',
944
- language: 'en',
934
+ locale: 'en',
945
935
  limit: 10,
946
936
  orderBy: { column: 'createdAt', direction: 'desc' }
947
937
  });
@@ -969,6 +959,104 @@ Each entry in the result contains your field data plus metadata:
969
959
 
970
960
  > **pathTemplate:** The `_url` field is auto-generated from the collection's `pathTemplate` (e.g. `'blog/&#123;slug&#125;'`) and the entry's slug field. Configure it in your collection/single definition.
971
961
 
962
+ ## Error handling
963
+
964
+ Resolvers and write operations throw `CmsError` (extends `Error`) with a stable `code` and structured `context`. Match on `code` — never on the message text.
965
+
966
+ ```typescript
967
+ import { resolveEntry, CmsError } from 'includio-cms';
968
+
969
+ try {
970
+ const post = await resolveEntry({ collection: 'posts', id: postId, locale: 'en' });
971
+ if (!post) {
972
+ // resolveEntry returns null when nothing matches — not an error
973
+ return error(404, 'Not found');
974
+ }
975
+ return { post };
976
+ } catch (e) {
977
+ if (e instanceof CmsError) {
978
+ if (e.code === 'MISSING_REQUIRED_PARAM') return error(400, e.message);
979
+ if (e.code === 'ENTRY_VERSION_NOT_FOUND') return error(404, 'No version for this locale');
980
+ }
981
+ throw e;
982
+ }
983
+ ```
984
+
985
+ Stable codes (semver-protected, see [Stability Promise](/docs/stability-promise)):
986
+
987
+ | Code | Where | Meaning |
988
+ |---|---|---|
989
+ | `ENTRY_NOT_FOUND` | Internal `_getRawEntryOrThrow` (admin REST) | Entry record missing. |
990
+ | `ENTRY_VERSION_NOT_FOUND` | Internal `_getDbEntryVersionOrThrow` | No version row for the requested locale/status. |
991
+ | `INVALID_DATA` | `createEntryVersion`, `updateEntryVersion` | Zod validation against the field schema failed. `error.context.collection` and `error.context.entryId` set. |
992
+ | `MISSING_REQUIRED_PARAM` | `resolveEntry` / `resolveEntries` / `countEntries` | Neither `id` nor `collection` provided, or both omitted in a context that requires them. |
993
+ | `CONFIG_VALIDATION_FAILED` | `defineConfig` (boot) | Config-shape problem — see [Configuration](/docs/getting-started/configuration). |
994
+
995
+ Errors stringify as `[CODE] message (k=v, k=v)` — useful in logs. Never assert on `error.message` text:
996
+
997
+ ```typescript
998
+ // BAD — fragile, message wording can change in patch releases
999
+ if (e.message === 'Entry not found') { /* ... */ }
1000
+
1001
+ // GOOD
1002
+ if (e instanceof CmsError && e.code === 'ENTRY_NOT_FOUND') { /* ... */ }
1003
+ ```
1004
+
1005
+ ## Transaction patterns
1006
+
1007
+ The default Postgres adapter (`includio-cms/db-postgres`) uses short transactions inside individual operations (e.g. create + initial version) but **does not** wrap user-orchestrated multi-call sequences. Two patterns to know:
1008
+
1009
+ ### Race conditions are versioned, not blocked
1010
+
1011
+ If two editors save the same entry at the same time, both writes succeed and produce two new versions. The newer version becomes the current draft. There's no optimistic-lock check — design for "last write wins" semantics.
1012
+
1013
+ For sensitive flows (e.g. publishing a price change), gate at the application layer:
1014
+
1015
+ ```typescript
1016
+ const entry = await resolveEntry({ collection: 'products', id, locale: 'en' });
1017
+ if (entry?._version !== expectedVersion) {
1018
+ throw new Error('Stale view — reload and retry');
1019
+ }
1020
+ await updateEntryVersionCommand({ entryId: id, data: newData, type: 'published-now' });
1021
+ ```
1022
+
1023
+ ### Custom transactions across multiple operations
1024
+
1025
+ If you need create-and-publish or create-N-related-entries to be atomic, run them inside a Drizzle transaction. The Postgres adapter exposes its `db` instance:
1026
+
1027
+ ```typescript
1028
+ import { getCMS } from 'includio-cms/core';
1029
+
1030
+ const cms = getCMS();
1031
+ const db = cms.db.driver; // raw drizzle handle when using includio-cms/db-postgres
1032
+
1033
+ await db.transaction(async (tx) => {
1034
+ const author = await tx.insert(entries).values({ slug: 'authors' }).returning();
1035
+ await tx.insert(entries).values({
1036
+ slug: 'posts',
1037
+ // ... reference author.id
1038
+ });
1039
+ });
1040
+ ```
1041
+
1042
+ Inside the transaction, do **not** call CMS resolvers — they use the un-transacted connection. Either run the whole flow at the SQL layer, or accept that resolvers will read stale state until commit.
1043
+
1044
+ ### Plugin hooks and partial failure
1045
+
1046
+ Plugin lifecycle hooks (`beforeCreate`, `afterUpdate`, etc.) run **outside** the database write — a hook throwing won't roll back the write that already committed. If a hook does network work that must succeed (webhook, downstream sync), enqueue it via your own retry mechanism instead of relying on rollback.
1047
+
1048
+ ```typescript
1049
+ {
1050
+ hooks: {
1051
+ afterCreate: async (entry) => {
1052
+ // Don't throw on network errors — log + enqueue retry
1053
+ try { await fetch(WEBHOOK_URL, { method: 'POST', body: JSON.stringify(entry) }); }
1054
+ catch (e) { await queueRetry({ kind: 'webhook', entryId: entry._id }); }
1055
+ }
1056
+ }
1057
+ }
1058
+ ```
1059
+
972
1060
 
973
1061
  ---
974
1062
 
@@ -1231,6 +1319,65 @@ Returns a `number`.
1231
1319
 
1232
1320
  > **Decimals:** Set `step: 0.01` for currency values, or `step: 1` for integers only.
1233
1321
 
1322
+ ## Edge cases
1323
+
1324
+ The number field is backed by a Zod `z.number()` schema. The defaults reject the silent failure modes you'd expect from a JavaScript number.
1325
+
1326
+ | Input | Result |
1327
+ |---|---|
1328
+ | `NaN` | Rejected by Zod (`expected number, received nan`). |
1329
+ | `Infinity`, `-Infinity` | Rejected (Zod requires finite). |
1330
+ | `"42"` (string) | Rejected unless coerced upstream — the field hands a number to Zod. |
1331
+ | `null` | Allowed only when the field is **not** `required`. |
1332
+ | `42.0000001` past `step: 0.01` | Accepted as-is. The admin UI rounds for display; storage keeps the raw value. |
1333
+
1334
+ ### Floating-point precision
1335
+
1336
+ JavaScript numbers are IEEE-754 doubles, so `0.1 + 0.2 === 0.30000000000000004`. If the value is money or an exact unit count, store it with a `step` that matches your precision (e.g. `step: 0.01` for cents) and convert at the boundary:
1337
+
1338
+ ```typescript
1339
+ // Field
1340
+ { type: 'number', slug: 'priceUsd', label: 'Price (USD)', step: 0.01 }
1341
+
1342
+ // Frontend rendering (shop)
1343
+ const display = (entry.priceUsd ?? 0).toFixed(2); // "29.99"
1344
+
1345
+ // Database math — convert to integer cents to avoid drift
1346
+ const cents = Math.round(entry.priceUsd * 100);
1347
+ ```
1348
+
1349
+ For high-precision money in the shop module, `numeric(20,6)` is the storage format — see [Shop Overview](/docs/shop).
1350
+
1351
+ ### `min`/`max` are inclusive
1352
+
1353
+ ```typescript
1354
+ { type: 'number', slug: 'rating', min: 0, max: 5 }
1355
+ ```
1356
+
1357
+ `0` and `5` are valid. To exclude an endpoint, narrow `step` (`min: 0.01` for "must be > 0") or check in your handler.
1358
+
1359
+ ### Integer-only values
1360
+
1361
+ There's no native "integer" flag — emulate it with `step: 1`. The HTML input enforces step on UI submit; Zod runs the same comparison on the backend:
1362
+
1363
+ ```typescript
1364
+ { type: 'number', slug: 'pages', min: 1, step: 1 }
1365
+ // 5 → ok
1366
+ // 5.5 → rejected (`Number must be a multiple of 1`)
1367
+ ```
1368
+
1369
+ ### Parsing user input from forms
1370
+
1371
+ The form field receives a `number` already, but if you pre-process URLs or query params yourself, guard `parseFloat`:
1372
+
1373
+ ```typescript
1374
+ const raw = url.searchParams.get('count');
1375
+ const count = raw ? Number(raw) : null;
1376
+ if (count !== null && !Number.isFinite(count)) {
1377
+ // null, NaN, Infinity — handle as invalid
1378
+ }
1379
+ ```
1380
+
1234
1381
 
1235
1382
  ---
1236
1383
 
@@ -1277,6 +1424,56 @@ Returns a `boolean`.
1277
1424
 
1278
1425
  > **Common Uses:** Use for feature flags, visibility toggles, or any binary state like "Show in navigation" or "Allow comments".
1279
1426
 
1427
+ ## Edge cases
1428
+
1429
+ ### `null` vs `false`
1430
+
1431
+ These mean different things:
1432
+
1433
+ - `false` — explicit "off". The user toggled it.
1434
+ - `null` — "not set". The field is optional and was never touched.
1435
+
1436
+ If you treat them the same, you'll surprise users who expected the default to apply. Always pick one and stick with it:
1437
+
1438
+ ```typescript
1439
+ // Pattern A — required, always boolean
1440
+ { type: 'boolean', slug: 'featured', required: true, defaultValue: false }
1441
+ // entry.featured is `boolean`
1442
+
1443
+ // Pattern B — optional, may be null
1444
+ { type: 'boolean', slug: 'showInNav', required: false }
1445
+ // entry.showInNav is `boolean | null`
1446
+
1447
+ // Frontend: always normalize
1448
+ const showInNav = entry.showInNav ?? true; // default to "on" if unset
1449
+ ```
1450
+
1451
+ ### Don't coerce strings
1452
+
1453
+ A common bug: getting `"false"` from a query string and using it directly:
1454
+
1455
+ ```typescript
1456
+ const flag = url.searchParams.get('preview'); // "false" or null
1457
+ if (flag) { /* runs even when flag is "false"! */ }
1458
+
1459
+ // Correct
1460
+ const flag = url.searchParams.get('preview') === 'true';
1461
+ ```
1462
+
1463
+ This isn't specific to the field, but the field stores a real `boolean` — converting back is your responsibility.
1464
+
1465
+ ### Default value in schema vs runtime
1466
+
1467
+ `defaultValue` in the field config controls the **admin form's initial state** for new entries. It does **not** retroactively populate existing entries. If you add `defaultValue: true` to a field that previously had no default, entries created before the change still have whatever they had (often `null`).
1468
+
1469
+ For a one-time backfill, run a migration:
1470
+
1471
+ ```sql
1472
+ UPDATE entry_versions
1473
+ SET data = jsonb_set(data, '{showInNav}', 'true')
1474
+ WHERE data->>'showInNav' IS NULL;
1475
+ ```
1476
+
1280
1477
 
1281
1478
  ---
1282
1479
 
@@ -1325,6 +1522,57 @@ Returns an ISO date `string`.
1325
1522
 
1326
1523
  > **Date vs DateTime:** Use Date for day-level precision (publish dates, birthdays). Use [DateTime](/docs/fields/datetime) when you need time-of-day.
1327
1524
 
1525
+ ## Edge cases
1526
+
1527
+ ### Storage format
1528
+
1529
+ Stored as an ISO-8601 calendar date (`YYYY-MM-DD`), no time, no timezone. There's no `Date` instance on disk — just the string. Parse to a `Date` only when you need to compute (`new Date(entry.publishedAt + 'T00:00:00Z')`).
1530
+
1531
+ ### Timezones — there are none
1532
+
1533
+ A `date` field has no timezone. `"2024-06-15"` is the same calendar day everywhere. **Don't convert it through `new Date()` for display** — that introduces a phantom timezone shift:
1534
+
1535
+ ```typescript
1536
+ // BAD — shifts to UTC, may render as "2024-06-14" depending on locale
1537
+ new Date(entry.publishedAt).toLocaleDateString();
1538
+
1539
+ // GOOD — split, format as-is
1540
+ const [year, month, day] = entry.publishedAt.split('-');
1541
+ const display = `${day}.${month}.${year}`;
1542
+ ```
1543
+
1544
+ If you absolutely need a timezone, switch to [DateTime](/docs/fields/datetime).
1545
+
1546
+ ### `null` handling
1547
+
1548
+ When `required: false`, the value can be `null`. Always default before formatting:
1549
+
1550
+ ```typescript
1551
+ const date = entry.publishedAt ?? '1970-01-01';
1552
+ ```
1553
+
1554
+ ### Invalid strings
1555
+
1556
+ The admin UI uses a calendar picker, so it can't emit invalid dates. The REST API and programmatic flows can — pre-validate with `z.string().date()` if you accept user input from outside the admin:
1557
+
1558
+ ```typescript
1559
+ import { z } from 'zod';
1560
+ const Schema = z.object({ publishedAt: z.string().date() }); // YYYY-MM-DD
1561
+ ```
1562
+
1563
+ ### `minDate` / `maxDate` use the same string format
1564
+
1565
+ Compare as strings — ISO `YYYY-MM-DD` sorts lexicographically:
1566
+
1567
+ ```typescript
1568
+ { type: 'date', slug: 'eventDate', minDate: '2024-01-01', maxDate: '2025-12-31' }
1569
+ // '2024-06-15' >= '2024-01-01' → ok
1570
+ ```
1571
+
1572
+ ### Edge dates
1573
+
1574
+ Years before 1000 or after 9999 break ISO formatting in some pickers. If you need historical or far-future dates, store as a regular text field with your own parser, not `date`.
1575
+
1328
1576
 
1329
1577
  ---
1330
1578
 
@@ -1373,6 +1621,57 @@ Returns an ISO datetime `string`.
1373
1621
 
1374
1622
  > **Scheduling:** Use DateTime for scheduled publishing, event start/end times, or any timestamp that needs time-of-day precision.
1375
1623
 
1624
+ ## Edge cases
1625
+
1626
+ ### Always store UTC, render local
1627
+
1628
+ The picker writes a UTC ISO timestamp (`2024-06-15T14:30:00.000Z`). Render with `toLocaleString` so users see their own timezone:
1629
+
1630
+ ```svelte
1631
+ <time datetime={entry.scheduledAt}>{local}</time>
1632
+ ```
1633
+
1634
+ ### DST transitions
1635
+
1636
+ Daylight saving creates ambiguous local times — e.g. in `America/New_York`, the clock skips from 02:00 to 03:00 on the spring switch, so `02:30` doesn't exist that day. Storing UTC dodges this entirely; the issue only appears if you accept _local_ datetimes from a form. If you do, convert at the boundary and warn for known-ambiguous times.
1637
+
1638
+ ### `null` and invalid strings
1639
+
1640
+ ```typescript
1641
+ // REST or programmatic flow — validate first
1642
+ import { z } from 'zod';
1643
+ const Schema = z.object({ scheduledAt: z.string().datetime() }); // ISO 8601 full
1644
+ ```
1645
+
1646
+ `null` is allowed when `required: false`. Default before parsing:
1647
+
1648
+ ```typescript
1649
+ const ts = entry.scheduledAt ? new Date(entry.scheduledAt) : null;
1650
+ ```
1651
+
1652
+ ### Comparison
1653
+
1654
+ Always compare as `Date` objects or ISO strings — both sort correctly:
1655
+
1656
+ ```typescript
1657
+ const past = entry.scheduledAt < new Date().toISOString();
1658
+ // or
1659
+ const past = new Date(entry.scheduledAt) < new Date();
1660
+ ```
1661
+
1662
+ ### Range with `minDate` / `maxDate`
1663
+
1664
+ The bounds are full ISO timestamps. Lexicographic comparison still works for ISO strings:
1665
+
1666
+ ```typescript
1667
+ {
1668
+ type: 'datetime',
1669
+ slug: 'startsAt',
1670
+ minDate: '2024-01-01T00:00:00.000Z',
1671
+ maxDate: '2025-12-31T23:59:59.999Z'
1672
+ }
1673
+ ```
1674
+
1376
1675
 
1377
1676
  ---
1378
1677
 
@@ -2974,6 +3273,111 @@ const myCustomDb: DatabaseAdapter = {
2974
3273
 
2975
3274
  > **Custom Adapters:** You can build adapters for any provider (MySQL, S3, Resend, Claude, etc). Just implement the required interface methods and pass the adapter to `defineConfig`.
2976
3275
 
3276
+ ## Adapter contracts
3277
+
3278
+ Adapter interfaces are part of the public API surface (`@public`). They will not break inside a major version — see [Stability Promise](/docs/stability-promise).
3279
+
3280
+ | Interface | Methods | Optional? | Default implementation |
3281
+ |---|---:|---|---|
3282
+ | `DatabaseAdapter` | 38 | required | `includio-cms/db-postgres` |
3283
+ | `FilesAdapter` | 5 + 3 optional | required | `includio-cms/files-local` |
3284
+ | `EmailAdapter` | 1 + 2 config props | required | `includio-cms/email-nodemailer` |
3285
+ | `AIAdapter` | 1 | optional (only if `ai:` set) | `includio-cms/ai-openai`, `includio-cms/ai-claude` |
3286
+
3287
+ Optional methods are marked with `?` in the type definition (`getConsentLogs?`, `uploadPrivateFile?`). Returning `undefined` is fine — the CMS skips features that depend on absent capabilities.
3288
+
3289
+ Full method lists live in the source:
3290
+
3291
+ - `src/lib/types/adapters/db.ts` — `DatabaseAdapter`
3292
+ - `src/lib/types/adapters/files.ts` — `FilesAdapter`
3293
+ - `src/lib/types/adapters/email.ts` — `EmailAdapter`
3294
+ - `src/lib/types/adapters/ai.ts` — `AIAdapter`
3295
+
3296
+ Browse the per-adapter docs ([Database](/docs/adapters/database), [Files](/docs/adapters/files), [Email](/docs/adapters/email), [AI](/docs/adapters/ai)) for method-level expectations and the README's "Writing your own adapter" section for a worked example.
3297
+
3298
+ ## Peer dependencies — required vs optional
3299
+
3300
+ The CMS package declares its peers in `package.json`. Some are required for the CMS to boot at all; others are tied to specific adapters and only need to be installed if you use them.
3301
+
3302
+ **Required** (must be installed in your app):
3303
+
3304
+ ```text
3305
+ @sveltejs/kit, @sveltejs/vite-plugin-svelte, better-auth, bits-ui,
3306
+ drizzle-orm, formsnap, postgres, runed, sharp, svelte,
3307
+ sveltekit-superforms, tailwind-merge, tailwindcss, vite, zod
3308
+ ```
3309
+
3310
+ **Optional** (`peerDependenciesMeta.optional` — install only if you use the matching adapter or feature):
3311
+
3312
+ | Package | When to install |
3313
+ |---|---|
3314
+ | `nodemailer` | `includio-cms/email-nodemailer` |
3315
+ | `openai` | `includio-cms/ai-openai` |
3316
+ | `@anthropic-ai/sdk` | `includio-cms/ai-claude` |
3317
+ | `svelte-tiptap` | rich-text content field |
3318
+ | `paneforge` | admin layout split-panes |
3319
+ | `svelte-sonner` | admin toasts |
3320
+ | `embla-carousel-svelte` | optional carousel UI |
3321
+
3322
+ Install only what you need:
3323
+
3324
+ ```bash
3325
+ # Minimum production app (db + local files only)
3326
+ pnpm add includio-cms
3327
+
3328
+ # With email and OpenAI alt-text
3329
+ pnpm add includio-cms nodemailer openai
3330
+ ```
3331
+
3332
+ The CLI `includio install-peers` command walks you through installing required peers based on your `cms.config.ts`.
3333
+
3334
+ ## Lazy import pattern
3335
+
3336
+ Optional SDK peers (`nodemailer`, `openai`, `@anthropic-ai/sdk`) are imported **lazily** inside their adapter factories — never at module top-level. This keeps the bundle clean for users who don't use the adapter, even though the import statement exists in `dist/`.
3337
+
3338
+ ```typescript
3339
+ // Pattern used by includio-cms/email-nodemailer
3340
+ export const nodemailerAdapter = (config: NodemailerConfig): EmailAdapter => {
3341
+ let transporterPromise: Promise<Transporter> | null = null;
3342
+
3343
+ const getTransporter = async () => {
3344
+ if (!transporterPromise) {
3345
+ // Imported only on first sendMail call
3346
+ const { createTransport } = await import('nodemailer');
3347
+ transporterPromise = Promise.resolve(createTransport(config.transportOptions));
3348
+ }
3349
+ return transporterPromise;
3350
+ };
3351
+
3352
+ return {
3353
+ defaultFromAddress: config.defaultFromAddress,
3354
+ defaultFromName: config.defaultFromName,
3355
+ sendMail: async ({ to, subject, html }) => {
3356
+ const t = await getTransporter();
3357
+ await t.sendMail({ from: config.defaultFromAddress, to, subject, html });
3358
+ }
3359
+ };
3360
+ };
3361
+ ```
3362
+
3363
+ If your custom adapter wraps a heavyweight SDK, follow the same shape: defer the `import('your-sdk')` until the first call that needs it.
3364
+
3365
+ ## Error contracts
3366
+
3367
+ Adapters can throw any `Error` (or subclass). The CMS does **not** wrap unknown errors automatically — they propagate up to your handler. For consistency, surface the same kinds of failures the built-in adapters do:
3368
+
3369
+ - **Configuration errors** at construction time (e.g. missing API key) — throw a plain `Error` with a clear message. The CMS will fail fast at `getCMS()`.
3370
+ - **Recoverable runtime errors** (rate limit, transient network) — throw a typed error your callers can match on, or return `null` from a read method when "not found" is the natural answer.
3371
+ - **Validation errors** are the CMS's job — adapters should accept the data shape they're given and not re-validate.
3372
+
3373
+ ## Best practices
3374
+
3375
+ - **Timeouts.** Wrap third-party calls in a timeout (Sharp's 30 s wrapper in `src/lib/server/utils/withTimeout.ts` is a useful template). A hung adapter pins a SvelteKit worker.
3376
+ - **Retry strategy is the caller's job.** Adapters should fail fast and surface the error. Retries belong to whatever orchestrates the call (background job, queue, user-initiated action).
3377
+ - **Be honest about partial state.** If `uploadFile` writes to disk but the DB insert fails, the file is leaked. The built-in `files-local` survives this via the maintenance GC pass — design your adapter so a similar reconciliation is possible.
3378
+ - **Don't reach into other adapters.** A `FilesAdapter` should not query the DB. The CMS coordinates cross-adapter flows; adapters stay narrow.
3379
+ - **Bench locally.** Run a `pnpm test:integration` with your adapter swapped in before production. The integration suite covers ~30 REST scenarios and 6 shop checkout flows that exercise the adapter contract end-to-end.
3380
+
2977
3381
 
2978
3382
  ---
2979
3383
 
@@ -3527,38 +3931,192 @@ Requires the **email adapter** to be configured. Without it, password reset is u
3527
3931
 
3528
3932
  ---
3529
3933
 
3530
- # Plugins
3934
+ # Security Model
3531
3935
 
3532
- Plugins extend CMS behavior through lifecycle hooks that run before or after CRUD operations.
3936
+ Includio CMS ships with security defaults that work for typical single-tenant deployments. This page describes what's enforced, what's configurable, and where the trade-offs are.
3533
3937
 
3534
- ## Defining a Plugin
3938
+ > **Accepted risks:** Some defaults trade strictness for compatibility with common dependencies. Each trade-off is documented in `KNOWN-RISKS.md` at the repo root. See "Known accepted risks" at the bottom of this page.
3535
3939
 
3536
- ```typescript
3537
- import type { PluginConfig } from 'includio-cms/types';
3940
+ ## Threat model in scope
3538
3941
 
3539
- const auditLog: PluginConfig = {
3540
- slug: 'audit-log',
3541
- hooks: {
3542
- afterCreate: async (entry) => {
3543
- console.log('Created entry:', entry.id, entry.slug);
3544
- },
3545
- afterUpdate: async (entry) => {
3546
- console.log('Updated entry:', entry.id);
3547
- },
3548
- afterDelete: async (id) => {
3549
- console.log('Deleted entry:', id);
3550
- }
3551
- }
3552
- };
3553
- ```
3942
+ | In scope | Out of scope |
3943
+ |---|---|
3944
+ | CSRF on admin endpoints | Network-level DDoS (use a CDN/WAF) |
3945
+ | Brute force on admin login + API keys | Physical/host security of your deploy |
3946
+ | XSS via stored content (TipTap, SEO, custom fields) | Compromised admin browser/extensions |
3947
+ | Image/video processing crashes (Sharp/ffmpeg) | Compromised database server |
3948
+ | Unauthorized API key reuse | Compromised filesystem on the host |
3554
3949
 
3555
- ## Hook Lifecycle
3950
+ ## CSRF Protection
3556
3951
 
3557
- Hooks execute in this order:
3952
+ Mutating requests to `/admin/api/*` (POST, PUT, PATCH, DELETE) require a same-origin `Origin` or `Referer` header. The check lives in `src/lib/server/security/csrf.ts` and runs from the SvelteKit `handle` chain.
3558
3953
 
3559
- 1. `beforeCreate` / `beforeUpdate` / `beforeDelete` Before the database operation
3560
- 2. **Database operation executes**
3561
- 3. `afterCreate` / `afterUpdate` / `afterDelete` — After the operation succeeds
3954
+ A request with no `Origin`/`Referer` or with a foreign origin returns `403 Forbidden` and does not reach handlers.
3955
+
3956
+ ```ts
3957
+ // Configure allowed origins (default: same-origin only)
3958
+ // .env
3959
+ INCLUDIO_CSRF_ALLOWED_ORIGINS=https://staging.example.com,https://admin.example.com
3960
+ ```
3961
+
3962
+ Public endpoints (`/api/forms/*/submit`, shop public API) have their own protections (rate limit, signed cookies for cart) and are not behind CSRF.
3963
+
3964
+ ## Content Security Policy
3965
+
3966
+ A CSP header is set on every admin response. The shape:
3967
+
3968
+ ```
3969
+ default-src 'self';
3970
+ script-src 'self' 'unsafe-inline';
3971
+ style-src 'self' 'unsafe-inline';
3972
+ object-src 'none';
3973
+ frame-ancestors 'self';
3974
+ base-uri 'self';
3975
+ img-src 'self' data: blob:;
3976
+ connect-src 'self';
3977
+ ```
3978
+
3979
+ `'unsafe-inline'` on `script-src` and `style-src` is **required** by two admin dependencies (TipTap rich-text editor, Paraglide i18n runtime). The other directives still cover the high-impact vectors:
3980
+
3981
+ - `object-src 'none'` blocks Flash/legacy plugins.
3982
+ - `frame-ancestors 'self'` blocks clickjacking.
3983
+ - `base-uri 'self'` blocks `<base>` injection.
3984
+
3985
+ This trade-off is logged as **KNOWN-RISKS §1**. Plan to revisit when TipTap supports nonce-based CSP.
3986
+
3987
+ ## Rate Limiting
3988
+
3989
+ Two rate limit guards ship by default, both backed by the in-process `MemoryRateLimitStore`:
3990
+
3991
+ | Surface | Default limit | Env override |
3992
+ |---|---|---|
3993
+ | `/admin/api/*` | 200 requests / 60 s per IP | `INCLUDIO_RATE_LIMIT_MAX`, `INCLUDIO_RATE_LIMIT_WINDOW_MS` |
3994
+ | `POST /api/forms/[slug]/submit` | 5 requests / 1 h per IP | `INCLUDIO_FORM_RATE_LIMIT_MAX`, `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS` |
3995
+
3996
+ ```ts
3997
+ // Example: lower the form limit for a high-volume newsletter form
3998
+ // .env
3999
+ INCLUDIO_FORM_RATE_LIMIT_MAX=20
4000
+ INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS=3600000
4001
+ ```
4002
+
4003
+ Hitting the limit returns `429 Too Many Requests` with a `Retry-After` header.
4004
+
4005
+ > **Multi-node deployments:** The default store is in-process. Behind a load balancer with N nodes, the effective limit is N×. This is **KNOWN-RISKS §3** — fix is a Redis-backed store, planned post-v1.0 as `includio-cms/rate-limit-redis`.
4006
+
4007
+ The `RateLimitStore` interface is pluggable — provide a custom store today if you need shared state:
4008
+
4009
+ ```ts
4010
+ import { rateLimitGuard } from 'includio-cms/sveltekit/server';
4011
+ import { redisStore } from './my-redis-store.js';
4012
+
4013
+ // Wire it into your handle chain
4014
+ rateLimitGuard({ store: redisStore });
4015
+ ```
4016
+
4017
+ ## API Keys
4018
+
4019
+ API keys authenticate REST clients (`/admin/api/*` consumers, third-party integrations). Configure them in `cms.config.ts`:
4020
+
4021
+ ```ts
4022
+ import { defineConfig, generateApiKey } from 'includio-cms/sveltekit';
4023
+
4024
+ export default defineConfig({
4025
+ // ...
4026
+ apiKeys: [
4027
+ {
4028
+ key: generateApiKey(), // 32 bytes, base64url
4029
+ name: 'mobile-app',
4030
+ role: 'editor',
4031
+ expiresAt: '2027-01-01T00:00:00Z', // opt-in expiry
4032
+ rotatedAt: '2026-04-30T10:00:00Z' // audit trail (not enforced)
4033
+ }
4034
+ ]
4035
+ });
4036
+ ```
4037
+
4038
+ - `generateApiKey()` returns a 256-bit cryptographic token (43 chars, URL-safe).
4039
+ - `expiresAt` is **opt-in**. Without it, the key never expires. Once set, an expired key returns generic `401 Unauthorized` (no leak about whether the key existed).
4040
+ - `rotatedAt` is purely audit metadata — not enforced. Update it when you manually rotate.
4041
+ - Comparison is timing-safe (`crypto.timingSafeEqual`).
4042
+
4043
+ > **Static key model:** Keys live in the config file and load into process memory at boot. Rotation requires updating `cms.config.ts` and redeploying. There's no `POST /admin/api/keys/rotate` endpoint in v1.0 — multi-tenant rotation is **KNOWN-RISKS §2**, deferred post-v1.0.
4044
+
4045
+ ## Image processing safety
4046
+
4047
+ Every Sharp call (`metadata`, `resize`, `toBuffer`) is wrapped in a 30-second timeout. Without it, a malicious upload (zip-bomb image, ultra-high-resolution TIFF) could pin a worker indefinitely.
4048
+
4049
+ ```ts
4050
+ // Configurable via env
4051
+ // .env
4052
+ INCLUDIO_SHARP_TIMEOUT_MS=60000 # default 30000
4053
+ ```
4054
+
4055
+ A timeout aborts the upload and logs `TimeoutError` to stderr — the original file remains in storage but is not finalized in the DB. See `src/lib/server/utils/withTimeout.ts`.
4056
+
4057
+ Subprocess audits for `ffmpeg` and `sharp` (no shell, args passed as arrays, paths normalized) are documented in **KNOWN-RISKS §5**.
4058
+
4059
+ ## Other defaults
4060
+
4061
+ | Default | Source |
4062
+ |---|---|
4063
+ | HTTP-only, signed session cookies | `better-auth` |
4064
+ | `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` | `src/lib/sveltekit/server/handle.ts` |
4065
+ | Filename sanitization (Unicode NFC, no `..`) on uploads | `src/lib/files-local/sanitizeFilename.ts` |
4066
+ | MIME blocklist on uploads | `src/lib/core/server/media/mimeBlocklist.ts` |
4067
+ | Timing-safe API key comparison | `src/lib/admin/api/rest/middleware/apiKey.ts` |
4068
+
4069
+ ## Known accepted risks
4070
+
4071
+ Five trade-offs are documented in **`KNOWN-RISKS.md`** at the repository root. Each entry follows the format _description → mitigation → when fix_:
4072
+
4073
+ 1. **CSP `'unsafe-inline'`** — required by TipTap/Paraglide. Fix when nonce-based CSP is supported upstream.
4074
+ 2. **API key rotation = opt-in** — `expiresAt` optional. Fix when DB-backed rotation lands (post-v1.0).
4075
+ 3. **Rate limit store is in-process** — single-node only by default. Fix with `includio-cms/rate-limit-redis` (post-v1.0).
4076
+ 4. **Sharp 30 s timeout** — ultra-large/expensive images error. Fix by raising `INCLUDIO_SHARP_TIMEOUT_MS` or pre-validating dimensions.
4077
+ 5. **ffmpeg/sharp CLI args** — audit complete, all uses safe. Re-audit if user-controllable transcode params are ever added.
4078
+
4079
+ Read the full file before deploying to a multi-tenant or compliance-sensitive environment.
4080
+
4081
+ ## Reporting a vulnerability
4082
+
4083
+ Email `patryk.pilarczyk.00@gmail.com` with subject `[security] AriaCMS …`. Do not file a public GitHub issue for unpatched security bugs.
4084
+
4085
+
4086
+ ---
4087
+
4088
+ # Plugins
4089
+
4090
+ Plugins extend CMS behavior through lifecycle hooks that run before or after CRUD operations.
4091
+
4092
+ ## Defining a Plugin
4093
+
4094
+ ```typescript
4095
+ import type { PluginConfig } from 'includio-cms/types';
4096
+
4097
+ const auditLog: PluginConfig = {
4098
+ slug: 'audit-log',
4099
+ hooks: {
4100
+ afterCreate: async (entry) => {
4101
+ console.log('Created entry:', entry.id, entry.slug);
4102
+ },
4103
+ afterUpdate: async (entry) => {
4104
+ console.log('Updated entry:', entry.id);
4105
+ },
4106
+ afterDelete: async (id) => {
4107
+ console.log('Deleted entry:', id);
4108
+ }
4109
+ }
4110
+ };
4111
+ ```
4112
+
4113
+ ## Hook Lifecycle
4114
+
4115
+ Hooks execute in this order:
4116
+
4117
+ 1. `beforeCreate` / `beforeUpdate` / `beforeDelete` — Before the database operation
4118
+ 2. **Database operation executes**
4119
+ 3. `afterCreate` / `afterUpdate` / `afterDelete` — After the operation succeeds
3562
4120
 
3563
4121
  ## Available Hooks
3564
4122
 
@@ -3775,6 +4333,40 @@ When building admin UI plugins:
3775
4333
  3. Use `glass-inset` for input areas or recessed sections
3776
4334
  4. Test in both light and dark modes
3777
4335
 
4336
+ ## Accessibility
4337
+
4338
+ The admin UI targets WCAG 2.1 AA. Built-in defaults:
4339
+
4340
+ | Surface | What's enforced |
4341
+ |---|---|
4342
+ | Color contrast | Plus Jakarta Sans + the brand palette hits ≥ 4.5:1 for body text and ≥ 3:1 for large text and UI graphics. Don't override `--depth-*-bg` or text-color tokens without re-checking. |
4343
+ | Keyboard navigation | Every action button is reachable with `Tab`. `Esc` closes modals/popovers, `Enter`/`Space` activates focused controls. Tabbing through `bits-ui` menus restores focus to the trigger. |
4344
+ | Focus management | `:focus-visible` outlines on every interactive element. Modals trap focus until dismissed; restoring focus after close is handled by `bits-ui`. |
4345
+ | Skip-link | The admin layout includes a "Skip to main content" link as the first focusable element. |
4346
+ | Heading hierarchy | One `<h1>` per page (the breadcrumb-driven page title). Sections use `<h2>`/`<h3>` consistently. |
4347
+ | ARIA roles | Inline alerts use `role="alert"` + `aria-live="polite"`. Toasters from `svelte-sonner` announce via the same mechanism. Form fields are wired via `aria-describedby` to their hint and error nodes. |
4348
+ | Reduced motion | Animations respect `prefers-reduced-motion`. Custom transitions in plugins should follow the same rule. |
4349
+
4350
+ ### Building accessible custom panels
4351
+
4352
+ When you scaffold a custom admin page or plugin panel, preserve the same defaults. The minimal pattern:
4353
+
4354
+ ```svelte
4355
+ <section aria-labelledby="panel-title" class="glass-depth-1 rounded-lg p-4">
4356
+ <h2 id="panel-title">{title}</h2>
4357
+ {@render children()}
4358
+ </section>
4359
+ ```
4360
+
4361
+ Things that come up most often when adding custom UI:
4362
+
4363
+ - **Buttons, not divs.** Use `<button type="button">` or `<a href>` — never an `onclick` on a `<div>`. Screen readers and keyboards rely on the role.
4364
+ - **Label every input.** A `<label for>` paired with the input, or `aria-label` if visually labelless. The form fields shipped with the admin already do this; don't bypass them with raw inputs.
4365
+ - **Announce async results.** When a save succeeds, push a toast or update an `aria-live` region. A silent UI is invisible to screen readers.
4366
+ - **Don't kill focus rings.** `outline: none` on focus is a WCAG violation unless you replace it with an equivalent indicator. The admin uses `:focus-visible` with a 2px outline at sufficient contrast.
4367
+
4368
+ > **Audit before shipping:** WCAG/ATAG compliance is a v1.x roadmap item — current defaults are correct on the surfaces above, but a full audit (axe, manual screen-reader passes) is post-v1.0. If you're targeting public-sector or accessibility-regulated deployments, schedule your own audit.
4369
+
3778
4370
 
3779
4371
  ---
3780
4372
 
@@ -4055,62 +4647,60 @@ Type-safe, RPC-style communication for SvelteKit apps.
4055
4647
  For `+page.server.ts` load functions, you can also import from `includio-cms/sveltekit/server`:
4056
4648
 
4057
4649
  ```typescript
4058
- import { getEntries, getEntry, countEntries } from 'includio-cms/sveltekit/server';
4650
+ import { resolveEntry, resolveEntries, countEntries } from 'includio-cms/sveltekit/server';
4059
4651
  ```
4060
4652
 
4061
4653
  ### Queries (Read)
4062
4654
 
4063
4655
  ```typescript
4064
- import { getEntries, getEntry, getRawEntries } from 'includio-cms/admin/remote';
4656
+ import { resolveEntry, resolveEntries, countEntries } from 'includio-cms/admin/remote';
4065
4657
 
4066
- // Get published entries
4067
- const posts = await getEntries({
4068
- slug: 'posts',
4658
+ // List published entries (collection name + locale)
4659
+ const posts = await resolveEntries({
4660
+ collection: 'posts',
4069
4661
  status: 'published',
4070
- language: 'en'
4662
+ locale: 'en'
4071
4663
  });
4072
4664
 
4073
4665
  // Get single entry by ID
4074
- const post = await getEntry({ id: 'uuid-here' });
4666
+ const post = await resolveEntry({ id: 'uuid-here', locale: 'en' });
4075
4667
 
4076
- // Filter by exact field values
4077
- const featured = await getEntries({
4078
- slug: 'posts',
4079
- dataValues: { featured: true, category: 'tech' }
4080
- });
4081
-
4082
- // Search in field content (LIKE query)
4083
- const results = await getEntries({
4084
- slug: 'posts',
4085
- dataLike: { title: 'search term' }
4086
- });
4668
+ // Get a singleton (treated as a one-entry collection)
4669
+ const settings = await resolveEntry({ collection: 'settings', locale: 'en' });
4087
4670
 
4088
- // Case-insensitive OR search
4089
- const search = await getEntries({
4090
- slug: 'posts',
4091
- dataILikeOr: { title: 'svelte', description: 'svelte' }
4092
- });
4093
-
4094
- // Sort by data field
4095
- const sorted = await getEntries({
4096
- slug: 'events',
4097
- status: 'published',
4098
- dataOrderBy: { field: 'eventDate', direction: 'asc' }
4099
- });
4671
+ // Count without populating
4672
+ const total = await countEntries({ collection: 'posts', status: 'published' });
4100
4673
 
4101
4674
  // Pagination
4102
- const page = await getEntries({
4103
- slug: 'posts',
4675
+ const page = await resolveEntries({
4676
+ collection: 'posts',
4104
4677
  status: 'published',
4105
4678
  limit: 10,
4106
4679
  offset: 20,
4107
4680
  orderBy: { column: 'createdAt', direction: 'desc' }
4108
4681
  });
4682
+
4683
+ // Opt out of populating a relation field — return the raw ID instead
4684
+ const postNoAuthor = await resolveEntry({
4685
+ collection: 'posts',
4686
+ id: 'uuid-here',
4687
+ locale: 'en',
4688
+ populate: { fields: { author: false } }
4689
+ });
4690
+ console.log(postNoAuthor.author); // string ID
4691
+
4692
+ // Cap nested relation depth
4693
+ const flat = await resolveEntry({
4694
+ collection: 'posts',
4695
+ id: 'uuid-here',
4696
+ locale: 'en',
4697
+ populate: { maxDepth: 1 }
4698
+ });
4109
4699
  ```
4110
4700
 
4111
4701
  ### Commands (Write)
4112
4702
 
4113
- Commands mutate data and require authentication.
4703
+ Commands mutate data and require admin authentication.
4114
4704
 
4115
4705
  ```typescript
4116
4706
  import {
@@ -4121,7 +4711,7 @@ import {
4121
4711
  deleteEntryCommand
4122
4712
  } from 'includio-cms/admin/remote';
4123
4713
 
4124
- // Create entry
4714
+ // Create entry (always starts as draft)
4125
4715
  await createEntry({ slug: 'posts', type: 'collection' });
4126
4716
 
4127
4717
  // Save as draft
@@ -4138,7 +4728,7 @@ await updateEntryVersionCommand({
4138
4728
  type: 'published-now'
4139
4729
  });
4140
4730
 
4141
- // Unpublish
4731
+ // Unpublish (status returns to draft)
4142
4732
  await updateEntryVersionCommand({
4143
4733
  entryId: 'uuid',
4144
4734
  data: { title: 'Hello' },
@@ -4151,21 +4741,19 @@ await unarchiveEntryCommand('entry-uuid');
4151
4741
  await deleteEntryCommand('entry-uuid');
4152
4742
  ```
4153
4743
 
4154
- ### GetEntriesOptions
4744
+ ### `ResolveEntriesOptions`
4155
4745
 
4156
4746
  ```typescript
4157
- interface GetEntriesOptions {
4747
+ interface ResolveEntriesOptions {
4748
+ collection: string; // Collection or single slug
4749
+ locale?: string; // Defaults to cms.languages[0]
4750
+ status?: 'draft' | 'published' | 'scheduled';
4158
4751
  ids?: string[];
4159
- slug?: string;
4160
- dataValues?: Record<string, unknown>; // Exact match
4161
- dataLike?: Record<string, unknown>; // Partial text match (LIKE)
4162
- dataILikeOr?: Record<string, unknown>; // Case-insensitive OR search
4163
- language?: string;
4164
- status?: 'draft' | 'published' | 'scheduled' | 'archived';
4752
+ filter?: Record<string, unknown>;
4165
4753
  orderBy?: { column: 'createdAt' | 'updatedAt' | 'sortOrder'; direction: 'asc' | 'desc' };
4166
- dataOrderBy?: { field: string; direction: 'asc' | 'desc' };
4167
4754
  limit?: number;
4168
4755
  offset?: number;
4756
+ populate?: { maxDepth?: number; fields?: Record<string, false> };
4169
4757
  }
4170
4758
  ```
4171
4759
 
@@ -4183,7 +4771,7 @@ For external clients. Requires API key authentication.
4183
4771
 
4184
4772
  ### Authentication
4185
4773
 
4186
- Send your API key in the request header:
4774
+ Send your API key in the `Authorization` header:
4187
4775
 
4188
4776
  ```
4189
4777
  Authorization: Bearer YOUR_API_KEY
@@ -4192,13 +4780,22 @@ Authorization: Bearer YOUR_API_KEY
4192
4780
  Configure API keys in your CMS config:
4193
4781
 
4194
4782
  ```typescript
4783
+ import { defineConfig, generateApiKey } from 'includio-cms/sveltekit';
4784
+
4195
4785
  defineConfig({
4196
4786
  apiKeys: [
4197
- { key: process.env.API_KEY, name: 'My App', role: 'editor' }
4787
+ {
4788
+ key: process.env.API_KEY ?? generateApiKey(),
4789
+ name: 'My App',
4790
+ role: 'editor',
4791
+ expiresAt: '2027-01-01T00:00:00Z' // opt-in expiry
4792
+ }
4198
4793
  ]
4199
4794
  });
4200
4795
  ```
4201
4796
 
4797
+ See [Security Model — API Keys](/docs/security#api-keys) for rotation guidance.
4798
+
4202
4799
  ### Endpoints
4203
4800
 
4204
4801
  All REST endpoints are prefixed with your admin API path (e.g. `/admin/api/rest/`).
@@ -4241,15 +4838,96 @@ All REST endpoints are prefixed with your admin API path (e.g. `/admin/api/rest/
4241
4838
 
4242
4839
  | Method | Path | Description |
4243
4840
  |--------|------|-------------|
4244
- | `POST` | `/upload` | Upload file |
4841
+ | `POST` | `/upload` | Upload file (multipart/form-data) |
4245
4842
  | `GET` | `/media/:id` | Get media file metadata |
4246
4843
 
4844
+ ### cURL examples
4845
+
4846
+ List published entries from the `posts` collection:
4847
+
4848
+ ```bash
4849
+ curl -s -H "Authorization: Bearer $API_KEY" \
4850
+ "https://your-site.com/admin/api/rest/collections/posts?lang=en&status=published&limit=10"
4851
+ ```
4852
+
4853
+ Fetch one entry by ID:
4854
+
4855
+ ```bash
4856
+ curl -s -H "Authorization: Bearer $API_KEY" \
4857
+ "https://your-site.com/admin/api/rest/collections/posts/2c1b9a8e-1234-4567-89ab-cdef01234567"
4858
+ ```
4859
+
4860
+ Create a draft entry:
4861
+
4862
+ ```bash
4863
+ curl -s -X POST \
4864
+ -H "Authorization: Bearer $API_KEY" \
4865
+ -H "Content-Type: application/json" \
4866
+ -d '{"data":{"title":"Hello","slug":"hello"},"lang":"en"}' \
4867
+ "https://your-site.com/admin/api/rest/collections/posts"
4868
+ ```
4869
+
4870
+ Update an existing draft:
4871
+
4872
+ ```bash
4873
+ curl -s -X PUT \
4874
+ -H "Authorization: Bearer $API_KEY" \
4875
+ -H "Content-Type: application/json" \
4876
+ -d '{"data":{"title":"Hello, world"},"lang":"en"}' \
4877
+ "https://your-site.com/admin/api/rest/collections/posts/2c1b9a8e-1234-4567-89ab-cdef01234567"
4878
+ ```
4879
+
4880
+ Publish an entry (per locale):
4881
+
4882
+ ```bash
4883
+ curl -s -X POST -H "Authorization: Bearer $API_KEY" \
4884
+ "https://your-site.com/admin/api/rest/entries/2c1b9a8e-1234-4567-89ab-cdef01234567/publish?lang=en"
4885
+ ```
4886
+
4887
+ Upload a media file:
4888
+
4889
+ ```bash
4890
+ curl -s -X POST \
4891
+ -H "Authorization: Bearer $API_KEY" \
4892
+ -F "file=@./hero.jpg" \
4893
+ "https://your-site.com/admin/api/rest/upload"
4894
+ ```
4895
+
4896
+ ### Error responses
4897
+
4898
+ REST errors return a JSON body with a stable `code` (`CmsError.code`) plus a human-readable `message`. Match on `code`, not `message`.
4899
+
4900
+ | HTTP | `code` | When |
4901
+ |------|--------|------|
4902
+ | `400` | `INVALID_DATA` | Body fails Zod validation. `details` lists `field.path: message` per issue. |
4903
+ | `400` | `MISSING_REQUIRED_PARAM` | Required query/body param missing (e.g. `collection`). |
4904
+ | `401` | `UNAUTHORIZED` | Missing/expired API key, or `Authorization` header malformed. |
4905
+ | `403` | `FORBIDDEN` | Key role insufficient for operation, or CSRF check failed. |
4906
+ | `404` | `ENTRY_NOT_FOUND` | Resource ID doesn't exist or isn't visible at the requested locale/status. |
4907
+ | `404` | `ENTRY_VERSION_NOT_FOUND` | Entry exists but has no version for that locale/status. |
4908
+ | `409` | `ENTRY_NOT_RETRIABLE` | Operation conflicts with current state (e.g. publishing an archived entry). |
4909
+ | `422` | `CONFIG_VALIDATION_FAILED` | Request implicitly references a misconfigured collection/field. |
4910
+ | `429` | `RATE_LIMITED` | Per-IP limit exceeded. `Retry-After` header included. |
4911
+ | `500` | `INTERNAL_ERROR` | Unhandled error. Check server logs; the response intentionally omits internals. |
4912
+
4913
+ Example error body:
4914
+
4915
+ ```json
4916
+ {
4917
+ "code": "INVALID_DATA",
4918
+ "message": "Invalid data: title.length: must be at least 3 characters",
4919
+ "details": [
4920
+ { "path": "title", "message": "must be at least 3 characters" }
4921
+ ]
4922
+ }
4923
+ ```
4924
+
4247
4925
  ## Entity API
4248
4926
 
4249
4927
  Server-side programmatic CRUD — for scripts, migrations, seeds, webhooks.
4250
4928
 
4251
4929
  ```typescript
4252
- import { createEntityAPI } from 'includio-cms/entity';
4930
+ import { createEntityAPI } from 'includio-cms/core';
4253
4931
  import { getCMS } from 'includio-cms/core';
4254
4932
 
4255
4933
  const cms = getCMS();
@@ -4300,6 +4978,8 @@ Options for `create`/`createAndPublish`: `{ skipValidation?, sortOrder?, lang? }
4300
4978
 
4301
4979
  > **Delete Restrictions:** Only archived entries can be permanently deleted. Call `archive` first, then `delete`.
4302
4980
 
4981
+ For error handling patterns (`CmsError`, `instanceof`-based branching), see [Entries — Error handling](/docs/entries#error-handling).
4982
+
4303
4983
 
4304
4984
  ---
4305
4985
 
@@ -4499,21 +5179,58 @@ For fully custom designs, use `createOrderState` — a reactive Svelte 5 state o
4499
5179
 
4500
5180
  # Retry payment
4501
5181
 
4502
- When a payment attempt is rejected or left hanging, the customer must be able to try again without going through checkout a second time.
5182
+ When a payment attempt is rejected or left hanging, the customer must be able to try again without going through checkout a second time. This page documents the lifecycle, the API, and the failure modes.
4503
5183
 
4504
5184
  ## Lifecycle
4505
5185
 
4506
- 1. Customer completes checkout → order created with status `awaitingPayment`, adapter creates a payment session, `paymentProviderRef` stored.
4507
- 2. Something goes wrong — customer cancels on gateway, card declined, session expires → webhook flips status to `paymentRejected` (or stays `awaitingPayment` if the gateway never called back).
4508
- 3. Customer hits "Zapłać ponownie" on the order view.
4509
- 4. `POST /api/shop/orders/[number]/retry-payment?token=...`
4510
- - token-gated, status must be `awaitingPayment` or `paymentRejected` (409 otherwise)
4511
- - adapter's `createPayment()` is called again with the same `OrderRef`
4512
- - new `paymentProviderRef` replaces the old one
4513
- - if coming from `paymentRejected`, status rolls back to `awaitingPayment` (logged in status history as `changedBy: 'retry-payment'`)
4514
- - response contains `redirectUrl` for the new gateway session
5186
+ 1. **Checkout completes.** Order created with status `awaitingPayment`. The payment adapter's `createPayment()` is called and `paymentProviderRef` is stored on the order.
5187
+ 2. **Something goes wrong:**
5188
+ - Customer abandons the gateway status stays `awaitingPayment`.
5189
+ - Customer cancels on the gateway webhook flips status to `paymentRejected`.
5190
+ - Card declined webhook flips status to `paymentRejected`.
5191
+ - Gateway session expires before payment → status stays `awaitingPayment` indefinitely (no automatic timeout; let the customer retry or the admin cancel).
5192
+ 3. **Customer clicks "Zapłać ponownie"** on the token-gated order view.
5193
+ 4. **Retry endpoint runs:**
5194
+ - `POST /api/shop/orders/[number]/retry-payment?token=...`
5195
+ - Token validated, status must be `awaitingPayment` or `paymentRejected`.
5196
+ - Adapter's `createPayment()` is called again with the same `OrderRef` — the order, items, totals, and order number are reused.
5197
+ - New `paymentProviderRef` replaces the old one.
5198
+ - If the previous status was `paymentRejected`, status rolls back to `awaitingPayment` (logged in `order_status_history` with `changedBy: 'retry-payment'`).
5199
+ - Response contains `redirectUrl` for the new gateway session.
5200
+ 5. **Webhook eventually settles.** New attempt either succeeds (`paid`) or fails again (`paymentRejected`, retry available).
5201
+
5202
+ ```
5203
+ ┌──────────────────┐ customer cancels ┌──────────────────┐
5204
+ │ awaitingPayment ├────────────────────▶│ paymentRejected │
5205
+ └────────┬─────────┘ └────────┬─────────┘
5206
+ │ │
5207
+ │ retry-payment │ retry-payment
5208
+ │ (new providerRef, same order) │ (rollback to awaitingPayment)
5209
+ ▼ ▼
5210
+ ┌──────────────────┐ webhook ok ┌──────────────────┐
5211
+ │ paid │◀─────────────────┤ awaitingPayment │
5212
+ └──────────────────┘ └──────────────────┘
5213
+ ```
5214
+
5215
+ ## Server endpoint
5216
+
5217
+ The endpoint is scaffolded by `includio scaffold admin` into your app. Default location: `src/routes/api/shop/orders/[number]/retry-payment/+server.ts`. It calls `retryPayment(orderNumber, token)` from the shop server module:
5218
+
5219
+ ```ts
5220
+ // src/routes/api/shop/orders/[number]/retry-payment/+server.ts
5221
+ import { json, error } from '@sveltejs/kit';
5222
+ import { retryPayment } from 'includio-cms/shop';
5223
+
5224
+ export const POST = async ({ params, url }) => {
5225
+ const token = url.searchParams.get('token');
5226
+ if (!token) throw error(400, 'Missing token');
5227
+
5228
+ const result = await retryPayment(params.number, token);
5229
+ return json(result);
5230
+ };
5231
+ ```
4515
5232
 
4516
- ## SDK
5233
+ ## Headless SDK
4517
5234
 
4518
5235
  ```ts
4519
5236
  import { createShopClient } from 'includio-cms/shop/client';
@@ -4528,23 +5245,53 @@ if (result.requiresPaymentRedirect && result.redirectUrl) {
4528
5245
 
4529
5246
  ## With `createOrderState`
4530
5247
 
4531
- The headless helper handles the redirect for you it returns the URL, you navigate:
5248
+ The headless helper exposes a `retry()` method that returns just the redirect URL. You navigate:
4532
5249
 
4533
- ```ts
4534
- const url = await order.retry();
4535
- if (url) window.location.href = url;
5250
+ ```svelte
5251
+ <button onclick={payAgain} disabled={!order.canRetry}>Zapłać ponownie</button>
5252
+ ```
5253
+
5254
+ ## Error responses
5255
+
5256
+ The endpoint returns standard HTTP statuses with stable codes (`CmsError.code`). Match on `code`, not message text.
5257
+
5258
+ | HTTP | Code | When |
5259
+ |---|---|---|
5260
+ | `400` | `MISSING_REQUIRED_PARAM` | Order has no payment method, or adapter was removed from config. |
5261
+ | `401` | `UNAUTHORIZED` | Token missing or doesn't match the order. |
5262
+ | `404` | `ENTRY_NOT_FOUND` | Order number doesn't exist. |
5263
+ | `409` | `ORDER_NOT_RETRIABLE` | Status is terminal (`paid`, `done`, `cancelled`) — retry is impossible. |
5264
+ | `429` | `RATE_LIMITED` | Per-IP retry limit exceeded (default 5 retries / hour per IP). |
5265
+ | `502` | `PAYMENT_PROVIDER_ERROR` | Adapter's `createPayment()` threw. Body includes `details.providerMessage` if available. |
5266
+
5267
+ Example error body for a terminal-status retry:
5268
+
5269
+ ```json
5270
+ {
5271
+ "code": "ORDER_NOT_RETRIABLE",
5272
+ "message": "Order is already paid; retry is not allowed",
5273
+ "details": { "currentStatus": "paid" }
5274
+ }
4536
5275
  ```
4537
5276
 
4538
5277
  ## Constraints
4539
5278
 
4540
- - The same order is reused numbers, tokens, items, totals stay the same.
4541
- - Terminal statuses (`paid`, `done`, `cancelled`) block retry with **409**.
4542
- - Orders without a payment method or whose adapter was removed from config return **400**.
4543
- - Adapter errors bubble up as **502**.
5279
+ - **Same order, same totals.** Retry never recalculates prices, taxes, or shipping. If you need a price update, cancel and let the customer re-checkout.
5280
+ - **Token-gated.** The token comes from the order confirmation email. Without it, the endpoint returns 401 even for a logged-in customer — by design (orders are guest-flow first).
5281
+ - **Rate-limited per IP.** Default 5 retries per hour. Override with `INCLUDIO_FORM_RATE_LIMIT_*` env vars or a custom `rateLimitGuard` store. See [Security Model — Rate Limiting](/docs/security#rate-limiting).
5282
+ - **Idempotent at the gateway.** Each retry produces a new `paymentProviderRef`. Old refs are not cancelled at the gateway — they expire on their own (provider-specific, typically 30 min).
5283
+ - **No automatic retries.** The system never retries on the customer's behalf. If a webhook is missed, the customer must click again. Background polling is a v1.x roadmap item.
4544
5284
 
4545
5285
  ## Admin override
4546
5286
 
4547
- If the customer is completely stuck, the admin can manually move the order to `paid` / `cancelled` from the orders detail page — same as before, status-change emails trigger automatically.
5287
+ If a customer is stuck (e.g. webhook lost, retry limit hit, gateway down), an admin can manually move the order from the **Orders detail** page:
5288
+
5289
+ - **Mark as paid** — fires the same status-change email + invoice flow as a real success. Use only when payment was confirmed out-of-band.
5290
+ - **Cancel** — releases stock reservation, sends cancellation email, status `cancelled` (terminal). Customer cannot retry afterwards.
5291
+
5292
+ Status changes from the admin are logged in `order_status_history` with `changedBy: <admin user id>` for audit.
5293
+
5294
+ > **Retry vs new order:** Retry reuses the order. If the customer wants to change items, addresses, or shipping method, cancel the order and start checkout again. There's no "edit then retry" flow — by design, to keep the audit trail clean.
4548
5295
 
4549
5296
 
4550
5297
  ---
@@ -4666,6 +5413,118 @@ Tracking number is updated whenever the webhook payload contains a non-null `tra
4666
5413
  `OrderStatus.svelte` (the built-in component) renders a "Śledzenie przesyłki" section automatically when the order has a `trackingNumber`. If you wrote a custom order view, read `order.trackingNumber` and `order.trackingUrl` from the order endpoint response and render them yourself.
4667
5414
 
4668
5415
 
5416
+ ---
5417
+
5418
+ # Stability Promise
5419
+
5420
+ What's stable, what may change, and how breaking changes are introduced. Use this as the contract between Includio CMS and your code.
5421
+
5422
+ ## Semantic Versioning
5423
+
5424
+ Includio CMS follows [Semantic Versioning 2.0](https://semver.org/) starting with `1.0.0`:
5425
+
5426
+ | Bump | Trigger | Example |
5427
+ |---|---|---|
5428
+ | **MAJOR** (`1.0.0 → 2.0.0`) | Removed/renamed `@public` symbol, changed function signature, removed config option | Renaming `resolveEntry({ collection })` to `resolveEntry({ table })` |
5429
+ | **MINOR** (`1.0.0 → 1.1.0`) | New `@public` symbol, additive option, new optional adapter method | Adding `resolveEntries({ ..., search? })` |
5430
+ | **PATCH** (`1.0.0 → 1.0.1`) | Bug fix, internal refactor, doc/typing fix | Fixing a regression in `resolveEntry` cycle detection |
5431
+
5432
+ Pre-1.0 releases (`0.x`) made breaking changes in any version — that ends with `1.0.0`.
5433
+
5434
+ ## API tags — three tiers of stability
5435
+
5436
+ Every public symbol carries one of three JSDoc tags. The tag tells you how aggressively you can rely on it.
5437
+
5438
+ > **Where to look:** Tags are emitted by `pnpm api:generate` into `API.md` (single source of truth for the public surface). Each entry on `API.md` shows its tag.
5439
+
5440
+ ### `@public` — semver-protected
5441
+
5442
+ Stable. Will not break inside a major version. Removal or signature change requires a MAJOR bump and a migration guide entry.
5443
+
5444
+ ```ts
5445
+ /** @public */
5446
+ export function resolveEntry(opts: ResolveEntryOptions): Promise<Entry | null>;
5447
+ ```
5448
+
5449
+ Use freely in production code. Examples: `defineConfig`, `resolveEntry`, `resolveEntries`, `getCMS`, all `define*` helpers, all adapter factories (`pg`, `local`, `nodemailerAdapter`, `openAIAdapter`, `claudeAdapter`).
5450
+
5451
+ ### `@experimental` — opt-in to change
5452
+
5453
+ May change without a MAJOR bump. Ships in a MINOR or PATCH if usage feedback prompts a redesign. Always documented in `CHANGELOG.md` even when it changes.
5454
+
5455
+ ```ts
5456
+ /** @experimental Plugin hooks API — signature may change before 2.0. */
5457
+ export interface PluginHooks { /* ... */ }
5458
+ ```
5459
+
5460
+ Use in production at your discretion. Pin the patch version (e.g. `"includio-cms": "1.4.7"` instead of `"^1.4.0"`) if you want to lock against silent changes.
5461
+
5462
+ ### `@internal` — outside the contract
5463
+
5464
+ Not part of the public surface. May change in any release. **Do not import directly** — TypeScript will allow it, but `API.md` does not list these and CHANGELOG does not announce changes.
5465
+
5466
+ ```ts
5467
+ /** @internal */
5468
+ export function _populate(/* ... */) { /* ... */ }
5469
+ ```
5470
+
5471
+ Internal symbols use a leading underscore by convention (`_resolveEntry`, `_getRawEntry`).
5472
+
5473
+ ## Deprecation timeline
5474
+
5475
+ When a `@public` symbol needs to go away, the deprecation flow is:
5476
+
5477
+ 1. **MINOR release** — symbol is annotated `@deprecated` in JSDoc and continues to work. CHANGELOG calls out the deprecation and points to the replacement.
5478
+ 2. **At least one MINOR cycle later** — the symbol can be removed in the next MAJOR release. Migration guide entry is mandatory.
5479
+
5480
+ ```ts
5481
+ /**
5482
+ * @public
5483
+ * @deprecated Since 1.4.0. Use `resolveEntry({ collection })` instead. Removed in 2.0.0.
5484
+ */
5485
+ export function getEntry(opts: GetEntryOptions): Promise<Entry | null>;
5486
+ ```
5487
+
5488
+ > **Pre-1.0 exception:** Some breaking changes between 0.16.0 and 0.22.0 were introduced as **hard removes** (no deprecated wrapper) — consistent with the lean v1.0 policy of "all breaking before v1.0". See the [Migration Guide](/docs/migration) for find-replace patterns.
5489
+
5490
+ ## What is _not_ part of the contract
5491
+
5492
+ These can change in any release without notice:
5493
+
5494
+ - File paths under `src/lib/**` (only `package.json` exports map is contractual)
5495
+ - Internal helpers with `_` prefix or `@internal` JSDoc
5496
+ - Database migrations between minor versions (run `pnpm db:push` after upgrading)
5497
+ - The exact wording of error messages — only `CmsError.code` is stable
5498
+ - HTML structure of admin UI (admin is a finished surface, not a customizable theme)
5499
+ - Implementation details of bundled adapters (`db-postgres`, `files-local`, etc.) — only the `DatabaseAdapter` / `FilesAdapter` interfaces are contractual
5500
+
5501
+ ## How to consume Includio CMS safely
5502
+
5503
+ ```json
5504
+ {
5505
+ "dependencies": {
5506
+ "includio-cms": "^1.0.0"
5507
+ }
5508
+ }
5509
+ ```
5510
+
5511
+ - `^1.0.0` accepts all PATCH and MINOR. Never accepts MAJOR.
5512
+ - Pin to exact `1.0.0` if relying on `@experimental` symbols.
5513
+ - After `pnpm update`, run `pnpm db:push` and re-run integration tests.
5514
+
5515
+ ## Reference: public surface
5516
+
5517
+ The current public surface is auto-generated and lives in `API.md` at the repo root. Browse it before relying on a symbol — if it's not there, it's not `@public`.
5518
+
5519
+ ```bash
5520
+ pnpm api:generate
5521
+ ```
5522
+
5523
+ Outputs a tree of every exported symbol grouped by entry point (`includio-cms`, `includio-cms/core`, `includio-cms/sveltekit`, `includio-cms/db-postgres`, etc.) with its tag.
5524
+
5525
+ See also: [Migration Guide](/docs/migration), [Adapter Contracts](/docs/adapters).
5526
+
5527
+
4669
5528
  ---
4670
5529
 
4671
5530
  # Migration Guide
@@ -4685,6 +5544,170 @@ Version-by-version upgrade guide. Each section lists what changed, what to do, a
4685
5544
 
4686
5545
  ---
4687
5546
 
5547
+ ## Migrating from 0.x to 1.0 — master cheatsheet
5548
+
5549
+ If you're jumping from 0.15.x straight to a v1.0 release, this section is the consolidated set of breaking changes between 0.16.0 and 0.22.0. Sections below the cheatsheet cover the older 0.7–0.15 changes for reference.
5550
+
5551
+ ### Global find-replace (0.18.0)
5552
+
5553
+ The single biggest churn is the entry resolver rename. Run these find-replace passes across your project:
5554
+
5555
+ | Find | Replace |
5556
+ |---|---|
5557
+ | `getEntry({ slug:` | `resolveEntry({ collection:` |
5558
+ | `getEntries({ slug:` | `resolveEntries({ collection:` |
5559
+ | `language:` (inside resolver options) | `locale:` |
5560
+ | `import { getEntry, getEntries } from 'includio-cms` | `import { resolveEntry, resolveEntries, countEntries } from 'includio-cms` |
5561
+ | `getEntryOrThrow(opts)` | `const e = await resolveEntry(opts); if (!e) throw new Error('Entry not found');` |
5562
+
5563
+ `status: 'archived'` is no longer valid in resolvers — call `archiveEntryCommand(id)` / `unarchiveEntryCommand(id)` instead.
5564
+
5565
+ ### 0.16.0 — Hard reset
5566
+
5567
+ **Breaking.** Removed unused prototypes: `src/lib/inline-edit-proto/*`, `src/lib/demo/seed.ts`, demo routes (`/demo/*`, `/admin/demo/*`), `ROADMAP-EDITOR.md`. None of these were ever in `package.json` exports — if your code didn't import them, no action needed.
5568
+
5569
+ ### 0.18.0 — Entry resolver consolidation
5570
+
5571
+ **Breaking.** Hard-removed from public API: `getEntry`, `getEntries`, `getEntryOrThrow`, `getRawEntry`, `getRawEntries`, `getRawEntryOrThrow`, `getDbEntry`, `getDbEntries`, `populateEntryData`. Replaced by canonical `resolveEntry` / `resolveEntries` / `countEntries`.
5572
+
5573
+ What changed in the call shape:
5574
+
5575
+ | Old | New |
5576
+ |---|---|
5577
+ | `getEntry({ slug, id, language, status })` | `resolveEntry({ collection: slug, id, locale: language, status })` |
5578
+ | `getEntries({ slug, language, status, ids?, limit?, offset? })` | `resolveEntries({ collection: slug, locale: language, status, ids?, limit?, offset? })` |
5579
+ | `populateEntryData(...)` | INTERNAL — admin preview uses internal helpers |
5580
+
5581
+ Other behavior changes:
5582
+
5583
+ - `status` enum narrowed: removed `'archived'`. Use archive operations instead.
5584
+ - `PopulateConfig` is now an explicit option for opt-out (`populate: { fields: { author: false } }`) and depth caps (`populate: { maxDepth: 1 }`). Default is full populate with `maxDepth: 5`.
5585
+ - Plugin `populateResolver(value, field)` got a third arg: `populateResolver(value, field, ctx: PopulateCtx)`. `ctx` carries locale, status, depth, visited, populate config, entryId. Add the third arg even if you ignore it.
5586
+
5587
+ ```typescript
5588
+ // Before (0.16)
5589
+ import { getEntry, getEntries } from 'includio-cms/server';
5590
+ const post = await getEntry({ slug: 'posts', id: postId, language: 'pl' });
5591
+ const all = await getEntries({ slug: 'posts', language: 'pl', limit: 10 });
5592
+
5593
+ // After (0.18+)
5594
+ import { resolveEntry, resolveEntries } from 'includio-cms/server';
5595
+ const post = await resolveEntry({ collection: 'posts', id: postId, locale: 'pl' });
5596
+ const all = await resolveEntries({ collection: 'posts', locale: 'pl', limit: 10 });
5597
+ ```
5598
+
5599
+ ### 0.19.0 — Adapter peers go optional
5600
+
5601
+ **Breaking.** `@anthropic-ai/sdk`, `nodemailer`, `openai` moved from `dependencies` to `peerDependenciesMeta.optional`. `runed` changed from optional to **required** peer.
5602
+
5603
+ What you need to do:
5604
+
5605
+ ```bash
5606
+ # Required for everyone (now hard-required peer)
5607
+ pnpm add runed
5608
+
5609
+ # Install only if you use the matching adapter
5610
+ pnpm add nodemailer # email-nodemailer
5611
+ pnpm add openai # ai-openai
5612
+ pnpm add @anthropic-ai/sdk # ai-claude
5613
+ ```
5614
+
5615
+ Adapter factories (`pg`, `local`, `nodemailerAdapter`, `openAIAdapter`, `claudeAdapter`) are now `@public` and stable. SDK imports inside the adapters are lazy — installing the SDK is only required when the adapter is actually used at runtime.
5616
+
5617
+ ### 0.20.0 — API surface lock
5618
+
5619
+ **Breaking.** Package exports trimmed from 26 entry points to 16. JSDoc tags (`@public` / `@experimental` / `@internal`) added across the public API. Auto-generated `API.md` is the new single source of truth.
5620
+
5621
+ Most-affected import paths (full list of 10 removed/merged paths in [API.md](../API.md)):
5622
+
5623
+ | Removed/changed | Replacement |
5624
+ |---|---|
5625
+ | `includio-cms/admin/*` (wildcard) | `includio-cms/admin/client`, `includio-cms/admin/remote` |
5626
+ | `includio-cms/auth` | `includio-cms/core` |
5627
+ | `includio-cms/entity` | `includio-cms/core` |
5628
+ | `includio-cms/shop/svelte` | `includio-cms/shop/client` |
5629
+ | `includio-cms/db-postgres/schema-*` | `includio-cms/db-postgres` |
5630
+ | `includio-cms/admin/api/*` | (internal — removed) |
5631
+ | `includio-cms/updates` | (internal — removed) |
5632
+
5633
+ Run `pnpm check` after upgrading — TypeScript flags every stale import.
5634
+
5635
+ ### 0.21.0 — Security hardening
5636
+
5637
+ **Feature.** No code changes required to consume the new defaults, but you should review them:
5638
+
5639
+ - API keys gain optional `expiresAt` (ISO-8601) and audit-only `rotatedAt`. Without `expiresAt`, the key never expires — opt in if you want rotation.
5640
+ - Sharp calls (image processing) wrapped in 30-second timeout. Override with `INCLUDIO_SHARP_TIMEOUT_MS`.
5641
+ - Form submission rate limit refactored to use shared `MemoryRateLimitStore`. Configure with `INCLUDIO_FORM_RATE_LIMIT_MAX` / `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS`.
5642
+ - New file at repo root: `KNOWN-RISKS.md` — five accepted security trade-offs (CSP `'unsafe-inline'`, opt-in API key rotation, in-memory rate-limit store, Sharp timeout limits, ffmpeg/sharp CLI args). Read it before deploying to compliance-sensitive environments.
5643
+
5644
+ See [Security Model](/docs/security) for the full picture.
5645
+
5646
+ ### 0.22.0 — DX & config validation
5647
+
5648
+ **Breaking.** `defineConfig()` now runs strict Zod validation at boot. Configs that previously "worked" with subtle bugs throw `ConfigValidationError` on startup. Resolver and write operations throw typed `CmsError` (with stable `code` + `context`) instead of generic `Error`.
5649
+
5650
+ Common config validation failures and fixes:
5651
+
5652
+ - **Duplicate slug** — two collections (or singles, or forms) share a slug → make each unique within its category.
5653
+ - **Invalid language code** — `'ENGLISH'` → `'en'` (ISO-639-1, optionally with region: `'pl-PL'`).
5654
+ - **Multiple default locales** — only one `{ default: true }` allowed.
5655
+ - **Missing adapter method** — partial adapter stub → import the full `pg()` / `local()` or implement every required method.
5656
+ - **Invalid relation target** — `{ type: 'relation', collection: 'authors' }` while no `authors` collection exists → declare it or fix the target.
5657
+
5658
+ Replace string-matching error catches with `code` matching:
5659
+
5660
+ ```typescript
5661
+ // Before
5662
+ catch (e) {
5663
+ if ((e as Error).message === 'Entry not found') { /* 404 */ }
5664
+ }
5665
+
5666
+ // After
5667
+ import { CmsError } from 'includio-cms';
5668
+ catch (e) {
5669
+ if (e instanceof CmsError && e.code === 'ENTRY_NOT_FOUND') { /* 404 */ }
5670
+ }
5671
+ ```
5672
+
5673
+ Stable `CmsError.code` values: `ENTRY_NOT_FOUND`, `ENTRY_VERSION_NOT_FOUND`, `INVALID_DATA`, `MISSING_REQUIRED_PARAM`, `CONFIG_VALIDATION_FAILED`. See [Entries — Error handling](/docs/entries#error-handling).
5674
+
5675
+ `.env.example` was reformatted with all `INCLUDIO_*` env vars (sharp timeout, rate limits, CSRF allowed origins). The demo-only `DEMO_USER_*` vars were removed.
5676
+
5677
+ ### Suggested upgrade path
5678
+
5679
+ If you're on 0.15.x and want to land on the v1.0 release in one go:
5680
+
5681
+ ```bash
5682
+ # 1. Upgrade dependencies (pnpm flags missing peers)
5683
+ pnpm add includio-cms@latest
5684
+ pnpm add runed # required peer since 0.19.0
5685
+ pnpm add nodemailer openai # if you use them
5686
+
5687
+ # 2. Run TypeScript — fixes most breakage
5688
+ pnpm check
5689
+ # Fix every error: it's mechanical (find/replace patterns above)
5690
+
5691
+ # 3. Sync DB
5692
+ pnpm db:push
5693
+
5694
+ # 4. Run integration tests
5695
+ pnpm test
5696
+
5697
+ # 5. Boot the dev server — strict config validation will surface any latent issues
5698
+ pnpm dev
5699
+ ```
5700
+
5701
+ If `defineConfig` throws at boot, read the bullet list — every issue has a `path` (e.g. `collections[1].slug`) and a `Hint:` line. Fix top-down; one issue often reveals the next.
5702
+
5703
+ ---
5704
+
5705
+ ## Pre-v1.0 deprecations and changes
5706
+
5707
+ The sections below cover changes between 0.7 and 0.15. Useful only if you're on a much older release; otherwise skip.
5708
+
5709
+ ---
5710
+
4688
5711
  ## New in 0.14.0 — Video Transcoding
4689
5712
 
4690
5713
  **Added:** Auto-transcode videos to mp4/webm, system info, disk usage.