@objectstack/types 17.0.0-rc.5 → 17.0.0-rc.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,307 @@
1
1
  # @objectstack/types
2
2
 
3
+ ## 17.0.0-rc.6
4
+
5
+ ### Minor Changes
6
+
7
+ - 91cefb8: refactor(types,rest,metadata,analytics): Postgres 的 `"x" of relation "y"` 短语收归一处,三个包不再各修一遍同一个超串洞(#6615)
8
+
9
+ Postgres 把「关系内部某个子对象」的失败写成 `column "label" of relation "sys_team" does not exist`——里面**逐字包含**一句合法的「表不存在」短语 `relation "sys_team" does not exist`,含义却相反:关系正因为存在才被点名。任何对「这句话是不是在说表没了」的正则收紧都消不掉这个匹配,短语确实在里面;唯一的修法是**先问更具体的问题**。所以修的是**顺序**,不是模式。
10
+
11
+ 正因为如此,这个短语被分三次教给了这个仓库,分属三个包、三个 PR,其中两次是在别处已经踩过同一个洞之后:`@objectstack/rest` 的 `mapDataError`(#5352)、`@objectstack/service-analytics` 的缺列扣除(#6035 / PR #6346)、`@objectstack/metadata` 的 `MISSING_TABLE.excludes`(#6347 / PR #6613)。本次把它收进 `@objectstack/types`,与 `isUniqueViolationError`(#6250)和 `isModuleNotFoundError`(framework#3265)同一个理由与同一个位置。
12
+
13
+ **两种宽度,故意保留成两个导出。** 三个消费者要的并不是同一条正则,差别也不是随手写的,而是**每个站点哪个方向的误差是安全的**:
14
+
15
+ - `matchMissingColumnOfRelation(message)` —— 严格提取器,锚定 Postgres 的 errmsg 模板 `column "%s" of relation "%s" does not exist`,返回列名。`rest` 用它把 42703 答成 `400 INVALID_FIELD` 而不是 `404`;`service-analytics` 用它在分类前扣除缺列。这两处**过宽**会把真正缺失的表变成硬失败、回退 #5033 刻意保留的宽容,**漏匹配**只是让消息含糊一点——所以必须严格。
16
+ - `isRelationSubObjectPhrase(message)` —— 宽检测器,丢掉 `column` / `[a-z0-9_]+` / `does not exist` 三个锚点:任意子对象、任意带引号标识符、任意判词。`metadata` 用它做排除。这一处**过宽**只会把良性判定变成响亮判定,**漏匹配**却会让 `event_seq` 从 1 重新开始、撞进一张已有行的历史表——方向正好相反。
17
+
18
+ 把两者合并成一条正则,无论哪种宽度胜出都会对其中一个调用方是错的;这是卡片记录在案的风险,两个导出即为此而设,理由是承重的而非风格的。仓库里第四份拷贝(`service-analytics` 测试内用于守护 fixture 的那条正则)同时收编:它本是为「两张面孔别对不上」而写,却把断言打在其中一面的私有复述上,因而正是它要防的漂移。
19
+
20
+ 行为逐字保持不变:搬进来的两条模式与原站点逐字节相同。`@objectstack/service-analytics` 因此新增一条对 `@objectstack/types` 的依赖边——这是本次唯一的依赖变化,构造上无环(`@objectstack/types` 只依赖 `@objectstack/spec`,后者无仓内依赖),且仓库 73 个包中已有 25 个、16 个 service 中已有 5 个携带同一条边。
21
+
22
+ - 129b378: fix(types,rest): one named answer for "which column conflicted" — an index name is never returned as one (#6544)
23
+
24
+ #6250 retired four private "is this a unique violation?" vocabularies into
25
+ `isUniqueViolationError`. It left the harder half of the question behind: the
26
+ import runner's `sanitizeRowError` still carried its own three-dialect regex
27
+ chain, because it does **more** than answer yes/no — it names the offending
28
+ column so the importer can say _"A record with this `email` already exists."_
29
+ This lands that second answer as a shared export and migrates the last private
30
+ copy onto it.
31
+
32
+ **New — `uniqueViolationColumn(error)` in `@objectstack/types`** (`string |
33
+ undefined`), sibling to `isUniqueViolationError` and gated on it, reading the
34
+ same channels one step down the same bounded `cause` chain, plus
35
+ node-postgres' `detail` field.
36
+
37
+ **Its contract, per the maintainer's 2026-08-08 ruling: a value comes back only
38
+ when the identifier the driver printed is determinably a COLUMN.** When a
39
+ dialect names an _index_ instead — MySQL's `Duplicate entry … for key
40
+ 'idx_email_unique'`, Postgres' `violates unique constraint "sys_user_email_key"`,
41
+ SQLite's `UNIQUE constraint failed: index 'x'` — the answer is `undefined`,
42
+ never the index name. Callers render this into a form field, and an index name
43
+ mistaken for a column points the user at a field that does not exist, whereas
44
+ `undefined` degrades to generic copy. A **composite** key (`Key (tenant_id,
45
+ email)=(…)`) is `undefined` for the same reason: there is no single offending
46
+ column, and naming the first is the same class of wrong answer.
47
+
48
+ **⚠️ User-visible change on MySQL imports.** MySQL's duplicate-entry message
49
+ names the index and never the column, so the importer no longer names a column
50
+ there: rows that used to read _"A record with this `idx_email_unique` already
51
+ exists."_ — or, on MySQL 8's table-qualified `for key 'sys_user.email'`, a
52
+ plausible-looking _`email`_ that was still an index name — now read **"A record
53
+ with this value already exists."** That is deliberate and is the accepted cost
54
+ of the ruling. The conflict is still recognised as a conflict; only the naming
55
+ narrowed.
56
+
57
+ Three smaller import messages improve in the same move, all previously wrong
58
+ rather than merely vague:
59
+
60
+ - SQLite's expression/partial-index form used to render as _"A record with this
61
+ **index** already exists."_
62
+ - Postgres' expression index used to render the truncated fragment _"A record
63
+ with this **lower(email** already exists."_
64
+ - A Postgres conflict with no `DETAIL:` line used to fall through to the SQL
65
+ backstop and echo the driver's own sentence — index name included — at the
66
+ importer. It now gets the same generic conflict copy, which is also the exact
67
+ wording `mapDataError` puts in the 409 `UNIQUE_VIOLATION` body, so the
68
+ importer and the API say one thing about one condition.
69
+
70
+ Not changed: the NOT NULL branch, the raw-SQL backstop, and every non-conflict
71
+ message, which pass through exactly as before.
72
+
73
+ ### Patch Changes
74
+
75
+ - 88f9d94: fix(types,rest): one named unique-violation predicate — a MySQL conflict is 409 UNIQUE_VIOLATION, not 500 (#6250)
76
+
77
+ **On MySQL, every unique-constraint conflict came back as `500 INTERNAL_ERROR`.**
78
+ The API contract registers `UNIQUE_VIOLATION` as a 409 code
79
+ (`packages/spec/src/api/error-code-ledger.zod.ts`), so a front end had no way to
80
+ tell "this email is already taken" from "the server fell over" — no retry advice,
81
+ no field to point at, and a 5xx in the operator's dashboards for what is an
82
+ ordinary client outcome. SQLite and Postgres deployments never saw it, which is
83
+ why it survived: their conflict prose happens to contain the words the mapping
84
+ looked for.
85
+
86
+ **Cause: the conflict verdict was nested inside a leak heuristic.** REST's 409
87
+ branch lived inside the true-branch of `looksLikeInternalErrorLeak()`, keyed on
88
+ the substrings `unique constraint` / `unique violation`. MySQL says
89
+ `ER_DUP_ENTRY: Duplicate entry '…' for key '…'`, which matches no limb of that
90
+ heuristic, so the conflict never reached the `if` at all and fell out of the
91
+ terminal `UNCLASSIFIED_FAULT`. Two unrelated questions — "is this a conflict?"
92
+ and "would echoing this text leak internals?" — had been fused into one, and
93
+ MySQL is where they disagree.
94
+
95
+ Measured on the previous release, through the real error mapper:
96
+
97
+ ```
98
+ mysql, bare message 500 INTERNAL_ERROR → 409 UNIQUE_VIOLATION
99
+ mysql, knex-wrapped SQL 500 DATABASE_ERROR → 409 UNIQUE_VIOLATION
100
+ postgres, SQLSTATE only 500 INTERNAL_ERROR → 409 UNIQUE_VIOLATION
101
+ sqlite, message 409 UNIQUE_VIOLATION (unchanged)
102
+ postgres, message 409 UNIQUE_VIOLATION (unchanged)
103
+ ```
104
+
105
+ So the hole was never MySQL-only: the mapping read one of the two channels
106
+ drivers use. A Postgres error carrying SQLSTATE `23505` with unremarkable prose
107
+ was a 500 as well.
108
+
109
+ **New: `isUniqueViolationError(error)`, exported from `@objectstack/types`.** One
110
+ named predicate replaces the substring test, reading every channel a driver
111
+ uses — `code` (`23505` / `ER_DUP_ENTRY` / `SQLITE_CONSTRAINT_UNIQUE`), `errno`
112
+ (`1062`), the message, and one step down the `cause` chain that pool and
113
+ query-builder layers wrap with. Its vocabulary is the union of the four
114
+ hand-written copies the repo already carried, so routing REST through it cannot
115
+ narrow any verdict clients rely on today; an unrecognised error is never a
116
+ conflict, because a false 409 tells an SDK not to retry and points the user at a
117
+ value that is fine.
118
+
119
+ **The internal-leak classifier is byte-identical.** The fix hoists the conflict
120
+ question out of it rather than widening its criteria, so nothing else it guards
121
+ is reclassified as safe-to-expose. And the 409 body is fixed text: MySQL embeds
122
+ the offending user data in its message (`Duplicate entry 'a@b.com' …`) and
123
+ Postgres the index and column names, none of which reaches the client. The full
124
+ driver text still reaches the server log.
125
+
126
+ No action needed. Clients that already handled `409 UNIQUE_VIOLATION` on SQLite
127
+ and Postgres now receive it on MySQL too.
128
+
129
+ - Updated dependencies [3d5c090]
130
+ - Updated dependencies [e5bd768]
131
+ - Updated dependencies [e027b3e]
132
+ - Updated dependencies [c2429b0]
133
+ - Updated dependencies [445a0c2]
134
+ - Updated dependencies [f6609e6]
135
+ - Updated dependencies [a70358a]
136
+ - Updated dependencies [97e7e3c]
137
+ - Updated dependencies [8828b9e]
138
+ - Updated dependencies [53068c1]
139
+ - Updated dependencies [ee58392]
140
+ - Updated dependencies [f16e54e]
141
+ - Updated dependencies [06be54e]
142
+ - Updated dependencies [259459d]
143
+ - Updated dependencies [3f7f14e]
144
+ - Updated dependencies [6968885]
145
+ - Updated dependencies [eaed61f]
146
+ - Updated dependencies [debe2f6]
147
+ - Updated dependencies [97b0798]
148
+ - Updated dependencies [43a7a8d]
149
+ - Updated dependencies [73f69dc]
150
+ - Updated dependencies [04c56aa]
151
+ - Updated dependencies [b3efeb7]
152
+ - Updated dependencies [ddd075a]
153
+ - Updated dependencies [88154be]
154
+ - Updated dependencies [e8dc61e]
155
+ - Updated dependencies [2f3e793]
156
+ - Updated dependencies [d8e8d9c]
157
+ - Updated dependencies [94e749b]
158
+ - Updated dependencies [ea1d916]
159
+ - Updated dependencies [ae31a19]
160
+ - Updated dependencies [e0f300b]
161
+ - Updated dependencies [62b6a2f]
162
+ - Updated dependencies [5b4780b]
163
+ - Updated dependencies [a933452]
164
+ - Updated dependencies [8140915]
165
+ - Updated dependencies [7b48cf9]
166
+ - Updated dependencies [b5404f4]
167
+ - Updated dependencies [f764691]
168
+ - Updated dependencies [e120a5a]
169
+ - Updated dependencies [e650d67]
170
+ - Updated dependencies [04476e7]
171
+ - Updated dependencies [79228cd]
172
+ - Updated dependencies [b3363e9]
173
+ - Updated dependencies [2ef1807]
174
+ - Updated dependencies [d03fe25]
175
+ - Updated dependencies [2672f85]
176
+ - Updated dependencies [11066f6]
177
+ - Updated dependencies [916af17]
178
+ - Updated dependencies [84c86fb]
179
+ - Updated dependencies [2a2a9fb]
180
+ - Updated dependencies [a2e157c]
181
+ - Updated dependencies [95c4227]
182
+ - Updated dependencies [2a61116]
183
+ - Updated dependencies [d4df105]
184
+ - Updated dependencies [e2798fa]
185
+ - Updated dependencies [0fd8556]
186
+ - Updated dependencies [74155c7]
187
+ - Updated dependencies [6908830]
188
+ - Updated dependencies [8b06bba]
189
+ - Updated dependencies [4c54037]
190
+ - Updated dependencies [0f7157b]
191
+ - Updated dependencies [d9bef45]
192
+ - Updated dependencies [f549a0d]
193
+ - Updated dependencies [82da264]
194
+ - Updated dependencies [9b9b70f]
195
+ - Updated dependencies [f5a9bc2]
196
+ - Updated dependencies [881a3cc]
197
+ - Updated dependencies [ad6317b]
198
+ - Updated dependencies [8a88885]
199
+ - Updated dependencies [5f7669e]
200
+ - Updated dependencies [becbe53]
201
+ - Updated dependencies [b127c8b]
202
+ - Updated dependencies [a80302a]
203
+ - Updated dependencies [474f131]
204
+ - Updated dependencies [050cd82]
205
+ - Updated dependencies [4d552af]
206
+ - Updated dependencies [44d677c]
207
+ - Updated dependencies [c32944d]
208
+ - Updated dependencies [1dd780f]
209
+ - Updated dependencies [c8d6f6e]
210
+ - Updated dependencies [92a67f2]
211
+ - Updated dependencies [9136327]
212
+ - Updated dependencies [bf0ae99]
213
+ - Updated dependencies [cb3b6cd]
214
+ - Updated dependencies [73b7234]
215
+ - Updated dependencies [d2b97c3]
216
+ - Updated dependencies [59b794f]
217
+ - Updated dependencies [fc3a36a]
218
+ - Updated dependencies [69787f0]
219
+ - Updated dependencies [5d022a1]
220
+ - Updated dependencies [042b9ee]
221
+ - Updated dependencies [f549a0d]
222
+ - Updated dependencies [a36db28]
223
+ - Updated dependencies [3f8817a]
224
+ - Updated dependencies [a2443e3]
225
+ - Updated dependencies [e1554b1]
226
+ - Updated dependencies [4856789]
227
+ - Updated dependencies [c3f4916]
228
+ - Updated dependencies [33e0385]
229
+ - Updated dependencies [2205363]
230
+ - Updated dependencies [09fe58d]
231
+ - Updated dependencies [d0a5ceb]
232
+ - Updated dependencies [e18a162]
233
+ - Updated dependencies [d127ff0]
234
+ - Updated dependencies [9b86cf6]
235
+ - Updated dependencies [8825a06]
236
+ - Updated dependencies [5087ac6]
237
+ - Updated dependencies [2d1ddf0]
238
+ - Updated dependencies [354b00f]
239
+ - Updated dependencies [3de535b]
240
+ - Updated dependencies [fe2e15a]
241
+ - Updated dependencies [c6b6bb4]
242
+ - Updated dependencies [2f59da0]
243
+ - Updated dependencies [8ad609c]
244
+ - Updated dependencies [bbee302]
245
+ - Updated dependencies [08863dd]
246
+ - Updated dependencies [56664f5]
247
+ - Updated dependencies [31cbe90]
248
+ - Updated dependencies [90bbf25]
249
+ - Updated dependencies [eb91eba]
250
+ - Updated dependencies [42da73d]
251
+ - Updated dependencies [643b7c7]
252
+ - Updated dependencies [1a15893]
253
+ - Updated dependencies [b70e534]
254
+ - Updated dependencies [2233a85]
255
+ - Updated dependencies [62dd69a]
256
+ - Updated dependencies [e15e679]
257
+ - Updated dependencies [2ab1257]
258
+ - Updated dependencies [4cc4fb7]
259
+ - Updated dependencies [2c26040]
260
+ - Updated dependencies [f758cec]
261
+ - Updated dependencies [78f0be8]
262
+ - Updated dependencies [35f7fb4]
263
+ - Updated dependencies [a5302c7]
264
+ - Updated dependencies [7084313]
265
+ - Updated dependencies [0e043d8]
266
+ - Updated dependencies [dadd1ad]
267
+ - Updated dependencies [2f2e63c]
268
+ - Updated dependencies [486d526]
269
+ - Updated dependencies [89d7b35]
270
+ - Updated dependencies [85ec26d]
271
+ - Updated dependencies [f6476fc]
272
+ - Updated dependencies [4ac12ef]
273
+ - Updated dependencies [b88f5e8]
274
+ - Updated dependencies [42cc219]
275
+ - Updated dependencies [d7e0b42]
276
+ - Updated dependencies [3510e4a]
277
+ - Updated dependencies [aa4b90d]
278
+ - Updated dependencies [54299ca]
279
+ - Updated dependencies [dc61def]
280
+ - Updated dependencies [251e888]
281
+ - Updated dependencies [183b4c4]
282
+ - Updated dependencies [2fdb36e]
283
+ - Updated dependencies [20526f5]
284
+ - Updated dependencies [c5eef1d]
285
+ - Updated dependencies [e0f300b]
286
+ - Updated dependencies [761a0ba]
287
+ - Updated dependencies [be87153]
288
+ - Updated dependencies [60f0dd8]
289
+ - Updated dependencies [a87c5cd]
290
+ - Updated dependencies [a47f338]
291
+ - Updated dependencies [2598216]
292
+ - Updated dependencies [2c7e62d]
293
+ - Updated dependencies [eb7613c]
294
+ - Updated dependencies [ecc9110]
295
+ - Updated dependencies [f7bd4e2]
296
+ - Updated dependencies [361bd5b]
297
+ - Updated dependencies [1818998]
298
+ - Updated dependencies [09ee21c]
299
+ - Updated dependencies [f549a0d]
300
+ - Updated dependencies [3fc2e48]
301
+ - Updated dependencies [e8f435c]
302
+ - Updated dependencies [41610f6]
303
+ - @objectstack/spec@17.0.0-rc.6
304
+
3
305
  ## 17.0.0-rc.5
4
306
 
5
307
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -680,6 +680,197 @@ declare function sendOk(res: EnvelopeResponse, data: unknown, status?: number):
680
680
  */
681
681
  declare function sendError(res: EnvelopeResponse, status: number, code: ErrorCode, message: string, extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>): void;
682
682
 
683
+ /**
684
+ * The one home for Postgres' `«sub-object» "x" of relation "y" …` phrasing
685
+ * (#6615).
686
+ *
687
+ * ## The superstring hole, stated once
688
+ *
689
+ * Postgres phrases a failure about something *inside* a relation by naming the
690
+ * relation too:
691
+ *
692
+ * ```
693
+ * column "label" of relation "sys_team" does not exist (42703)
694
+ * constraint "uq_sys_team_name" of relation "sys_team" does not exist (42704)
695
+ * column "environment_id" of relation "sys_metadata" already exists (42701)
696
+ * ```
697
+ *
698
+ * Every one of those **contains a complete, legal missing-TABLE phrase** —
699
+ * `relation "sys_team" does not exist` — as a substring, while meaning the
700
+ * opposite: the relation is right there, which is precisely why it could be
701
+ * named. No amount of tightening a "does this say a relation is missing?"
702
+ * regex can remove that match, because the phrase really is in there. The only
703
+ * repair is to ask the more specific question FIRST. That makes the ORDER the
704
+ * fix, not the pattern — and it is why three packages each grew their own copy
705
+ * of this phrase (#5352, #6035/PR #6346, #6347/PR #6613) before it was given a
706
+ * home.
707
+ *
708
+ * ## Two widths, on purpose — never collapse them
709
+ *
710
+ * The three consumers do not want the same regex, and the difference is not
711
+ * sloppiness: it is **which direction of error is safe** at each site.
712
+ *
713
+ * | consumer | asks | uses | a MISS costs |
714
+ * |:---|:---|:---|:---|
715
+ * | `@objectstack/rest` `mapDataError` (#5352) | which column? | {@link matchMissingColumnOfRelation} | a vaguer message (`404` instead of `400 INVALID_FIELD`) |
716
+ * | `@objectstack/service-analytics` `isMissingSourceError` / `missingSourceRelation` (#6035) | is this a missing COLUMN, so keep it hard? | {@link matchMissingColumnOfRelation} | a mistyped column degrades to a confident empty chart |
717
+ * | `@objectstack/metadata` `MISSING_TABLE.excludes` (#6347) | is this about a sub-object, so not a missing table? | {@link isRelationSubObjectPhrase} | a corruption verdict returns (`event_seq` restarts at 1) |
718
+ *
719
+ * The first two **extract**, so they must be strict: over-matching there would
720
+ * turn a genuinely missing table into a hard failure and regress #5033's
721
+ * deliberate leniency, while under-matching merely keeps today's verdict. The
722
+ * third **excludes**, so it is deliberately wider — any sub-object, any quoted
723
+ * identifier, any verdict — because over-matching there only ever converts a
724
+ * benign verdict into a loud one, and a miss restores data corruption.
725
+ *
726
+ * Collapsing the two into one regex would therefore be wrong for one caller
727
+ * whichever width won. They are two exports for that reason, and the reason is
728
+ * load-bearing rather than stylistic.
729
+ *
730
+ * ## Home
731
+ *
732
+ * `@objectstack/types`, following `isUniqueViolationError`'s move
733
+ * (#6250 — four hand-written answers to one question) and
734
+ * `isModuleNotFoundError`'s (framework#3265 — "single shared owner … so the
735
+ * parallel loaders cannot drift apart"). This module deliberately imports
736
+ * nothing.
737
+ *
738
+ * ⚠️ Unlike #6250, adopting this **does** add one dependency edge:
739
+ * `@objectstack/service-analytics` did not depend on `@objectstack/types`
740
+ * before #6615. It is acyclic by construction — `@objectstack/types` depends
741
+ * only on `@objectstack/spec`, which depends on nothing in-repo, so no package
742
+ * except `spec` itself can form a cycle by consuming it — and 25 of the repo's
743
+ * 73 packages (5 of 16 services) already carry the same edge. Recorded here
744
+ * rather than left for a reader to rediscover.
745
+ */
746
+ /**
747
+ * Postgres' missing-COLUMN template, strictly. Returns the column name, or
748
+ * `undefined` when the message is not that phrase.
749
+ *
750
+ * Anchored to `column "%s" of relation "%s" does not exist` — the exact errmsg
751
+ * template Postgres emits for SQLSTATE 42703 on the write path
752
+ * (`INSERT` / `UPDATE` / `ALTER`). Both quotes are required because Postgres
753
+ * always emits them here, and requiring them is the safe direction of error for
754
+ * the two consumers that call this.
755
+ *
756
+ * Deliberately narrow in two further ways, both preserved verbatim from the
757
+ * open-coded copies this replaces:
758
+ *
759
+ * - the identifier is `[a-z0-9_]+` (case-insensitive), so a quoted identifier
760
+ * carrying a space or punctuation is NOT matched. Postgres can quote such
761
+ * names; the consumers accept the miss because a miss is the cheap direction.
762
+ * - the relation is `\S+` — quoted or bare, unparsed. This function answers
763
+ * "which COLUMN", never "which relation".
764
+ *
765
+ * The read-path phrasing `column "bogus" does not exist` is a different
766
+ * sentence with no relation in it, so it does not match — and it does not need
767
+ * to: it carries no missing-table substring, which is the whole hole this
768
+ * module exists for.
769
+ */
770
+ declare function matchMissingColumnOfRelation(message: string): string | undefined;
771
+ /**
772
+ * The same quirk, **wider**: does this message talk about any sub-object of a
773
+ * relation, in any verdict?
774
+ *
775
+ * Drops all three of {@link matchMissingColumnOfRelation}'s anchors — the
776
+ * literal `column`, the `[a-z0-9_]+` identifier shape, and the trailing
777
+ * `does not exist` — so it also recognises `constraint "uq_x" of relation "y"
778
+ * does not exist` (42704), `column "x" of relation "y" already exists` (42701),
779
+ * and every other sub-object Postgres phrases this way.
780
+ *
781
+ * For **exclusion** callers only. A `true` here means "the relation is present,
782
+ * so whatever else this error is, it is not a missing table"; it does not mean
783
+ * the error is benign and it names nothing. Using it to extract would be a
784
+ * category error — there is no capture group precisely so that it cannot be.
785
+ */
786
+ declare function isRelationSubObjectPhrase(message: string): boolean;
787
+
788
+ /**
789
+ * Whether a thrown driver error is a unique/primary-key constraint violation.
790
+ *
791
+ * Reads all three channels in turn — `code`, `errno`, `message` — then one step
792
+ * down the `cause` chain, because pool and query-builder layers re-throw with
793
+ * the original attached. A plain string is judged on the message channel, so a
794
+ * caller that has already unwrapped `err.message` can pass it straight in.
795
+ *
796
+ * **Unrecognised is always `false`.** The default has to be "not a conflict":
797
+ * a false positive relabels an unrelated failure as the client's fault (a 409
798
+ * an SDK will not retry, pointing at a value that is fine), while a false
799
+ * negative costs only the generic envelope that was the status quo.
800
+ *
801
+ * @param error - the thrown value, of any shape.
802
+ *
803
+ * @example
804
+ * ```ts
805
+ * catch (error) {
806
+ * if (isUniqueViolationError(error)) return conflict(); // 409 UNIQUE_VIOLATION
807
+ * throw error;
808
+ * }
809
+ * ```
810
+ */
811
+ declare function isUniqueViolationError(error: unknown): boolean;
812
+ /**
813
+ * Which column a unique-constraint violation was raised on — or `undefined`
814
+ * when the dialect did not determinably name one (#6544).
815
+ *
816
+ * ## The contract, and why it is this narrow
817
+ *
818
+ * **A value comes back only when the identifier the driver printed is
819
+ * determinably a COLUMN.** When a dialect names an *index* instead — MySQL's
820
+ * `Duplicate entry 'a@b.com' for key 'idx_email_unique'`, Postgres'
821
+ * `violates unique constraint "sys_user_email_key"`, SQLite's
822
+ * `UNIQUE constraint failed: index 'idx_lower_email'` — the answer is
823
+ * `undefined`, never the index name.
824
+ *
825
+ * That is the maintainer's 2026-08-08 ruling on #6544, and the reasoning is the
826
+ * caller's, not this module's: **an index name mistaken for a column is worse
827
+ * than no answer at all.**
828
+ *
829
+ * - `@objectstack/rest`'s import runner renders this into a form field —
830
+ * "A record with this `email` already exists." An index name there points
831
+ * the user at a field that does not exist on the object, so they cannot act
832
+ * on it; `undefined` degrades to generic copy, which is merely less helpful.
833
+ * - #5495's autonumber-retry branch asks a yes/no question of the answer —
834
+ * "is the conflicting column the autonumber field?" — and an index name
835
+ * produces a *wrong retry decision*, not a vaguer one.
836
+ *
837
+ * ⛔ **The accepted cost: MySQL deployments usually get no column.** MySQL's
838
+ * duplicate-entry message names the index and never the column, so there is
839
+ * nothing here to read. That is deliberate. Do not "improve" this by deriving a
840
+ * column from an index name (`idx_email_unique` → `email`, or MySQL 8's
841
+ * `for key 'sys_user.email'` → `email`): index names are free-form, a
842
+ * deployment's may match no column at all, and a plausible-looking wrong field
843
+ * is exactly the failure this export exists to avoid. If MySQL must name
844
+ * columns, the answer is a schema lookup of the index — a different, wider
845
+ * contract — not a guess in this function.
846
+ *
847
+ * A **composite** key is `undefined` for the same reason: `Key (tenant_id,
848
+ * email)=(…)` has no single offending column, and naming the first is the same
849
+ * class of wrong answer.
850
+ *
851
+ * ## What it reads
852
+ *
853
+ * Gated on {@link isUniqueViolationError}, so a NOT NULL or FOREIGN KEY failure
854
+ * can never reach the extraction — SQLite's `NOT NULL constraint failed: t.c`
855
+ * shares its shape with the positive and is refused at the gate, not by the
856
+ * patterns. Then `message`, then `detail` (node-postgres keeps its `DETAIL:`
857
+ * line there), then one step down the `cause` chain, bounded exactly as the
858
+ * predicate's walk is. A bare string is read as a message, so a caller holding
859
+ * only `err.message` can pass it straight in.
860
+ *
861
+ * @param error - the thrown value, of any shape.
862
+ * @returns the conflicting column, or `undefined` when none is determinable.
863
+ *
864
+ * @example
865
+ * ```ts
866
+ * const column = uniqueViolationColumn(error);
867
+ * return column
868
+ * ? `A record with this ${column} already exists.`
869
+ * : 'A record with this value already exists.';
870
+ * ```
871
+ */
872
+ declare function uniqueViolationColumn(error: unknown): string | undefined;
873
+
683
874
  /**
684
875
  * [ADR-0120 D5e] The `isolated`-posture install gate for `'global'` uniqueness.
685
876
  *
@@ -904,4 +1095,4 @@ interface RuntimePlugin {
904
1095
  onStart?: (ctx: RuntimeContext) => void | Promise<void>;
905
1096
  }
906
1097
 
907
- export { type EnvelopeResponse, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueAttestation, type GlobalUniqueFinding, type IKernel, INTERNAL_ERROR_MESSAGE, type KeysetPageQuery, type KeysetWalk, type KeysetWalkOptions, type RuntimeContext, type RuntimePlugin, _resetEnvDeprecationWarnings, buildGlobalUniqueStopMessage, collectConfiguredLocales, collectGlobalUniques, declaredIndexUniqueIsGlobal, declaresServerFault, describeGlobalUniqueFinding, emitDegradedBootBanner, fieldUniqueIsGlobal, globalUniqueFindingId, isMcpServerEnabled, isModuleNotFoundError, isPlatformOwnedObject, keysetWalk, looksLikeInternalErrorLeak, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, sendError, sendOk, stampSearchPinyinEnabled, unconfirmedGlobalUniques };
1098
+ export { type EnvelopeResponse, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueAttestation, type GlobalUniqueFinding, type IKernel, INTERNAL_ERROR_MESSAGE, type KeysetPageQuery, type KeysetWalk, type KeysetWalkOptions, type RuntimeContext, type RuntimePlugin, _resetEnvDeprecationWarnings, buildGlobalUniqueStopMessage, collectConfiguredLocales, collectGlobalUniques, declaredIndexUniqueIsGlobal, declaresServerFault, describeGlobalUniqueFinding, emitDegradedBootBanner, fieldUniqueIsGlobal, globalUniqueFindingId, isMcpServerEnabled, isModuleNotFoundError, isPlatformOwnedObject, isRelationSubObjectPhrase, isUniqueViolationError, keysetWalk, looksLikeInternalErrorLeak, matchMissingColumnOfRelation, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, sendError, sendOk, stampSearchPinyinEnabled, unconfirmedGlobalUniques, uniqueViolationColumn };