|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25522 cut 2] The datasource-routing bodies of `ObjectQL` — `getDriver` |
| 5 | + * (the name the routing order decides on, turned into a driver, with the |
| 6 | + * diagnosis when that driver is missing: #4462, framework#3828), |
| 7 | + * `resolveDatasourceFromMapping` (first matching `datasourceMapping` rule |
| 8 | + * wins) and `matchPattern` (the rules' glob match) — moved out of `engine.ts` |
| 9 | + * whole. |
| 10 | + * |
| 11 | + * A MOVE, not a redesign. Each function below is the body of the `ObjectQL` |
| 12 | + * method of the same name as it stood, re-indented one level and otherwise |
| 13 | + * unedited: the same debug lines emitted at the decision they describe, the |
| 14 | + * same three refusals for a binding with no driver, the same priority sort and |
| 15 | + * rule ladder, the same escaped glob. |
| 16 | + * |
| 17 | + * What stays in the class: `resolveDatasourceBinding`, the routing order |
| 18 | + * itself, because the liveness ledgers in `packages/spec/liveness/` and an ADR |
| 19 | + * cite it by `engine.ts#resolveDatasourceBinding` as the reader of |
| 20 | + * `object.datasource` and `manifest.defaultDatasource`; its two public faces |
| 21 | + * `resolveEffectiveDatasource` and `resolveMappedDatasource`, and |
| 22 | + * `isSystemLedgerObject`, whose bodies are shorter than the host literal |
| 23 | + * moving them would cost; `setDatasourceMapping`; and the two public statics. |
| 24 | + * |
| 25 | + * ## How the moved bodies reach the class |
| 26 | + * |
| 27 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 28 | + * header carries the full reasoning), applied per method as |
| 29 | + * `engine-validation-messages.ts` applies it. The class keeps every method — |
| 30 | + * the published `.d.ts` lists each one's name, docblock and position — and |
| 31 | + * each body is one literal of exactly the {@link DatasourceRoutingHost} |
| 32 | + * members that method reads, plus a `.call`; `matchPattern` reads no member, |
| 33 | + * takes no host and is called directly. A method is handed over as a closure |
| 34 | + * that calls it on the engine at call time, so a test double or spy set on the |
| 35 | + * instance is still the one the body reaches: `getDriver` reaches |
| 36 | + * `resolveDatasourceBinding`, and `resolveDatasourceFromMapping` reaches |
| 37 | + * `matchPattern`, through the engine, exactly as before. |
| 38 | + * |
| 39 | + * The fields the bodies read (`drivers`, `logger`, `unavailableDatasources`, |
| 40 | + * `datasourceMapping`) go as GETTERS, so each read happens when the body makes |
| 41 | + * it: `setDatasourceMapping` replaces the rule array and `registerDriver` |
| 42 | + * changes the driver map, and a body sees whatever is installed at the moment |
| 43 | + * it reads, as it did. |
| 44 | + * |
| 45 | + * This module imports the engine only as a TYPE, so there is no module cycle |
| 46 | + * at run time, and nothing re-exports it, so the published surface does not |
| 47 | + * move. |
| 48 | + */ |
| 49 | + |
| 50 | +import type { IDataDriver } from '@objectstack/core'; |
| 51 | +import type { ObjectQL } from './engine.js'; |
| 52 | +import { DatasourceUnavailableError } from './driver-connect-errors.js'; |
| 53 | + |
| 54 | +/** |
| 55 | + * The `ObjectQL` members the functions below read — every one of them, and no |
| 56 | + * other; each function's `this` is the `Pick` of the members IT reads. Each is |
| 57 | + * typed AS the class member (an indexed access on the class), so a change to a |
| 58 | + * member's signature on the class is a change here too, and naming a member |
| 59 | + * the class does not have is a compile error. See the module header for which |
| 60 | + * members are getters. |
| 61 | + */ |
| 62 | +export interface DatasourceRoutingHost { |
| 63 | + readonly drivers: ObjectQL['drivers']; |
| 64 | + readonly logger: ObjectQL['logger']; |
| 65 | + readonly unavailableDatasources: ObjectQL['unavailableDatasources']; |
| 66 | + readonly datasourceMapping: ObjectQL['datasourceMapping']; |
| 67 | + resolveDatasourceBinding: ObjectQL['resolveDatasourceBinding']; |
| 68 | + matchPattern: ObjectQL['matchPattern']; |
| 69 | +} |
| 70 | + |
| 71 | +/** The body of `ObjectQL#getDriver`; the method's docblock there is its contract. */ |
| 72 | +export function getDriver( |
| 73 | + this: Pick<DatasourceRoutingHost, 'resolveDatasourceBinding' | 'drivers' | 'logger' | 'unavailableDatasources'>, |
| 74 | + objectName: string, |
| 75 | +): IDataDriver { |
| 76 | + const binding = this.resolveDatasourceBinding(objectName); |
| 77 | + |
| 78 | + if (binding) { |
| 79 | + const driver = this.drivers.get(binding.datasource); |
| 80 | + if (driver) { |
| 81 | + // Debug lines kept at the DECISION they describe, but emitted here so |
| 82 | + // the resolver stays free of side effects: it also answers the public |
| 83 | + // {@link resolveEffectiveDatasource} probe, which must be able to name |
| 84 | + // a datasource without logging a routing event that never happened. |
| 85 | + if (binding.via === 'mapping') { |
| 86 | + this.logger.debug('Resolved datasource from mapping', { |
| 87 | + object: objectName, |
| 88 | + datasource: binding.datasource |
| 89 | + }); |
| 90 | + } else if (binding.via === 'package') { |
| 91 | + this.logger.debug('Resolved datasource from package manifest', { |
| 92 | + object: objectName, |
| 93 | + package: binding.packageId, |
| 94 | + datasource: binding.datasource |
| 95 | + }); |
| 96 | + } |
| 97 | + return driver; |
| 98 | + } |
| 99 | + |
| 100 | + // Only steps 1 and 2 reach here. Steps 3-5 answer ONLY when their driver |
| 101 | + // is registered (that registration is what opts the deployment into |
| 102 | + // lifecycle separation / a package default / a global default at all), so |
| 103 | + // a binding they produce always resolves. |
| 104 | + // |
| 105 | + // The datasource layer may have recorded WHY this one has no driver — |
| 106 | + // refused by the host policy, or failed to connect under |
| 107 | + // OS_ALLOW_DRIVER_CONNECT_FAILURE (framework#3828). Saying so beats |
| 108 | + // sending the reader hunting for a typo that isn't there. |
| 109 | + const unavailable = this.unavailableDatasources.get(binding.datasource); |
| 110 | + if (unavailable) { |
| 111 | + throw new DatasourceUnavailableError( |
| 112 | + binding.datasource, |
| 113 | + objectName, |
| 114 | + unavailable.kind, |
| 115 | + unavailable.publicDetail, |
| 116 | + ); |
| 117 | + } |
| 118 | + if (binding.via === 'mapping') { |
| 119 | + throw new Error( |
| 120 | + `[ObjectQL] Datasource '${binding.datasource}' mapped for object '${objectName}' is not registered. ` + |
| 121 | + `A datasourceMapping rule routes this object to it, so falling back to the default store would ` + |
| 122 | + `write the object's data to a different database than the one it declares. Fix the datasource ` + |
| 123 | + `configuration, or remove the mapping rule.`, |
| 124 | + ); |
| 125 | + } |
| 126 | + // No record: nothing ever tried to connect this name, so it is genuinely |
| 127 | + // undeclared (or misspelled). Unchanged message — there is nothing to add. |
| 128 | + throw new Error(`[ObjectQL] Datasource '${binding.datasource}' configured for object '${objectName}' is not registered.`); |
| 129 | + } |
| 130 | + |
| 131 | + throw new Error(`[ObjectQL] No driver available for object '${objectName}'`); |
| 132 | +} |
| 133 | + |
| 134 | +/** The body of `ObjectQL#resolveDatasourceFromMapping`; the method's docblock there is its contract. */ |
| 135 | +export function resolveDatasourceFromMapping( |
| 136 | + this: Pick<DatasourceRoutingHost, 'datasourceMapping' | 'matchPattern'>, |
| 137 | + objectName: string, |
| 138 | + object?: any |
| 139 | +): string | null { |
| 140 | + if (!this.datasourceMapping || this.datasourceMapping.length === 0) { |
| 141 | + return null; |
| 142 | + } |
| 143 | + |
| 144 | + // Sort rules by priority if any have priority set |
| 145 | + const sortedRules = [...this.datasourceMapping].sort((a, b) => { |
| 146 | + const aPriority = a.priority ?? 1000; |
| 147 | + const bPriority = b.priority ?? 1000; |
| 148 | + return aPriority - bPriority; |
| 149 | + }); |
| 150 | + |
| 151 | + for (const rule of sortedRules) { |
| 152 | + // 1. Match by namespace |
| 153 | + if (rule.namespace && object?.namespace === rule.namespace) { |
| 154 | + return rule.datasource; |
| 155 | + } |
| 156 | + |
| 157 | + // 2. Match by package ID |
| 158 | + if (rule.package && object?.packageId === rule.package) { |
| 159 | + return rule.datasource; |
| 160 | + } |
| 161 | + |
| 162 | + // 3. Match by object name pattern (glob-style) |
| 163 | + if (rule.objectPattern && this.matchPattern(objectName, rule.objectPattern)) { |
| 164 | + return rule.datasource; |
| 165 | + } |
| 166 | + |
| 167 | + // 4. Default fallback rule |
| 168 | + if (rule.default) { |
| 169 | + return rule.datasource; |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + return null; |
| 174 | +} |
| 175 | + |
| 176 | +/** The body of `ObjectQL#matchPattern`; the method's docblock there is its contract. */ |
| 177 | +export function matchPattern(objectName: string, pattern: string): boolean { |
| 178 | + const regexPattern = pattern |
| 179 | + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars |
| 180 | + .replace(/\*/g, '.*') // * → .* |
| 181 | + .replace(/\?/g, '.'); // ? → . |
| 182 | + |
| 183 | + const regex = new RegExp(`^${regexPattern}$`); |
| 184 | + return regex.test(objectName); |
| 185 | +} |
0 commit comments