Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7c320c3
refactor(postgres): migrate Store to current core API
loopingz May 6, 2026
d55ec5d
test(postgres): cover pubsub and queue services against real database
loopingz May 6, 2026
3b0348f
fix(postgres): unblock CI failures from queue index, channel regex, s…
loopingz May 6, 2026
2be8240
fix(postgres): smoke test extends WebdaApplicationTest for InstanceSt…
loopingz May 6, 2026
fe4a834
fix(postgres): import WebdaApplicationTest from core test index, not …
loopingz May 6, 2026
736b866
fix(postgres): underscore service name for prom-client; drop deep CRU…
loopingz May 6, 2026
e1bc8a4
test(postgres): broaden smoke coverage of migrated store paths
loopingz May 6, 2026
35f398f
fix(postgres): drop getRepository smoke test that needs model metadata
loopingz May 6, 2026
1dc06f7
fix(core,postgres): make StoreTest harness work for every store
loopingz May 6, 2026
cd3b862
fix(postgres): revert store spec to smoke tests pending repository fixes
loopingz May 6, 2026
b2549b3
feat(core): populate _modelsHierarchy in Store.computeParameters
loopingz May 7, 2026
09ab33c
feat(postgres): per-model repository + table mapping
loopingz May 7, 2026
2669537
test(postgres): extend StoreTest harness end-to-end
loopingz May 7, 2026
c79cdac
fix(postgres): register Webda/Ident and Webda/User in tweakApp
loopingz May 7, 2026
e1673ef
fix(postgres): revert to smoke tests pending repository routing diagn…
loopingz May 7, 2026
10f683d
fix(core): drop @InstanceCache on Store.computeStores so re-init re-r…
loopingz May 7, 2026
2fc1bc6
chore(core): temp diagnostic logging in Store.computeStores
loopingz May 7, 2026
fd2aa09
chore(core): switch computeStores diagnostic to console.log to surfac…
loopingz May 7, 2026
66fb0a6
chore(core): more diagnostic in computeParameters
loopingz May 7, 2026
5157cda
fix(core): bail computeParameters by checking model directly
loopingz May 7, 2026
3497110
chore(core): log hierarchy state in computeStores
loopingz May 7, 2026
034cf02
fix(postgres): alias bare @webda/core through src for class identity
loopingz May 7, 2026
ba860b4
fix(postgres): alias @webda/models too — Repositories WeakMap lives t…
loopingz May 7, 2026
5b5ca35
fix(postgres): revert to smoke tests, document class-identity blocker
loopingz May 7, 2026
ddcc709
fix(core): repair stale type references in shared test harnesses
loopingz May 7, 2026
58207b4
test(postgres): extend StoreTest harness end-to-end (round 2)
loopingz May 7, 2026
e0243f8
fix(postgres): drop .js extension from store.spec import (ESM strict …
loopingz May 7, 2026
afde042
fix(postgres): alias store.spec to compiled lib file for ESM resolution
loopingz May 7, 2026
b7f2cb0
fix(core): add exports field for lib subpaths
loopingz May 7, 2026
f1c1406
fix(core): add types + ./package.json subpaths to exports map
loopingz May 7, 2026
58ddafd
fix(core): explicit exports for shared spec subpaths
loopingz May 7, 2026
5d556cf
fix(postgres): alias store.spec to lib instead of using exports field
loopingz May 7, 2026
72df95b
fix(postgres): match lib/test alias style — point store.spec to sourc…
loopingz May 7, 2026
e55010d
fix(postgres): final revert to smoke tests; lock in harness improvements
loopingz May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions packages/core/src/services/binary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,28 +52,37 @@ abstract class BinaryTest<T extends BinaryService = BinaryService> extends Webda
* @returns
*/
tweakApp(app: TestApplication): Promise<void> {
app.addModel("ImageUser", ImageUser);
// Add the binaries relationship
app.getRelations("WebdaDemo/ImageUser").binaries = [
{
attribute: "images",
cardinality: "MANY"
// Register ImageUser with Relations.behaviors so that Binary/Binaries
// cardinality detection in uploadSuccess/deleteSuccess works correctly.
// (Previously used the now-removed app.getRelations() API.)
app.addModel("ImageUser", ImageUser, {
Identifier: "WebdaDemo/ImageUser",
Ancestors: [],
Subclasses: [],
Relations: {
behaviors: [
{ attribute: "images", behavior: "Webda/BinariesImpl" },
{ attribute: "profile", behavior: "Webda/Binary" }
]
},
{
attribute: "profile",
cardinality: "ONE"
}
];
PrimaryKey: ["uuid"],
Events: [],
Schemas: {},
Actions: {},
Import: "",
Plural: "ImageUsers",
Reflection: {}
});
return super.tweakApp(app);
}

getTestFile(): string {
return process.cwd() + "/test/Dockerfile.txt";
}

async beforeEach(init: boolean = true) {
async beforeEach() {
this.cleanFiles.push("./downloadTo.tmp");
await super.beforeEach(init);
await super.beforeEach();
this.binary = await this.getBinary();
assert.notStrictEqual(this.binary, undefined);
await this.binary.__clean();
Expand Down Expand Up @@ -156,7 +165,7 @@ abstract class BinaryTest<T extends BinaryService = BinaryService> extends Webda
await user1.refresh();
const ctx = await this.newContext();
if (withLogin) {
ctx.getSession().login(user1.getUuid(), "fake");
ctx.getSession().login(user1.getUUID(), "fake");
}
return { binary, user1, ctx };
}
Expand Down
44 changes: 17 additions & 27 deletions packages/core/src/stores/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ export class PermissionModel extends CoreModel {
* Use a custom model for the test
*/
export class UserTest extends User {
uuid: string;
declare uuid: string;
name: string;
counter: number;
idents: any[];
}

export class IdentTest extends Ident {
_lastUpdate: Date;
counter: number;
counter2: number;
counter3: number;
Expand Down Expand Up @@ -88,7 +89,7 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {
};
}

getModelClass(): ModelDefinition {
getModelClass(): any {
return TestIdent;
}

Expand All @@ -114,18 +115,7 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {
/**
* Fill the Store with data to be queried
*/
async fillForQuery(): Promise<
ModelDefinition<
CoreModelAny<{
state: string;
team: {
id: number;
};
role: number;
order: number;
}>
>
> {
async fillForQuery(): Promise<any> {
User.prototype.canAct = async () => true;
//this.webda.getApplication().getModel("Webda/User").prototype.canAct = async () => true;
//userStore._model.prototype.canAct = async () => true;
Expand Down Expand Up @@ -259,7 +249,7 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {

@test
async collection() {
const Ident: ModelDefinition<CoreModelAny> = this.getModelClass();
const Ident = this.getModelClass();
let ident = await Ident.create(<any>{
test: "plop"
});
Expand Down Expand Up @@ -357,12 +347,12 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {
const user3 = await UserTest.create({
name: "test3"
});
let users = await userStore.getAll();
let users = await (userStore as any).getAll();
assert.strictEqual(users.length, 3);
assert.strictEqual(users[0] instanceof userStore._model, true);
assert.strictEqual(users[1] instanceof userStore._model, true);
assert.strictEqual(users[2] instanceof userStore._model, true);
users = await userStore.getAll([user1.uuid, user3.uuid, randomUUID()]);
users = await (userStore as any).getAll([user1.uuid, user3.uuid, randomUUID()]);
assert.strictEqual(users.length, 2);
assert.strictEqual(users[0] instanceof userStore._model, true);
assert.strictEqual(users[1] instanceof userStore._model, true);
Expand Down Expand Up @@ -432,21 +422,21 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {
this.log("DEBUG", "Retrieved object", object);
assert.strictEqual(object.test, "plop2");
assert.strictEqual(object.details.plop, "plop2");
getter = await identStore.get(object.uuid);
getter = await (identStore as any).get(object.uuid);
assert.strictEqual(eventFired, 2);
assert.strictEqual(getter.test, "plop2");
await this.sleep(10);
this.log("DEBUG", "Increment attribute");
await IdentTest.ref(ident1.uuid).incrementAttribute("counter", 1);
let ident = await identStore.get(ident1.uuid);
let ident = await (identStore as any).get(ident1.uuid);

// Verify lastUpdate is updated too
this.assertLastUpdateNotEqual(ident._lastUpdate, ident1._lastUpdate, "lastUpdate after incrementAttribute failed");
assert.strictEqual(ident.counter, 1);
await IdentTest.ref(ident1.uuid).incrementAttribute("counter", 3);
ident1 = await identStore.get(ident1.uuid);
ident1 = await (identStore as any).get(ident1.uuid);
assert.strictEqual(ident1.counter, 4);
await identStore.incrementAttributes(ident1.uuid, [
await (identStore as any).incrementAttributes(ident1.uuid, [
{ property: "counter", value: -6 },
{ property: "counter2", value: 10 }
]);
Expand Down Expand Up @@ -543,15 +533,15 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {
const store = this.userStore;
let model = await UserTest.create({ counter: 1 });
// Delete with condition
await assert.rejects(() => store.delete(model.getUuid(), 4, "counter"), UpdateConditionFailError);
await assert.rejects(() => (store as any).delete(model.getUuid(), 4, "counter"), UpdateConditionFailError);

await model.delete("counter", 1);
// Test without condition
model = await UserTest.create({ counter: 2 });
await model.delete();

// Deleting a non-existing object should be ignored
await store.delete(randomUUID());
await (store as any).delete(randomUUID());
}

async deleteConcurrent() {
Expand Down Expand Up @@ -611,7 +601,7 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {
await model.save();
model.saveInnerMethod = true;
await model.save();
await IdentTest.ref(model.getUuid()).setAttribute("_lastUpdate", new Date(100));
await (IdentTest.ref(model.getUuid()) as any).setAttribute("_lastUpdate", new Date(100));
model.test = "yop";
// Delete with condition
await assert.rejects(() => model.save(), UpdateConditionFailError);
Expand Down Expand Up @@ -654,12 +644,12 @@ abstract class StoreTest<T extends Store<any>> extends WebdaApplicationTest {

@test
async upsert() {
const ref = IdentTest.ref(getUuid());
const ref = IdentTest.ref(randomUUID());
if (await ref.exists()) {
await ref.delete();
}
await ref.upsert({ test: "true" });
await ref.upsert({ test: "false" });
await (ref as any).upsert({ test: "true" });
await (ref as any).upsert({ test: "false" });
}
}

Expand Down
128 changes: 97 additions & 31 deletions packages/core/src/stores/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { ServiceParameters } from "../services/serviceparameters.js";
import { Service } from "../services/service.js";
import * as WebdaQL from "@webda/ql";
import type { WebdaQLString } from "@webda/ql";
import { useApplication, useModelId } from "../application/hooks.js";
import { useApplication, useModel, useModelId } from "../application/hooks.js";
import { useLog } from "../loggers/hooks.js";
import { useCore } from "../core/hooks.js";
import { useCore, useModelMetadata } from "../core/hooks.js";
import type { ModelMetadata } from "@webda/compiler";
import { InstanceCache } from "../cache/cache.js";

/** Error thrown when an item is not found in a store */
Expand Down Expand Up @@ -323,6 +324,10 @@ abstract class Store<K extends StoreParameters = StoreParameters, E extends Stor
* Contains the current model
*/
_model: ModelClass;
/**
* Contains the current model metadata
*/
_modelMetadata: ModelMetadata;
/**
* Store the manager hierarchy with their depth
*/
Expand All @@ -331,6 +336,17 @@ abstract class Store<K extends StoreParameters = StoreParameters, E extends Stor
* Contains the current model type
*/
_modelType: string;

/**
* Override the resolved model class for this store at runtime. Used by
* the abstract `StoreTest` harness so test fixtures can substitute a
* subclass with extra fields in place of the configured model. In
* production this is a no-op the framework never calls.
* @param model - the substitute model class
*/
setModelDefinitionHelper(model: ModelClass): void {
this._model = model;
}
/**
* Add metrics counter
* ' UNION SELECT name, tbl_name as email, "" as col1, "" as col2, "" as col3, "" as col4, "" as col5, "" as col6, "" as col7, "" as col8 FROM sqlite_master --
Expand All @@ -342,8 +358,17 @@ abstract class Store<K extends StoreParameters = StoreParameters, E extends Stor
queries: Histogram;
};

/** Compute and register repositories for all known models based on available stores */
@InstanceCache()
/**
* Compute and register repositories for all known models based on available stores.
*
* Called from `Store.init()` so each store's init triggers a recompute.
* Cannot be cached: the FIRST store to init (typically `Registry`) would
* claim every model with its fallback repository, and subsequent
* stores wouldn't get a chance to claim their configured models.
* Re-running is idempotent — `registerRepository` overwrites map entries
* in place, and the resolution loop is deterministic per current
* service/model state.
*/
static computeStores() {
// Gather all stores and register Repository
const stores = Object.values(useCore().getServices()).filter(s => s instanceof Store);
Expand Down Expand Up @@ -378,54 +403,95 @@ abstract class Store<K extends StoreParameters = StoreParameters, E extends Stor
}

/**
* Retrieve the Model
* Retrieve the Model and build the models hierarchy map.
*
* @throws Error if model is not found
* If the configured model cannot be found, or if no model was explicitly
* configured (store uses the default RegistryEntry fallback), the hierarchy
* stays empty and the store will not claim any model — preserving the
* pre-migration fallback behaviour for test-only or misconfigured stores.
*/
computeParameters(): void {
/*
super.computeParameters();
const app = useApplication();
this._model = useModel(this.parameters.model);

// Stores left with the default "Webda/RegistryEntry" model (and no
// additionalModels) should not participate in the hierarchy — they are
// typically the Registry fallback or test-only stores. Checking the
// resolved model directly avoids relying on a flag set by
// StoreParameters.load: addService bypasses subclass load() and
// constructs its parameters via the generic ServiceParameters.load,
// which never runs the StoreParameters override.
const isDefaultModel = !this.parameters.model || this.parameters.model === "Webda/RegistryEntry";
const hasAdditional = (this.parameters.additionalModels?.length ?? 0) > 0;
if (isDefaultModel && !hasAdditional) {
return;
}

// Guard: useModel throws on undefined and may return undefined/null for
// unknown models. Catch both so test-only or misconfigured stores stay
// harmless.
try {
this._model = useModel(this.parameters.model);
} catch {
this._model = undefined;
}
if (!this._model) {
throw new Error(`Model not found: ${this.parameters.model}`);
useLog("TRACE", `Store ${this.getName?.() ?? "unknown"}: model not found: ${this.parameters.model}`);
return;
}
this._modelMetadata = useModelMetadata(this._model);
if (!this._modelMetadata) {
throw new Error(`Model Metadata not found: ${this.parameters.model}`);
useLog("WARN", `Store ${this.getName?.() ?? "unknown"}: model metadata not found for ${this.parameters.model}`);
return;
}
useLog("TRACE", "METADATA", this._modelMetadata);
this._modelType = this._modelMetadata.Identifier;
const recursive = (tree: ModelClass[], depth) => {
for (const model of tree) {
this._modelsHierarchy[this._modelMetadata.Identifier] ??= depth;
this._modelsHierarchy[this._modelMetadata.Identifier] = Math.min(
depth,
this._modelsHierarchy[this._modelMetadata.Identifier]
);
recursive(this._modelMetadata.Subclasses, depth + 1);

// Recursively populate _modelsHierarchy for a model's subclass tree.
// Each subclass identifier in meta.Subclasses is a string that we resolve
// via useModel. We keep the minimum depth seen for each identifier.
const recursive = (subclassIds: string[], depth: number) => {
for (const id of subclassIds) {
this._modelsHierarchy[id] = Math.min(depth, this._modelsHierarchy[id] ?? depth);
let subModel: ModelClass | undefined;
try {
subModel = useModel(id);
} catch {
continue;
}
if (!subModel) continue;
const subMeta = useModelMetadata(subModel);
if (!subMeta) continue;
recursive(subMeta.Subclasses ?? [], depth + 1);
}
};
// Compute the hierarchy

// Compute the hierarchy — reset first so re-resolve is idempotent
this._modelsHierarchy = {};
this._modelsHierarchy[this._modelMetadata.Identifier] = 0;
// Strict Store only store their model
// Strict Store only stores their exact model
if (!this.parameters.strict) {
recursive(this._modelMetadata.Subclasses, 1);
recursive(this._modelMetadata.Subclasses ?? [], 1);
}
// Add additional models
if (this.parameters.additionalModels.length) {
// Strict mode is to only allow one model per store
// Add additional models (each treated as depth-0 roots with their own subtree)
if ((this.parameters.additionalModels ?? []).length) {
if (this.parameters.strict) {
this.log("ERROR", "Cannot add additional models in strict mode");
useLog("ERROR", "Cannot add additional models in strict mode");
} else {
for (const modelType of this.parameters.additionalModels) {
const model = useModel(modelType);
this._modelsHierarchy[this._modelMetadata.Identifier] = 0;
recursive(this._modelMetadata.Subclasses, 1);
for (const modelType of this.parameters.additionalModels!) {
let addModel: ModelClass | undefined;
try {
addModel = useModel(modelType);
} catch {
continue;
}
if (!addModel) continue;
const addMeta = useModelMetadata(addModel);
if (!addMeta) continue;
this._modelsHierarchy[addMeta.Identifier] = 0;
recursive(addMeta.Subclasses ?? [], 1);
}
}
}
*/
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/webda.module.json
Original file line number Diff line number Diff line change
Expand Up @@ -3832,5 +3832,5 @@
"rest-domain": "Webda/RESTOperationsTransport",
"http-server": "Webda/HttpServer"
},
"sourceDigest": "afd15f7002c79d9532e27b39040242e2"
"sourceDigest": "6d6c8cbfa29410b7b9d07a680a68daf2"
}
Loading
Loading