Skip to content
11 changes: 7 additions & 4 deletions examples/angular/src/app/RxDB.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import type {
RxCollection,
RxDatabase
} from 'rxdb';
import type { AngularSignalReactivityLambda } from 'rxdb/plugins/reactivity-angular';
import { RxHeroDocumentType } from './schemas/hero.schema';
import { Signal } from '@angular/core';

// ORM methods
type RxHeroDocMethods = {
hpPercent(): number;
};

export type RxHeroDocument = RxDocument<RxHeroDocumentType, RxHeroDocMethods>;
/**
* Use AngularSignalReactivityLambda so that doc.$$ and doc.field$$ resolve to Signal<T>.
*/
export type RxHeroDocument = RxDocument<RxHeroDocumentType, RxHeroDocMethods, AngularSignalReactivityLambda>;

export type RxHeroCollection = RxCollection<RxHeroDocumentType, RxHeroDocMethods, unknown, unknown, Signal<unknown>>;
export type RxHeroCollection = RxCollection<RxHeroDocumentType, RxHeroDocMethods, unknown, unknown, AngularSignalReactivityLambda>;

export type RxHeroesCollections = {
hero: RxHeroCollection;
Expand All @@ -27,5 +30,5 @@ export type RxHeroesDatabase = RxDatabase<
RxHeroesCollections,
unknown,
unknown,
Signal<unknown>
AngularSignalReactivityLambda
>;
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class HeroesListComponent {
* You can also get singals instead of observables
* @link https://rxdb.info/reactivity.html
*/
public heroCount$$: Signal<unknown>;
public heroCount$$: Signal<number>;

@Output('edit') editChange: EventEmitter<RxHeroDocument> = new EventEmitter();

Expand Down
3 changes: 2 additions & 1 deletion examples/angular/src/app/services/database.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Injector, inject } from '@angular/core';
import { createReactivityFactory } from 'rxdb/plugins/reactivity-angular';
import type { AngularSignalReactivityLambda } from 'rxdb/plugins/reactivity-angular';

// import typings
import {
Expand Down Expand Up @@ -61,7 +62,7 @@ async function _create(): Promise<RxHeroesDatabase> {

console.log('DatabaseService: creating database..');

const db = await createRxDatabase<RxHeroesCollections>({
const db = await createRxDatabase<RxHeroesCollections, unknown, unknown, AngularSignalReactivityLambda>({
name: DATABASE_NAME,
storage: environment.getRxStorage(),
multiInstance: environment.multiInstance,
Expand Down
58 changes: 58 additions & 0 deletions examples/angular/src/app/typings-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Verifies that Angular Signal typing works correctly with RxDB.
* @link https://github.com/pubkey/rxdb/issues/8488
*/

/* eslint-disable @typescript-eslint/no-unused-vars */

import type { Signal } from '@angular/core';
import type { RxDocument } from 'rxdb';
import type { AngularSignalReactivityLambda } from 'rxdb/plugins/reactivity-angular';
import type {
RxHeroDocument,
RxHeroesDatabase,
RxHeroCollection
} from './RxDB.d';
import type { RxHeroDocumentType } from './schemas/hero.schema';

function checkDocumentDoubleDollar(doc: RxHeroDocument): Signal<RxHeroDocument> {
return doc.$$;
}

function checkDocumentDeletedDoubleDollar(doc: RxHeroDocument): Signal<boolean> {
return doc.deleted$$;
}

function checkDocumentFieldSignal(doc: RxHeroDocument): Signal<string> {
return doc.name$$;
}

function checkDocumentHpSignal(doc: RxHeroDocument): Signal<number> {
return doc.hp$$;
}

function checkQueryDoubleDollar(collection: RxHeroCollection): Signal<RxHeroDocument[]> {
return collection.find().$$;
}

function checkFindOneDoubleDollar(collection: RxHeroCollection): Signal<RxHeroDocument | null> {
return collection.findOne().$$;
}

function checkCountDoubleDollar(collection: RxHeroCollection): Signal<number> {
return collection.count().$$;
}

async function checkViaDatabase(db: RxHeroesDatabase) {
const heroCountSignal: Signal<number> = db.hero.count().$$;
const heroesSignal: Signal<RxHeroDocument[]> = db.hero.find().$$;
const firstHeroSignal: Signal<RxHeroDocument | null> = db.hero.findOne().$$;

const doc = await db.hero.findOne().exec();
if (doc) {
const nameSignal: Signal<string> = doc.name$$;
const hpSignal: Signal<number> = doc.hp$$;
const deletedSignal: Signal<boolean> = doc.deleted$$;
const docSignal: Signal<RxDocument<RxHeroDocumentType, any, AngularSignalReactivityLambda>> = doc.$$;
}
}
2 changes: 1 addition & 1 deletion examples/angular/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
]
},
"angularCompilerOptions": {
"fullTemplateTypeCheck": true,
"strictTemplates": true,
"strictInjectionParameters": true,
"disableTypeScriptVersionCheck": true
}
Expand Down
1 change: 1 addition & 0 deletions orga/changelog/fix-signal-types-angular.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- FIX generated `.d.ts` files under `dist/types/` contained `.ts` extension import specifiers that TypeScript with `moduleResolution: "bundler"` could not resolve, causing reactive field types (`doc.field$$`) to degrade to `Signal<unknown>` in Angular projects. `scripts/fix-types.mjs` now rewrites all generated declaration files. See [#8488](https://github.com/pubkey/rxdb/issues/8488)
54 changes: 40 additions & 14 deletions scripts/fix-types.mjs
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';

async function main () {
const file = './dist/types/index.d.ts';
try {
let content = await fs.readFile(file, { encoding: 'utf-8' });
// Convert only bare '.ts' specifier endings, keep existing '.d.ts' intact.
content = content.replace(/(?<!\.d)\.ts(?=['"])/g, '.d.ts');

// Guard against malformed declaration specifiers.
if (/\.d\.d\.ts(?=['"])/.test(content)) {
throw new Error('malformed declaration specifier found (.d.d.ts)');
/**
* Recursively collect all .d.ts files under a directory.
*/
async function collectDtsFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await collectDtsFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.d.ts')) {
files.push(fullPath);
}
}
return files;
}

async function fixDtsFile(file, addTsNoCheck = false) {
let content = await fs.readFile(file, { encoding: 'utf-8' });

// Replace .ts/.d.ts specifiers with .js so TypeScript resolves the companion .d.ts file.
content = content.replace(/(?:\.d)?\.ts(?=['"])/g, '.js');

if (addTsNoCheck) {
content = `// @ts-nocheck\n${content}`;
}

await fs.writeFile(file, content);
}

async function main() {
const distTypesDir = './dist/types';

try {
const files = await collectDtsFiles(distTypesDir);

content = `// @ts-nocheck
${content}`;
await fs.writeFile(file, content);
await Promise.all(files.map(file => {
const addTsNoCheck = path.resolve(file) === path.resolve(`${distTypesDir}/index.d.ts`);
return fixDtsFile(file, addTsNoCheck);
}));
} catch (err) {
console.error(`Fix types error${err.message}`);
console.error(`Fix types error: ${err.message}`);
process.exit(1);
}
}
Expand Down
43 changes: 43 additions & 0 deletions test/typings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
Reactified
} from '../plugins/core/index.mjs';
import { getRxStorageMemory } from '../plugins/storage-memory/index.mjs';
import type { AngularSignalReactivityLambda } from '../plugins/reactivity-angular/index.mjs';
import type { Signal } from '@angular/core';

type DefaultDocType = {
passportId: string;
Expand Down Expand Up @@ -600,6 +602,47 @@ describe('typings.test.ts', function () {
// @ts-expect-error should not be assignable to number
const queryWrong: number = doc.collection.find().$$;
});
/**
* @link https://github.com/pubkey/rxdb/issues/8488
* Verify that AngularSignalReactivityLambda produces Signal<T> with the
* correct inner type for $$, field$$, deleted$$, count().$$ etc.
* This tests the real Angular integration pattern.
*/
it('#8488 AngularSignalReactivityLambda should produce properly typed Signals', async () => {
type DbCollections = {
hero: RxCollection<DocType, unknown, unknown, unknown, AngularSignalReactivityLambda>;
};
type Db = RxDatabase<DbCollections, unknown, unknown, AngularSignalReactivityLambda>;
const db: Db = {} as any;

// collection.find().$$ must be Signal<RxDocument<DocType, ...>[]>
const heroesSignal: Signal<RxDocument<DocType, unknown, AngularSignalReactivityLambda>[]> = db.hero.find().$$;

// collection.findOne().$$ must be Signal<RxDocument<DocType, ...> | null>
const firstHeroSignal: Signal<RxDocument<DocType, unknown, AngularSignalReactivityLambda> | null> = db.hero.findOne().$$;

// collection.count().$$ must be Signal<number>
const countSignal: Signal<number> = db.hero.count().$$;

// doc.$$ must be Signal<RxDocument<DocType, ...>>
const doc = await db.hero.findOne().exec(true);
const docSignal: Signal<RxDocument<DocType, unknown, AngularSignalReactivityLambda>> = doc.$$;

// doc.deleted$$ must be Signal<boolean>
const deletedSignal: Signal<boolean> = doc.deleted$$;

// doc.age$$ must be Signal<number>
const ageSignal: Signal<number> = doc.age$$;

// doc.firstName$$ must be Signal<string>
const nameSignal: Signal<string> = doc.firstName$$;

// @ts-expect-error age$$ must not be assignable to Signal<string>
const ageWrong: Signal<string> = doc.age$$;

// @ts-expect-error count().$$ must not be assignable to Signal<string>
const countWrong: Signal<string> = db.hero.count().$$;
});
});
});
describe('local documents', () => {
Expand Down
Loading