@dudousxd/nestjs-catalog 0.8.0 → 0.10.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/catalog.controller.js +39 -11
- package/dist/catalog.events.d.ts +143 -1
- package/dist/catalog.events.js +46 -1
- package/dist/catalog.overlay-store.d.ts +51 -1
- package/dist/catalog.overlay-store.js +92 -8
- package/dist/catalog.pipeline.d.ts +22 -1
- package/dist/catalog.registry.base.d.ts +35 -4
- package/dist/catalog.registry.d.ts +20 -3
- package/dist/catalog.registry.js +67 -3
- package/dist/catalog.service.d.ts +20 -5
- package/dist/catalog.service.js +23 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -2
- package/dist/transform-runner.js +223 -10
- package/package.json +1 -1
|
@@ -148,24 +148,44 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
148
148
|
/**
|
|
149
149
|
* Tier 0. Renames a type, regroups it, changes its icon. No migration, no
|
|
150
150
|
* deploy, no engineer.
|
|
151
|
+
*
|
|
152
|
+
* The principal is passed for the reason the sharing routes below pass one:
|
|
153
|
+
* a curated label outlives the publisher's next deploy, so "who renamed this
|
|
154
|
+
* column" is asked long after any log has rotated. `actorOf` and not
|
|
155
|
+
* `request.principal.id` directly, so an unguarded mount records `console`
|
|
156
|
+
* rather than an empty actor — see the note on it.
|
|
157
|
+
*
|
|
158
|
+
* The body deliberately names no `createdBy`-style override, unlike the
|
|
159
|
+
* saved-query create route. Nothing here stores an author; the only consumer
|
|
160
|
+
* of the name is the audit entry, and a caller that can write any string into
|
|
161
|
+
* the trail's actor column is worse than one recorded as the console.
|
|
151
162
|
*/
|
|
152
|
-
async patchType(name, body) {
|
|
153
|
-
const updated = await this.registry.patchType(name, body);
|
|
163
|
+
async patchType(name, body, request) {
|
|
164
|
+
const updated = await this.registry.patchType(name, body, actorOf(request));
|
|
154
165
|
if (!updated)
|
|
155
166
|
throw new common_1.NotFoundException(`Unknown object type: ${name}`);
|
|
156
167
|
return updated;
|
|
157
168
|
}
|
|
158
169
|
/** Tier 0, one property at a time. */
|
|
159
|
-
async patchProperty(name, property, body) {
|
|
160
|
-
const updated = await this.registry.patchProperty(name, property, body);
|
|
170
|
+
async patchProperty(name, property, body, request) {
|
|
171
|
+
const updated = await this.registry.patchProperty(name, property, body, actorOf(request));
|
|
161
172
|
if (!updated) {
|
|
162
173
|
throw new common_1.NotFoundException(`Unknown property: ${name}.${property}`);
|
|
163
174
|
}
|
|
164
175
|
return updated;
|
|
165
176
|
}
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Drops every tier-0 edit and falls back to what the ORM says.
|
|
179
|
+
*
|
|
180
|
+
* The actor matters most here of the three. This destroys every curated
|
|
181
|
+
* label, unit and **classification** in the catalog in one request, under the
|
|
182
|
+
* same `catalog:curate` a rename needs — and un-classifying a property
|
|
183
|
+
* re-admits its name to searches by principals who could not see it an
|
|
184
|
+
* instant earlier. Nothing versions the overlay, so after this the only
|
|
185
|
+
* record that it happened, and of who did it, is the event.
|
|
186
|
+
*/
|
|
187
|
+
async reset(request) {
|
|
188
|
+
await this.registry.resetOverlay(actorOf(request));
|
|
169
189
|
return this.registry.getSnapshot();
|
|
170
190
|
}
|
|
171
191
|
/** One generic read endpoint for every type in the catalog. */
|
|
@@ -414,8 +434,9 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
414
434
|
(0, catalog_route_auth_1.RequireScopes)('catalog:curate'),
|
|
415
435
|
__param(0, (0, common_1.Param)('name')),
|
|
416
436
|
__param(1, (0, common_1.Body)()),
|
|
437
|
+
__param(2, (0, common_1.Req)()),
|
|
417
438
|
__metadata("design:type", Function),
|
|
418
|
-
__metadata("design:paramtypes", [String, Object]),
|
|
439
|
+
__metadata("design:paramtypes", [String, Object, Object]),
|
|
419
440
|
__metadata("design:returntype", Promise)
|
|
420
441
|
], CatalogController.prototype, "patchType", null);
|
|
421
442
|
__decorate([
|
|
@@ -424,15 +445,17 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
424
445
|
__param(0, (0, common_1.Param)('name')),
|
|
425
446
|
__param(1, (0, common_1.Param)('property')),
|
|
426
447
|
__param(2, (0, common_1.Body)()),
|
|
448
|
+
__param(3, (0, common_1.Req)()),
|
|
427
449
|
__metadata("design:type", Function),
|
|
428
|
-
__metadata("design:paramtypes", [String, String, Object]),
|
|
450
|
+
__metadata("design:paramtypes", [String, String, Object, Object]),
|
|
429
451
|
__metadata("design:returntype", Promise)
|
|
430
452
|
], CatalogController.prototype, "patchProperty", null);
|
|
431
453
|
__decorate([
|
|
432
454
|
(0, common_1.Post)('reset'),
|
|
433
455
|
(0, catalog_route_auth_1.RequireScopes)('catalog:curate'),
|
|
456
|
+
__param(0, (0, common_1.Req)()),
|
|
434
457
|
__metadata("design:type", Function),
|
|
435
|
-
__metadata("design:paramtypes", []),
|
|
458
|
+
__metadata("design:paramtypes", [Object]),
|
|
436
459
|
__metadata("design:returntype", Promise)
|
|
437
460
|
], CatalogController.prototype, "reset", null);
|
|
438
461
|
__decorate([
|
|
@@ -656,7 +679,12 @@ function createCatalogController(path, guards, decorators = []) {
|
|
|
656
679
|
return CatalogController;
|
|
657
680
|
}
|
|
658
681
|
/**
|
|
659
|
-
* Who to record a workspace
|
|
682
|
+
* Who to record a change against — a workspace one, and now a curation one.
|
|
683
|
+
*
|
|
684
|
+
* Shared by both on purpose. The two halves of the trail used to answer the "who"
|
|
685
|
+
* question differently: sharing named its principal and curation named nobody at
|
|
686
|
+
* all, which reads as a bug in whichever half you look at second. One helper is
|
|
687
|
+
* what keeps the two from drifting again, including on the fallback below.
|
|
660
688
|
*
|
|
661
689
|
* The host's resolved principal wins over anything the body claimed, and that
|
|
662
690
|
* order is the whole point: a `createdBy` in a request body is a name the caller
|
package/dist/catalog.events.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const CATALOG_LIB = "catalog";
|
|
16
16
|
/** Every event name this package emits. Exported so a watcher can claim them. */
|
|
17
|
-
export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
|
|
17
|
+
export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "overlay.reset", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
|
|
18
18
|
export type CatalogEvent = (typeof CATALOG_EVENTS)[number];
|
|
19
19
|
/**
|
|
20
20
|
* Where each event sits in the life of one load.
|
|
@@ -44,6 +44,41 @@ export declare const CATALOG_EVENT_PHASE: Record<CatalogEvent, number>;
|
|
|
44
44
|
*/
|
|
45
45
|
export declare const CATALOG_EVENT_PHASE_FALLBACK = 4;
|
|
46
46
|
export declare function catalogEventPhase(event: string): number;
|
|
47
|
+
/**
|
|
48
|
+
* What a curation entry says when nothing told it who.
|
|
49
|
+
*
|
|
50
|
+
* A value rather than an empty string or a missing key, and that difference is
|
|
51
|
+
* the whole reason it exists. The shipped recorder writes
|
|
52
|
+
* `principalId: undefined` for anything falsy, which lands as NULL in the column
|
|
53
|
+
* every governance query filters on — and a NULL there is indistinguishable from
|
|
54
|
+
* the rows written before this library recorded actors at all. "Not captured"
|
|
55
|
+
* and "nobody did this" are different statements, and only the first one is true.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately not the same string as the controller's `console` fallback, which
|
|
58
|
+
* is a narrower and more useful claim: `console` says a request came through
|
|
59
|
+
* this library's own HTTP surface and no guard resolved a principal onto it —
|
|
60
|
+
* the deployment has an unauthenticated mount. This one says the registry API
|
|
61
|
+
* was called in-process and the caller named nobody: a host script, a scheduled
|
|
62
|
+
* job, or a subclass compiled against the signature before it took an actor.
|
|
63
|
+
* Collapsing them would throw away the only clue about where to go looking, in
|
|
64
|
+
* exchange for one fewer constant.
|
|
65
|
+
*/
|
|
66
|
+
export declare const UNATTRIBUTED_PRINCIPAL_ID = "unattributed";
|
|
67
|
+
/**
|
|
68
|
+
* The actor a curation event will carry, given whatever the caller passed.
|
|
69
|
+
*
|
|
70
|
+
* Exported because both registries need it and they ship in different packages —
|
|
71
|
+
* the in-app one here, the stored one in `store-mikro-orm` — so a copy each is a
|
|
72
|
+
* rule that holds in two places right up until it holds in one.
|
|
73
|
+
*
|
|
74
|
+
* Total, and it re-checks a parameter the types already made required. That is
|
|
75
|
+
* not belt-and-braces: `CatalogRegistry` binds TypeScript callers, and the
|
|
76
|
+
* callers whose omission must never reach the trail are precisely the ones it
|
|
77
|
+
* does not bind — a JavaScript host, and a subclass declaring the older
|
|
78
|
+
* argument list, which stays a legal override because TypeScript lets an
|
|
79
|
+
* implementation take fewer parameters than it promised.
|
|
80
|
+
*/
|
|
81
|
+
export declare function curationActor(principalId: string | undefined): string;
|
|
47
82
|
export interface CatalogEventPayloads {
|
|
48
83
|
/** DDL was applied to an object type's physical table. Always additive. */
|
|
49
84
|
'schema.changed': {
|
|
@@ -80,6 +115,113 @@ export interface CatalogEventPayloads {
|
|
|
80
115
|
typeName: string;
|
|
81
116
|
property?: string;
|
|
82
117
|
changed: string[];
|
|
118
|
+
/**
|
|
119
|
+
* Who renamed it — the half of that sentence this payload used to leave out.
|
|
120
|
+
*
|
|
121
|
+
* It carried `typeName`, `property` and `changed`, which answers "what" and
|
|
122
|
+
* (with the row's timestamp) "when", and never "who" — while `query.shared`
|
|
123
|
+
* two screens away named its actor from the day it was added. An audit trail
|
|
124
|
+
* that is inconsistent about attribution reads as broken in whichever half
|
|
125
|
+
* you look at second, and curation is the side where it matters more:
|
|
126
|
+
* a curated label is the one decision this library describes as surviving
|
|
127
|
+
* the publisher's next deploy.
|
|
128
|
+
*
|
|
129
|
+
* **`principalId`, not `curatedBy`,** because the spelling is a contract with
|
|
130
|
+
* a recorder this package cannot import. `CatalogAuditRecorder` lifts exactly
|
|
131
|
+
* this key into the audit table's indexed column; a payload that names it
|
|
132
|
+
* anything else still carries the actor, in a JSON blob no query anybody runs
|
|
133
|
+
* will look inside, and the entry lands attributed to nobody while looking
|
|
134
|
+
* complete.
|
|
135
|
+
*
|
|
136
|
+
* **The whole `CatalogPrincipal.id`, composite half included.** The same
|
|
137
|
+
* choice `query.shared` made, for the reason `catalog.principal.ts` argues at
|
|
138
|
+
* length: `parsePrincipalId` recovers the application from an
|
|
139
|
+
* `<app>#<person>` id, so carrying the person costs the machine-level
|
|
140
|
+
* question nothing — while dropping to `applicationId` would file a curator's
|
|
141
|
+
* decision under the console they happened to sign into, and "the console
|
|
142
|
+
* renamed this column" is the answer that file says nobody accepts.
|
|
143
|
+
*
|
|
144
|
+
* **Required, and never the empty string.** The recorder treats a falsy value
|
|
145
|
+
* as absent and writes NULL, which reads as "nobody did this" rather than
|
|
146
|
+
* "this was not captured". A producer holding no principal emits
|
|
147
|
+
* {@link UNATTRIBUTED_PRINCIPAL_ID} instead, which is a statement.
|
|
148
|
+
*/
|
|
149
|
+
principalId: string;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* The whole overlay was discarded — every curated label, description, unit,
|
|
153
|
+
* order, hidden flag and classification in the catalog, in one request.
|
|
154
|
+
*
|
|
155
|
+
* An event of its own rather than a `type.curated` with the name left off.
|
|
156
|
+
* That payload leads with `typeName`, and a recorder lifts it into an indexed
|
|
157
|
+
* column; a reset has no single type, so it would land as a curation edit
|
|
158
|
+
* belonging to no type, indistinguishable from a malformed one. It is also a
|
|
159
|
+
* different act: `type.curated` records a decision about one column, this
|
|
160
|
+
* records the destruction of every such decision. Without it the trail could
|
|
161
|
+
* say who renamed one column and not who reverted every name at once — and
|
|
162
|
+
* both are `catalog:curate`, so the same curator can do either.
|
|
163
|
+
*
|
|
164
|
+
* **The classifications are why this is not merely tidy.** A classification is
|
|
165
|
+
* what `visibleToPrincipal` filters search results on, so a reset silently
|
|
166
|
+
* re-admits every classified property's *name* to searches by principals who
|
|
167
|
+
* could not see it an instant earlier. That is a change in who can see what,
|
|
168
|
+
* made by a route the controller documents as presentation-only.
|
|
169
|
+
*
|
|
170
|
+
* WHAT IT CARRIES, AND WHAT IT DELIBERATELY DOES NOT
|
|
171
|
+
* --------------------------------------------------
|
|
172
|
+
* The overlay is discarded rather than versioned, so nothing can be looked up
|
|
173
|
+
* afterwards: what is not in this payload is nowhere. That argues for carrying
|
|
174
|
+
* all of it, and all of it is the wrong answer — an audit row holding a
|
|
175
|
+
* verbatim copy of the overlay is a backup, and a backup nobody designed: no
|
|
176
|
+
* restore path, no retention policy of its own, and a JSON column that grows
|
|
177
|
+
* with the catalog. It would be read as one, too. The first person who needed
|
|
178
|
+
* it would find it, and the second would rely on it.
|
|
179
|
+
*
|
|
180
|
+
* So: a summary, drawn where the reader's question stops being "what did I
|
|
181
|
+
* lose" and starts being "give it back".
|
|
182
|
+
*
|
|
183
|
+
* - {@link typeNames}, because "somebody reset the catalog" is nearly useless
|
|
184
|
+
* six months later and "was the work on `Dispute` in it" is what is actually
|
|
185
|
+
* asked. Bounded by how many types anyone had curated.
|
|
186
|
+
* - {@link properties}, the scale of it as one number. The property *names* are
|
|
187
|
+
* where a summary would turn into the dump.
|
|
188
|
+
* - {@link classifications} in full, values included, despite that line. They
|
|
189
|
+
* are the one part of the overlay whose loss changes what the catalog shows
|
|
190
|
+
* to whom, they are a small subset of it, and re-typing them is the only
|
|
191
|
+
* recovery anybody can perform.
|
|
192
|
+
*
|
|
193
|
+
* **It carries `principalId`, which it did not at first**, and the reason the
|
|
194
|
+
* gap existed is worth keeping: `resetOverlay()` took no principal, the route
|
|
195
|
+
* resolved none for it, and `RoutingCatalogRegistry` forwards the call by hand,
|
|
196
|
+
* so a field here would have been empty on every row — and an audit table
|
|
197
|
+
* lifts `principalId` into a column where empty reads as "nobody did this"
|
|
198
|
+
* rather than "this was not captured". The answer was to thread the actor
|
|
199
|
+
* through all three rather than to keep documenting its absence, because this
|
|
200
|
+
* is the one act on the catalog that destroys decisions in bulk and needs only
|
|
201
|
+
* `catalog:curate` to do it. See `type.curated` above for what the field holds
|
|
202
|
+
* and why it is spelled that way.
|
|
203
|
+
*
|
|
204
|
+
* Emitted even when the overlay was empty, with zeroes. A trail that recorded
|
|
205
|
+
* only destructive resets cannot tell "nobody pressed it" from "somebody
|
|
206
|
+
* pressed it and nothing was there", and the second is worth seeing.
|
|
207
|
+
*/
|
|
208
|
+
'overlay.reset': {
|
|
209
|
+
/** Who reverted the catalog. See `type.curated`'s `principalId`. */
|
|
210
|
+
principalId: string;
|
|
211
|
+
/** Every type that carried curation, so the trail names what was lost. */
|
|
212
|
+
typeNames: string[];
|
|
213
|
+
/** How many per-property entries went with them, across every type. */
|
|
214
|
+
properties: number;
|
|
215
|
+
/**
|
|
216
|
+
* Every classification that stopped applying, with its value — because that
|
|
217
|
+
* is what somebody restoring one needs, and after the reset there is nowhere
|
|
218
|
+
* left to read it.
|
|
219
|
+
*/
|
|
220
|
+
classifications: Array<{
|
|
221
|
+
typeName: string;
|
|
222
|
+
property: string;
|
|
223
|
+
classification: string;
|
|
224
|
+
}>;
|
|
83
225
|
};
|
|
84
226
|
/** A connector began pulling. */
|
|
85
227
|
'connector.run.started': {
|
package/dist/catalog.events.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CATALOG_EVENTS = exports.CATALOG_LIB = void 0;
|
|
3
|
+
exports.UNATTRIBUTED_PRINCIPAL_ID = exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CATALOG_EVENTS = exports.CATALOG_LIB = void 0;
|
|
4
4
|
exports.catalogEventPhase = catalogEventPhase;
|
|
5
|
+
exports.curationActor = curationActor;
|
|
5
6
|
exports.channelNameFor = channelNameFor;
|
|
6
7
|
exports.emitCatalog = emitCatalog;
|
|
7
8
|
const nestjs_diagnostics_1 = require("@dudousxd/nestjs-diagnostics");
|
|
@@ -27,6 +28,7 @@ exports.CATALOG_EVENTS = [
|
|
|
27
28
|
'snapshot.committed',
|
|
28
29
|
'snapshot.dropped',
|
|
29
30
|
'type.curated',
|
|
31
|
+
'overlay.reset',
|
|
30
32
|
'connector.run.started',
|
|
31
33
|
'connector.run.finished',
|
|
32
34
|
'transform.changed',
|
|
@@ -67,6 +69,11 @@ exports.CATALOG_EVENT_PHASE = {
|
|
|
67
69
|
// be complete is exactly the mechanism that will fail the build the day a new
|
|
68
70
|
// event is added and nobody thinks about where it belongs.
|
|
69
71
|
'type.curated': 2,
|
|
72
|
+
// The other curation event, ranked with the one it undoes. It carries no
|
|
73
|
+
// snapshot id either — a reset is not part of any load — so like the rank
|
|
74
|
+
// above this is never consulted, and it is written out because the `Record`
|
|
75
|
+
// has to be complete.
|
|
76
|
+
'overlay.reset': 2,
|
|
70
77
|
// Sharing carries no snapshot id either, for the same reason curation does
|
|
71
78
|
// not: it is a standalone act on a saved query or a board, not a step of any
|
|
72
79
|
// load. So these ranks are never consulted, and they are written out for the
|
|
@@ -96,6 +103,44 @@ function catalogEventPhase(event) {
|
|
|
96
103
|
const phase = Reflect.get(exports.CATALOG_EVENT_PHASE, event);
|
|
97
104
|
return typeof phase === 'number' ? phase : exports.CATALOG_EVENT_PHASE_FALLBACK;
|
|
98
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* What a curation entry says when nothing told it who.
|
|
108
|
+
*
|
|
109
|
+
* A value rather than an empty string or a missing key, and that difference is
|
|
110
|
+
* the whole reason it exists. The shipped recorder writes
|
|
111
|
+
* `principalId: undefined` for anything falsy, which lands as NULL in the column
|
|
112
|
+
* every governance query filters on — and a NULL there is indistinguishable from
|
|
113
|
+
* the rows written before this library recorded actors at all. "Not captured"
|
|
114
|
+
* and "nobody did this" are different statements, and only the first one is true.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately not the same string as the controller's `console` fallback, which
|
|
117
|
+
* is a narrower and more useful claim: `console` says a request came through
|
|
118
|
+
* this library's own HTTP surface and no guard resolved a principal onto it —
|
|
119
|
+
* the deployment has an unauthenticated mount. This one says the registry API
|
|
120
|
+
* was called in-process and the caller named nobody: a host script, a scheduled
|
|
121
|
+
* job, or a subclass compiled against the signature before it took an actor.
|
|
122
|
+
* Collapsing them would throw away the only clue about where to go looking, in
|
|
123
|
+
* exchange for one fewer constant.
|
|
124
|
+
*/
|
|
125
|
+
exports.UNATTRIBUTED_PRINCIPAL_ID = 'unattributed';
|
|
126
|
+
/**
|
|
127
|
+
* The actor a curation event will carry, given whatever the caller passed.
|
|
128
|
+
*
|
|
129
|
+
* Exported because both registries need it and they ship in different packages —
|
|
130
|
+
* the in-app one here, the stored one in `store-mikro-orm` — so a copy each is a
|
|
131
|
+
* rule that holds in two places right up until it holds in one.
|
|
132
|
+
*
|
|
133
|
+
* Total, and it re-checks a parameter the types already made required. That is
|
|
134
|
+
* not belt-and-braces: `CatalogRegistry` binds TypeScript callers, and the
|
|
135
|
+
* callers whose omission must never reach the trail are precisely the ones it
|
|
136
|
+
* does not bind — a JavaScript host, and a subclass declaring the older
|
|
137
|
+
* argument list, which stays a legal override because TypeScript lets an
|
|
138
|
+
* implementation take fewer parameters than it promised.
|
|
139
|
+
*/
|
|
140
|
+
function curationActor(principalId) {
|
|
141
|
+
const trimmed = typeof principalId === 'string' ? principalId.trim() : '';
|
|
142
|
+
return trimmed.length > 0 ? trimmed : exports.UNATTRIBUTED_PRINCIPAL_ID;
|
|
143
|
+
}
|
|
99
144
|
/**
|
|
100
145
|
* The channel an event is published on.
|
|
101
146
|
*
|
|
@@ -17,7 +17,57 @@ export declare class FileCatalogOverlayStore implements CatalogOverlayStore {
|
|
|
17
17
|
load(): Promise<CatalogOverlay>;
|
|
18
18
|
save(overlay: CatalogOverlay): Promise<void>;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* For tests, and for a single-process deployment content to lose every curated
|
|
22
|
+
* value when it restarts.
|
|
23
|
+
*
|
|
24
|
+
* **Not a read-only mode**, which is what this used to offer itself as. Nothing
|
|
25
|
+
* here refuses anything: `save` takes the overlay and keeps it, `PATCH
|
|
26
|
+
* /catalog/types/:name` answers 200 and emits `type.curated`, and the rename is
|
|
27
|
+
* real right up until the process ends. What it is not is *shared* — the overlay
|
|
28
|
+
* lives in one process's heap, so two replicas behind the same load balancer
|
|
29
|
+
* disagree about what a column is called, and which name a curator sees back
|
|
30
|
+
* depends on which pod took their request. A deployment that chose this store
|
|
31
|
+
* because the docblock promised read-only got precisely the writes it was trying
|
|
32
|
+
* to prevent, and got them inconsistently.
|
|
33
|
+
*
|
|
34
|
+
* There is no mode here to turn on instead, because read-only is not a store's
|
|
35
|
+
* decision. The writes arrive on routes that declare `catalog:curate`, and
|
|
36
|
+
* whether a deployment grants that scope is its guard's business — see the
|
|
37
|
+
* declare-and-enforce split in `catalog.route-auth.ts`. A store that threw would
|
|
38
|
+
* turn a policy answer ("you may not curate here") into a 500 from a route the
|
|
39
|
+
* library documents as working, and would put a second mechanism beside the one
|
|
40
|
+
* that already decides.
|
|
41
|
+
*
|
|
42
|
+
* **Both ends copy, and one end would not have been enough.** The registry holds
|
|
43
|
+
* the overlay it loaded and edits it in place — `this.overlay.types[name] = {
|
|
44
|
+
* ...current, ...patch }` — before calling `save`. Copy only on `load` and the
|
|
45
|
+
* object handed to `save` becomes the store's own, so the next patch is writing
|
|
46
|
+
* into the store again; copy only on `save` and the object handed out by `load`
|
|
47
|
+
* already is the store's own. Either way this store's state moves before
|
|
48
|
+
* anybody asked it to, and "nothing is stored until save" — the one sentence a
|
|
49
|
+
* store is for — is not true of it.
|
|
50
|
+
*
|
|
51
|
+
* That mattered in two directions, neither of them the net behaviour, which was
|
|
52
|
+
* and is identical because every edit is followed by a persist.
|
|
53
|
+
*
|
|
54
|
+
* - **The two bundled stores disagreed.** {@link FileCatalogOverlayStore}
|
|
55
|
+
* round-trips through JSON and so has never aliased anything. Every spec in
|
|
56
|
+
* this repository runs on this one, so a test asserting that an edit had not
|
|
57
|
+
* been written yet passed here and would have failed on the store a
|
|
58
|
+
* deployment actually uses. A vacuous pass is worse than no test: it is a
|
|
59
|
+
* claim with evidence attached to it.
|
|
60
|
+
* - **Two registries over one store shared mutable state.** One would see the
|
|
61
|
+
* other's half-applied edit with no write between them, which is the shape
|
|
62
|
+
* that produces a report nobody can reproduce.
|
|
63
|
+
*
|
|
64
|
+
* **What it costs.** One deep copy per load and per save. The overlay is the
|
|
65
|
+
* names, descriptions and per-property patches a human has typed — not the
|
|
66
|
+
* catalog, which is derived from entity metadata and does not live here — so a
|
|
67
|
+
* heavily curated thousand-type catalog is a few hundred kilobytes and a copy
|
|
68
|
+
* in the low milliseconds. The two paths that pay it are a boot and a curator
|
|
69
|
+
* pressing save. Neither is a read, and nothing on a request path calls either.
|
|
70
|
+
*/
|
|
21
71
|
export declare class InMemoryCatalogOverlayStore implements CatalogOverlayStore {
|
|
22
72
|
private overlay;
|
|
23
73
|
load(): Promise<CatalogOverlay>;
|
|
@@ -13,12 +13,15 @@ class FileCatalogOverlayStore {
|
|
|
13
13
|
try {
|
|
14
14
|
const raw = await (0, promises_1.readFile)(this.path, 'utf8');
|
|
15
15
|
const parsed = JSON.parse(raw);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
// Narrowed by a guard rather than asserted. The file on disk is edited by
|
|
17
|
+
// hand — that is the whole point of a JSON overlay — so its contents are
|
|
18
|
+
// exactly as trustworthy as whoever last opened it, and an assertion here
|
|
19
|
+
// would hand the registry a shape it promised to have rather than one it
|
|
20
|
+
// was checked for. The failure that buys is quiet: `types` arriving as
|
|
21
|
+
// `null` (an object, by `typeof`) or as an array reads as an overlay with
|
|
22
|
+
// no curation, and every label somebody wrote silently stops applying.
|
|
23
|
+
if (isOverlay(parsed))
|
|
20
24
|
return parsed;
|
|
21
|
-
}
|
|
22
25
|
return { types: {} };
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
@@ -31,14 +34,95 @@ class FileCatalogOverlayStore {
|
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
exports.FileCatalogOverlayStore = FileCatalogOverlayStore;
|
|
34
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* For tests, and for a single-process deployment content to lose every curated
|
|
39
|
+
* value when it restarts.
|
|
40
|
+
*
|
|
41
|
+
* **Not a read-only mode**, which is what this used to offer itself as. Nothing
|
|
42
|
+
* here refuses anything: `save` takes the overlay and keeps it, `PATCH
|
|
43
|
+
* /catalog/types/:name` answers 200 and emits `type.curated`, and the rename is
|
|
44
|
+
* real right up until the process ends. What it is not is *shared* — the overlay
|
|
45
|
+
* lives in one process's heap, so two replicas behind the same load balancer
|
|
46
|
+
* disagree about what a column is called, and which name a curator sees back
|
|
47
|
+
* depends on which pod took their request. A deployment that chose this store
|
|
48
|
+
* because the docblock promised read-only got precisely the writes it was trying
|
|
49
|
+
* to prevent, and got them inconsistently.
|
|
50
|
+
*
|
|
51
|
+
* There is no mode here to turn on instead, because read-only is not a store's
|
|
52
|
+
* decision. The writes arrive on routes that declare `catalog:curate`, and
|
|
53
|
+
* whether a deployment grants that scope is its guard's business — see the
|
|
54
|
+
* declare-and-enforce split in `catalog.route-auth.ts`. A store that threw would
|
|
55
|
+
* turn a policy answer ("you may not curate here") into a 500 from a route the
|
|
56
|
+
* library documents as working, and would put a second mechanism beside the one
|
|
57
|
+
* that already decides.
|
|
58
|
+
*
|
|
59
|
+
* **Both ends copy, and one end would not have been enough.** The registry holds
|
|
60
|
+
* the overlay it loaded and edits it in place — `this.overlay.types[name] = {
|
|
61
|
+
* ...current, ...patch }` — before calling `save`. Copy only on `load` and the
|
|
62
|
+
* object handed to `save` becomes the store's own, so the next patch is writing
|
|
63
|
+
* into the store again; copy only on `save` and the object handed out by `load`
|
|
64
|
+
* already is the store's own. Either way this store's state moves before
|
|
65
|
+
* anybody asked it to, and "nothing is stored until save" — the one sentence a
|
|
66
|
+
* store is for — is not true of it.
|
|
67
|
+
*
|
|
68
|
+
* That mattered in two directions, neither of them the net behaviour, which was
|
|
69
|
+
* and is identical because every edit is followed by a persist.
|
|
70
|
+
*
|
|
71
|
+
* - **The two bundled stores disagreed.** {@link FileCatalogOverlayStore}
|
|
72
|
+
* round-trips through JSON and so has never aliased anything. Every spec in
|
|
73
|
+
* this repository runs on this one, so a test asserting that an edit had not
|
|
74
|
+
* been written yet passed here and would have failed on the store a
|
|
75
|
+
* deployment actually uses. A vacuous pass is worse than no test: it is a
|
|
76
|
+
* claim with evidence attached to it.
|
|
77
|
+
* - **Two registries over one store shared mutable state.** One would see the
|
|
78
|
+
* other's half-applied edit with no write between them, which is the shape
|
|
79
|
+
* that produces a report nobody can reproduce.
|
|
80
|
+
*
|
|
81
|
+
* **What it costs.** One deep copy per load and per save. The overlay is the
|
|
82
|
+
* names, descriptions and per-property patches a human has typed — not the
|
|
83
|
+
* catalog, which is derived from entity metadata and does not live here — so a
|
|
84
|
+
* heavily curated thousand-type catalog is a few hundred kilobytes and a copy
|
|
85
|
+
* in the low milliseconds. The two paths that pay it are a boot and a curator
|
|
86
|
+
* pressing save. Neither is a read, and nothing on a request path calls either.
|
|
87
|
+
*/
|
|
35
88
|
class InMemoryCatalogOverlayStore {
|
|
36
89
|
overlay = { types: {} };
|
|
37
90
|
async load() {
|
|
38
|
-
return this.overlay;
|
|
91
|
+
return copyOverlay(this.overlay);
|
|
39
92
|
}
|
|
40
93
|
async save(overlay) {
|
|
41
|
-
this.overlay = overlay;
|
|
94
|
+
this.overlay = copyOverlay(overlay);
|
|
42
95
|
}
|
|
43
96
|
}
|
|
44
97
|
exports.InMemoryCatalogOverlayStore = InMemoryCatalogOverlayStore;
|
|
98
|
+
/**
|
|
99
|
+
* A deep copy, so a caller and the store never hold one object between them.
|
|
100
|
+
*
|
|
101
|
+
* `structuredClone` rather than a `JSON.parse(JSON.stringify(...))` round-trip,
|
|
102
|
+
* which differs on a key whose value is `undefined`: JSON drops it, so
|
|
103
|
+
* `{ displayName: undefined }` comes back as `{}` and `'displayName' in entry`
|
|
104
|
+
* flips from true to false. Nothing reads the overlay that way today. A copy
|
|
105
|
+
* that quietly edits what it copies is still not a copy, and the day something
|
|
106
|
+
* does read it that way the difference is a curated field that vanished with no
|
|
107
|
+
* write behind it.
|
|
108
|
+
*/
|
|
109
|
+
function copyOverlay(overlay) {
|
|
110
|
+
return structuredClone(overlay);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether a parsed file is an overlay, checked to the depth that matters.
|
|
114
|
+
*
|
|
115
|
+
* The nesting below `types` is deliberately NOT walked. Every consumer reads it
|
|
116
|
+
* defensively — an entry that is missing, or missing the key it wanted, is the
|
|
117
|
+
* ordinary case for a type nobody has curated — so validating each entry would
|
|
118
|
+
* be re-implementing a tolerance the readers already have, and refusing the
|
|
119
|
+
* whole file over one malformed entry would discard every good one beside it.
|
|
120
|
+
*/
|
|
121
|
+
function isOverlay(value) {
|
|
122
|
+
if (!value || typeof value !== 'object')
|
|
123
|
+
return false;
|
|
124
|
+
const types = Reflect.get(value, 'types');
|
|
125
|
+
// Not `typeof types === 'object'`, which admits `null` and arrays. Both parse
|
|
126
|
+
// from a hand-edited file, and both read downstream as "nothing is curated".
|
|
127
|
+
return Boolean(types) && typeof types === 'object' && !Array.isArray(types);
|
|
128
|
+
}
|
|
@@ -158,7 +158,28 @@ export interface CatalogTransform {
|
|
|
158
158
|
}
|
|
159
159
|
export interface TransformResult {
|
|
160
160
|
rows: Array<Record<string, unknown>>;
|
|
161
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Anything the code logged. Surfaced in the run, never in the rows.
|
|
163
|
+
*
|
|
164
|
+
* "Anything the code logged" is meant literally, and in whichever language the
|
|
165
|
+
* transform is written: `console.log` and its siblings in JavaScript and
|
|
166
|
+
* TypeScript, `print` and anything written to `sys.stderr` in Python. A
|
|
167
|
+
* transform author's first instinct for finding out what their code is doing
|
|
168
|
+
* has to be the thing that works, because the alternative — an empty panel and
|
|
169
|
+
* no explanation — reads as "my code never ran" rather than as "you used the
|
|
170
|
+
* wrong function".
|
|
171
|
+
*
|
|
172
|
+
* In call order, with the channels interleaved rather than separated: a reader
|
|
173
|
+
* is reconstructing a sequence, and two lists cannot be zipped back together.
|
|
174
|
+
*
|
|
175
|
+
* **Bounded, by the runner, before it is returned.** These are lines user code
|
|
176
|
+
* chose and they cross a durable step boundary into the run record, so an
|
|
177
|
+
* unbounded capture would make the size of a `finishRun` write a property of
|
|
178
|
+
* somebody's source data. The bundled runner keeps the first 500 lines at
|
|
179
|
+
* 2,000 characters each and appends a line saying how many it dropped — a
|
|
180
|
+
* truncation nobody is told about is the same failure as a log nobody is told
|
|
181
|
+
* about. Consumers cap again for display, more tightly.
|
|
182
|
+
*/
|
|
162
183
|
logs: string[];
|
|
163
184
|
elapsedMs: number;
|
|
164
185
|
}
|
|
@@ -59,8 +59,39 @@ export declare abstract class CatalogRegistry {
|
|
|
59
59
|
* request named) overriding is still right; deriving it a second time is not.
|
|
60
60
|
*/
|
|
61
61
|
getGraph(): CatalogGraph;
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Presentation-only edits. Never a schema change.
|
|
64
|
+
*
|
|
65
|
+
* @param curatedBy the acting principal's id, recorded on `type.curated`.
|
|
66
|
+
*/
|
|
67
|
+
abstract patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>, curatedBy: string): Promise<CatalogObjectTypeDef | undefined>;
|
|
68
|
+
/** @param curatedBy the acting principal's id, recorded on `type.curated`. */
|
|
69
|
+
abstract patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string], curatedBy: string): Promise<CatalogObjectTypeDef | undefined>;
|
|
70
|
+
/**
|
|
71
|
+
* Discard every tier-0 edit at once.
|
|
72
|
+
*
|
|
73
|
+
* **An implementation that really discards must emit `overlay.reset`**, and
|
|
74
|
+
* the reason is an asymmetry this class would otherwise have: the two patches
|
|
75
|
+
* above are audited one field at a time, so a trail could say who renamed one
|
|
76
|
+
* column and not who reverted every name in the catalog — while both need only
|
|
77
|
+
* `catalog:curate`. The summary has to be built before the write; nothing
|
|
78
|
+
* versions an overlay, so afterwards there is nothing left to read.
|
|
79
|
+
*
|
|
80
|
+
* **Refusing is an implementation too, and refusing emits nothing.** A
|
|
81
|
+
* registry whose curated values have no derived layer underneath them has
|
|
82
|
+
* nothing to fall back to, so a reset there is destruction rather than a
|
|
83
|
+
* revert, and the throw is the whole answer — no act, no record of one.
|
|
84
|
+
* `StoredCatalogRegistry` is that case.
|
|
85
|
+
*
|
|
86
|
+
* Which of those a deployment runs is why the event is worth more than the
|
|
87
|
+
* call it accompanies: a registry that quietly resets without emitting looks
|
|
88
|
+
* exactly like one that never ran a reset at all.
|
|
89
|
+
*
|
|
90
|
+
* @param resetBy the acting principal's id, recorded on `overlay.reset`. An
|
|
91
|
+
* implementation that refuses is free to declare no parameter at all — an
|
|
92
|
+
* override may take fewer than it was promised — and `StoredCatalogRegistry`
|
|
93
|
+
* does, because an argument it accepted and never recorded would read as a
|
|
94
|
+
* dropped actor rather than as a reset that never happened.
|
|
95
|
+
*/
|
|
96
|
+
abstract resetOverlay(resetBy: string): Promise<void>;
|
|
66
97
|
}
|
|
@@ -32,10 +32,27 @@ export declare class MikroOrmCatalogRegistry extends CatalogRegistry implements
|
|
|
32
32
|
*/
|
|
33
33
|
getEntityClass(name: string): EntityClass<Record<string, unknown>> | undefined;
|
|
34
34
|
/** Tier-0 edit on a type. Never touches the database. */
|
|
35
|
-
patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]
|
|
35
|
+
patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>, curatedBy: string): Promise<CatalogObjectTypeDef | undefined>;
|
|
36
36
|
/** Tier-0 edit on a property. Never touches the database. */
|
|
37
|
-
patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
|
|
38
|
-
|
|
37
|
+
patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string], curatedBy: string): Promise<CatalogObjectTypeDef | undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Drop every tier-0 edit, and leave a record that it happened.
|
|
40
|
+
*
|
|
41
|
+
* The summary is taken before the overlay is cleared because it is the only
|
|
42
|
+
* record there will ever be: nothing versions an overlay, so the discarded
|
|
43
|
+
* values are gone the instant the store is written. See `overlay.reset` in
|
|
44
|
+
* `catalog.events.ts` for why the payload is a summary and not a copy.
|
|
45
|
+
*
|
|
46
|
+
* Emitted after the write, like the two patches above, so the trail says what
|
|
47
|
+
* happened rather than what was about to.
|
|
48
|
+
*
|
|
49
|
+
* The actor is applied here rather than inside {@link summariseOverlay}, which
|
|
50
|
+
* stays a pure function of the overlay. What was destroyed and who destroyed it
|
|
51
|
+
* are facts from two different places, and folding the principal into the
|
|
52
|
+
* summariser would mean the one function that must be callable with nothing but
|
|
53
|
+
* an old overlay suddenly needing the request as well.
|
|
54
|
+
*/
|
|
55
|
+
resetOverlay(resetBy: string): Promise<void>;
|
|
39
56
|
private persist;
|
|
40
57
|
private rebuild;
|
|
41
58
|
private shouldInclude;
|
package/dist/catalog.registry.js
CHANGED
|
@@ -179,7 +179,7 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
179
179
|
return this.entityClasses.get(name);
|
|
180
180
|
}
|
|
181
181
|
/** Tier-0 edit on a type. Never touches the database. */
|
|
182
|
-
async patchType(typeName, patch) {
|
|
182
|
+
async patchType(typeName, patch, curatedBy) {
|
|
183
183
|
const type = this.getType(typeName);
|
|
184
184
|
if (!type)
|
|
185
185
|
return undefined;
|
|
@@ -192,11 +192,15 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
192
192
|
(0, catalog_events_1.emitCatalog)('type.curated', {
|
|
193
193
|
typeName: type.name,
|
|
194
194
|
changed: Object.keys(rest),
|
|
195
|
+
// Through `curationActor` rather than passed straight in, even though the
|
|
196
|
+
// parameter is required: the callers this class actually has to survive are
|
|
197
|
+
// the ones the compiler never saw. See the note on that function.
|
|
198
|
+
principalId: (0, catalog_events_1.curationActor)(curatedBy),
|
|
195
199
|
});
|
|
196
200
|
return this.getType(type.name);
|
|
197
201
|
}
|
|
198
202
|
/** Tier-0 edit on a property. Never touches the database. */
|
|
199
|
-
async patchProperty(typeName, propertyName, patch) {
|
|
203
|
+
async patchProperty(typeName, propertyName, patch, curatedBy) {
|
|
200
204
|
const type = this.getType(typeName);
|
|
201
205
|
if (!type)
|
|
202
206
|
return undefined;
|
|
@@ -218,12 +222,32 @@ let MikroOrmCatalogRegistry = MikroOrmCatalogRegistry_1 = class MikroOrmCatalogR
|
|
|
218
222
|
typeName: type.name,
|
|
219
223
|
property: propertyName,
|
|
220
224
|
changed: Object.keys(patch),
|
|
225
|
+
principalId: (0, catalog_events_1.curationActor)(curatedBy),
|
|
221
226
|
});
|
|
222
227
|
return this.getType(type.name);
|
|
223
228
|
}
|
|
224
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Drop every tier-0 edit, and leave a record that it happened.
|
|
231
|
+
*
|
|
232
|
+
* The summary is taken before the overlay is cleared because it is the only
|
|
233
|
+
* record there will ever be: nothing versions an overlay, so the discarded
|
|
234
|
+
* values are gone the instant the store is written. See `overlay.reset` in
|
|
235
|
+
* `catalog.events.ts` for why the payload is a summary and not a copy.
|
|
236
|
+
*
|
|
237
|
+
* Emitted after the write, like the two patches above, so the trail says what
|
|
238
|
+
* happened rather than what was about to.
|
|
239
|
+
*
|
|
240
|
+
* The actor is applied here rather than inside {@link summariseOverlay}, which
|
|
241
|
+
* stays a pure function of the overlay. What was destroyed and who destroyed it
|
|
242
|
+
* are facts from two different places, and folding the principal into the
|
|
243
|
+
* summariser would mean the one function that must be callable with nothing but
|
|
244
|
+
* an old overlay suddenly needing the request as well.
|
|
245
|
+
*/
|
|
246
|
+
async resetOverlay(resetBy) {
|
|
247
|
+
const discarded = summariseOverlay(this.overlay);
|
|
225
248
|
this.overlay = { types: {} };
|
|
226
249
|
await this.persist();
|
|
250
|
+
(0, catalog_events_1.emitCatalog)('overlay.reset', { ...discarded, principalId: (0, catalog_events_1.curationActor)(resetBy) });
|
|
227
251
|
}
|
|
228
252
|
async persist() {
|
|
229
253
|
await this.overlayStore.save(this.overlay);
|
|
@@ -368,6 +392,46 @@ exports.MikroOrmCatalogRegistry = MikroOrmCatalogRegistry = MikroOrmCatalogRegis
|
|
|
368
392
|
__param(2, (0, common_1.Inject)(catalog_overlay_store_token_1.CATALOG_OVERLAY_STORE)),
|
|
369
393
|
__metadata("design:paramtypes", [core_1.MikroORM, Object, Object])
|
|
370
394
|
], MikroOrmCatalogRegistry);
|
|
395
|
+
/**
|
|
396
|
+
* What a reset is about to destroy, in the shape the trail keeps it.
|
|
397
|
+
*
|
|
398
|
+
* Here rather than in `catalog.events.ts` because it reads a `CatalogOverlay`,
|
|
399
|
+
* and this is the only registry that has one — the payload type is the contract,
|
|
400
|
+
* this is one producer of it. Pure and taking the overlay as an argument so the
|
|
401
|
+
* order is forced: a caller has to hold the old overlay to call it, and cannot
|
|
402
|
+
* accidentally summarise the empty one it just installed.
|
|
403
|
+
*
|
|
404
|
+
* Everything the payload holds except the actor, stated as an `Omit` of the
|
|
405
|
+
* payload rather than a shape of its own. A hand-written interface here would be
|
|
406
|
+
* a second copy of the contract, free to fall behind the day a field is added —
|
|
407
|
+
* and the failure would be a summary silently missing a key that the type says
|
|
408
|
+
* is required. `principalId` is the caller's to supply because it is a fact about
|
|
409
|
+
* the request, not about the overlay.
|
|
410
|
+
*
|
|
411
|
+
* A type entry counts whatever it holds, including an entry that ended up empty.
|
|
412
|
+
* `buildType` treats a present entry as enrichment on the same terms, and the
|
|
413
|
+
* honest reading of one is "somebody patched this type" — which is exactly what
|
|
414
|
+
* the reset undid.
|
|
415
|
+
*/
|
|
416
|
+
function summariseOverlay(overlay) {
|
|
417
|
+
const typeNames = Object.keys(overlay.types);
|
|
418
|
+
const classifications = [];
|
|
419
|
+
let properties = 0;
|
|
420
|
+
for (const typeName of typeNames) {
|
|
421
|
+
const patched = overlay.types[typeName]?.properties ?? {};
|
|
422
|
+
for (const [property, patch] of Object.entries(patched)) {
|
|
423
|
+
properties += 1;
|
|
424
|
+
const { classification } = patch;
|
|
425
|
+
// Only a classification that was actually set. An entry that merely
|
|
426
|
+
// renamed the column carries the key as `undefined`, and listing it would
|
|
427
|
+
// report a classification lost that nobody had applied.
|
|
428
|
+
if (classification !== undefined) {
|
|
429
|
+
classifications.push({ typeName, property, classification });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { typeNames, properties, classifications };
|
|
434
|
+
}
|
|
371
435
|
/**
|
|
372
436
|
* How one field is presented, resolved across the tiers.
|
|
373
437
|
*
|
|
@@ -27,11 +27,26 @@ export declare class CatalogService {
|
|
|
27
27
|
getType(name: string): CatalogObjectTypeDef | undefined;
|
|
28
28
|
/** Nodes and edges, for drawing the model. */
|
|
29
29
|
getGraph(): CatalogGraph;
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Presentation-only. Never a schema change.
|
|
32
|
+
*
|
|
33
|
+
* @param curatedBy who is doing it, for the audit trail — the same required
|
|
34
|
+
* argument the sharing methods below take, and required for the same reason
|
|
35
|
+
* `deleteSavedQuery` gives. This facade forwards it rather than resolving it:
|
|
36
|
+
* a host writing its own controller has the request and this does not, and a
|
|
37
|
+
* default here would attribute every such host's curation to nobody.
|
|
38
|
+
*/
|
|
39
|
+
patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>, curatedBy: string): Promise<CatalogObjectTypeDef | undefined>;
|
|
40
|
+
/** @param curatedBy who is doing it, for the audit trail. */
|
|
41
|
+
patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string], curatedBy: string): Promise<CatalogObjectTypeDef | undefined>;
|
|
42
|
+
/**
|
|
43
|
+
* Drops every runtime edit, where the registry supports it.
|
|
44
|
+
*
|
|
45
|
+
* @param resetBy who is doing it. The one act here that destroys curation in
|
|
46
|
+
* bulk, so it is the one whose actor is hardest to reconstruct afterwards —
|
|
47
|
+
* nothing versions an overlay, and after this there is nothing left to read.
|
|
48
|
+
*/
|
|
49
|
+
resetOverlay(resetBy: string): Promise<void>;
|
|
35
50
|
/** Columns a generic UI may render: visible, and not a blob. */
|
|
36
51
|
visibleColumns(type: CatalogObjectTypeDef): import("./catalog.types").CatalogPropertyDef[];
|
|
37
52
|
/**
|
package/dist/catalog.service.js
CHANGED
|
@@ -66,16 +66,31 @@ let CatalogService = class CatalogService {
|
|
|
66
66
|
getGraph() {
|
|
67
67
|
return this.registry.getGraph();
|
|
68
68
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Presentation-only. Never a schema change.
|
|
71
|
+
*
|
|
72
|
+
* @param curatedBy who is doing it, for the audit trail — the same required
|
|
73
|
+
* argument the sharing methods below take, and required for the same reason
|
|
74
|
+
* `deleteSavedQuery` gives. This facade forwards it rather than resolving it:
|
|
75
|
+
* a host writing its own controller has the request and this does not, and a
|
|
76
|
+
* default here would attribute every such host's curation to nobody.
|
|
77
|
+
*/
|
|
78
|
+
patchType(typeName, patch, curatedBy) {
|
|
79
|
+
return this.registry.patchType(typeName, patch, curatedBy);
|
|
72
80
|
}
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
/** @param curatedBy who is doing it, for the audit trail. */
|
|
82
|
+
patchProperty(typeName, propertyName, patch, curatedBy) {
|
|
83
|
+
return this.registry.patchProperty(typeName, propertyName, patch, curatedBy);
|
|
75
84
|
}
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Drops every runtime edit, where the registry supports it.
|
|
87
|
+
*
|
|
88
|
+
* @param resetBy who is doing it. The one act here that destroys curation in
|
|
89
|
+
* bulk, so it is the one whose actor is hardest to reconstruct afterwards —
|
|
90
|
+
* nothing versions an overlay, and after this there is nothing left to read.
|
|
91
|
+
*/
|
|
92
|
+
resetOverlay(resetBy) {
|
|
93
|
+
return this.registry.resetOverlay(resetBy);
|
|
79
94
|
}
|
|
80
95
|
/** Columns a generic UI may render: visible, and not a blob. */
|
|
81
96
|
visibleColumns(type) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { CatalogProperty, type CatalogPropertyOptions, CatalogType, type CatalogTypeOptions, } from './catalog.decorators';
|
|
2
|
-
export { CATALOG_EVENT_PHASE, CATALOG_EVENT_PHASE_FALLBACK, CATALOG_EVENTS, CATALOG_LIB, type CatalogEvent, type CatalogEventPayloads, catalogEventPhase, channelNameFor, emitCatalog, } from './catalog.events';
|
|
2
|
+
export { CATALOG_EVENT_PHASE, CATALOG_EVENT_PHASE_FALLBACK, CATALOG_EVENTS, CATALOG_LIB, type CatalogEvent, type CatalogEventPayloads, UNATTRIBUTED_PRINCIPAL_ID, catalogEventPhase, channelNameFor, curationActor, emitCatalog, } from './catalog.events';
|
|
3
3
|
export { CatalogModule } from './catalog.module';
|
|
4
4
|
export { assertReadOnlyShape, type CatalogQueryRelation, type CatalogQueryRequest, type CatalogQueryResult, type CatalogQueryStore, isQueryStore, } from './catalog.query';
|
|
5
5
|
export { CATALOG_OPTIONS, type CatalogModuleOptions } from './catalog.options';
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.
|
|
18
|
-
exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.supportsCarryForward = exports.isWriteStore = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = void 0;
|
|
17
|
+
exports.searchCatalog = exports.maySearch = exports.emptySearch = exports.bestMatch = exports.MAX_SEARCH_LIMIT = exports.DEFAULT_SEARCH_LIMIT = exports.CatalogService = exports.SubprocessTransformRunner = exports.toCsv = exports.QueryCache = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.TRANSFORM_RUNNER = exports.supportsWorkflowStages = exports.supportsWorkflows = exports.isWorkflowNodeKind = exports.isWorkflowNode = exports.isWorkflowExecutionMode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isPipelineStore = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.CATALOG_PIPELINE_STORE = exports.CatalogRegistry = exports.MikroOrmCatalogRegistry = exports.CATALOG_OVERLAY_STORE = exports.InMemoryCatalogOverlayStore = exports.FileCatalogOverlayStore = exports.CATALOG_OPTIONS = exports.isQueryStore = exports.assertReadOnlyShape = exports.CatalogModule = exports.emitCatalog = exports.curationActor = exports.channelNameFor = exports.catalogEventPhase = exports.UNATTRIBUTED_PRINCIPAL_ID = exports.CATALOG_LIB = exports.CATALOG_EVENTS = exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CatalogType = exports.CatalogProperty = void 0;
|
|
18
|
+
exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.supportsCarryForward = exports.isWriteStore = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = exports.CATALOG_TRACE_OUTCOMES = exports.visibleToPrincipal = void 0;
|
|
19
19
|
var catalog_decorators_1 = require("./catalog.decorators");
|
|
20
20
|
Object.defineProperty(exports, "CatalogProperty", { enumerable: true, get: function () { return catalog_decorators_1.CatalogProperty; } });
|
|
21
21
|
Object.defineProperty(exports, "CatalogType", { enumerable: true, get: function () { return catalog_decorators_1.CatalogType; } });
|
|
@@ -24,8 +24,10 @@ Object.defineProperty(exports, "CATALOG_EVENT_PHASE", { enumerable: true, get: f
|
|
|
24
24
|
Object.defineProperty(exports, "CATALOG_EVENT_PHASE_FALLBACK", { enumerable: true, get: function () { return catalog_events_1.CATALOG_EVENT_PHASE_FALLBACK; } });
|
|
25
25
|
Object.defineProperty(exports, "CATALOG_EVENTS", { enumerable: true, get: function () { return catalog_events_1.CATALOG_EVENTS; } });
|
|
26
26
|
Object.defineProperty(exports, "CATALOG_LIB", { enumerable: true, get: function () { return catalog_events_1.CATALOG_LIB; } });
|
|
27
|
+
Object.defineProperty(exports, "UNATTRIBUTED_PRINCIPAL_ID", { enumerable: true, get: function () { return catalog_events_1.UNATTRIBUTED_PRINCIPAL_ID; } });
|
|
27
28
|
Object.defineProperty(exports, "catalogEventPhase", { enumerable: true, get: function () { return catalog_events_1.catalogEventPhase; } });
|
|
28
29
|
Object.defineProperty(exports, "channelNameFor", { enumerable: true, get: function () { return catalog_events_1.channelNameFor; } });
|
|
30
|
+
Object.defineProperty(exports, "curationActor", { enumerable: true, get: function () { return catalog_events_1.curationActor; } });
|
|
29
31
|
Object.defineProperty(exports, "emitCatalog", { enumerable: true, get: function () { return catalog_events_1.emitCatalog; } });
|
|
30
32
|
var catalog_module_1 = require("./catalog.module");
|
|
31
33
|
Object.defineProperty(exports, "CatalogModule", { enumerable: true, get: function () { return catalog_module_1.CatalogModule; } });
|
package/dist/transform-runner.js
CHANGED
|
@@ -17,6 +17,57 @@ const node_path_1 = require("node:path");
|
|
|
17
17
|
const common_1 = require("@nestjs/common");
|
|
18
18
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
19
|
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* How much of what a transform logged is carried back, on both axes.
|
|
22
|
+
*
|
|
23
|
+
* Both, because either one alone leaves the capture unbounded in the dimension
|
|
24
|
+
* it does not cover, and this capture is *user code writing whatever it likes*.
|
|
25
|
+
* A transform that logs one line per record — the most natural debugging move
|
|
26
|
+
* there is — puts a copy of the source's data into `logs`, and `logs` is the one
|
|
27
|
+
* thing that crosses a durable step boundary and lands in the run record. So the
|
|
28
|
+
* ceiling is fixed here, in the child, before any of it is serialised: the
|
|
29
|
+
* alternative is a `finishRun` write whose size is a property of somebody's
|
|
30
|
+
* data.
|
|
31
|
+
*
|
|
32
|
+
* The same two numbers for JavaScript and for Python, applied by the two
|
|
33
|
+
* harnesses below in the same order. A transform's log behaviour changing
|
|
34
|
+
* because of the language it happens to be written in is a difference nobody can
|
|
35
|
+
* predict from reading either one.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately far above what anything downstream keeps — the connector runner
|
|
38
|
+
* takes fifty lines, the workflow runner twenty per node at four hundred
|
|
39
|
+
* characters — because this is the *safety* bound and those are the *display*
|
|
40
|
+
* bounds. A harness that truncated at the display limit would decide, in the
|
|
41
|
+
* child, what a future consumer is allowed to see.
|
|
42
|
+
*
|
|
43
|
+
* What is dropped is said out loud, in a final line, rather than dropped
|
|
44
|
+
* quietly. Silence about a missing log is the exact failure this whole capture
|
|
45
|
+
* exists to remove; reproducing it at line 501 would only move it.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_LOG_LINES = 500;
|
|
48
|
+
const MAX_LOG_LINE_CHARS = 2_000;
|
|
49
|
+
/**
|
|
50
|
+
* How much of what a *failing* transform logged is folded into the error.
|
|
51
|
+
*
|
|
52
|
+
* A failure throws, and a throw carries a message and nothing else — so the
|
|
53
|
+
* `logs` of a run that raised never reach the caller at all, and every consumer
|
|
54
|
+
* records the traceback with none of the output that led to it. Capturing
|
|
55
|
+
* `print` and then discarding it at the exact moment it is most wanted would be
|
|
56
|
+
* a fix that stops one step short of the case it was written for.
|
|
57
|
+
*
|
|
58
|
+
* The **last** lines, not the first, which is the opposite of what the display
|
|
59
|
+
* caps downstream do — and deliberately. Those are trimming a successful run's
|
|
60
|
+
* narrative, where the beginning is the story; this is the approach to a
|
|
61
|
+
* traceback, where the last thing printed is the one that says where the code
|
|
62
|
+
* got to.
|
|
63
|
+
*
|
|
64
|
+
* Small on both axes because this lands in an error message, and an error
|
|
65
|
+
* message ends up in a run row, a log line and a console toast. The full set is
|
|
66
|
+
* still on the result whenever the transform returned at all; this is the
|
|
67
|
+
* consolation for the path where there is no result.
|
|
68
|
+
*/
|
|
69
|
+
const FAILURE_LOG_LINES = 10;
|
|
70
|
+
const FAILURE_LOG_CHARS = 200;
|
|
20
71
|
/** Packages worth telling the author about, if the environment has them. */
|
|
21
72
|
const REPORTED_PACKAGES = ['pandas', 'numpy', 'pyarrow', 'requests'];
|
|
22
73
|
/**
|
|
@@ -111,14 +162,15 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
|
|
|
111
162
|
catch {
|
|
112
163
|
throw new Error(`The transform did not return anything readable. stderr: ${stderr.slice(0, 500)}`);
|
|
113
164
|
}
|
|
165
|
+
const logs = Array.isArray(parsed.logs) ? parsed.logs.map(String) : [];
|
|
114
166
|
if (parsed.error)
|
|
115
|
-
throw new Error(parsed.error);
|
|
167
|
+
throw new Error(withFinalLogs(parsed.error, logs));
|
|
116
168
|
if (!Array.isArray(parsed.rows)) {
|
|
117
169
|
throw new Error('The transform must return an array of rows. Returning anything else would leave the load ambiguous.');
|
|
118
170
|
}
|
|
119
171
|
return {
|
|
120
172
|
rows: parsed.rows.filter((row) => typeof row === 'object' && row !== null && !Array.isArray(row)),
|
|
121
|
-
logs
|
|
173
|
+
logs,
|
|
122
174
|
elapsedMs: Date.now() - started,
|
|
123
175
|
};
|
|
124
176
|
}
|
|
@@ -204,18 +256,68 @@ exports.SubprocessTransformRunner = SubprocessTransformRunner = SubprocessTransf
|
|
|
204
256
|
(0, common_1.Injectable)(),
|
|
205
257
|
__metadata("design:paramtypes", [Object])
|
|
206
258
|
], SubprocessTransformRunner);
|
|
259
|
+
/**
|
|
260
|
+
* The traceback, plus the tail of what the code printed on its way to it.
|
|
261
|
+
*
|
|
262
|
+
* Named in the message rather than appended bare, and counted rather than
|
|
263
|
+
* merely truncated: "the last 10 of 57 lines" tells a reader there is more to
|
|
264
|
+
* find on the run's own log, where a silent tail would let them believe they
|
|
265
|
+
* were looking at everything the transform said.
|
|
266
|
+
*/
|
|
267
|
+
function withFinalLogs(error, logs) {
|
|
268
|
+
if (logs.length === 0)
|
|
269
|
+
return error;
|
|
270
|
+
const tail = logs
|
|
271
|
+
.slice(-FAILURE_LOG_LINES)
|
|
272
|
+
.map((line) => line.length > FAILURE_LOG_CHARS ? `${line.slice(0, FAILURE_LOG_CHARS)}…` : line);
|
|
273
|
+
const heading = logs.length > tail.length
|
|
274
|
+
? `The last ${tail.length} of ${logs.length} lines it logged first:`
|
|
275
|
+
: `${tail.length === 1 ? 'The line' : `The ${tail.length} lines`} it logged first:`;
|
|
276
|
+
return `${error}\n${heading}\n${tail.map((line) => ` ${line}`).join('\n')}`;
|
|
277
|
+
}
|
|
207
278
|
/**
|
|
208
279
|
* The JavaScript and TypeScript harness.
|
|
209
280
|
*
|
|
210
281
|
* `console.log` is captured rather than left on stdout so user code cannot
|
|
211
282
|
* corrupt the single JSON line this prints — a transform that logs a `{` would
|
|
212
283
|
* otherwise break its own result parsing, which is a maddening thing to debug.
|
|
284
|
+
*
|
|
285
|
+
* Every console channel that reaches a terminal is overridden, not just the four
|
|
286
|
+
* that were here first. `console.debug` writes to stdout exactly as `console.log`
|
|
287
|
+
* does, so leaving it alone left one spelling of "log something" that silently
|
|
288
|
+
* corrupted the result line; `console.trace` writes to stderr, so leaving it
|
|
289
|
+
* alone left one spelling that silently went nowhere. Both are the same mistake
|
|
290
|
+
* the Python harness made with `print`, and there is no reading of "anything the
|
|
291
|
+
* code logged" under which they are not it.
|
|
292
|
+
*
|
|
293
|
+
* The channels share one array and keep call order, which is the only ordering
|
|
294
|
+
* that answers the question logs are read for — what happened, and in what
|
|
295
|
+
* sequence. Nothing marks which channel a line came from: a reader looking at a
|
|
296
|
+
* failed run wants the sequence, and splitting it into two lists would make the
|
|
297
|
+
* interleaving unrecoverable to buy a label the line's own text usually carries.
|
|
298
|
+
*
|
|
299
|
+
* Bounded by {@link MAX_LOG_LINES} and {@link MAX_LOG_LINE_CHARS}, applied here
|
|
300
|
+
* rather than after the fact, so a transform that logs a copy of its input never
|
|
301
|
+
* gets as far as being serialised.
|
|
213
302
|
*/
|
|
214
303
|
function javascriptHarness(code) {
|
|
215
304
|
return `
|
|
216
305
|
const logs = [];
|
|
217
|
-
|
|
306
|
+
let dropped = 0;
|
|
307
|
+
const keep = (line) => {
|
|
308
|
+
if (logs.length >= ${MAX_LOG_LINES}) { dropped += 1; return; }
|
|
309
|
+
logs.push(
|
|
310
|
+
line.length > ${MAX_LOG_LINE_CHARS}
|
|
311
|
+
? line.slice(0, ${MAX_LOG_LINE_CHARS}) + "… (" + (line.length - ${MAX_LOG_LINE_CHARS}) + " more characters)"
|
|
312
|
+
: line,
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
const write = (...args) => keep(args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" "));
|
|
218
316
|
console.log = write; console.info = write; console.warn = write; console.error = write;
|
|
317
|
+
console.debug = write; console.trace = write;
|
|
318
|
+
const captured = () => dropped === 0
|
|
319
|
+
? logs
|
|
320
|
+
: logs.concat(["… " + dropped + " more line(s) were logged and dropped: a transform keeps its first ${MAX_LOG_LINES}."]);
|
|
219
321
|
|
|
220
322
|
let input = "";
|
|
221
323
|
process.stdin.setEncoding("utf8");
|
|
@@ -225,11 +327,11 @@ try {
|
|
|
225
327
|
const records = JSON.parse(input || "[]");
|
|
226
328
|
const transform = async (records) => { ${code} };
|
|
227
329
|
const rows = await transform(records);
|
|
228
|
-
process.stdout.write(JSON.stringify({ rows: rows ?? [], logs }));
|
|
330
|
+
process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
|
|
229
331
|
} catch (error) {
|
|
230
332
|
process.stdout.write(JSON.stringify({
|
|
231
333
|
error: error instanceof Error ? \`\${error.name}: \${error.message}\` : String(error),
|
|
232
|
-
logs,
|
|
334
|
+
logs: captured(),
|
|
233
335
|
}));
|
|
234
336
|
}
|
|
235
337
|
`;
|
|
@@ -241,6 +343,44 @@ try {
|
|
|
241
343
|
* that reaches for pandas will naturally end with one — making it write
|
|
242
344
|
* `.to_dict("records")` would be a papercut on the only path pandas is worth
|
|
243
345
|
* importing for.
|
|
346
|
+
*
|
|
347
|
+
* **`print` is redirected, for the same reason `console.log` is.** It used to go
|
|
348
|
+
* straight through to the child's real stdout, where the last-line result parse
|
|
349
|
+
* discarded it — so the single most obvious thing a person writes while working
|
|
350
|
+
* out what their transform is doing produced an empty log panel and no
|
|
351
|
+
* explanation. That is not a missing nicety: it costs the author their trust in
|
|
352
|
+
* the runner before they have written anything real, and the conclusion it
|
|
353
|
+
* invites ("my code never ran") is the wrong one. `log()` still exists, because
|
|
354
|
+
* transforms in the wild call it and a `NameError` is a worse answer than a
|
|
355
|
+
* redundant helper, but it is now literally `print` — one buffer, one ordering,
|
|
356
|
+
* and nothing that only works if you already knew about it.
|
|
357
|
+
*
|
|
358
|
+
* **stderr is captured too**, into the same list and in call order. `warnings`,
|
|
359
|
+
* a `logging` handler at its default configuration, and a traceback the code
|
|
360
|
+
* printed itself all land there, and those are precisely the lines somebody is
|
|
361
|
+
* looking for when a transform misbehaves. It is not marked as stderr, matching
|
|
362
|
+
* the JavaScript harness, which does not distinguish `console.error` either: the
|
|
363
|
+
* sequence is what a reader is reconstructing, and two lists would make the
|
|
364
|
+
* interleaving unrecoverable.
|
|
365
|
+
*
|
|
366
|
+
* What was written **before** an exception survives it. The redirect is a
|
|
367
|
+
* context manager around the call rather than a swap held for the whole script,
|
|
368
|
+
* so it unwinds on the way out of a traceback with the buffer intact, and the
|
|
369
|
+
* error branch reports the same lines the success branch would have. A
|
|
370
|
+
* transform that printed three things and then divided by zero is the case logs
|
|
371
|
+
* matter most for, and it is the case a naive swap loses.
|
|
372
|
+
*
|
|
373
|
+
* Bounded by {@link MAX_LOG_LINES} and {@link MAX_LOG_LINE_CHARS}, the same two
|
|
374
|
+
* numbers the JavaScript harness applies. Note that this bounds the *sink*, not
|
|
375
|
+
* only the result: an unterminated write longer than a line's ceiling is flushed
|
|
376
|
+
* as its own line rather than accumulated, so a transform writing without
|
|
377
|
+
* newlines cannot grow the child's memory either.
|
|
378
|
+
*
|
|
379
|
+
* The limit worth stating: this redirects Python-level writes to `sys.stdout`
|
|
380
|
+
* and `sys.stderr`. Output from a C extension or a subprocess that writes to the
|
|
381
|
+
* file descriptors underneath goes to the real streams, exactly as it does past
|
|
382
|
+
* an overridden `console` in Node. Redirecting the descriptors themselves would
|
|
383
|
+
* take the result channel with it.
|
|
244
384
|
*/
|
|
245
385
|
function pythonHarness(code) {
|
|
246
386
|
const indented = code
|
|
@@ -248,11 +388,68 @@ function pythonHarness(code) {
|
|
|
248
388
|
.map((line) => ` ${line}`)
|
|
249
389
|
.join('\n');
|
|
250
390
|
return `
|
|
251
|
-
import sys, json
|
|
391
|
+
import sys, json, contextlib
|
|
252
392
|
|
|
253
393
|
logs = []
|
|
394
|
+
# A one-element list rather than a module global reassigned inside the helper,
|
|
395
|
+
# so the counter needs no \`global\` statement in generated code.
|
|
396
|
+
dropped = [0]
|
|
397
|
+
|
|
398
|
+
def keep(line):
|
|
399
|
+
if len(logs) >= ${MAX_LOG_LINES}:
|
|
400
|
+
dropped[0] += 1
|
|
401
|
+
return
|
|
402
|
+
if len(line) > ${MAX_LOG_LINE_CHARS}:
|
|
403
|
+
line = "{}… ({} more characters)".format(
|
|
404
|
+
line[:${MAX_LOG_LINE_CHARS}], len(line) - ${MAX_LOG_LINE_CHARS}
|
|
405
|
+
)
|
|
406
|
+
logs.append(line)
|
|
407
|
+
|
|
408
|
+
class Sink:
|
|
409
|
+
"""Stands in for stdout and stderr while the transform runs.
|
|
410
|
+
|
|
411
|
+
Line-buffered by hand because \`print("a", "b")\` arrives as four separate
|
|
412
|
+
writes — the parts, the separators and the terminator — and appending each
|
|
413
|
+
one as its own entry would shred every multi-argument call.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
def __init__(self):
|
|
417
|
+
self.partial = ""
|
|
418
|
+
|
|
419
|
+
def write(self, text):
|
|
420
|
+
if not isinstance(text, str):
|
|
421
|
+
text = str(text)
|
|
422
|
+
self.partial += text
|
|
423
|
+
while "\\n" in self.partial:
|
|
424
|
+
line, self.partial = self.partial.split("\\n", 1)
|
|
425
|
+
keep(line)
|
|
426
|
+
# A write with no newline in it is still bounded: past a line's ceiling
|
|
427
|
+
# there is nothing more to keep, so it is emitted rather than held.
|
|
428
|
+
if len(self.partial) > ${MAX_LOG_LINE_CHARS}:
|
|
429
|
+
keep(self.partial)
|
|
430
|
+
self.partial = ""
|
|
431
|
+
return len(text)
|
|
432
|
+
|
|
433
|
+
def writelines(self, lines):
|
|
434
|
+
for line in lines:
|
|
435
|
+
self.write(line)
|
|
436
|
+
|
|
437
|
+
def flush(self):
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
def isatty(self):
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
def drain(self):
|
|
444
|
+
"""Whatever was written without a trailing newline is still output."""
|
|
445
|
+
if self.partial:
|
|
446
|
+
keep(self.partial)
|
|
447
|
+
self.partial = ""
|
|
448
|
+
|
|
449
|
+
sink = Sink()
|
|
450
|
+
|
|
254
451
|
def log(*args):
|
|
255
|
-
|
|
452
|
+
print(*args)
|
|
256
453
|
|
|
257
454
|
def transform(records):
|
|
258
455
|
${indented || ' return records'}
|
|
@@ -266,15 +463,31 @@ def to_rows(result):
|
|
|
266
463
|
return result.to_dict("records")
|
|
267
464
|
return result
|
|
268
465
|
|
|
466
|
+
def captured():
|
|
467
|
+
sink.drain()
|
|
468
|
+
if dropped[0] == 0:
|
|
469
|
+
return logs
|
|
470
|
+
return logs + [
|
|
471
|
+
"… {} more line(s) were logged and dropped: a transform keeps its first {}.".format(
|
|
472
|
+
dropped[0], ${MAX_LOG_LINES}
|
|
473
|
+
)
|
|
474
|
+
]
|
|
475
|
+
|
|
269
476
|
try:
|
|
270
477
|
raw = sys.stdin.read()
|
|
271
478
|
records = json.loads(raw) if raw.strip() else []
|
|
272
|
-
|
|
273
|
-
|
|
479
|
+
# \`to_rows\` is inside the redirect as well: a lazily-evaluated return value
|
|
480
|
+
# does its printing here, not before.
|
|
481
|
+
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
|
482
|
+
rows = to_rows(transform(records))
|
|
483
|
+
# Back on the real stdout by now — the context manager restores on the way
|
|
484
|
+
# out, including out of an exception — so this is the only thing on it.
|
|
485
|
+
out = captured()
|
|
486
|
+
sys.stdout.write(json.dumps(rows and {"rows": rows, "logs": out} or {"rows": [], "logs": out}, default=str))
|
|
274
487
|
except Exception as error:
|
|
275
488
|
sys.stdout.write(json.dumps({
|
|
276
489
|
"error": "{}: {}".format(type(error).__name__, error),
|
|
277
|
-
"logs":
|
|
490
|
+
"logs": captured(),
|
|
278
491
|
}))
|
|
279
492
|
`;
|
|
280
493
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|