@dudousxd/nestjs-catalog 0.14.0 → 0.15.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.
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ /**
3
+ * How a name becomes a column, and what it has to look like by the end.
4
+ *
5
+ * Two rules, and they are here together because neither is usable without the
6
+ * other. {@link physicalColumn} is the *cleaning* — the lossy map from a
7
+ * property's name to the column a store creates for it. {@link isSafeIdentifier}
8
+ * is the *character set* the result of that cleaning has to be in. What a
9
+ * publisher is actually promised is the composition: a name may be spelled
10
+ * however the source spells it, and what the cleaning produces has to be
11
+ * something a store can write.
12
+ *
13
+ * Identifiers themselves are *rejected*, never escaped. Every table and column
14
+ * name a store emits arrives from another application over HTTP and ends up in
15
+ * DDL and in SELECT lists, where no placeholder can stand in for it, so anything
16
+ * outside this character set never becomes SQL at all.
17
+ *
18
+ * It is part of what the catalog promises a *publisher*. Refuse a property name
19
+ * and the sentence explaining why is the only statement of the rule most people
20
+ * will ever read, so it belongs to the contract rather than to whichever adapter
21
+ * happens to be mounted.
22
+ *
23
+ * And for one more reason. It used to be two copies — `store-mikro-orm` and
24
+ * `store-clickhouse` each carried this pattern and this sentence, byte for byte
25
+ * — and the publish-time refusal in the pipeline package borrowed the MySQL one
26
+ * so that publish-time and DDL-time could not disagree about the character set,
27
+ * the length or the wording. That bought the guarantee for a MySQL deployment
28
+ * and left a ClickHouse-only one trusting two files to be edited together. One
29
+ * definition is the guarantee; two identical ones are a habit.
30
+ *
31
+ * ---
32
+ *
33
+ * **Why this is its own module, and why it imports nothing.**
34
+ *
35
+ * All of this used to live in `catalog.store.ts`, which is still where every
36
+ * server-side caller reaches it from — that file re-exports all five names, so no
37
+ * import anywhere had to change. What could not stay there is the
38
+ * *reachability*: `catalog.store.ts` imports `BadRequestException` from
39
+ * `@nestjs/common` at module scope, so anything importing a **value** out of it
40
+ * drags NestJS along. That is fine on the server and disqualifying for
41
+ * `/client`, which exists precisely so a browser can share the server's rules
42
+ * without shipping the server.
43
+ *
44
+ * And a browser now has to be able to ask this question. A workflow template
45
+ * that proposes replicating a table has to know, while somebody is still
46
+ * choosing, whether the source's column names could be published as property
47
+ * names — because if they could not, the graph it would draw is refused at
48
+ * publish, or worse, gets "fixed" by a rename that commits nulls and reports
49
+ * success. Answering that from a copy of the pattern is the one thing this
50
+ * module's own history says not to do: the copy is what drifts, and a canvas
51
+ * whose idea of a legal name differs from the store's by one character is a
52
+ * canvas that promises a load the publisher then refuses.
53
+ *
54
+ * That is why {@link physicalColumn} had to come along rather than only
55
+ * {@link isSafeIdentifier}. The question a publisher is refused on is
56
+ * `isSafeIdentifier(physicalColumn(name))`, not `isSafeIdentifier(name)`, and a
57
+ * browser holding only half the composition would answer a different question
58
+ * from the server's — which is the same drift by another route.
59
+ *
60
+ * So the rule moved to a file with no imports, and is exported from both entry
61
+ * points. `validateWorkflow` set the precedent and made the same argument.
62
+ */
63
+ Object.defineProperty(exports, "__esModule", { value: true });
64
+ exports.UnsafeIdentifierError = void 0;
65
+ exports.isSafeIdentifier = isSafeIdentifier;
66
+ exports.assertSafeIdentifier = assertSafeIdentifier;
67
+ exports.physicalColumn = physicalColumn;
68
+ exports.outputAlias = outputAlias;
69
+ /**
70
+ * 63 characters because it is under MySQL's 64-character ceiling and no engine
71
+ * a store here targets refuses a name that short, and because the number is
72
+ * quoted in the refusal below: a per-store limit would mean a publisher being
73
+ * told a different rule depending on what is mounted, for a name the catalog
74
+ * would then be unable to promise anything about across a fan-out.
75
+ *
76
+ * Not exported. A `RegExp` is mutable and shared state, and the two questions
77
+ * anyone has of it — "may I?" and "why not?" — are {@link isSafeIdentifier} and
78
+ * {@link UnsafeIdentifierError}.
79
+ */
80
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
81
+ /**
82
+ * Why a name cannot be written into SQL, in the words a publisher is given.
83
+ *
84
+ * One class for the whole ecosystem rather than one per adapter, so
85
+ * `instanceof` is a usable question across packages. The publish-time check in
86
+ * the pipeline package catches this to tell "that name cannot be an identifier"
87
+ * from "something else failed inside the store", and with a class per adapter
88
+ * that check would re-throw the moment the mounted store was not the one it
89
+ * imported — turning a 400 that names the property into a 500 that names
90
+ * nothing.
91
+ */
92
+ class UnsafeIdentifierError extends Error {
93
+ constructor(value) {
94
+ super(`Refusing to use "${value}" as a SQL identifier: letters, digits and underscore only, starting with a letter or underscore, 63 characters max.`);
95
+ }
96
+ }
97
+ exports.UnsafeIdentifierError = UnsafeIdentifierError;
98
+ /** Whether a name can be written into SQL as it stands. */
99
+ function isSafeIdentifier(value) {
100
+ return SAFE_IDENTIFIER.test(value);
101
+ }
102
+ /**
103
+ * Refuse a name that cannot be a SQL identifier.
104
+ *
105
+ * Throws rather than answering, because the caller's next line writes the value
106
+ * into a statement: a boolean that can be ignored is a boolean that eventually
107
+ * is. {@link isSafeIdentifier} is there for the callers that are asking rather
108
+ * than about to build.
109
+ */
110
+ function assertSafeIdentifier(value) {
111
+ if (!isSafeIdentifier(value))
112
+ throw new UnsafeIdentifierError(value);
113
+ }
114
+ /**
115
+ * A property's name, cleaned into the column a store can create for it.
116
+ *
117
+ * Here rather than in each adapter for the reason {@link assertSafeIdentifier}
118
+ * is: this is no longer only an adapter's private repair. It decides the column
119
+ * a load's values are written to, the column a filter is applied to, the name a
120
+ * committed view exposes the field under, and — since it does all of that — it
121
+ * decides whether a published name can work at all. The pipeline package refuses
122
+ * a name at publish time by asking whether *this* produces an identifier, so the
123
+ * refusal and the DDL have to be running the same cleaning rather than two
124
+ * copies of it. `store-mikro-orm` and `store-clickhouse` each carried a
125
+ * byte-identical private copy, and `store-mikro-orm` carried two of its own —
126
+ * one in `query.ts` for the view, one in `mysql-warehouse.store.ts` for
127
+ * everything else. Three copies of the function that decides where a column's
128
+ * data lives is three chances for a view to point at a column no load ever
129
+ * wrote.
130
+ *
131
+ * Lossy on purpose, and lossy in a way callers must handle rather than assume
132
+ * away: `Asset Id` and `Asset/Id` both clean to `Asset_Id`, which is what
133
+ * `assertNoColumnCollisions` exists to catch.
134
+ *
135
+ * 60 characters, not the 63 the identifier rule allows, and the three characters
136
+ * of headroom are not decorative — a store that needs to derive a second name
137
+ * from a column has room inside MySQL's 64-character ceiling to do it. Widening
138
+ * this would silently rename the column of every property whose name is 61 to 63
139
+ * characters long, so it stays where it is.
140
+ *
141
+ * Not every output is an identifier: `1 2 3` cleans to `1_2_3`, which no store
142
+ * will quote. That is not this function's business to fix — a suggestion is
143
+ * `toPhysicalName` in an adapter, and a refusal is `assertSafeIdentifier` on the
144
+ * result.
145
+ */
146
+ function physicalColumn(propertyName) {
147
+ return propertyName
148
+ .replace(/[^A-Za-z0-9_]/g, '_')
149
+ .replace(/_+/g, '_')
150
+ .slice(0, 60);
151
+ }
152
+ /**
153
+ * The column name a store exposes a property under, in the committed view and
154
+ * in the SELECT list of a read.
155
+ *
156
+ * **Why this is not simply the property's name.** It used to be. Every store
157
+ * wrote `\`Asset_Id\` AS \`Asset Id\`` — the physical column reached by cleaning,
158
+ * the alias taken verbatim — and the alias went through `ident`, which refuses
159
+ * rather than escapes. So a property could only be named something that was
160
+ * already a SQL identifier, which meant a source column genuinely called `Asset
161
+ * Id` could not be published under its own spelling.
162
+ *
163
+ * That mattered far more than it looks. A load matches a source's record to a
164
+ * property by property NAME — the store reads `row[property.name]` — so a
165
+ * publisher forced to rename the property to `Asset_Id`, keeping `Asset Id` in
166
+ * `columnName`, was publishing a type whose every read of that field returned
167
+ * `undefined`. `columnName` is lineage; nothing consults it on the write path.
168
+ * The loads committed, the row counts were right, and the column was NULL in
169
+ * every row. Thirteen types were loaded that way and six of them came out with
170
+ * most of their columns empty — 73 of 84 on the largest, across 313,833 rows.
171
+ * The verbatim alias is what forced the rename, so the alias is what changed.
172
+ *
173
+ * **Why the name is still kept when it is already an identifier.** The obvious
174
+ * fix — always alias to {@link physicalColumn} — would rename the output column
175
+ * of every existing view whose property name is not equal to its own cleaned
176
+ * form: a property called `Asset__Id` (two underscores collapse to one) or one
177
+ * 61 characters long (cut to 60). Those views work today and somebody is
178
+ * selecting from them by name. Renaming a column under a working consumer to
179
+ * tidy up an inconsistency is not a trade worth making, so the rule is the
180
+ * narrower one: **a name that SQL can take as it stands is kept exactly; only a
181
+ * name SQL cannot take is cleaned.** Every view that resolves today keeps every
182
+ * column name it has today.
183
+ *
184
+ * **This introduces no new way for two properties to collide.** If two distinct
185
+ * names produce one alias then they also produce one {@link physicalColumn}, so
186
+ * `assertNoColumnCollisions` already refuses the pair. Both unsafe: equal
187
+ * aliases *are* equal physical columns. Both safe: equal aliases are equal
188
+ * names, and there is only one property per name. One of each — a safe `x` and
189
+ * an unsafe `y` with `physicalColumn(y) === x` — means `x` contains no run of
190
+ * underscores and is at most 60 characters, so `physicalColumn(x) === x ===
191
+ * physicalColumn(y)` and the columns collide too.
192
+ */
193
+ function outputAlias(propertyName) {
194
+ return isSafeIdentifier(propertyName) ? propertyName : physicalColumn(propertyName);
195
+ }