@venizia/ignis-atlas 0.1.0 → 0.1.1-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/corpus/changelogs/2026-09-25-mail-attachment-limits.md +135 -0
- package/dist/corpus/changelogs/2026-09-25-static-asset-and-storage-fixes.md +202 -0
- package/dist/corpus/changelogs/2026-09-26-json-path-column-qualification.md +34 -0
- package/dist/corpus/changelogs/2026-09-26-order-entry-parsing-and-sort-expressions.md +83 -0
- package/dist/corpus/changelogs/2026-09-26-run-in-transaction-and-transaction-double.md +125 -0
- package/dist/corpus/changelogs/2026-09-26-typed-json-path-keys-and-order-entries.md +69 -0
- package/dist/corpus/changelogs/2026-09-29-escaped-input-in-filter-errors.md +36 -0
- package/dist/corpus/changelogs/2026-09-29-json-path-null-segment.md +37 -0
- package/dist/corpus/changelogs/2026-09-29-where-sql-subquery.md +43 -0
- package/dist/corpus/changelogs/2026-09-30-bff-fetch-bridge-matching.md +30 -0
- package/dist/corpus/changelogs/index.md +10 -0
- package/dist/corpus/releases.json +148 -0
- package/dist/corpus/symbols.json +653 -213
- package/dist/corpus/wiki/best-practices/contribution-workflow.md +9 -8
- package/dist/corpus/wiki/best-practices/performance-optimization.md +1 -1
- package/dist/corpus/wiki/best-practices/security-guidelines.md +20 -12
- package/dist/corpus/wiki/best-practices/testing-strategies.md +62 -0
- package/dist/corpus/wiki/extensions/components/mail/api.md +35 -9
- package/dist/corpus/wiki/extensions/components/mail/errors.md +35 -3
- package/dist/corpus/wiki/extensions/components/mail/index.md +2 -0
- package/dist/corpus/wiki/extensions/components/mail/usage.md +67 -2
- package/dist/corpus/wiki/extensions/components/request-tracker.md +7 -1
- package/dist/corpus/wiki/extensions/components/static-asset/api.md +139 -41
- package/dist/corpus/wiki/extensions/components/static-asset/direct-upload.md +4 -1
- package/dist/corpus/wiki/extensions/components/static-asset/errors.md +28 -2
- package/dist/corpus/wiki/extensions/components/static-asset/index.md +6 -2
- package/dist/corpus/wiki/extensions/components/static-asset/usage.md +56 -2
- package/dist/corpus/wiki/extensions/helpers/storage/api.md +13 -7
- package/dist/corpus/wiki/extensions/helpers/storage/index.md +2 -1
- package/dist/corpus/wiki/guides/core-concepts/persistent/sqlite.md +5 -3
- package/dist/corpus/wiki/guides/core-concepts/persistent/transactions.md +74 -0
- package/dist/corpus/wiki/guides/get-started/5-minute-quickstart.md +2 -3
- package/dist/corpus/wiki/references/base/application.md +5 -1
- package/dist/corpus/wiki/references/base/datasources-reference.md +1 -1
- package/dist/corpus/wiki/references/base/filter-system/fields-order-pagination.md +78 -5
- package/dist/corpus/wiki/references/base/filter-system/index.md +2 -1
- package/dist/corpus/wiki/references/base/filter-system/json-filtering.md +33 -7
- package/dist/corpus/wiki/references/base/filter-system/list-operators.md +21 -0
- package/dist/corpus/wiki/references/base/filter-system/quick-reference.md +5 -1
- package/dist/corpus/wiki/references/base/filter-system/tips.md +4 -2
- package/dist/corpus/wiki/references/base/filter-system/use-cases.md +13 -11
- package/dist/corpus/wiki/references/base/middlewares.md +85 -12
- package/dist/corpus/wiki/references/base/repositories/advanced.md +3 -0
- package/dist/corpus/wiki/references/base/repositories/index.md +3 -0
- package/dist/corpus/wiki/references/utilities/request.md +5 -2
- package/package.json +2 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Mail Attachments Read Only Under a Root, and Stop at a Size Limit
|
|
3
|
+
description: "An attachment path is read only inside the new attachmentRoot option and refused without it, a text or html body must be a string, a new maxAttachmentBytes option (default 25 MB) caps the attachments of one message, and a failed send no longer echoes the raw error text."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Changelog - 2026-09-25
|
|
7
|
+
|
|
8
|
+
The mail component no longer reads a file or URL a caller names, and no longer buffers attachments without limit. Every change applies to every transport - Nodemailer, Mailgun and Amazon SES.
|
|
9
|
+
|
|
10
|
+
| Change | Who is affected |
|
|
11
|
+
|---|---|
|
|
12
|
+
| [An attachment `path` is read only under `attachmentRoot`](#an-attachment-path-is-read-only-under-attachmentroot) | Applications that send an attachment by `path` |
|
|
13
|
+
| [`text` and `html` must be strings](#text-and-html-must-be-strings) | Applications that pass anything but a string as the body |
|
|
14
|
+
| [Attachments stop at `maxAttachmentBytes`](#attachments-stop-at-maxattachmentbytes) | Applications that send more than 25 MB of attachments in one message, or stream them |
|
|
15
|
+
| [A failed send no longer echoes the raw error](#a-failed-send-no-longer-echoes-the-raw-error) | Code that reads the text of `core.mail.send_failed` |
|
|
16
|
+
| [Every breaking change at a glance](#every-breaking-change-at-a-glance) | Everyone upgrading |
|
|
17
|
+
|
|
18
|
+
## An attachment `path` is read only under `attachmentRoot`
|
|
19
|
+
|
|
20
|
+
<Badge type="danger" text="Security" /> <Badge type="danger" text="Breaking" />
|
|
21
|
+
|
|
22
|
+
**In one line.** `attachment.path` no longer reads any file the caller names - set `attachmentRoot`, or pass the bytes as `content`.
|
|
23
|
+
|
|
24
|
+
Before, `{ path: '/etc/passwd' }` put that file on the email. An application that copied `attachments` from a request body let its caller mail any file the process could read. Nodemailer and the SES transport read `path` themselves; the Mailgun transport forwarded the `path` string as the attachment data.
|
|
25
|
+
|
|
26
|
+
Now `MailService.send()` reads every attachment into `content` bytes before the transport sees it:
|
|
27
|
+
|
|
28
|
+
- With no `attachmentRoot` set, any `path` is refused.
|
|
29
|
+
- With `attachmentRoot` set, a `path` is resolved against it and refused if it lands outside. Symlinks and `..` are followed first, so neither can escape.
|
|
30
|
+
- A missing file, a directory, a FIFO, an unreadable file and an escape all answer the same error and the same message, so a caller cannot probe which files exist.
|
|
31
|
+
- Nodemailer's `href` (a URL) and `raw` sources are refused too. The Nodemailer transport also sets Nodemailer's own `disableFileAccess` and `disableUrlAccess`.
|
|
32
|
+
- An attachment read from `path` keeps the file's name as `filename`, and gets a `contentType` from its extension, when you set neither.
|
|
33
|
+
|
|
34
|
+
Every refusal is `400 core.mail.attachment_path_refused`. `attachmentRoot` must be an absolute path to an existing directory; anything else fails the application at startup with `Invalid mail options | attachmentRoot ...`.
|
|
35
|
+
|
|
36
|
+
**Who is affected:** applications that send an attachment by `path`. Those sends now fail until you migrate. Messages with `content` attachments, or none, behave as before.
|
|
37
|
+
|
|
38
|
+
**Migration.** Pick one:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// Option 1 - keep path, and name the one directory it may read from
|
|
42
|
+
this.component(MailComponent, {
|
|
43
|
+
options: {
|
|
44
|
+
provider: MailProviders.NODEMAILER,
|
|
45
|
+
config: { host, port, secure, auth },
|
|
46
|
+
attachmentRoot: '/srv/app/mail-assets',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await mailService.send({
|
|
51
|
+
to: 'user@example.com',
|
|
52
|
+
subject: 'Hello',
|
|
53
|
+
html: '<img src="cid:logo">',
|
|
54
|
+
attachments: [{ path: 'logo.png', cid: 'logo' }], // relative to the root; filename and type follow from it
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Option 2 - pass the bytes yourself
|
|
58
|
+
attachments: [{ filename: 'logo.png', content: await readFile('./assets/logo.png'), cid: 'logo' }], // readFile from node:fs/promises
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
An absolute `path` still works when it lies inside the root. A `path` is always a file name now: Nodemailer's `path: 'https://...'` (a URL fetch) and `path: 'data:...'` (a data URI) no longer work - fetch or decode the bytes yourself and pass them as `content`. A built-in transport called directly, outside `MailService`, has no root and refuses every `path`.
|
|
62
|
+
|
|
63
|
+
See [Attachments](/extensions/components/mail/usage#attachments).
|
|
64
|
+
|
|
65
|
+
## `text` and `html` must be strings
|
|
66
|
+
|
|
67
|
+
<Badge type="danger" text="Security" /> <Badge type="danger" text="Breaking" />
|
|
68
|
+
|
|
69
|
+
**In one line.** A body that is not a string - a `Buffer`, a stream, a number, `{ path }`, `{ href }`, any object - is refused. Pass the content itself as a string.
|
|
70
|
+
|
|
71
|
+
Nodemailer treats `html: { path: '/etc/passwd' }` like an attachment path: it reads that file and sends it as the body. `IMailMessage` types `text` and `html` as strings, but a caller that forwards a request body is not bound by that type.
|
|
72
|
+
|
|
73
|
+
Now `MailService.send()` refuses a `text` or `html` that is not a string with `400 core.mail.body_source_refused`, before any transport runs. The Nodemailer transport checks the same when called directly. `null` and `undefined` count as absent, so a body built from a database row with `html: null` still sends its `text`.
|
|
74
|
+
|
|
75
|
+
**Who is affected:** code that passes a `Buffer`, a stream or any other non-string as the body. String bodies are unchanged.
|
|
76
|
+
|
|
77
|
+
**Migration.** Pass the string: `html: buffer.toString('utf-8')`, or `html: await readFile('./templates/welcome.html', 'utf-8')`.
|
|
78
|
+
|
|
79
|
+
## Attachments stop at `maxAttachmentBytes`
|
|
80
|
+
|
|
81
|
+
<Badge type="tip" text="Enhancement" /> <Badge type="warning" text="Behavior Change" />
|
|
82
|
+
|
|
83
|
+
**In one line.** A message's attachments may carry at most `maxAttachmentBytes` bytes, 25 MB by default.
|
|
84
|
+
|
|
85
|
+
Before, every attachment was read whole into memory with no limit. A 200 MB attachment stayed resident for the whole send, with a base64 copy on top.
|
|
86
|
+
|
|
87
|
+
The limit counts all attachments of one message together, so it also bounds each one:
|
|
88
|
+
|
|
89
|
+
| Content | When it is checked |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `Buffer`, `Uint8Array` or string | Before the send, by byte length |
|
|
92
|
+
| File under `attachmentRoot` | By its size on disk before it is opened, then again while it is read - a file that grows in between is still capped |
|
|
93
|
+
| Node `Readable` or web `ReadableStream` | Per chunk while it drains - the read stops as soon as the total passes the limit, and the rest is never buffered |
|
|
94
|
+
|
|
95
|
+
Over the limit, `send()` throws `413 core.mail.attachment_too_large`. Any other `content` - `{ path }`, a number, a plain object - answers `400 core.mail.invalid_configuration` instead of a `500`. When a send is refused for any reason, every attachment stream it was given is destroyed, so no file descriptor stays open.
|
|
96
|
+
|
|
97
|
+
`maxAttachmentBytes` must be a positive integer; anything else (`NaN`, `0`, `Infinity`, a string) fails the application at startup.
|
|
98
|
+
|
|
99
|
+
**Who is affected:**
|
|
100
|
+
|
|
101
|
+
- Applications that send more than 25 MB of attachments in one message. Raise the limit on the mail options.
|
|
102
|
+
- Applications that pass streams to Nodemailer or Mailgun. Those transports now receive the stream's bytes, buffered whole (within the limit), instead of the stream itself.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
{
|
|
106
|
+
provider: MailProviders.AMAZON_SES,
|
|
107
|
+
config: { region: 'us-east-1' },
|
|
108
|
+
maxAttachmentBytes: 35 * 1024 * 1024,
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The limit counts decoded bytes. Base64 makes the message about 37% larger on the wire, so the 25 MB default becomes about 34 MB - over the 25 MB message cap of Gmail and of Mailgun. For those, set `maxAttachmentBytes: 18 * 1024 * 1024`. Plan memory for roughly four times `maxAttachmentBytes` per send in flight: the bytes, their base64 copy, and the built message. `sendBatch()` runs five sends at once by default.
|
|
113
|
+
|
|
114
|
+
## A failed send no longer echoes the raw error
|
|
115
|
+
|
|
116
|
+
<Badge type="danger" text="Security" /> <Badge type="warning" text="Behavior Change" />
|
|
117
|
+
|
|
118
|
+
**In one line.** `core.mail.send_failed`, `core.mail.batch_send_failed` and `core.mail.verification_failed` now carry a fixed message; the original error moves to `cause`.
|
|
119
|
+
|
|
120
|
+
Before, the message was `Failed to send email: <error text>`, and the error envelope sends it in every environment. A stream error such as `ENOENT: no such file or directory, open '/srv/...'` put a server path in the response. Now the message is `Failed to send email` (and `Failed to send batch emails`, `Mail transport verification failed`). The original error is logged, and rides in `cause`, which only a `development` response shows.
|
|
121
|
+
|
|
122
|
+
**Who is affected:** code that parsed the text after `Failed to send email:`. Read `error.cause` instead.
|
|
123
|
+
|
|
124
|
+
## Every breaking change at a glance
|
|
125
|
+
|
|
126
|
+
| Before | Now | What to do |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `attachment.path` read any file | Refused without `attachmentRoot`, and outside it | Set `attachmentRoot`, or pass `content` |
|
|
129
|
+
| `path: 'https://...'` or `path: 'data:...'` (Nodemailer) | Refused - a `path` is a file under the root | Fetch or decode the bytes, pass `content` |
|
|
130
|
+
| `href` and `raw` attachments (Nodemailer) | Refused | Pass `content` |
|
|
131
|
+
| A built-in transport called directly read `path` | It refuses every `path` | Send through `MailService`, or pass `content` |
|
|
132
|
+
| `text`/`html` as a `Buffer`, stream or object | Refused (`null` still counts as absent) | Pass a string |
|
|
133
|
+
| No attachment size limit | 25 MB per message by default | Raise `maxAttachmentBytes` if you need more |
|
|
134
|
+
| Nodemailer and Mailgun streamed a stream attachment | They receive its bytes, buffered within the limit | Nothing, unless you relied on streaming past the limit |
|
|
135
|
+
| `send_failed` text carried the raw error | Fixed text; the error is in `cause` | Read `error.cause` |
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Static Asset Scoping, a Real Body Limit, and Storage File Names
|
|
3
|
+
description: "A shared bucket can be scoped per controller and per route, configs.middlewares.bodyLimit finally does what its type promised, a recreated MetaLink refreshes every row instead of one, non-ASCII file names survive a direct-upload commit, and office files resolve to their registered MIME type."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Changelog - 2026-09-25
|
|
7
|
+
|
|
8
|
+
Rebuilding the static-asset and storage examples exposed these defects. Each one is fixed at its root.
|
|
9
|
+
|
|
10
|
+
| Fix | Who is affected |
|
|
11
|
+
|---|---|
|
|
12
|
+
| [`controller.keyPrefix` scopes a shared bucket](#controller-keyprefix-scopes-a-shared-bucket) | Two controllers or tenants sharing one bucket |
|
|
13
|
+
| [`routes.<key>.enabled` drops a built-in route](#routes-key-enabled-drops-a-built-in-route) | Applications that closed a route with a middleware override |
|
|
14
|
+
| [`PUT .../meta-links/{objectName}` refreshes every row](#put-meta-links-objectname-refreshes-every-row) | Anyone calling the recreate route on a MetaLink table of their own |
|
|
15
|
+
| [`configs.middlewares.bodyLimit` is applied](#configs-middlewares-bodylimit-is-applied) | Applications passing `middlewares` through the constructor `config`; a hook-time assignment still has no effect and now fails the boot |
|
|
16
|
+
| [Request spy parses forms only in development](#request-spy-parses-forms-only-in-development) | Any route that reads a form body |
|
|
17
|
+
| [`extra.maxBytes` checks `content-length` before the body is read](#extra-maxbytes-checks-content-length-before-the-body-is-read) | Applications with their own upload size guard |
|
|
18
|
+
| [The error envelope honors the status on a thrown HTTPException](#the-error-envelope-honors-the-status-on-a-thrown-httpexception) | Every JSON-body route (malformed JSON is now `400`, not `500`), and any route whose handler throws an `HTTPException` |
|
|
19
|
+
| [Storage accepts non-ASCII and unusual file names](#storage-accepts-non-ascii-and-unusual-file-names) | Direct upload commits; uploads with a generated key; `MinioHelper` metadata; `Content-Disposition` |
|
|
20
|
+
| [Office file types resolve to their registered MIME type](#office-file-types-resolve-to-their-registered-mime-type) | `.xlsx`, `.xls`, `.docx`, `.doc`, `.pptx`, `.ppt`, `.odt`, `.ods` |
|
|
21
|
+
| [`presignPost` stops naming the bucket twice in virtual-hosted style](#presignpost-stops-naming-the-bucket-twice-in-virtual-hosted-style) | `BunS3Helper` with `virtualHostedStyle: true` |
|
|
22
|
+
|
|
23
|
+
## `controller.keyPrefix` scopes a shared bucket
|
|
24
|
+
|
|
25
|
+
<Badge type="tip" text="Enhancement" />
|
|
26
|
+
|
|
27
|
+
**In one line.** Two controllers can now point at the same bucket without either one seeing the other's keys.
|
|
28
|
+
|
|
29
|
+
Set `controller.keyPrefix` and every route on that controller is confined to it:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
{
|
|
33
|
+
controller: { name: 'TenantAAssets', basePath: '/tenant-a/assets', bucket: 'shared', keyPrefix: 'tenant-a' },
|
|
34
|
+
storage: StaticAssetStorageTypes.BUN_S3,
|
|
35
|
+
helper: bunS3Helper,
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `getObjectByName`, `downloadObjectByName`, `deleteObject`, and `recreateMetaLink` answer `404 core.storage.object_not_found` for a key outside the prefix - before any storage call, so a caller learns nothing about a key it does not own.
|
|
40
|
+
- `listObjects` narrows a caller `prefix` that is wider than the scope, and returns `[]` for one that is disjoint from it, without calling storage.
|
|
41
|
+
- `upload` places its own default key under the prefix. A `resolveObjectName` or `extra.normalizeNameFn` key outside it is refused with `400 core.static_asset.object_key_out_of_scope` - never rewritten, because the application may have recorded that key elsewhere.
|
|
42
|
+
- With **neither** hook set, the component's own default naming under a scope keeps the original file name to one segment - a `/` in it is `400 [upload] Invalid original file name`, exactly as without `keyPrefix`. Set `resolveObjectName` or `extra.normalizeNameFn` to relax that.
|
|
43
|
+
- Direct upload's pending key and policy `starts-with` condition also carry the prefix, and the commit route re-checks it.
|
|
44
|
+
- `maxFolderDepth` counts folders below the prefix, not from the bucket root.
|
|
45
|
+
- `keyPrefix` requires `controller.bucket` and throws at registration without it - the same requirement `directUpload` already has. Without a configured bucket the four bucket-management routes stay unscoped, so `keyPrefix` alone would isolate only part of the controller's surface.
|
|
46
|
+
- Not scoped: `defineRoutesBefore` and `defineExtraRoutes`. The bucket-management routes are not registered at all once `controller.bucket` is set.
|
|
47
|
+
|
|
48
|
+
**Who is affected:** nobody who leaves `keyPrefix` unset - behavior is unchanged. A controller that set `keyPrefix` without `bucket` (bucket-in-URL mode) now fails to register - add `controller.bucket`. See [`controller.keyPrefix`](/extensions/components/static-asset/api#controller-keyprefix) for the full reference and [Share one bucket between controllers](/extensions/components/static-asset/usage#share-one-bucket-between-controllers) for a worked example.
|
|
49
|
+
|
|
50
|
+
## `routes.<key>.enabled` drops a built-in route
|
|
51
|
+
|
|
52
|
+
<Badge type="tip" text="Enhancement" />
|
|
53
|
+
|
|
54
|
+
Every built-in static-asset route now takes `enabled: false`, the same switch name the CRUD controller factory uses:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
routes: {
|
|
58
|
+
deleteBucket: { enabled: false },
|
|
59
|
+
deleteObject: { enabled: false },
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Before, closing a route meant overriding it with a middleware that always refused the request. `enabled` is stripped before the route reaches Hono, so a disabled route is not registered at all - no path, no entry in the OpenAPI document.
|
|
64
|
+
|
|
65
|
+
**Who is affected:** nobody who leaves `routes` unset. An application that closed a route with a hand-written middleware can replace it with `enabled: false`.
|
|
66
|
+
|
|
67
|
+
## `PUT .../meta-links/{objectName}` refreshes every row
|
|
68
|
+
|
|
69
|
+
<Badge type="warning" text="Behavior Change" />
|
|
70
|
+
|
|
71
|
+
**In one line.** Recreating a MetaLink row now refreshes every row for that object, keeps each row's own `storageType` and labels, and only creates a new row when none existed.
|
|
72
|
+
|
|
73
|
+
Before, the route did a plain `findOne` then `updateById`/`create`: on a table with two rows for the same object - a product image attached to the product and to a variant, the documented case for the table's non-unique pair - only the first row found was touched, and a refresh silently overwrote the row's own `storageType` with the storage backend this controller was configured for.
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
const response = await fetch(`/assets/buckets/uploads/meta-links/${encodeURIComponent(objectName)}`, { method: 'PUT' });
|
|
77
|
+
const { success, action, count, metaLink, metaLinks } = await response.json();
|
|
78
|
+
// action: 'refreshed' when rows for the object existed, 'created' when none did
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `action`, `count`, and `metaLinks` are new on the response. `success` and `metaLink` (now `metaLinks[0]`) are unchanged in shape. `action` is a real OpenAPI enum, `'refreshed' | 'created'`, not a bare `string` - a generated client now gets the narrow type.
|
|
82
|
+
- A refresh always updates `mimetype`, `size`, `etag`, and `isSynced: true` on every row of the pair, and leaves each row's `storageType`, `variant`, `principalType`, `principalId`, and `sequence` untouched.
|
|
83
|
+
- **`link` and `metadata` depend on `metaLink.createMetaLink`.** With a hook configured, a refresh writes only `mimetype`, `size`, `etag`, `isSynced` - `link` and `metadata` are left alone, because the hook already owns them, so an enrichment it wrote (dimensions, a placeholder, anything beyond `getStat()`) survives a recreate. Without a hook, one statement refreshes `link` plus the stat columns on every row, then one more statement **per row** merges that row's own `metadata` with the stat's - the stat's keys win, everything else the row had survives, and the step is skipped entirely when the stat carries no metadata.
|
|
84
|
+
- That merge is a read-then-write: the first statement's own result supplies the "read", and each row's update is a separate statement after it. A concurrent write to that row's `metadata` landing in between is lost.
|
|
85
|
+
- A create (no existing rows) goes through the same `createMetaLink` hook and builder an upload uses, so it gets your `storageType` and defaults - not a bare guess.
|
|
86
|
+
- Refresh-then-create is still not atomic: two concurrent calls on an object with no existing row can both create one. Rows per pair are deliberately not unique, so the table allows it.
|
|
87
|
+
|
|
88
|
+
> [!IMPORTANT]
|
|
89
|
+
> The MetaLink type contract checks the **select row** only - the columns the component reads and writes, on whatever table `metaLink.model`/`metaLink.repository` point at. A column you add of your own must be nullable or carry a default: the component's default insert writes exactly the columns it knows about, so a `NOT NULL` column with no default passes the type check and then fails every insert the component makes without your own `createMetaLink` hook.
|
|
90
|
+
|
|
91
|
+
**Who is affected:** anyone calling `PUT .../meta-links/{objectName}` on a table with more than one row per `(bucketName, objectName)` pair, or reading `metaLink.storageType` after a refresh - it now keeps the value your `createMetaLink` hook wrote, instead of the component's own. A `createMetaLink` hook that enriches `metadata` (for example BANA inventory's width, height, and placeholder) is no longer overwritten by a recreate call. Code reading `metaLink` off the response is unaffected; code that assumed one row per object should read `metaLinks` instead.
|
|
92
|
+
|
|
93
|
+
## `configs.middlewares.bodyLimit` is applied
|
|
94
|
+
|
|
95
|
+
<Badge type="info" text="Bug Fix" /> <Badge type="warning" text="Behavior Change" />
|
|
96
|
+
|
|
97
|
+
`IMiddlewareConfigs.bodyLimit` existed as a type with nothing reading it. `RestApplication.registerDefaultMiddlewares()` now installs it - right after the request id, ahead of every other middleware, the request spy included:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
const application = new MyApplication({
|
|
101
|
+
scope: 'MyApp',
|
|
102
|
+
config: {
|
|
103
|
+
// ...the rest of IApplicationConfigs
|
|
104
|
+
middlewares: { bodyLimit: { enable: true, maxSize: 10 * 1024 * 1024 } },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
> [!IMPORTANT]
|
|
110
|
+
> `configs.middlewares` is read inside `registerDefaultMiddlewares()`, before `staticConfigure()` runs. Pass `middlewares` through the constructor's `config` - or an application-config factory that builds that object - not by assigning `this.configs.middlewares` in `staticConfigure()` or a later hook: it is read too late to take effect, and the application refuses to boot when it detects the value changed after the fact.
|
|
111
|
+
|
|
112
|
+
Over the limit answers `413 core.request.body_too_large`, or your own `onError`. A missing `enable` still applies the limit - fail-closed for a security control, even though the type marks `enable` required. A `maxSize` that is not a finite non-negative number throws at boot. Every other `IMiddlewareConfigs` key (`compress`, `cors`, `csrf`, `ipRestriction`) is still a type only - wire it yourself in `setupMiddlewares()`.
|
|
113
|
+
|
|
114
|
+
`bodyLimit` bounds the request stream; it does not replace `extra.maxBytes` or the other way around. `extra.maxBytes` only checks a declared `Content-Length`, so a chunked request with none skips it - pair the two. Raise `configs.server.maxRequestBodySize` above `bodyLimit.maxSize`, or Bun's own limit answers first, with no IGNIS envelope. Keep `bodyLimit.path` away from a route that streams a body through - the limiter buffers the whole body on any path it covers.
|
|
115
|
+
|
|
116
|
+
**Who is affected:** an application that assigns `configs.middlewares.bodyLimit` inside a hook like `staticConfigure()`, expecting it to take effect - it still does not, and the boot now fails instead of accepting it silently. Passed through the constructor, the limit is enforced for the first time. Everyone who never touched `configs.middlewares`: no action needed. See [Body limit](/references/base/middlewares#body-limit-configs-middlewares-bodylimit).
|
|
117
|
+
|
|
118
|
+
## Request spy parses forms only in development
|
|
119
|
+
|
|
120
|
+
<Badge type="warning" text="Behavior Change" />
|
|
121
|
+
|
|
122
|
+
**In one line.** Outside a development environment, the request spy no longer parses a form body - and a malformed form is still `400 core.request.body_malformed` everywhere it matters, through a new shared reader.
|
|
123
|
+
|
|
124
|
+
Before, `RequestSpyMiddleware` parsed every content type in every environment, just to log it - a multipart upload was fully read once by the spy and again by the route. Outside development the spy now parses only `application/json`, for its own malformed-body check; forms, `text/*`, and everything else reach the route unread.
|
|
125
|
+
|
|
126
|
+
A route whose `request.body.content` declares `multipart/form-data` or `application/x-www-form-urlencoded` now gets a `formBodyReader` middleware appended, after every application middleware and just ahead of the route's own validator. It parses the form through a new shared reader and maps a parse failure to `400 core.request.body_malformed`, before the validator gets a chance to throw its own generic, uncoded 400 - see [the error envelope fix](#the-error-envelope-honors-the-status-on-a-thrown-httpexception) for what happens when it does.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { readFormBody } from '@venizia/ignis-helpers';
|
|
130
|
+
|
|
131
|
+
const formData = await readFormBody({ req: context.req });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`readFormBody` is new in `@venizia/ignis-helpers` (and `/core`). `parseMultipartBody` now goes through it too.
|
|
135
|
+
|
|
136
|
+
> [!WARNING]
|
|
137
|
+
> An application that calls `context.req.formData()` itself - middleware running ahead of any framework reader, such as an upload authorization guard - is **not** covered by `formBodyReader`. A malformed body there is a `500` in production, not a `400`, unless that middleware calls `readFormBody({ req: context.req })` instead. It returns the same cached `FormData`, so the swap is a one-line change.
|
|
138
|
+
|
|
139
|
+
**Who is affected:** a route that reads a form body by hand, ahead of the framework's own reader, must switch to `readFormBody`. A route that declares its form body in OpenAPI (the static-asset upload, any `@hono/zod-openapi` form route) needs no change - its `400` on a malformed body is unchanged. Code that relied on the spy logging a non-JSON body outside development already saw nothing logged; this only changes whether it is *parsed*, which was never observable from the log line.
|
|
140
|
+
|
|
141
|
+
## `extra.maxBytes` checks `content-length` before the body is read
|
|
142
|
+
|
|
143
|
+
<Badge type="info" text="Bug Fix" />
|
|
144
|
+
|
|
145
|
+
The `content-length` pre-check moved from the upload handler into route middleware, appended **after** your own `routes.upload.middleware` - so an application's own size guard (for example BANA inventory's `REQUEST_TOO_LARGE`) still answers first.
|
|
146
|
+
|
|
147
|
+
It moved because `@hono/zod-openapi`'s form validator already reads the whole multipart body before any handler runs: a check inside the handler saw a body that was already fully read, so it never saved the memory it was written to save. The per-file `buffer.length` check - the authoritative one, since a multipart envelope is larger than the files inside it - stays in the handler.
|
|
148
|
+
|
|
149
|
+
`extra.maxBytes` bounds only a **declared** `content-length` - a chunked request with none skips it entirely. Pair it with [`configs.middlewares.bodyLimit`](#configs-middlewares-bodylimit-is-applied) for a ceiling nothing can skip.
|
|
150
|
+
|
|
151
|
+
**Who is affected:** nobody by default. An application with its own upload size middleware keeps answering first, unchanged. An application relying on `extra.maxBytes` alone against a chunked client should add `configs.middlewares.bodyLimit`.
|
|
152
|
+
|
|
153
|
+
## The error envelope honors the status on a thrown HTTPException
|
|
154
|
+
|
|
155
|
+
<Badge type="info" text="Bug Fix" />
|
|
156
|
+
|
|
157
|
+
The error handler read `statusCode` only. A Hono `HTTPException` - thrown by `@hono/zod-openapi`'s own request validator, or by your own code - carries its status on `.status`, so every 4xx `HTTPException` rendered as a generic `500 core.system_error` instead of its real status. The handler now reads `.status` too: a 4xx `HTTPException` renders as its own status and keeps its own message, sanitized environment or not; a 5xx one still stays an unexpected `500` whose message never reaches the client.
|
|
158
|
+
|
|
159
|
+
This is a general correctness fix, not specific to forms: any route whose validator or handler throws an `HTTPException` is affected. The clearest case is JSON: malformed JSON on **any** JSON-body route - every `@hono/zod-openapi` route with a `jsonContent` body, including the generated CRUD controllers - now answers `400 Malformed JSON in request body` instead of `500 core.system_error`. It does not cover a raw runtime error - a `TypeError`, for instance - which still renders as a `500`; see [`readFormBody`](#request-spy-parses-forms-only-in-development) for that case on a form body specifically.
|
|
160
|
+
|
|
161
|
+
**Who is affected:** any route whose validator or handler throws a Hono `HTTPException` - in practice, every JSON-body and form-body route, through the request validator, and any consumer code that throws one by hand. A response that used to be a `500` for one of these now carries the exception's own 4xx status and its own message (malformed JSON, for example, now reads `Malformed JSON in request body`). Code that matched on `statusCode: 500` for one of these cases should match on the new status instead.
|
|
162
|
+
|
|
163
|
+
## Storage accepts non-ASCII and unusual file names
|
|
164
|
+
|
|
165
|
+
<Badge type="info" text="Bug Fix" /> <Badge type="tip" text="Enhancement" />
|
|
166
|
+
|
|
167
|
+
**In one line.** A file called `Kiểm kê.xlsx` or `Báo cáo [Q3] & tổng hợp #1!.xlsx` now uploads, commits, and downloads under its own name.
|
|
168
|
+
|
|
169
|
+
- **`BunS3Helper.copyObject` encodes the copy source.** The `x-amz-copy-source` header is now built with per-segment SigV4 encoding. Before, a non-ASCII key made Bun throw `Header 'x-amz-copy-source' has invalid value`, so a direct-upload commit failed outright; a key with `+`, `%`, `?`, or `#` risked copying the wrong object, because S3 URL-decodes the header and splits `?versionId=` off it.
|
|
170
|
+
- **A generated key no longer rejects the original name.** When `upload` gets a naming hook (`normalizeNameFn`, or the static-asset controller's own default, `resolveObjectName`, or `controller.keyPrefix`), the original name is only metadata, not the key. Names containing `& # ! [ ] { } ;` or a leading dot are now accepted; control characters, an empty name, and more than 255 characters are still refused. Without a naming hook, the name is the key, and the key rules apply exactly as before.
|
|
171
|
+
- **Every key in a batch is checked before the first write.** One bad name in a multi-file upload now stores nothing, instead of writing the files ahead of the bad one.
|
|
172
|
+
- **`MinioHelper` stores a non-ASCII name.** The `originalName` and `normalizeName` metadata values are RFC 2047-encoded outside printable ASCII. Before, the upload crashed with `ERR_INVALID_CHAR`.
|
|
173
|
+
- **`createContentDispositionHeader` keeps the real name, and now keeps emoji and script-joining marks too.** `filename*` carries the UTF-8 name with every control, bidi-override, and other format character replaced by `_` (so a right-to-left override in a name can no longer relabel the extension a user sees) - **except** the zero-width joiner and non-joiner, which are left alone, because they build emoji sequences (a family emoji is several code points joined by U+200D) and join letters in Persian and other scripts, and reorder nothing. The `filename` fallback stays printable ASCII only. ASCII names produce the same header as before.
|
|
174
|
+
|
|
175
|
+
**Who is affected:** direct upload and static-asset uploads with non-ASCII or punctuated file names - fixed, no action needed. Code that matched the old error text `[upload] Invalid original file name` for a name copied into a generated key now sees `[upload] Invalid normalized object name | name: ...` instead. `MinioHelper` consumers reading `originalName` metadata get an RFC 2047 value (`=?UTF-8?B?...?=`) for a non-ASCII name; ASCII values are unchanged. A file name with a bidi override or another format character now downloads under a sanitized `filename*`, where it previously carried the raw character through.
|
|
176
|
+
|
|
177
|
+
## Office file types resolve to their registered MIME type
|
|
178
|
+
|
|
179
|
+
<Badge type="info" text="Bug Fix" />
|
|
180
|
+
|
|
181
|
+
`.xlsx`, `.xls`, `.docx`, `.doc`, `.pptx`, `.ppt`, `.odt`, and `.ods` resolve to their real content type in `ContentTypeTable`, instead of falling back to `application/octet-stream`. `.csv`, `.zip`, and `.txt` were already in the table.
|
|
182
|
+
|
|
183
|
+
This changes what the MetaLink `mimetype` column, `DiskHelper.getStat`, `writeStream`'s default type, and `AssetIngest` record - not what the static-asset routes serve. None of the office types is on `RENDERABLE_CONTENT_TYPES`, so the served `Content-Type` is still `application/octet-stream` with `Content-Disposition: attachment`, unchanged.
|
|
184
|
+
|
|
185
|
+
**Who is affected:** anyone reading a stored office file's recorded MIME type. Served behavior is unchanged.
|
|
186
|
+
|
|
187
|
+
## `presignPost` stops naming the bucket twice in virtual-hosted style
|
|
188
|
+
|
|
189
|
+
<Badge type="info" text="Bug Fix" />
|
|
190
|
+
|
|
191
|
+
With `virtualHostedStyle: true`, `presignPost`'s `postURL` was `https://<bucket>.<host>/<bucket>` - the bucket named in the host and repeated as a path segment, so the browser posted to a path that does not exist. It is now `https://<bucket>.<host>`. Path-style addressing (the common case, and what every current `BunS3Helper` construction in BANA uses) is unchanged: `http://minio:9000/<bucket>`.
|
|
192
|
+
|
|
193
|
+
**Who is affected:** `BunS3Helper` constructed with `virtualHostedStyle: true` and used with `directUpload` or a hand-called `presignPost`. Nobody using path-style addressing (the default) sees any change.
|
|
194
|
+
|
|
195
|
+
## See also
|
|
196
|
+
|
|
197
|
+
- [Static Asset Component](/extensions/components/static-asset/) - overview and quick start
|
|
198
|
+
- [Static Asset Component - Full Reference](/extensions/components/static-asset/api) - `controller.keyPrefix`, per-route `enabled`, recreate-metalink
|
|
199
|
+
- [Static Asset Component - Error Reference](/extensions/components/static-asset/errors) - every code, including the two new ones
|
|
200
|
+
- [Middlewares Reference](/references/base/middlewares) - body limit, the form body reader, the request spy
|
|
201
|
+
- [Storage Helpers - Full Reference](/extensions/helpers/storage/api) - `upload`, MIME table, name validation
|
|
202
|
+
- [Request Utility](/references/utilities/request) - `readFormBody`, `createContentDispositionHeader`
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: JSON-Path Where and Order Keys Are Now Qualified With the Table
|
|
3
|
+
description: "A JSON-path filter or order key now renders qualified with its table or query alias, in Postgres and SQLite, so a query joining two tables that share a JSON column name no longer fails with an ambiguous-column error."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Changelog - 2026-09-26
|
|
7
|
+
|
|
8
|
+
## JSON-path where and order keys are qualified
|
|
9
|
+
|
|
10
|
+
<Badge type="info" text="Bug Fix" />
|
|
11
|
+
|
|
12
|
+
**In one line.** A `where` or `order` key on a JSON/JSONB column now renders qualified with the table - or the query alias, on an included relation - exactly like a plain column already did, in both Postgres and SQLite.
|
|
13
|
+
|
|
14
|
+
## What changed
|
|
15
|
+
|
|
16
|
+
- **Postgres.** A JSON-path `where` (`#>>`) and `order` (`#>`) extraction now qualifies the column: `"orders"."metadata" #>> '{tier}'` instead of `"metadata" #>> '{tier}'`. The numeric-cast branch (`::numeric`) and a JSON path nested under `not` qualify the same way.
|
|
17
|
+
- **SQLite.** `json_extract` now takes the qualified column: `json_extract("orders"."metadata", '$."tier"')` instead of `json_extract("metadata", '$."tier"')`. A numeric path component that doubles the candidate reads still works - both extractions inside the `coalesce(...)` qualify.
|
|
18
|
+
- **The path segment is unchanged.** Only the column part renders differently; the validated path literal (`'{tier}'`, `'$."tier"'`) is exactly as before, and no new injection surface opens.
|
|
19
|
+
|
|
20
|
+
## Who is affected
|
|
21
|
+
|
|
22
|
+
- **A query that joins a second table with a same-named JSON column.** Before, Postgres rejected it with `column reference "metadata" is ambiguous` and SQLite with `SQLITE_ERROR: ambiguous column name: metadata`; it now runs on both. This is the defect this change fixes.
|
|
23
|
+
- **A test or a log line that asserts the exact rendered SQL string for a JSON-path filter or order key.** It now sees the table (or alias) prefix on that fragment. Update the expected string - the filter shape and the returned rows are unchanged.
|
|
24
|
+
- **A `FilterBuilder` subclass author.** The protected `buildJsonOperatorConditions` now receives `jsonPath`/`safeNumericCast` typed `string | SQL`, not `string`. An existing override still compiles either way (parameters are bivariant) and one that only forwards the value (to `super`, or straight into an operator function) keeps working, still qualified. But one still typed `string` that treats the value as text (`sql.raw(opts.jsonPath)`, or interpolating it into a template string) now receives an `SQL` object and emits `[object Object]` - the query then fails. Retype it to `string | SQL` and pass the value through instead of stringifying it.
|
|
25
|
+
- **Everyone else.** No action needed. The filter API, the rows returned, and their order are all unchanged.
|
|
26
|
+
|
|
27
|
+
## Migration
|
|
28
|
+
|
|
29
|
+
None. This is a defect fix with no public API change - a filter such as `{ 'metadata.tier': 'gold' }` is written exactly as before.
|
|
30
|
+
|
|
31
|
+
## See also
|
|
32
|
+
|
|
33
|
+
- [JSON/JSONB Filtering](/references/base/filter-system/json-filtering)
|
|
34
|
+
- [Fields, Order & Pagination](/references/base/filter-system/fields-order-pagination)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Order Entries Get One Parser, and toOrderBy Can Sort by an Expression
|
|
3
|
+
description: "parseOrderEntry is now a public export every relational and search dialect shares, toOrderBy gains an expressions option for sorting by a joined column or a computed SQL expression, and a malformed order entry is now a 400 instead of a silent truncation."
|
|
4
|
+
packages: [connectors, core-server, filter, kernel]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Changelog - 2026-09-26
|
|
8
|
+
|
|
9
|
+
## Order entries: one parser, and new failure modes for malformed input
|
|
10
|
+
|
|
11
|
+
<Badge type="tip" text="Feature" /> <Badge type="warning" text="Behavior Change" />
|
|
12
|
+
|
|
13
|
+
**In one line.** Every `order` entry - relational, Typesense, Meilisearch - is now read by one exported `parseOrderEntry` function, and an entry it cannot make sense of is a `400` instead of a silent truncation.
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { parseOrderEntry } from '@venizia/ignis-filter';
|
|
17
|
+
|
|
18
|
+
parseOrderEntry({ entry: 'createdAt DESC' }); // { field: 'createdAt', direction: 'desc' }
|
|
19
|
+
parseOrderEntry({ entry: 'name' }); // { field: 'name', direction: 'asc' }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What changed
|
|
23
|
+
|
|
24
|
+
- **`parseOrderEntry` is a new export** of `@venizia/ignis-filter` (also from `@venizia/ignis-kernel` and `@venizia/ignis`), alongside the new `TSortDirection` and `TParsedOrderEntry` types. It replaces three private copies of the same parsing logic in the relational, Typesense, and Meilisearch dialects.
|
|
25
|
+
- **An entry with more than two tokens is now a 400.** `'createdAt DESC NULLS LAST'` used to sort on `createdAt DESC` and silently drop `NULLS LAST` - IGNIS order entries never gave a `NULLS LAST`/`FIRST` clause any effect, so the tail was always dead weight, just quietly accepted. It now throws before the query runs.
|
|
26
|
+
- **An empty entry (`''`, or all whitespace) is now a 400.** It used to reach the relational dialect as an empty field name and read as "column not found"; it now fails at the parser with a clearer message.
|
|
27
|
+
- **Direction error messages changed.** They now come from `parseOrderEntry` and carry the whole entry text, not the table name or the field alone. See the table below.
|
|
28
|
+
- **The entry in a message is now `JSON.stringify`-quoted**, not interpolated raw - `entry: "name DESC NULLS LAST"` rather than `entry: 'name DESC NULLS LAST'`. A newline or other ASCII control character inside a malformed entry is escaped instead of reaching the log or the response as a literal character. DEL, the C1 range, and the Unicode line separators `U+2028`/`U+2029` still pass through raw - `JSON.stringify` does not escape them (tracked in #65).
|
|
29
|
+
- **Only ASCII whitespace separates a field from its direction.** A non-breaking space (`<NBSP>`) or another Unicode space no longer splits `'name<NBSP>DESC'` into two tokens - the old `split(/\s+/)` did split on it. The whole string is now read as one field name.
|
|
30
|
+
- **`toOrderBy` (relational dialects) gains an `expressions` option**, for sorting by a column on a joined table or a computed SQL expression - see the next section.
|
|
31
|
+
|
|
32
|
+
## Error messages, before and after
|
|
33
|
+
|
|
34
|
+
| Case | Relational, before | Relational, after |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Invalid direction | `[FilterBuilder][toOrderBy] Table: <t> \| Invalid direction: 'RANDOM' \| Expected: 'ASC' or 'DESC'` | `[parseOrderEntry] Invalid direction \| entry: "name RANDOM" \| Expected: 'ASC' or 'DESC'` |
|
|
37
|
+
| Extra tokens | Silently accepted; the tail was dropped | `[parseOrderEntry] Too many tokens \| entry: "name DESC NULLS LAST" \| Expected: '<field>' or '<field> ASC\|DESC'` |
|
|
38
|
+
| Empty entry | Reached the dialect as an unknown column | `[parseOrderEntry] Order entry has no field \| entry: ""` |
|
|
39
|
+
|
|
40
|
+
| Case | Search (Typesense/Meilisearch), before | Search, after |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Invalid direction | `Invalid sort direction '<direction>' for field '<field>'` | Same `[parseOrderEntry] Invalid direction` message as relational |
|
|
43
|
+
| Extra tokens | Silently accepted; the tail was dropped | Same `[parseOrderEntry] Too many tokens` message |
|
|
44
|
+
| Empty entry | Silently accepted as an empty field | Same `[parseOrderEntry] Order entry has no field` message |
|
|
45
|
+
|
|
46
|
+
Status codes: every case that was already an error was a `400` and still is; the "silently accepted" cases (extra tokens, an empty entry) are now `400` for the first time. Unknown-column messages (`Column NOT FOUND | key: '<key>'`) are unchanged and still name the key.
|
|
47
|
+
|
|
48
|
+
## `toOrderBy` can sort by a joined column or a computed expression
|
|
49
|
+
|
|
50
|
+
<Badge type="tip" text="Feature" />
|
|
51
|
+
|
|
52
|
+
**In one line.** `IRelationalQueryDialect.toOrderBy` takes a new `expressions` option: a map from the name an order entry uses to a Drizzle column or `SQL`, for sort targets the model's own schema cannot name.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { sql } from 'drizzle-orm';
|
|
56
|
+
|
|
57
|
+
const orderBy = queryDialect.toOrderBy({
|
|
58
|
+
tableName: 'item',
|
|
59
|
+
schema: itemTable,
|
|
60
|
+
order: ['groupLabel ASC', 'displayName DESC'],
|
|
61
|
+
expressions: {
|
|
62
|
+
groupLabel: groupTable.label, // a column on a joined table
|
|
63
|
+
displayName: sql`COALESCE(${itemTable.nickname}, ${itemTable.name})`, // a computed value
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
// ORDER BY "group"."label" asc, COALESCE("item"."nickname", "item"."name") desc, "item"."id" asc
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A key resolves as an own key of `expressions` first (`Object.hasOwn`, so an inherited name like `constructor` never matches), then as a JSON path, then as a schema column. The `id ASC` tie-breaker still closes the list unless an entry names `id`; an expression keyed `id` never counts as naming it. Omitting `expressions` costs nothing extra. See [Fields, Order & Pagination](/references/base/filter-system/fields-order-pagination#sorting-by-a-joined-column-or-a-computed-expression) for the full reference.
|
|
70
|
+
|
|
71
|
+
## Who is affected
|
|
72
|
+
|
|
73
|
+
- **Everyone calling `order` with well-formed entries (`'field'` or `'field ASC|DESC'`, ASCII whitespace):** no change in behavior.
|
|
74
|
+
- **An order entry with more than two tokens, or an empty entry:** now a `400` instead of a silently accepted (relational and search dialects alike). Drop the extra tokens from the entry - IGNIS never honored them.
|
|
75
|
+
- **An order entry using a non-ASCII space to separate field and direction:** now reads as one field name and likely resolves to an unknown column. Use a regular space or tab.
|
|
76
|
+
- **Code matching the old relational or search direction-error text** (`Invalid direction: '...' | Expected:`, `Invalid sort direction '...' for field '...'`): match the new `[parseOrderEntry] Invalid direction` message instead. The status code is unchanged.
|
|
77
|
+
- **Nobody needs to opt in to keep the old parser** - there is no flag; every dialect calls `parseOrderEntry` unconditionally. Applications wanting a joined-column or computed sort should adopt `toOrderBy`'s new `expressions` option; nothing is required if none is passed.
|
|
78
|
+
|
|
79
|
+
## See also
|
|
80
|
+
|
|
81
|
+
- [Fields, Order & Pagination](/references/base/filter-system/fields-order-pagination) - `order`, `parseOrderEntry`, `toOrderBy`'s `expressions` option
|
|
82
|
+
- [Filter System Overview](/references/base/filter-system/) - the `filter` shape and every operator family
|
|
83
|
+
- [Quick Reference](/references/base/filter-system/quick-reference) - every filter property, one line each
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: A Repository Method Runs Its Own Transaction, and a Double Stubs One In Tests
|
|
3
|
+
description: "runInTransaction begins, commits, and rolls back for you; TransactionDouble from @venizia/ignis/testing stubs beginTransaction() with no database behind it. The real transaction handle is now backed by a shared class - behavior unchanged."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Changelog - 2026-09-26
|
|
7
|
+
|
|
8
|
+
## `runInTransaction` runs commit and rollback for you
|
|
9
|
+
|
|
10
|
+
<Badge type="tip" text="Feature" />
|
|
11
|
+
|
|
12
|
+
Hand-written begin / try / commit / catch / rollback blocks repeat wherever a repository method needs
|
|
13
|
+
a transaction. `runInTransaction` writes that block once:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
const order = await orderRepository.runInTransaction({
|
|
17
|
+
execute: async ({ transaction }) => {
|
|
18
|
+
const { data: created } = await orderRepository.create({
|
|
19
|
+
data: orderData,
|
|
20
|
+
options: { transaction },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await orderItemRepository.create({
|
|
24
|
+
data: { orderId: created.id, ...itemData },
|
|
25
|
+
options: { transaction },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return created;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Join or own, never both
|
|
34
|
+
|
|
35
|
+
Pass a `transaction` and `runInTransaction` joins it: it never commits or rolls back, and
|
|
36
|
+
`transactionOptions` is ignored. Pass none and it owns one: begins, commits on success, and rolls
|
|
37
|
+
back on failure - rethrowing the original error even when the rollback itself fails.
|
|
38
|
+
|
|
39
|
+
| | Joined (`transaction` passed) | Owned (none passed) |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Begin | Never | `beginTransaction(transactionOptions)` |
|
|
42
|
+
| Commit | Never | After `execute` resolves |
|
|
43
|
+
| Rollback | Never | On failure, then rethrows the original error |
|
|
44
|
+
|
|
45
|
+
That turns a service method that must work stand-alone and nested inside a caller's transaction into
|
|
46
|
+
a one-line forward, not two code paths:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
async function createOrder(opts: { data: TOrderCreate; transaction?: IDatabaseTransaction }) {
|
|
50
|
+
return orderRepository.runInTransaction({
|
|
51
|
+
transaction: opts.transaction,
|
|
52
|
+
execute: async ({ transaction }) =>
|
|
53
|
+
orderRepository.create({ data: opts.data, options: { transaction } }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See [Transactions](/guides/core-concepts/persistent/transactions#runintransaction) for the full
|
|
59
|
+
rules, including what happens to an inactive handle and a failed rollback.
|
|
60
|
+
|
|
61
|
+
## `TransactionDouble` stubs a transaction with no database
|
|
62
|
+
|
|
63
|
+
<Badge type="tip" text="Feature" />
|
|
64
|
+
|
|
65
|
+
Unit-testing `runInTransaction` used to mean standing up Postgres or SQLite. `TransactionDouble`,
|
|
66
|
+
from the new `@venizia/ignis/testing` entry (`@venizia/ignis-connectors/testing` in the connectors
|
|
67
|
+
package), is a transaction handle with no database behind it:
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { spyOn } from 'bun:test';
|
|
71
|
+
import { PostgresTransactionDouble } from '@venizia/ignis/testing';
|
|
72
|
+
|
|
73
|
+
const double = new PostgresTransactionDouble();
|
|
74
|
+
spyOn(orderRepository, 'beginTransaction').mockResolvedValue(double);
|
|
75
|
+
|
|
76
|
+
await orderRepository.runInTransaction({ execute: async () => 'ok' });
|
|
77
|
+
|
|
78
|
+
expect(double.commitCount).toBe(1);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Reading `double.connector` throws - a repository method that reaches the database must be stubbed
|
|
82
|
+
too. Inject `commitError` or `rollbackError` to test a failed end. See
|
|
83
|
+
[Transaction Doubles](/best-practices/testing-strategies#transaction-doubles) for the full guide,
|
|
84
|
+
including the limit: a double proves control flow, not SQL atomicity.
|
|
85
|
+
|
|
86
|
+
## If `execute` ends the owned transaction itself
|
|
87
|
+
|
|
88
|
+
`runInTransaction` expects `execute` to leave an owned transaction open for it to commit. Call
|
|
89
|
+
`commit()` or `rollback()` from inside `execute` instead, and `runInTransaction` never ends it a
|
|
90
|
+
second time: committed by `execute` - the result is returned, with no second commit attempt. Rolled
|
|
91
|
+
back or left failed by `execute` - a dedicated error, naming that `execute` ended the transaction
|
|
92
|
+
before it could be committed, with no rollback attempt. `execute` rejects after ending the
|
|
93
|
+
transaction itself - the ORIGINAL rejection, with no further rollback attempt. This reads the
|
|
94
|
+
transaction's own internal state through a registry symbol, so it recognises the real handle or
|
|
95
|
+
`TransactionDouble` even across a CommonJS/ESM mix (a `@venizia/ignis/testing` double with an
|
|
96
|
+
`@venizia/ignis-connectors` repository, or the reverse) - only a transaction from an entirely
|
|
97
|
+
different `ITransaction` implementation is unreadable, and reports the "ended before it could be
|
|
98
|
+
committed" error even when `execute` committed it successfully.
|
|
99
|
+
|
|
100
|
+
Always `await` `commit()`/`rollback()` when calling them from inside `execute`. A commit call that
|
|
101
|
+
`execute` does not await is read as COMMITTED before the COMMIT statement itself has settled, so
|
|
102
|
+
`runInTransaction` returns `execute`'s result while that commit can still go on to fail.
|
|
103
|
+
|
|
104
|
+
## Who is affected
|
|
105
|
+
|
|
106
|
+
Additive. Nothing existing changes behavior, and adopting either API is optional.
|
|
107
|
+
|
|
108
|
+
One shape changed under the hood, not in behavior: the handle `beginTransaction()` returns is now a
|
|
109
|
+
class, `ConnectionTransaction`, instead of a closure building a fresh object literal each time. Its
|
|
110
|
+
datasource, connection, and internal state are ES private fields - invisible to `JSON.stringify`,
|
|
111
|
+
`util.inspect`, `Object.keys`, and a spread copy, exactly as before. `commit()`, `rollback()`, and
|
|
112
|
+
`connector` behave exactly as before, and reading `isActive` on the handle itself still works.
|
|
113
|
+
|
|
114
|
+
The one real difference: `isActive` now lives on the shared prototype, not as an own property. A
|
|
115
|
+
spread copy - `{ ...transaction }` - no longer carries it, so passing a spread copy to a repository
|
|
116
|
+
now throws "Transaction is no longer active" instead of working. Reading `isActive` through a `Proxy`
|
|
117
|
+
or an `Object.create(transaction)` wrapper now throws a `TypeError` too - ES private fields are not
|
|
118
|
+
proxyable and are not inherited. Nothing in IGNIS does either.
|
|
119
|
+
|
|
120
|
+
**Files:**
|
|
121
|
+
|
|
122
|
+
- [`packages/connectors/src/relational/core/datasources/transaction-lifecycle.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/connectors/src/relational/core/datasources/transaction-lifecycle.ts) - the shared end-of-transaction state machine
|
|
123
|
+
- [`packages/connectors/src/relational/core/datasources/base.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/connectors/src/relational/core/datasources/base.ts) - the real handle, `ConnectionTransaction`
|
|
124
|
+
- [`packages/connectors/src/relational/core/repositories/core/base.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/connectors/src/relational/core/repositories/core/base.ts) - `runInTransaction`
|
|
125
|
+
- [`packages/connectors/src/testing/transaction-double.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/connectors/src/testing/transaction-double.ts) - `TransactionDouble`, `PostgresTransactionDouble`, `SqliteTransactionDouble`
|