@nodefony/drizzle 10.0.0-alpha.1
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.
- package/LICENSE +544 -0
- package/README.md +162 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +105 -0
- package/dist/nodefony/command/migrateShared.js +247 -0
- package/dist/nodefony/command/orm-generate.js +356 -0
- package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
- package/dist/nodefony/command/orm-migrate-repair.js +114 -0
- package/dist/nodefony/command/orm-migrate-status.js +67 -0
- package/dist/nodefony/command/orm-migrate.js +141 -0
- package/dist/nodefony/command/orm-reset.js +166 -0
- package/dist/nodefony/config/config.js +107 -0
- package/dist/nodefony/config/defineModuleConfig.js +63 -0
- package/dist/nodefony/entity/auditEventEntity.js +93 -0
- package/dist/nodefony/entity/colKit.js +260 -0
- package/dist/nodefony/entity/idempotencyEntity.js +74 -0
- package/dist/nodefony/entity/sessionEntity.js +75 -0
- package/dist/nodefony/entity/tokenEntity.js +198 -0
- package/dist/nodefony/entity/totpSecretEntity.js +98 -0
- package/dist/nodefony/entity/userTable.js +141 -0
- package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
- package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
- package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/migrations-schema/mysql.js +48 -0
- package/dist/nodefony/migrations-schema/postgres.js +48 -0
- package/dist/nodefony/migrations-schema/sqlite.js +48 -0
- package/dist/nodefony/registerStores.js +218 -0
- package/dist/nodefony/service/DrizzleService.js +282 -0
- package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
- package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
- package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
- package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
- package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
- package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
- package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
- package/dist/nodefony/src/SessionStorage.js +259 -0
- package/dist/nodefony/src/connectorTarget.js +59 -0
- package/dist/nodefony/src/likeSql.js +50 -0
- package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
- package/dist/nodefony/src/migrator/adopt.js +553 -0
- package/dist/nodefony/src/migrator/appSchema.js +414 -0
- package/dist/nodefony/src/migrator/catalog.js +76 -0
- package/dist/nodefony/src/migrator/destructive.js +213 -0
- package/dist/nodefony/src/migrator/divergence.js +84 -0
- package/dist/nodefony/src/migrator/drivers/index.js +39 -0
- package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
- package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
- package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
- package/dist/nodefony/src/migrator/explain.js +565 -0
- package/dist/nodefony/src/migrator/hash.js +47 -0
- package/dist/nodefony/src/migrator/history.js +219 -0
- package/dist/nodefony/src/migrator/index.js +16 -0
- package/dist/nodefony/src/migrator/kit.js +296 -0
- package/dist/nodefony/src/migrator/name.js +68 -0
- package/dist/nodefony/src/migrator/paths.js +88 -0
- package/dist/nodefony/src/migrator/refusals.js +143 -0
- package/dist/nodefony/src/migrator/resolve.js +281 -0
- package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
- package/dist/nodefony/src/migrator/sources.js +419 -0
- package/dist/nodefony/src/migrator/status.js +231 -0
- package/dist/nodefony/src/migrator/types.js +91 -0
- package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
- package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
- package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
- package/dist/nodefony/src/orm-core/index.js +4 -0
- package/dist/nodefony/src/queryKit.js +318 -0
- package/dist/nodefony/src/safeTarget.js +55 -0
- package/dist/types/index.d.ts +76 -0
- package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
- package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
- package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
- package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
- package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
- package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
- package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
- package/dist/types/nodefony/config/config.d.ts +110 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
- package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
- package/dist/types/nodefony/entity/colKit.d.ts +130 -0
- package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
- package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
- package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
- package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
- package/dist/types/nodefony/entity/userTable.d.ts +65 -0
- package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
- package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
- package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
- package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
- package/dist/types/nodefony/registerStores.d.ts +52 -0
- package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
- package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
- package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
- package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
- package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
- package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
- package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
- package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
- package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
- package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
- package/dist/types/nodefony/src/likeSql.d.ts +29 -0
- package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
- package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
- package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
- package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
- package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
- package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
- package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
- package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
- package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
- package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
- package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
- package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
- package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
- package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
- package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
- package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
- package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
- package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
- package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
- package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
- package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
- package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
- package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
- package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
- package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
- package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
- package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
- package/dist/types/nodefony/src/queryKit.d.ts +136 -0
- package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
- package/docs/index.md +954 -0
- package/docs/migrations.md +691 -0
- package/migrations/mysql/0000_framework_init.sql +137 -0
- package/migrations/mysql/meta/_journal.json +13 -0
- package/migrations/postgres/0000_framework_init.sql +128 -0
- package/migrations/postgres/meta/_journal.json +13 -0
- package/migrations/sqlite/0000_framework_init.sql +127 -0
- package/migrations/sqlite/meta/_journal.json +13 -0
- package/package.json +126 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { WEBAUTHN_CREDENTIAL_ENTITY } from "../entity/webAuthnCredentialEntity.js";
|
|
2
|
+
import { assertPageQuery } from "nodefony";
|
|
3
|
+
import { paginate, searchCriteria } from "@nodefony/orm-core";
|
|
4
|
+
//#region nodefony/src/DrizzleWebAuthnCredentialStore.ts
|
|
5
|
+
/**
|
|
6
|
+
* Store de credentials WebAuthn **Drizzle** (driver `better-sqlite3`) —
|
|
7
|
+
* implémentation SQL d'{@link IWebAuthnCredentialStore} au-dessus d'un unique
|
|
8
|
+
* repository `@nodefony/orm-core` (`webauthn_credential`).
|
|
9
|
+
*
|
|
10
|
+
* **Approche B** : l'ORM ne connaît `@nodefony/security` qu'en `import type` → 0
|
|
11
|
+
* dépendance runtime. C'est l'application qui enregistre la fabrique
|
|
12
|
+
* (`registerWebAuthnStore("drizzle", …)`) et l'entité
|
|
13
|
+
* (`registerWebAuthnCredentialEntity(orm)` avant `orm.connect()`).
|
|
14
|
+
*
|
|
15
|
+
* **100 % portable** (aucun SQL natif) — toutes les opérations passent par le
|
|
16
|
+
* contrat `IRepository`, donc le code se transpose tel quel aux autres drivers.
|
|
17
|
+
*
|
|
18
|
+
* **Mapping Row ↔ contrat** : le repository renvoie une {@link WebAuthnCredentialRow}
|
|
19
|
+
* plate (`nickname: string | null`, `transports` mutable) ; le store la normalise
|
|
20
|
+
* en `IWebAuthnCredential` (`nickname?` omis si `null`). Le store de jetons n'a pas
|
|
21
|
+
* ce mapping car `IAccessTokenRecord` est déjà la forme repository (tout `| null`).
|
|
22
|
+
*/
|
|
23
|
+
var DrizzleWebAuthnCredentialStore = class DrizzleWebAuthnCredentialStore {
|
|
24
|
+
#repo;
|
|
25
|
+
#location;
|
|
26
|
+
/**
|
|
27
|
+
* @param repo - repository de la table `webauthn_credential`.
|
|
28
|
+
* @param location - emplacement physique de la base (fichier SQLite) pour Studio
|
|
29
|
+
* ({@link DrizzleOrm.location}) ; `undefined` pour un backend réseau/`:memory:`.
|
|
30
|
+
*/
|
|
31
|
+
constructor(repo, location) {
|
|
32
|
+
this.#repo = repo;
|
|
33
|
+
this.#location = location;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
37
|
+
* — lu par `readStoreLocation`. `undefined` = backend réseau ou `:memory:`.
|
|
38
|
+
*/
|
|
39
|
+
get location() {
|
|
40
|
+
return this.#location;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Construit le store depuis un {@link DrizzleOrm} connecté. L'entité
|
|
44
|
+
* (`registerWebAuthnCredentialEntity`) doit avoir été enregistrée **avant**
|
|
45
|
+
* `orm.connect()`.
|
|
46
|
+
*
|
|
47
|
+
* @param orm - ORM Drizzle connecté hébergeant la table du store.
|
|
48
|
+
*/
|
|
49
|
+
static from(orm) {
|
|
50
|
+
return new DrizzleWebAuthnCredentialStore(orm.getRepository(WEBAUTHN_CREDENTIAL_ENTITY), orm.location);
|
|
51
|
+
}
|
|
52
|
+
/** Row plate → credential du contrat (`nickname?` omis si `null`). */
|
|
53
|
+
#toCredential(row) {
|
|
54
|
+
return {
|
|
55
|
+
id: row.id,
|
|
56
|
+
userId: row.userId,
|
|
57
|
+
publicKey: row.publicKey,
|
|
58
|
+
signCount: row.signCount,
|
|
59
|
+
transports: row.transports,
|
|
60
|
+
backupEligible: row.backupEligible,
|
|
61
|
+
backupState: row.backupState,
|
|
62
|
+
uvInitialized: row.uvInitialized,
|
|
63
|
+
createdAt: row.createdAt,
|
|
64
|
+
lastUsedAt: row.lastUsedAt,
|
|
65
|
+
...row.nickname !== null ? { nickname: row.nickname } : {}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Credential du contrat → row plate (`nickname` absent → `null`, transports copié). */
|
|
69
|
+
#toRow(c) {
|
|
70
|
+
return {
|
|
71
|
+
id: c.id,
|
|
72
|
+
userId: c.userId,
|
|
73
|
+
publicKey: c.publicKey,
|
|
74
|
+
signCount: c.signCount,
|
|
75
|
+
transports: [...c.transports],
|
|
76
|
+
backupEligible: c.backupEligible,
|
|
77
|
+
backupState: c.backupState,
|
|
78
|
+
uvInitialized: c.uvInitialized,
|
|
79
|
+
nickname: c.nickname ?? null,
|
|
80
|
+
createdAt: c.createdAt,
|
|
81
|
+
lastUsedAt: c.lastUsedAt
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async findById(credentialId) {
|
|
85
|
+
const row = await this.#repo.findOne({ id: credentialId });
|
|
86
|
+
return row ? this.#toCredential(row) : null;
|
|
87
|
+
}
|
|
88
|
+
async findByUser(userId) {
|
|
89
|
+
return (await this.#repo.find({ userId })).map((row) => this.#toCredential(row));
|
|
90
|
+
}
|
|
91
|
+
/** `COUNT(*)` natif — jamais un `find().length` (le plafond ne charge rien). */
|
|
92
|
+
countByUser(userId) {
|
|
93
|
+
return this.#repo.count({ userId });
|
|
94
|
+
}
|
|
95
|
+
async save(credential) {
|
|
96
|
+
const { id, ...rest } = this.#toRow(credential);
|
|
97
|
+
await this.#repo.upsert({ id }, rest);
|
|
98
|
+
}
|
|
99
|
+
async update(credentialId, patch) {
|
|
100
|
+
await this.#repo.updateOne({ id: credentialId }, {
|
|
101
|
+
signCount: patch.signCount,
|
|
102
|
+
backupState: patch.backupState,
|
|
103
|
+
uvInitialized: patch.uvInitialized,
|
|
104
|
+
lastUsedAt: patch.lastUsedAt
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async delete(credentialId) {
|
|
108
|
+
await this.#repo.delete({ id: credentialId });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Critères du listing admin. `q` = PRÉFIXE d'`userId` (`LIKE 'x%'`, indexable),
|
|
112
|
+
* jamais une recherche `%…%`.
|
|
113
|
+
*/
|
|
114
|
+
#listCriteria(query) {
|
|
115
|
+
const criteria = {};
|
|
116
|
+
if (query.userId !== void 0) criteria.userId = query.userId;
|
|
117
|
+
else Object.assign(criteria, searchCriteria(query.q, ["userId"]) ?? {});
|
|
118
|
+
if (query.backedUp !== void 0) criteria.backupState = query.backedUp;
|
|
119
|
+
return criteria;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* {@inheritDoc IWebAuthnCredentialStore.listPage}
|
|
123
|
+
*
|
|
124
|
+
* 100 % portable : le helper `paginate()` d'orm-core (LIMIT/OFFSET + COUNT
|
|
125
|
+
* optionnel). La projection en vue admin retire `publicKey` — elle ne franchit
|
|
126
|
+
* jamais la frontière du store, quel que soit l'appelant.
|
|
127
|
+
*/
|
|
128
|
+
async listPage(query) {
|
|
129
|
+
assertPageQuery(query, "offset");
|
|
130
|
+
const page = await paginate(this.#repo, {
|
|
131
|
+
criteria: this.#listCriteria(query),
|
|
132
|
+
limit: query.limit,
|
|
133
|
+
offset: query.offset,
|
|
134
|
+
withTotal: query.withTotal,
|
|
135
|
+
order: [["createdAt", "DESC"], ["id", "ASC"]]
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
...page,
|
|
139
|
+
items: page.items.map((row) => ({
|
|
140
|
+
id: row.id,
|
|
141
|
+
userId: row.userId,
|
|
142
|
+
transports: row.transports,
|
|
143
|
+
backupEligible: row.backupEligible,
|
|
144
|
+
backupState: row.backupState,
|
|
145
|
+
uvInitialized: row.uvInitialized,
|
|
146
|
+
signCount: row.signCount,
|
|
147
|
+
createdAt: row.createdAt,
|
|
148
|
+
lastUsedAt: row.lastUsedAt,
|
|
149
|
+
...row.nickname !== null ? { nickname: row.nickname } : {}
|
|
150
|
+
}))
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** {@inheritDoc IWebAuthnCredentialStore.countCredentials} */
|
|
154
|
+
countCredentials(query) {
|
|
155
|
+
return this.#repo.count(this.#listCriteria(query));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
export { DrizzleWebAuthnCredentialStore };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { WEBHOOK_ENDPOINT_ENTITY } from "../entity/webhookEndpointEntity.js";
|
|
2
|
+
import { countWebhookEndpoints, listWebhookIdsPage } from "./queryKit.js";
|
|
3
|
+
import { assertPageQuery } from "nodefony";
|
|
4
|
+
import { WEBHOOK_SORTABLE_FIELDS } from "@nodefony/security";
|
|
5
|
+
//#region nodefony/src/DrizzleWebhookStore.ts
|
|
6
|
+
/**
|
|
7
|
+
* Store d'endpoints webhook **Drizzle** (driver `better-sqlite3`) —
|
|
8
|
+
* implémentation SQL d'{@link IWebhookStore} au-dessus d'un unique repository
|
|
9
|
+
* `@nodefony/orm-core` (`webhook_endpoint`). Persistance DURABLE du registre
|
|
10
|
+
* (les endpoints survivent au redémarrage, contrairement à `MemoryWebhookStore`).
|
|
11
|
+
*
|
|
12
|
+
* **Approche B** : l'ORM ne connaît `@nodefony/security` qu'en `import type` → 0
|
|
13
|
+
* dépendance runtime. C'est l'application qui enregistre la fabrique
|
|
14
|
+
* (`registerWebhookStore("drizzle", …)`) et l'entité
|
|
15
|
+
* (`registerWebhookEndpointEntity(orm)` avant `orm.connect()`).
|
|
16
|
+
*
|
|
17
|
+
* **Portable sauf le listing paginé** : le CRUD passe par le contrat
|
|
18
|
+
* `IRepository` (transposable tel quel aux autres drivers) ; seuls `listPage` /
|
|
19
|
+
* `countEndpoints` descendent au SQL natif via le `queryKit`, parce que le
|
|
20
|
+
* filtre `event` cherche dans un tableau JSON — inexprimable en `Criteria`.
|
|
21
|
+
*
|
|
22
|
+
* **Mapping Row ↔ contrat** minimal : `IWebhookEndpoint` est déjà « plat tout
|
|
23
|
+
* `| null` » ; seuls les champs JSON `events` (`readonly` → mutable) et
|
|
24
|
+
* `metadata` sont copiés défensivement.
|
|
25
|
+
*/
|
|
26
|
+
var DrizzleWebhookStore = class DrizzleWebhookStore {
|
|
27
|
+
/**
|
|
28
|
+
* {@inheritDoc IWebhookStore.sortableFields}
|
|
29
|
+
*
|
|
30
|
+
* Le moteur SQL trie sur n'importe laquelle de ces colonnes. Cette liste sert
|
|
31
|
+
* DEUX fois : elle annonce la capacité au data plane, et elle borne le
|
|
32
|
+
* `ORDER BY` construit par le queryKit (identifiant concaténé, non liable).
|
|
33
|
+
*/
|
|
34
|
+
sortableFields = WEBHOOK_SORTABLE_FIELDS;
|
|
35
|
+
#repo;
|
|
36
|
+
#location;
|
|
37
|
+
#db;
|
|
38
|
+
#dialect;
|
|
39
|
+
/**
|
|
40
|
+
* @param repo - repository de la table `webhook_endpoint`.
|
|
41
|
+
* @param location - emplacement physique de la base (fichier SQLite) pour Studio
|
|
42
|
+
* ({@link DrizzleOrm.location}) ; `undefined` pour un backend réseau/`:memory:`.
|
|
43
|
+
* @param db - handle Drizzle natif, requis par le seul listing paginé (filtre
|
|
44
|
+
* `event` = containment dans un tableau JSON, hors `Criteria` portable).
|
|
45
|
+
* `null` = store construit sans handle : `listPage` refuse plutôt que de
|
|
46
|
+
* charger toute la table en silence.
|
|
47
|
+
* @param dialect - dialecte SQL du connecteur (route les requêtes du queryKit).
|
|
48
|
+
*/
|
|
49
|
+
constructor(repo, location, db = null, dialect = "sqlite") {
|
|
50
|
+
this.#repo = repo;
|
|
51
|
+
this.#location = location;
|
|
52
|
+
this.#db = db;
|
|
53
|
+
this.#dialect = dialect;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
57
|
+
* — lu par `readStoreLocation`. `undefined` = backend réseau ou `:memory:`.
|
|
58
|
+
*/
|
|
59
|
+
get location() {
|
|
60
|
+
return this.#location;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Construit le store depuis un {@link DrizzleOrm} connecté. L'entité
|
|
64
|
+
* (`registerWebhookEndpointEntity`) doit avoir été enregistrée **avant**
|
|
65
|
+
* `orm.connect()`.
|
|
66
|
+
*
|
|
67
|
+
* @param orm - ORM Drizzle connecté hébergeant la table du store.
|
|
68
|
+
*/
|
|
69
|
+
static from(orm) {
|
|
70
|
+
return new DrizzleWebhookStore(orm.getRepository(WEBHOOK_ENDPOINT_ENTITY), orm.location, orm.getNativeConnection(), orm.dialect);
|
|
71
|
+
}
|
|
72
|
+
/** Row plate → endpoint du contrat (`events` mutable accepté en `readonly`). */
|
|
73
|
+
#toEndpoint(row) {
|
|
74
|
+
return { ...row };
|
|
75
|
+
}
|
|
76
|
+
/** Endpoint du contrat → row plate (copie défensive des champs JSON). */
|
|
77
|
+
#toRow(e) {
|
|
78
|
+
return {
|
|
79
|
+
...e,
|
|
80
|
+
events: [...e.events],
|
|
81
|
+
metadata: { ...e.metadata }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async save(endpoint) {
|
|
85
|
+
const { id, ...rest } = this.#toRow(endpoint);
|
|
86
|
+
await this.#repo.upsert({ id }, rest);
|
|
87
|
+
}
|
|
88
|
+
async findById(id) {
|
|
89
|
+
const row = await this.#repo.findOne({ id });
|
|
90
|
+
return row ? this.#toEndpoint(row) : null;
|
|
91
|
+
}
|
|
92
|
+
async update(id, patch) {
|
|
93
|
+
const { events, metadata, ...rest } = patch;
|
|
94
|
+
const row = { ...rest };
|
|
95
|
+
if (events !== void 0) row.events = [...events];
|
|
96
|
+
if (metadata !== void 0) row.metadata = { ...metadata };
|
|
97
|
+
await this.#repo.updateOne({ id }, row);
|
|
98
|
+
}
|
|
99
|
+
async delete(id) {
|
|
100
|
+
await this.#repo.delete({ id });
|
|
101
|
+
}
|
|
102
|
+
async listAll() {
|
|
103
|
+
return (await this.#repo.find({})).map((row) => this.#toEndpoint(row));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Handle natif ou erreur explicite — un `listPage` qui retomberait sur un
|
|
107
|
+
* `find({})` complet trahirait silencieusement la garantie du contrat
|
|
108
|
+
* (« jamais plus d'une page en mémoire »).
|
|
109
|
+
*/
|
|
110
|
+
#nativeDb() {
|
|
111
|
+
if (this.#db === null) throw new Error("DrizzleWebhookStore: listing paginé indisponible (store construit sans handle natif). Utiliser DrizzleWebhookStore.from(orm).");
|
|
112
|
+
return this.#db;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* {@inheritDoc IWebhookStore.listPage}
|
|
116
|
+
*
|
|
117
|
+
* Chemin NATIF (queryKit) : le filtre `event` cherche dans un tableau JSON —
|
|
118
|
+
* `json_each` (sqlite) / `@>` jsonb (postgres) / `JSON_CONTAINS` (mysql), non
|
|
119
|
+
* exprimable en `Criteria` portable. On sélectionne les `id` de la page (SQL
|
|
120
|
+
* pur, aucune ligne matérialisée), puis on recharge la page typée en 1
|
|
121
|
+
* requête `IN (...)` — coût O(page), jamais O(table).
|
|
122
|
+
*/
|
|
123
|
+
async listPage(query) {
|
|
124
|
+
assertPageQuery(query, "offset");
|
|
125
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
126
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
127
|
+
const db = this.#nativeDb();
|
|
128
|
+
const filters = {
|
|
129
|
+
enabled: query.enabled,
|
|
130
|
+
event: query.event,
|
|
131
|
+
failing: query.failing,
|
|
132
|
+
q: query.q
|
|
133
|
+
};
|
|
134
|
+
const { ids, hasNext } = await listWebhookIdsPage(db, this.#dialect, filters, {
|
|
135
|
+
limit,
|
|
136
|
+
offset,
|
|
137
|
+
...query.order ? { order: query.order } : {},
|
|
138
|
+
sortable: this.sortableFields
|
|
139
|
+
});
|
|
140
|
+
const total = query.withTotal === false ? void 0 : await countWebhookEndpoints(db, this.#dialect, filters);
|
|
141
|
+
if (ids.length === 0) return {
|
|
142
|
+
items: [],
|
|
143
|
+
total,
|
|
144
|
+
limit,
|
|
145
|
+
offset,
|
|
146
|
+
hasNext
|
|
147
|
+
};
|
|
148
|
+
const rows = await this.#repo.find({ id: { $in: ids } });
|
|
149
|
+
const byId = new Map(rows.map((row) => [row.id, this.#toEndpoint(row)]));
|
|
150
|
+
return {
|
|
151
|
+
items: ids.map((id) => byId.get(id)).filter((e) => e !== void 0),
|
|
152
|
+
total,
|
|
153
|
+
limit,
|
|
154
|
+
offset,
|
|
155
|
+
hasNext
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** {@inheritDoc IWebhookStore.countEndpoints} */
|
|
159
|
+
countEndpoints(query) {
|
|
160
|
+
return countWebhookEndpoints(this.#nativeDb(), this.#dialect, {
|
|
161
|
+
enabled: query.enabled,
|
|
162
|
+
event: query.event,
|
|
163
|
+
failing: query.failing,
|
|
164
|
+
q: query.q
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
//#endregion
|
|
169
|
+
export { DrizzleWebhookStore };
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { SESSION_CONNECTOR } from "../entity/sessionEntity.js";
|
|
2
|
+
import { assertPageQuery, pickOrder, renameOrderFields } from "nodefony";
|
|
3
|
+
import { ormRegistry, paginate } from "@nodefony/orm-core";
|
|
4
|
+
import { SESSION_COLUMN_ALIASES, SESSION_DEFAULT_ORDER, SESSION_SORTABLE_FIELDS, SessionsService } from "@nodefony/http";
|
|
5
|
+
//#region nodefony/src/SessionStorage.ts
|
|
6
|
+
/**
|
|
7
|
+
* Stockage de session **Drizzle** (driver `better-sqlite3`), branché sur
|
|
8
|
+
* `@nodefony/orm-core`.
|
|
9
|
+
*
|
|
10
|
+
* Implémente le contrat {@link ISessionStorage} consommé par le `SessionsService`
|
|
11
|
+
* de `@nodefony/http` — store de session portable. Persiste via
|
|
12
|
+
* le repository orm-core de l'entité `session` (connecteur `default`, table créée
|
|
13
|
+
* au boot par `DrizzleOrm`). Le GC supprime les sessions expirées avec un
|
|
14
|
+
* opérateur riche portable (`updatedAt < cutoff`).
|
|
15
|
+
*/
|
|
16
|
+
var SessionStorage = class SessionStorage {
|
|
17
|
+
manager;
|
|
18
|
+
idleTimeoutS;
|
|
19
|
+
absoluteTimeoutS;
|
|
20
|
+
/**
|
|
21
|
+
* Même vocabulaire public que les autres backends — le tri part dans le
|
|
22
|
+
* `ORDER BY`, donc il ne coûte rien de plus qu'un index bien posé.
|
|
23
|
+
*/
|
|
24
|
+
sortableFields = SESSION_SORTABLE_FIELDS;
|
|
25
|
+
constructor(manager) {
|
|
26
|
+
this.manager = manager;
|
|
27
|
+
this.idleTimeoutS = manager.options.idleTimeoutS;
|
|
28
|
+
this.absoluteTimeoutS = manager.options.absoluteTimeoutS;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
32
|
+
* — lu par `readStoreLocation` du core au boot de `SessionsService`. Résolu
|
|
33
|
+
* **lazy** depuis l'ORM du connecteur session (comme {@link SessionStorage.#repo}) :
|
|
34
|
+
* `undefined` si l'ORM n'est pas encore enregistré, en `:memory:`, ou réseau
|
|
35
|
+
* (postgres/mysql → l'emplacement EST l'infra déclarée, surfacée à part). Lecture
|
|
36
|
+
* DÉFENSIVE (getter `location` optionnel) — l'ORM base `IOrm` ne l'expose pas.
|
|
37
|
+
*/
|
|
38
|
+
get location() {
|
|
39
|
+
if (!ormRegistry.has("default")) return;
|
|
40
|
+
const loc = ormRegistry.get(SESSION_CONNECTOR).location;
|
|
41
|
+
return typeof loc === "string" && loc.length > 0 ? loc : void 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Repository de l'entité session, ou `null` si l'ORM n'est pas (ou plus)
|
|
45
|
+
* connecté.
|
|
46
|
+
*
|
|
47
|
+
* Cas concret : pendant le shutdown du kernel, `DrizzleService` déconnecte
|
|
48
|
+
* l'ORM (`disconnect()` annule ses tables) alors que des requêtes peuvent
|
|
49
|
+
* encore être en vol (firewall → `startSession`). Plutôt que de jeter
|
|
50
|
+
* « no entity table registered under session » (qui devenait un 500 sur ces
|
|
51
|
+
* requêtes + un `unhandledRejection` via le GC fire-and-forget), on renvoie
|
|
52
|
+
* `null` et chaque opération dégrade gracieusement (session non persistée le
|
|
53
|
+
* temps de l'arrêt). Une table réellement absente sur un ORM **connecté**
|
|
54
|
+
* (vraie misconfig) jette toujours via `getRepository`.
|
|
55
|
+
*/
|
|
56
|
+
#repo() {
|
|
57
|
+
const orm = ormRegistry.get(SESSION_CONNECTOR);
|
|
58
|
+
if (!orm.isConnected()) return null;
|
|
59
|
+
return orm.getRepository("session");
|
|
60
|
+
}
|
|
61
|
+
async read(id) {
|
|
62
|
+
const criteria = { session_id: id };
|
|
63
|
+
const repo = this.#repo();
|
|
64
|
+
if (!repo) return {};
|
|
65
|
+
const row = await repo.findOne(criteria);
|
|
66
|
+
if (!row) return {};
|
|
67
|
+
return {
|
|
68
|
+
Attributes: row.Attributes ?? {},
|
|
69
|
+
metaBag: row.metaBag ?? {},
|
|
70
|
+
flashBag: row.flashBag ?? {},
|
|
71
|
+
user: row.user ?? "",
|
|
72
|
+
createdAt: new Date(row.createdAt),
|
|
73
|
+
updatedAt: new Date(row.updatedAt)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async start(id) {
|
|
77
|
+
return this.read(id);
|
|
78
|
+
}
|
|
79
|
+
async write(id, data) {
|
|
80
|
+
const serialize = data;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const repo = this.#repo();
|
|
83
|
+
if (!repo) return {
|
|
84
|
+
...serialize,
|
|
85
|
+
createdAt: new Date(now),
|
|
86
|
+
updatedAt: new Date(now)
|
|
87
|
+
};
|
|
88
|
+
const fields = {
|
|
89
|
+
Attributes: serialize.Attributes,
|
|
90
|
+
flashBag: serialize.flashBag,
|
|
91
|
+
metaBag: serialize.metaBag,
|
|
92
|
+
user: serialize.user || null,
|
|
93
|
+
updatedAt: now
|
|
94
|
+
};
|
|
95
|
+
const row = await repo.upsert({ session_id: id }, fields, { createdAt: now });
|
|
96
|
+
return {
|
|
97
|
+
...serialize,
|
|
98
|
+
createdAt: new Date(row.createdAt),
|
|
99
|
+
updatedAt: new Date(now)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async open() {
|
|
103
|
+
await this.gc();
|
|
104
|
+
const repo = this.#repo();
|
|
105
|
+
if (!repo) return 0;
|
|
106
|
+
const count = await repo.count();
|
|
107
|
+
this.manager.log(`DRIZZLE SESSIONS STORAGE ==> COUNT SESSIONS : ${count}`, "INFO");
|
|
108
|
+
return count;
|
|
109
|
+
}
|
|
110
|
+
close() {
|
|
111
|
+
this.gc();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
async destroy(id) {
|
|
115
|
+
const criteria = { session_id: id };
|
|
116
|
+
const repo = this.#repo();
|
|
117
|
+
if (!repo) return true;
|
|
118
|
+
await repo.delete(criteria);
|
|
119
|
+
this.manager.log(`DRIZZLE DESTROY SESSION ID : ${id}`, "DEBUG");
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
async gc(idleSeconds, absoluteSeconds) {
|
|
123
|
+
const repo = this.#repo();
|
|
124
|
+
if (!repo) return;
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const idleCutoff = now - (idleSeconds ?? this.idleTimeoutS) * 1e3;
|
|
127
|
+
let deleted = await repo.delete({ updatedAt: { $lt: idleCutoff } });
|
|
128
|
+
const absoluteS = absoluteSeconds ?? this.absoluteTimeoutS;
|
|
129
|
+
if (absoluteS > 0) deleted += await repo.delete({ createdAt: { $lt: now - absoluteS * 1e3 } });
|
|
130
|
+
if (deleted > 0) this.manager.log(`DRIZZLE SESSIONS GC ==> ${deleted} DELETED`, "DEBUG");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Prolonge l'idle d'une session (timeout glissant) : `UPDATE updatedAt = now`
|
|
134
|
+
* sur la PK `session_id` — SANS réécrire le blob (touch NIST/OWASP). N'affecte
|
|
135
|
+
* pas `createdAt` (= borne absolute). ORM déconnecté → no-op ; une ligne absente
|
|
136
|
+
* (session expirée) → 0 row affectée, silencieux.
|
|
137
|
+
*/
|
|
138
|
+
async touch(id) {
|
|
139
|
+
const repo = this.#repo();
|
|
140
|
+
if (!repo) return;
|
|
141
|
+
await repo.updateOne({ session_id: id }, { updatedAt: Date.now() });
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Énumération admin (capacité optionnelle d'`ISessionStorage`) : un `SELECT`
|
|
145
|
+
* filtrable par `user` (WHERE indexable côté SQL). **Redaction par construction**
|
|
146
|
+
* — seuls `user`/`metaBag`/timestamps sortent de la base ; `Attributes`/`flashBag`
|
|
147
|
+
* (potentiellement sensibles) restent en base. ORM déconnecté → `[]`.
|
|
148
|
+
*/
|
|
149
|
+
async listAll(filter) {
|
|
150
|
+
const repo = this.#repo();
|
|
151
|
+
if (!repo) return [];
|
|
152
|
+
return (filter?.user !== void 0 ? await repo.find({ user: filter.user }) : await repo.find()).map((row) => SessionStorage.#toRecord(row));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Projette une ligne SQL en {@link ISessionRecord} **redacté** : `Attributes` et
|
|
156
|
+
* `flashBag` (potentiellement sensibles) restent en base, seuls `user`/`metaBag`/
|
|
157
|
+
* horodatages sortent. Une session anonyme est stockée `user = NULL` (cf `write`)
|
|
158
|
+
* et ressort en chaîne vide — la représentation du « pas d'utilisateur » est une
|
|
159
|
+
* affaire de backend, jamais du contrat.
|
|
160
|
+
*/
|
|
161
|
+
static #toRecord(row) {
|
|
162
|
+
return {
|
|
163
|
+
id: row.session_id,
|
|
164
|
+
data: {
|
|
165
|
+
Attributes: {},
|
|
166
|
+
flashBag: {},
|
|
167
|
+
metaBag: row.metaBag ?? {},
|
|
168
|
+
user: row.user ?? "",
|
|
169
|
+
createdAt: new Date(row.createdAt),
|
|
170
|
+
updatedAt: new Date(row.updatedAt)
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Traduit les filtres du contrat en `Criteria` orm-core — **portables** (égalité
|
|
176
|
+
* + `IS [NOT] NULL`), donc indexables par le SQL et jamais ré-appliqués en
|
|
177
|
+
* mémoire. Source unique du périmètre : {@link listPage}, {@link countSessions}
|
|
178
|
+
* et {@link countDistinctUsers} le partagent, ils ne peuvent pas diverger.
|
|
179
|
+
*
|
|
180
|
+
* Les deux filtres portent la **même colonne** (`authenticated` signifie
|
|
181
|
+
* « `user` non nul »), donc un critère AND-only ne peut en porter qu'une
|
|
182
|
+
* condition. Quand ils se contredisent — un identifiant nommé qu'on demande
|
|
183
|
+
* anonyme, ou l'inverse — la réponse honnête est **l'ensemble vide**, et c'est
|
|
184
|
+
* ce que dit `null`. En jeter un silencieusement rendait un compteur faux :
|
|
185
|
+
* `?user=alice` + `authenticated:false` répondait « 1 session anonyme »
|
|
186
|
+
* en désignant la session authentifiée d'alice.
|
|
187
|
+
*
|
|
188
|
+
* @returns `undefined` (aucun filtre), le critère, ou **`null`** si les
|
|
189
|
+
* filtres sont contradictoires — l'appelant court-circuite alors sa requête.
|
|
190
|
+
*/
|
|
191
|
+
static #criteria(query) {
|
|
192
|
+
if (!query) return void 0;
|
|
193
|
+
const { user, authenticated } = query;
|
|
194
|
+
if (user !== void 0) {
|
|
195
|
+
const anonymous = user === "";
|
|
196
|
+
if (authenticated !== void 0 && authenticated === anonymous) return null;
|
|
197
|
+
return { user: anonymous ? { $null: true } : user };
|
|
198
|
+
}
|
|
199
|
+
if (authenticated !== void 0) return { user: { $null: !authenticated } };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Pagination **native** `LIMIT/OFFSET` + `COUNT` (helper `paginate` orm-core) :
|
|
203
|
+
* une page = une requête bornée, quel que soit le nombre de sessions en base.
|
|
204
|
+
* Ordre `updatedAt` DESC départagé par `session_id` — déterministe même quand
|
|
205
|
+
* deux sessions partagent la milliseconde. ORM déconnecté → page vide.
|
|
206
|
+
*/
|
|
207
|
+
async listPage(query) {
|
|
208
|
+
assertPageQuery(query, "offset");
|
|
209
|
+
const repo = this.#repo();
|
|
210
|
+
if (!repo) return {
|
|
211
|
+
items: [],
|
|
212
|
+
total: query.withTotal === false ? void 0 : 0,
|
|
213
|
+
limit: query.limit,
|
|
214
|
+
offset: query.offset ?? 0,
|
|
215
|
+
hasNext: false
|
|
216
|
+
};
|
|
217
|
+
const criteria = SessionStorage.#criteria(query);
|
|
218
|
+
if (criteria === null) return {
|
|
219
|
+
items: [],
|
|
220
|
+
total: query.withTotal === false ? void 0 : 0,
|
|
221
|
+
limit: query.limit,
|
|
222
|
+
offset: query.offset ?? 0,
|
|
223
|
+
hasNext: false
|
|
224
|
+
};
|
|
225
|
+
const page = await paginate(repo, {
|
|
226
|
+
criteria,
|
|
227
|
+
limit: query.limit,
|
|
228
|
+
offset: query.offset,
|
|
229
|
+
withTotal: query.withTotal,
|
|
230
|
+
order: renameOrderFields(pickOrder(query.order, this.sortableFields, SESSION_DEFAULT_ORDER), SESSION_COLUMN_ALIASES)
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
...page,
|
|
234
|
+
items: page.items.map((row) => SessionStorage.#toRecord(row))
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** `COUNT(*)` natif filtré — aucune ligne matérialisée. ORM déconnecté → 0. */
|
|
238
|
+
async countSessions(query) {
|
|
239
|
+
const repo = this.#repo();
|
|
240
|
+
if (!repo) return 0;
|
|
241
|
+
const criteria = SessionStorage.#criteria(query);
|
|
242
|
+
return criteria === null ? 0 : repo.count(criteria);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* `COUNT(DISTINCT user)` natif filtré. `write` normalise l'anonyme en `NULL`,
|
|
246
|
+
* et `COUNT(DISTINCT …)` ignore les `NULL` : les sessions anonymes ne forment
|
|
247
|
+
* donc pas un « utilisateur » de plus, sans filtre supplémentaire.
|
|
248
|
+
* ORM déconnecté → 0 (même dégradation que {@link countSessions}).
|
|
249
|
+
*/
|
|
250
|
+
async countDistinctUsers(query) {
|
|
251
|
+
const repo = this.#repo();
|
|
252
|
+
if (!repo) return 0;
|
|
253
|
+
const criteria = SessionStorage.#criteria(query);
|
|
254
|
+
return criteria === null ? 0 : repo.countDistinct("user", criteria);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
SessionsService.registerStorage("drizzle", SessionStorage);
|
|
258
|
+
//#endregion
|
|
259
|
+
export { SessionStorage as default };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
//#region nodefony/src/connectorTarget.ts
|
|
3
|
+
/**
|
|
4
|
+
* OÙ vit la base d'un connecteur — **une seule implémentation, deux lecteurs**.
|
|
5
|
+
*
|
|
6
|
+
* Le service qui connecte l'application au démarrage et les commandes de
|
|
7
|
+
* migration doivent désigner exactement la même base. Ce n'est pas une
|
|
8
|
+
* commodité : quand les deux divergent, rien ne le signale. La commande décrit
|
|
9
|
+
* alors une base que l'application n'utilise pas, annonce « à jour » ou
|
|
10
|
+
* « appliqué », et rend le code de sortie du succès.
|
|
11
|
+
*
|
|
12
|
+
* C'est arrivé, et voici comment : `filename` est **optionnel sans défaut**
|
|
13
|
+
* dans le schéma de configuration, parce que sa valeur dépend du kernel
|
|
14
|
+
* (`<app>/var/databases/…`) et que le schéma reste pur. Une lecture naïve de la
|
|
15
|
+
* configuration rend donc `undefined` — et le pilote SQLite retombe alors sur
|
|
16
|
+
* une base **en mémoire**, vide, jetée à la fin du processus. Toutes les
|
|
17
|
+
* migrations s'y « appliquent » parfaitement.
|
|
18
|
+
*
|
|
19
|
+
* La règle vit donc ici, et les deux appelants l'appellent.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Chemin SQLite par défaut d'un connecteur, résolu depuis le kernel.
|
|
23
|
+
*
|
|
24
|
+
* Sous `kernel.varDir` (`<app>/var`) — la base commune des données persistées :
|
|
25
|
+
* un seul répertoire à sauvegarder et à ignorer du dépôt, et « où sont mes
|
|
26
|
+
* données » a une réponse unique.
|
|
27
|
+
*
|
|
28
|
+
* @param kernel - kernel courant (`null` accepté : repli sur le répertoire courant).
|
|
29
|
+
* @param name - nom du connecteur.
|
|
30
|
+
* @returns le chemin absolu du fichier de base.
|
|
31
|
+
*/
|
|
32
|
+
function defaultConnectorFilename(kernel, name) {
|
|
33
|
+
const root = (typeof kernel?.path === "string" ? kernel.path : null) ?? process.cwd();
|
|
34
|
+
const varPath = kernel?.varDir?.path;
|
|
35
|
+
const base = typeof varPath === "string" ? varPath : path.resolve(root, "var");
|
|
36
|
+
const file = name === "default" ? "nodefony-drizzle.db" : `nodefony-${name}.db`;
|
|
37
|
+
return path.resolve(base, "databases", file);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Résout les coordonnées d'un connecteur : dialecte, et fichier ou URL.
|
|
41
|
+
*
|
|
42
|
+
* @param kernel - kernel courant, pour le chemin par défaut.
|
|
43
|
+
* @param name - nom du connecteur.
|
|
44
|
+
* @param cfg - configuration déclarée du connecteur.
|
|
45
|
+
* @returns les coordonnées, avec le fichier SQLite TOUJOURS résolu.
|
|
46
|
+
*/
|
|
47
|
+
function resolveConnectorTarget(kernel, name, cfg) {
|
|
48
|
+
const dialect = cfg.dialect ?? "sqlite";
|
|
49
|
+
if (dialect !== "sqlite") return {
|
|
50
|
+
dialect,
|
|
51
|
+
url: cfg.url
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
dialect,
|
|
55
|
+
filename: cfg.filename ?? defaultConnectorFilename(kernel, name)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { defaultConnectorFilename, resolveConnectorTarget };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { LIKE_ESCAPE_CHAR } from "@nodefony/orm-core";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
//#region nodefony/src/likeSql.ts
|
|
4
|
+
/**
|
|
5
|
+
* Le littéral SQL portant le caractère d'échappement d'un `LIKE`, par dialecte.
|
|
6
|
+
*
|
|
7
|
+
* MySQL réinterprète l'antislash **à l'intérieur d'un littéral de chaîne** : y
|
|
8
|
+
* écrire `'\'` produit une chaîne inachevée, il faut `'\\'`. SQLite et
|
|
9
|
+
* PostgreSQL prennent le littéral tel quel. Cette divergence d'une ligne était
|
|
10
|
+
* recopiée à chaque site qui émettait une clause `ESCAPE` — et n'existait
|
|
11
|
+
* évidemment pas sur ceux qui l'oubliaient.
|
|
12
|
+
*
|
|
13
|
+
* @param dialect - dialecte du connecteur branché.
|
|
14
|
+
* @returns le littéral prêt à concaténer après `ESCAPE`.
|
|
15
|
+
*/
|
|
16
|
+
function escapeLiteral(dialect) {
|
|
17
|
+
const doubled = dialect === "mysql";
|
|
18
|
+
return sql.raw(`'${doubled ? LIKE_ESCAPE_CHAR : ""}${LIKE_ESCAPE_CHAR}'`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Compose une condition `LIKE` **avec sa clause `ESCAPE`** — le seul endroit de
|
|
22
|
+
* l'adapter Drizzle qui écrive un `LIKE`.
|
|
23
|
+
*
|
|
24
|
+
* La clause n'est pas un raffinement : sans elle, le contrat `$like` n'a pas une
|
|
25
|
+
* sémantique mais trois. PostgreSQL et MySQL appliquent déjà l'antislash comme
|
|
26
|
+
* échappement par défaut, SQLite n'en a aucun et cherche l'antislash littéral —
|
|
27
|
+
* si bien qu'un motif échappé rendait la bonne ligne en production et rien du
|
|
28
|
+
* tout en développement (mesuré sur les trois moteurs). L'émettre aligne les
|
|
29
|
+
* trois sur le comportement déjà majoritaire, et rend enfin exprimable un `%`
|
|
30
|
+
* littéral.
|
|
31
|
+
*
|
|
32
|
+
* Le motif est **bindé** (paramètre), jamais concaténé : seul le littéral
|
|
33
|
+
* d'échappement est brut, et il ne dépend que du dialecte.
|
|
34
|
+
*
|
|
35
|
+
* @param dialect - dialecte du connecteur branché.
|
|
36
|
+
* @param expr - l'expression de gauche, déjà composée (colonne, `LOWER(col)`…).
|
|
37
|
+
* @param pattern - le motif, dont les fragments littéraux ont été neutralisés
|
|
38
|
+
* par `escapeLikeTerm` (`@nodefony/orm-core`).
|
|
39
|
+
* @returns la condition complète.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* likeCond(dialect, sql`LOWER(${col})`, `${escapeLikeTerm(q.toLowerCase())}%`)
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
function likeCond(dialect, expr, pattern) {
|
|
47
|
+
return sql`${expr} LIKE ${pattern} ESCAPE ${escapeLiteral(dialect)}`;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { likeCond };
|