@pgsql/transform 18.5.0 → 18.6.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,236 @@
1
+ /**
2
+ * Extension routing for the core transform.
3
+ *
4
+ * A PostgreSQL extension is installed into exactly one schema, and the objects
5
+ * it provides (functions, types, operators) live in that schema. Two databases
6
+ * can install the *same* extension into *different* schemas — one leaves it in
7
+ * `public`, another isolates it in a dedicated schema — so portable SQL must be
8
+ * able to move an extension's install site **and** rewrite every reference to
9
+ * the symbols it provides.
10
+ *
11
+ * This is a distinct problem from {@link SchemaRouter}, which routes objects the
12
+ * SQL itself creates. Here the objects are owned by an extension and are not
13
+ * declared in the script; the only way to know that a bare `crypt(...)` call
14
+ * belongs to `pgcrypto` (and must move when `pgcrypto` moves) is an
15
+ * **inventory** of which symbols each extension provides. That inventory is
16
+ * also **version-aware**: a symbol can graduate into the server core and cease
17
+ * to be extension-owned (the canonical case is `gen_random_uuid()`, provided by
18
+ * `pgcrypto` before PostgreSQL 13 and part of core `pg_catalog` from 13 on — it
19
+ * must never be routed on 13+).
20
+ *
21
+ * An {@link ExtensionRouter} answers two questions:
22
+ *
23
+ * - **install**: where should `CREATE EXTENSION <e>` (or `ALTER EXTENSION <e>
24
+ * SET SCHEMA`) place the extension?
25
+ * - **reference**: given a reference to a symbol `(schema, name)` in namespace
26
+ * `ns`, what schema should it live in now — including stripping the qualifier
27
+ * entirely (relying on `search_path`) when the target is "unqualified".
28
+ *
29
+ * A schema of `null` denotes the **unqualified** site: a bare reference
30
+ * (`crypt(...)`) or installing an extension with no explicit `SCHEMA` clause.
31
+ * This makes the router fully bidirectional: `null -> 'extensions'` qualifies
32
+ * bare references, and `'extensions' -> null` strips them back to bare.
33
+ */
34
+ const DEFAULT_SERVER_VERSION = 9999;
35
+ /**
36
+ * A curated, platform-agnostic inventory of common contrib/extension symbols.
37
+ * Intentionally minimal and conservative — only well-known, stable symbols are
38
+ * listed so routing never touches something it cannot prove belongs to the
39
+ * extension. Callers extend this via {@link ExtensionRouterOptions.inventory}.
40
+ */
41
+ export const COMMON_EXTENSIONS = [
42
+ {
43
+ name: 'pgcrypto',
44
+ relocatable: true,
45
+ symbols: [
46
+ { name: 'crypt' },
47
+ { name: 'gen_salt' },
48
+ { name: 'digest' },
49
+ { name: 'hmac' },
50
+ { name: 'encrypt' },
51
+ { name: 'decrypt' },
52
+ { name: 'encrypt_iv' },
53
+ { name: 'decrypt_iv' },
54
+ { name: 'gen_random_bytes' },
55
+ // Provided by pgcrypto before PG13; part of core pg_catalog from 13 on.
56
+ { name: 'gen_random_uuid', coreSince: 13 },
57
+ { name: 'pgp_sym_encrypt' },
58
+ { name: 'pgp_sym_decrypt' },
59
+ { name: 'pgp_pub_encrypt' },
60
+ { name: 'pgp_pub_decrypt' }
61
+ ]
62
+ },
63
+ {
64
+ name: 'uuid-ossp',
65
+ relocatable: true,
66
+ symbols: [
67
+ { name: 'uuid_generate_v1' },
68
+ { name: 'uuid_generate_v1mc' },
69
+ { name: 'uuid_generate_v3' },
70
+ { name: 'uuid_generate_v4' },
71
+ { name: 'uuid_generate_v5' },
72
+ { name: 'uuid_nil' }
73
+ ]
74
+ },
75
+ {
76
+ name: 'citext',
77
+ relocatable: true,
78
+ symbols: [
79
+ { name: 'citext', namespace: 'type' },
80
+ { name: 'citextin', namespace: 'function' },
81
+ { name: 'citext_hash', namespace: 'function' }
82
+ ]
83
+ },
84
+ {
85
+ name: 'pg_trgm',
86
+ relocatable: true,
87
+ symbols: [
88
+ { name: 'similarity' },
89
+ { name: 'show_trgm' },
90
+ { name: 'word_similarity' },
91
+ { name: 'strict_word_similarity' }
92
+ ]
93
+ },
94
+ {
95
+ name: 'ltree',
96
+ relocatable: true,
97
+ symbols: [
98
+ { name: 'ltree', namespace: 'type' },
99
+ { name: 'lquery', namespace: 'type' },
100
+ { name: 'ltxtquery', namespace: 'type' },
101
+ { name: 'subltree' },
102
+ { name: 'subpath' },
103
+ { name: 'nlevel' },
104
+ { name: 'lca' }
105
+ ]
106
+ },
107
+ {
108
+ name: 'hstore',
109
+ relocatable: true,
110
+ symbols: [{ name: 'hstore', namespace: 'type' }]
111
+ },
112
+ {
113
+ name: 'unaccent',
114
+ relocatable: true,
115
+ symbols: [{ name: 'unaccent' }]
116
+ }
117
+ ];
118
+ /**
119
+ * Resolves install-schema and symbol-reference routing for extensions, backed
120
+ * by a version-aware symbol inventory.
121
+ */
122
+ export class ExtensionRouter {
123
+ routes;
124
+ serverVersion;
125
+ definitions;
126
+ /** `${namespace}:${name}` -> providing extension (+ version predicate). */
127
+ symbolIndex;
128
+ constructor(routes = {}, options = {}) {
129
+ this.routes = routes instanceof Map ? new Map(routes) : new Map(Object.entries(routes));
130
+ this.serverVersion = options.serverVersion ?? DEFAULT_SERVER_VERSION;
131
+ const inventory = options.inventory ?? COMMON_EXTENSIONS;
132
+ this.definitions = new Map(inventory.map(def => [def.name, def]));
133
+ this.symbolIndex = new Map();
134
+ for (const def of inventory) {
135
+ for (const sym of def.symbols) {
136
+ const key = symbolKey(sym.namespace ?? 'function', sym.name);
137
+ // First definition wins; inventories should not double-claim a symbol.
138
+ if (!this.symbolIndex.has(key)) {
139
+ this.symbolIndex.set(key, { extension: def.name, coreSince: sym.coreSince });
140
+ }
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Build a router that moves the given extensions (default: every inventoried
146
+ * one) to a single `targetSchema`, rewriting their references from `public`
147
+ * and from bare (unqualified) sites. Passing `null` as `targetSchema` strips
148
+ * qualification instead (the "repollute public / rely on search_path"
149
+ * direction).
150
+ */
151
+ static toSchema(targetSchema, options = {}) {
152
+ const inventory = options.inventory ?? COMMON_EXTENSIONS;
153
+ const names = options.extensions ?? inventory.map(d => d.name);
154
+ const from = options.from ?? ['public', null];
155
+ const spec = {};
156
+ for (const name of names)
157
+ spec[name] = { to: targetSchema, from };
158
+ return new ExtensionRouter(spec, options);
159
+ }
160
+ /** Coerce a spec, map, or existing router into a router. */
161
+ static from(source, options) {
162
+ if (source instanceof ExtensionRouter)
163
+ return source;
164
+ return new ExtensionRouter(source, options);
165
+ }
166
+ /** True when no extension routes are configured. */
167
+ get size() {
168
+ return this.routes.size;
169
+ }
170
+ /** Extension names this router may rewrite. */
171
+ routedExtensions() {
172
+ return [...this.routes.keys()];
173
+ }
174
+ /** True when any configured extension has inventoried symbols to rewrite. */
175
+ hasSymbolRoutes() {
176
+ for (const name of this.routes.keys()) {
177
+ const def = this.definitions.get(name);
178
+ if (def && !def.fixedSchema && def.symbols.length > 0)
179
+ return true;
180
+ }
181
+ return false;
182
+ }
183
+ /**
184
+ * Resolve the install schema for `CREATE EXTENSION <extname>` /
185
+ * `ALTER EXTENSION <extname> SET SCHEMA`. Returns:
186
+ * - a string to place/keep the extension in that schema,
187
+ * - `null` to install with no explicit `SCHEMA` clause (server default),
188
+ * - `undefined` to leave the statement unchanged (no route, or the extension
189
+ * is pinned to a fixed schema).
190
+ */
191
+ resolveInstall(extname) {
192
+ if (!extname)
193
+ return undefined;
194
+ const route = this.routes.get(extname);
195
+ if (!route)
196
+ return undefined;
197
+ if (this.definitions.get(extname)?.fixedSchema !== undefined)
198
+ return undefined;
199
+ return route.to;
200
+ }
201
+ /**
202
+ * Resolve a rewrite for a reference to `(schema, name)` in namespace `ns`,
203
+ * where `schema` is `null` for a bare reference. Returns the target
204
+ * ({@link SymbolRewrite}) or `undefined` to leave the reference unchanged.
205
+ *
206
+ * A reference is rewritten only when the inventory proves the symbol belongs
207
+ * to a routed extension, the symbol has not graduated to core at the target
208
+ * server version, the current schema is one the route rewrites `from`, and
209
+ * the destination actually differs from where it already is.
210
+ */
211
+ resolveSymbol(schema, name, ns) {
212
+ if (!name)
213
+ return undefined;
214
+ const indexed = this.symbolIndex.get(symbolKey(ns, name));
215
+ if (!indexed)
216
+ return undefined;
217
+ // Graduated into core at/after the target server version — resolves from
218
+ // pg_catalog and must not be tied to the extension's schema.
219
+ if (indexed.coreSince !== undefined && this.serverVersion >= indexed.coreSince)
220
+ return undefined;
221
+ const route = this.routes.get(indexed.extension);
222
+ if (!route)
223
+ return undefined;
224
+ if (this.definitions.get(indexed.extension)?.fixedSchema !== undefined)
225
+ return undefined;
226
+ const current = schema ?? null;
227
+ if (route.from !== undefined && !route.from.includes(current))
228
+ return undefined;
229
+ if (current === route.to)
230
+ return undefined;
231
+ return { to: route.to };
232
+ }
233
+ }
234
+ function symbolKey(ns, name) {
235
+ return `${ns}:${name}`;
236
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * AST transform that applies an {@link ExtensionRouter}.
3
+ *
4
+ * Unlike the schema transform — which only rewrites string *fields* already
5
+ * present on a node — moving an extension changes the **shape** of the AST:
6
+ *
7
+ * - `CREATE EXTENSION <e>` with no `SCHEMA` clause must gain one, which means
8
+ * constructing a new `DefElem` node and attaching it to a (possibly absent)
9
+ * `options` array; the reverse strips the `DefElem` (and empties `options`).
10
+ * - a bare `crypt(...)` reference gains a schema qualifier (a `String` node is
11
+ * prepended to `funcname`), and the reverse removes it.
12
+ *
13
+ * All rewrites are AST-precise and driven by the router's version-aware symbol
14
+ * inventory, so built-ins that merely share a name with an extension symbol
15
+ * (and symbols that have graduated into core) are left untouched.
16
+ */
17
+ import { walk as walkSql } from '@pgsql/traverse';
18
+ import { Deparser, parseSql, transformSync, walk as walkPlpgsql } from 'plpgsql-parser';
19
+ import { ExtensionRouter } from './extension-router';
20
+ /** Create a fresh result tracker. */
21
+ export function createExtensionResult() {
22
+ return { installsMoved: new Map(), symbolsRewritten: new Map() };
23
+ }
24
+ function schemaStringNode(schema) {
25
+ return { String: { sval: schema } };
26
+ }
27
+ /**
28
+ * Rewrite a (possibly schema-qualified) name list in place against the router.
29
+ * `names` is the node's own array (`funcname`, `TypeName.names`, ...); it is
30
+ * mutated in place so the enclosing node keeps referring to it.
31
+ */
32
+ function rewriteNameList(names, router, ns, result) {
33
+ if (!Array.isArray(names) || names.length === 0)
34
+ return;
35
+ const parts = names.map((n) => n?.String?.sval);
36
+ if (parts.some(p => typeof p !== 'string'))
37
+ return; // non-identifier element (e.g. %type)
38
+ const name = parts[parts.length - 1];
39
+ const schema = parts.length >= 2 ? parts[parts.length - 2] : null;
40
+ const rewrite = router.resolveSymbol(schema, name, ns);
41
+ if (!rewrite)
42
+ return;
43
+ if (rewrite.to === null) {
44
+ // Strip qualification: keep only the bare name node.
45
+ names.splice(0, names.length - 1);
46
+ }
47
+ else if (schema === null) {
48
+ // Qualify a bare reference: prepend the target schema.
49
+ names.unshift(schemaStringNode(rewrite.to));
50
+ }
51
+ else {
52
+ // Requalify: rewrite the existing schema element.
53
+ names[names.length - 2].String.sval = rewrite.to;
54
+ }
55
+ result.symbolsRewritten.set(name, (result.symbolsRewritten.get(name) ?? 0) + 1);
56
+ }
57
+ /** Read/replace the `schema` DefElem inside a `CreateExtensionStmt.options`. */
58
+ function setExtensionSchemaOption(node, target) {
59
+ const options = Array.isArray(node.options) ? node.options : [];
60
+ const idx = options.findIndex((opt) => opt?.DefElem?.defname === 'schema');
61
+ if (target === null) {
62
+ // Install with no explicit SCHEMA clause: remove any existing option.
63
+ if (idx === -1)
64
+ return false;
65
+ options.splice(idx, 1);
66
+ if (options.length === 0)
67
+ delete node.options;
68
+ else
69
+ node.options = options;
70
+ return true;
71
+ }
72
+ const defElem = {
73
+ DefElem: {
74
+ defname: 'schema',
75
+ arg: { String: { sval: target } },
76
+ defaction: 'DEFELEM_UNSPEC'
77
+ }
78
+ };
79
+ if (idx === -1) {
80
+ options.push(defElem);
81
+ }
82
+ else {
83
+ const existing = options[idx].DefElem;
84
+ if (existing.arg?.String?.sval === target)
85
+ return false;
86
+ existing.arg = { String: { sval: target } };
87
+ }
88
+ node.options = options;
89
+ return true;
90
+ }
91
+ /**
92
+ * Create a SQL AST visitor that applies extension routing. Composable with the
93
+ * walkers used by the core transform and by PL/pgSQL body traversal.
94
+ */
95
+ export function createExtensionVisitor(router, result) {
96
+ const rewriteBody = router.hasSymbolRoutes();
97
+ return {
98
+ CreateExtensionStmt: (path) => {
99
+ const node = path.node;
100
+ const target = router.resolveInstall(node.extname);
101
+ if (target === undefined)
102
+ return;
103
+ if (setExtensionSchemaOption(node, target)) {
104
+ result.installsMoved.set(node.extname, target);
105
+ }
106
+ },
107
+ // ALTER EXTENSION <e> SET SCHEMA <newschema>. The walker does not recurse
108
+ // into AlterObjectSchemaStmt.object; a null target has no SET SCHEMA form
109
+ // (a schema must be named), so it is left unchanged.
110
+ AlterObjectSchemaStmt: (path) => {
111
+ const node = path.node;
112
+ if (node.objectType !== 'OBJECT_EXTENSION')
113
+ return;
114
+ const extname = node.object?.String?.sval;
115
+ const target = router.resolveInstall(extname);
116
+ if (typeof target !== 'string' || target === node.newschema)
117
+ return;
118
+ node.newschema = target;
119
+ result.installsMoved.set(extname, target);
120
+ },
121
+ FuncCall: (path) => {
122
+ rewriteNameList(path.node.funcname, router, 'function', result);
123
+ },
124
+ // The walker does not auto-recurse into CallStmt.funccall.
125
+ CallStmt: (path) => {
126
+ if (path.node.funccall) {
127
+ rewriteNameList(path.node.funccall.funcname, router, 'function', result);
128
+ }
129
+ },
130
+ TypeName: (path) => {
131
+ rewriteNameList(path.node.names, router, 'type', result);
132
+ },
133
+ CreateFunctionStmt: (path) => {
134
+ const node = path.node;
135
+ if (!rewriteBody)
136
+ return;
137
+ // LANGUAGE sql bodies are opaque strings to the walker; parse and rewrite
138
+ // them separately (PL/pgSQL bodies ride the hydrated walk in transformExtensions).
139
+ const isPlpgsql = (node.options ?? []).some((opt) => opt?.DefElem?.defname === 'language' &&
140
+ opt.DefElem.arg?.String?.sval === 'plpgsql');
141
+ if (isPlpgsql || !Array.isArray(node.options))
142
+ return;
143
+ for (const opt of node.options) {
144
+ if (opt?.DefElem?.defname === 'as' && opt.DefElem.arg?.List?.items) {
145
+ for (const item of opt.DefElem.arg.List.items) {
146
+ if (typeof item?.String?.sval === 'string') {
147
+ try {
148
+ item.String.sval = rewriteSqlBodyString(item.String.sval, router, result);
149
+ }
150
+ catch {
151
+ // Not parseable standalone (e.g. C symbol names) — leave as-is.
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ };
159
+ }
160
+ /** Rewrite extension references inside a `LANGUAGE sql` body string. */
161
+ function rewriteSqlBodyString(body, router, result) {
162
+ const stmts = parseSql(body)?.stmts ?? [];
163
+ if (stmts.length === 0)
164
+ return body;
165
+ const visitor = createExtensionVisitor(router, result);
166
+ const pieces = [];
167
+ for (const stmt of stmts) {
168
+ if (!stmt?.stmt)
169
+ continue;
170
+ walkSql(stmt.stmt, visitor);
171
+ pieces.push(Deparser.deparse(stmt.stmt));
172
+ }
173
+ return pieces.join(';\n');
174
+ }
175
+ /**
176
+ * Apply extension routing to a SQL string: parse -> walk -> deparse, including
177
+ * PL/pgSQL and `LANGUAGE sql` bodies. Returns the rewritten SQL and a summary
178
+ * of what changed.
179
+ */
180
+ export function transformExtensions(sql, router, options = {}) {
181
+ const resolved = ExtensionRouter.from(router, options);
182
+ const result = createExtensionResult();
183
+ const out = transformSync(sql, (ctx) => {
184
+ const stmts = ctx.sql?.stmts ?? [];
185
+ const visitor = createExtensionVisitor(resolved, result);
186
+ for (const stmt of stmts) {
187
+ if (stmt?.stmt)
188
+ walkSql(stmt.stmt, visitor);
189
+ }
190
+ if (resolved.hasSymbolRoutes()) {
191
+ for (const fn of ctx.functions ?? []) {
192
+ if (fn.plpgsql?.hydrated) {
193
+ walkPlpgsql(fn.plpgsql.hydrated, {}, {
194
+ walkSqlExpressions: true,
195
+ sqlVisitor: visitor
196
+ });
197
+ }
198
+ }
199
+ }
200
+ }, { hydrate: true, pretty: true });
201
+ return { sql: out, result };
202
+ }
package/esm/facts.js CHANGED
@@ -13,6 +13,7 @@ const SECURITY_TAGS = new Set([
13
13
  ]);
14
14
  const KIND_BY_TAG = {
15
15
  CreateSchemaStmt: 'schema',
16
+ CreateExtensionStmt: 'extension',
16
17
  CreateStmt: 'table',
17
18
  ViewStmt: 'view',
18
19
  IndexStmt: 'index',
@@ -37,6 +38,23 @@ const KIND_BY_TAG = {
37
38
  function qn(schema, name) {
38
39
  return { schema: schema ?? null, name };
39
40
  }
41
+ /**
42
+ * Read the `SCHEMA <name>` clause of a `CreateExtensionStmt` from its options
43
+ * (`{ DefElem: { defname: 'schema', arg: { String: { sval } } } }`), or `null`
44
+ * when the statement specifies no schema.
45
+ */
46
+ function extensionSchemaOption(options) {
47
+ if (!Array.isArray(options))
48
+ return null;
49
+ for (const opt of options) {
50
+ const def = opt?.DefElem;
51
+ if (def?.defname === 'schema') {
52
+ const sval = def.arg?.String?.sval;
53
+ return typeof sval === 'string' ? sval : null;
54
+ }
55
+ }
56
+ return null;
57
+ }
40
58
  function nameListToQualified(names) {
41
59
  if (!Array.isArray(names) || names.length === 0)
42
60
  return null;
@@ -142,6 +160,14 @@ function classifyOne(nodeTag, node) {
142
160
  case 'CreateSchemaStmt':
143
161
  facts.creates.push(qn(null, node.schemaname));
144
162
  break;
163
+ case 'CreateExtensionStmt':
164
+ facts.extension = {
165
+ name: node.extname,
166
+ schema: extensionSchemaOption(node.options),
167
+ action: 'create',
168
+ ifNotExists: node.if_not_exists === true
169
+ };
170
+ break;
145
171
  case 'CreateStmt':
146
172
  case 'ViewStmt': {
147
173
  const rel = nodeTag === 'ViewStmt' ? node.view : node.relation;
@@ -203,6 +229,34 @@ function classifyOne(nodeTag, node) {
203
229
  facts.creates.push(name);
204
230
  break;
205
231
  }
232
+ case 'AlterObjectSchemaStmt':
233
+ // ALTER EXTENSION <name> SET SCHEMA <newschema>. Other object types
234
+ // (TABLE/TYPE/FUNCTION ... SET SCHEMA) keep their default classification.
235
+ if (node.objectType === 'OBJECT_EXTENSION') {
236
+ const name = node.object?.String?.sval;
237
+ if (typeof name === 'string') {
238
+ facts.kind = 'extension';
239
+ facts.extension = {
240
+ name,
241
+ schema: typeof node.newschema === 'string' ? node.newschema : null,
242
+ action: 'set_schema'
243
+ };
244
+ }
245
+ }
246
+ break;
247
+ case 'DropStmt':
248
+ // DROP EXTENSION <name> [, ...]. Extension names are bare String nodes.
249
+ // Other DROP object types keep their default classification. When
250
+ // several extensions are dropped in one statement the first names the
251
+ // fact; the full list stays available via the raw node.
252
+ if (node.removeType === 'OBJECT_EXTENSION' && Array.isArray(node.objects)) {
253
+ const name = node.objects[0]?.String?.sval;
254
+ if (typeof name === 'string') {
255
+ facts.kind = 'extension';
256
+ facts.extension = { name, schema: null, action: 'drop' };
257
+ }
258
+ }
259
+ break;
206
260
  case 'AlterTableStmt': {
207
261
  if (node.relation) {
208
262
  facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
package/esm/index.js CHANGED
@@ -1,4 +1,8 @@
1
1
  export { classifyStatements } from './facts';
2
+ export { COMMON_EXTENSIONS, ExtensionRouter } from './extension-router';
3
+ export { createExtensionResult, createExtensionVisitor, transformExtensions, } from './extension-transform';
4
+ export { RoleRouter } from './role-router';
5
+ export { createRoleResult, createRoleVisitor, transformRoles, } from './role-transform';
2
6
  export { collectCreatedObjects, createQualifyVisitor, mergeInventories, qualifyUnqualified, } from './qualify';
3
7
  export { SchemaRouter } from './router';
4
8
  export { captureAstsFromSql, captureTransformAsts, firstDifference, normalizeParseTree, validateRoundTrip, } from './round-trip';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Role-name routing for the core transform.
3
+ *
4
+ * Two databases can express the *same* access model with *different* role
5
+ * names — one calls the unauthenticated role `anonymous`, another `anon`; one
6
+ * calls the privileged role `administrator`, another something else. Portable
7
+ * SQL must be able to translate role identifiers between these conventions.
8
+ *
9
+ * Scope, deliberately narrow: a {@link RoleRouter} only renames role
10
+ * *identifiers* in the AST positions where a role name legitimately appears
11
+ * (grants, ownership, policies, default privileges, role membership, role
12
+ * settings). It does **not** alter role *attributes* (`BYPASSRLS`, `LOGIN`,
13
+ * ...): two roles that share a name across conventions do not necessarily
14
+ * share privilege scope, and inventing attribute changes would silently
15
+ * misrepresent the security model. Privilege-semantics reconciliation is a
16
+ * downstream, policy-level concern; the router restricts itself to the
17
+ * deterministic, reversible part — the name.
18
+ *
19
+ * Renaming is a pure identifier substitution, so a router is trivially
20
+ * invertible ({@link RoleRouter.invert}) for bidirectional translation.
21
+ */
22
+ /** Resolves a target role name for a source role name. */
23
+ export class RoleRouter {
24
+ map;
25
+ constructor(routes = {}) {
26
+ this.map = routes instanceof Map ? new Map(routes) : new Map(Object.entries(routes));
27
+ }
28
+ /** Coerce a spec, map, or existing router into a router. */
29
+ static from(source) {
30
+ return source instanceof RoleRouter ? source : new RoleRouter(source);
31
+ }
32
+ /** Number of configured renames. */
33
+ get size() {
34
+ return this.map.size;
35
+ }
36
+ /**
37
+ * Resolve the target name for `role`, or `undefined` when it is not routed
38
+ * or already at its destination.
39
+ */
40
+ resolve(role) {
41
+ if (!role)
42
+ return undefined;
43
+ const target = this.map.get(role);
44
+ if (target === undefined || target === role)
45
+ return undefined;
46
+ return target;
47
+ }
48
+ /**
49
+ * The inverse router (target -> source), for translating in the opposite
50
+ * direction. Throws if the mapping is not one-to-one.
51
+ */
52
+ invert() {
53
+ const inverted = new Map();
54
+ for (const [from, to] of this.map) {
55
+ if (inverted.has(to)) {
56
+ throw new Error(`RoleRouter.invert: mapping is not one-to-one (two sources map to "${to}")`);
57
+ }
58
+ inverted.set(to, from);
59
+ }
60
+ return new RoleRouter(inverted);
61
+ }
62
+ }