@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.
Files changed (143) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +162 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +105 -0
  6. package/dist/nodefony/command/migrateShared.js +247 -0
  7. package/dist/nodefony/command/orm-generate.js +356 -0
  8. package/dist/nodefony/command/orm-migrate-baseline.js +208 -0
  9. package/dist/nodefony/command/orm-migrate-repair.js +114 -0
  10. package/dist/nodefony/command/orm-migrate-status.js +67 -0
  11. package/dist/nodefony/command/orm-migrate.js +141 -0
  12. package/dist/nodefony/command/orm-reset.js +166 -0
  13. package/dist/nodefony/config/config.js +107 -0
  14. package/dist/nodefony/config/defineModuleConfig.js +63 -0
  15. package/dist/nodefony/entity/auditEventEntity.js +93 -0
  16. package/dist/nodefony/entity/colKit.js +260 -0
  17. package/dist/nodefony/entity/idempotencyEntity.js +74 -0
  18. package/dist/nodefony/entity/sessionEntity.js +75 -0
  19. package/dist/nodefony/entity/tokenEntity.js +198 -0
  20. package/dist/nodefony/entity/totpSecretEntity.js +98 -0
  21. package/dist/nodefony/entity/userTable.js +141 -0
  22. package/dist/nodefony/entity/webAuthnCredentialEntity.js +114 -0
  23. package/dist/nodefony/entity/webhookEndpointEntity.js +106 -0
  24. package/dist/nodefony/interfaces/IDrizzleConfig.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/migrations-schema/mysql.js +48 -0
  27. package/dist/nodefony/migrations-schema/postgres.js +48 -0
  28. package/dist/nodefony/migrations-schema/sqlite.js +48 -0
  29. package/dist/nodefony/registerStores.js +218 -0
  30. package/dist/nodefony/service/DrizzleService.js +282 -0
  31. package/dist/nodefony/src/DrizzleAuditStore.js +203 -0
  32. package/dist/nodefony/src/DrizzleIdempotencyStore.js +278 -0
  33. package/dist/nodefony/src/DrizzleTokenStore.js +244 -0
  34. package/dist/nodefony/src/DrizzleTotpSecretStore.js +151 -0
  35. package/dist/nodefony/src/DrizzleUserRepository.js +217 -0
  36. package/dist/nodefony/src/DrizzleWebAuthnCredentialStore.js +159 -0
  37. package/dist/nodefony/src/DrizzleWebhookStore.js +169 -0
  38. package/dist/nodefony/src/SessionStorage.js +259 -0
  39. package/dist/nodefony/src/connectorTarget.js +59 -0
  40. package/dist/nodefony/src/likeSql.js +50 -0
  41. package/dist/nodefony/src/migrator/DrizzleMigrator.js +775 -0
  42. package/dist/nodefony/src/migrator/adopt.js +553 -0
  43. package/dist/nodefony/src/migrator/appSchema.js +414 -0
  44. package/dist/nodefony/src/migrator/catalog.js +76 -0
  45. package/dist/nodefony/src/migrator/destructive.js +213 -0
  46. package/dist/nodefony/src/migrator/divergence.js +84 -0
  47. package/dist/nodefony/src/migrator/drivers/index.js +39 -0
  48. package/dist/nodefony/src/migrator/drivers/mysqlDriver.js +147 -0
  49. package/dist/nodefony/src/migrator/drivers/postgresDriver.js +151 -0
  50. package/dist/nodefony/src/migrator/drivers/sqliteDriver.js +121 -0
  51. package/dist/nodefony/src/migrator/explain.js +565 -0
  52. package/dist/nodefony/src/migrator/hash.js +47 -0
  53. package/dist/nodefony/src/migrator/history.js +219 -0
  54. package/dist/nodefony/src/migrator/index.js +16 -0
  55. package/dist/nodefony/src/migrator/kit.js +296 -0
  56. package/dist/nodefony/src/migrator/name.js +68 -0
  57. package/dist/nodefony/src/migrator/paths.js +88 -0
  58. package/dist/nodefony/src/migrator/refusals.js +143 -0
  59. package/dist/nodefony/src/migrator/resolve.js +281 -0
  60. package/dist/nodefony/src/migrator/schemaDiff.js +86 -0
  61. package/dist/nodefony/src/migrator/sources.js +419 -0
  62. package/dist/nodefony/src/migrator/status.js +231 -0
  63. package/dist/nodefony/src/migrator/types.js +91 -0
  64. package/dist/nodefony/src/orm-core/DrizzleOrm.js +1154 -0
  65. package/dist/nodefony/src/orm-core/DrizzleRepository.js +610 -0
  66. package/dist/nodefony/src/orm-core/DrizzleTransaction.js +106 -0
  67. package/dist/nodefony/src/orm-core/index.js +4 -0
  68. package/dist/nodefony/src/queryKit.js +318 -0
  69. package/dist/nodefony/src/safeTarget.js +55 -0
  70. package/dist/types/index.d.ts +76 -0
  71. package/dist/types/nodefony/command/migrateShared.d.ts +137 -0
  72. package/dist/types/nodefony/command/orm-generate.d.ts +53 -0
  73. package/dist/types/nodefony/command/orm-migrate-baseline.d.ts +87 -0
  74. package/dist/types/nodefony/command/orm-migrate-repair.d.ts +66 -0
  75. package/dist/types/nodefony/command/orm-migrate-status.d.ts +38 -0
  76. package/dist/types/nodefony/command/orm-migrate.d.ts +65 -0
  77. package/dist/types/nodefony/command/orm-reset.d.ts +55 -0
  78. package/dist/types/nodefony/config/config.d.ts +110 -0
  79. package/dist/types/nodefony/config/defineModuleConfig.d.ts +24 -0
  80. package/dist/types/nodefony/entity/auditEventEntity.d.ts +56 -0
  81. package/dist/types/nodefony/entity/colKit.d.ts +130 -0
  82. package/dist/types/nodefony/entity/idempotencyEntity.d.ts +52 -0
  83. package/dist/types/nodefony/entity/sessionEntity.d.ts +50 -0
  84. package/dist/types/nodefony/entity/tokenEntity.d.ts +53 -0
  85. package/dist/types/nodefony/entity/totpSecretEntity.d.ts +61 -0
  86. package/dist/types/nodefony/entity/userTable.d.ts +65 -0
  87. package/dist/types/nodefony/entity/webAuthnCredentialEntity.d.ts +57 -0
  88. package/dist/types/nodefony/entity/webhookEndpointEntity.d.ts +58 -0
  89. package/dist/types/nodefony/interfaces/IDrizzleConfig.d.ts +17 -0
  90. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  91. package/dist/types/nodefony/migrations-schema/mysql.d.ts +9 -0
  92. package/dist/types/nodefony/migrations-schema/postgres.d.ts +9 -0
  93. package/dist/types/nodefony/migrations-schema/sqlite.d.ts +9 -0
  94. package/dist/types/nodefony/registerStores.d.ts +52 -0
  95. package/dist/types/nodefony/service/DrizzleService.d.ts +27 -0
  96. package/dist/types/nodefony/src/DrizzleAuditStore.d.ts +65 -0
  97. package/dist/types/nodefony/src/DrizzleIdempotencyStore.d.ts +129 -0
  98. package/dist/types/nodefony/src/DrizzleTokenStore.d.ts +146 -0
  99. package/dist/types/nodefony/src/DrizzleTotpSecretStore.d.ts +60 -0
  100. package/dist/types/nodefony/src/DrizzleUserRepository.d.ts +67 -0
  101. package/dist/types/nodefony/src/DrizzleWebAuthnCredentialStore.d.ts +62 -0
  102. package/dist/types/nodefony/src/DrizzleWebhookStore.d.ts +79 -0
  103. package/dist/types/nodefony/src/SessionStorage.d.ts +72 -0
  104. package/dist/types/nodefony/src/connectorTarget.d.ts +48 -0
  105. package/dist/types/nodefony/src/likeSql.d.ts +29 -0
  106. package/dist/types/nodefony/src/migrator/DrizzleMigrator.d.ts +94 -0
  107. package/dist/types/nodefony/src/migrator/adopt.d.ts +280 -0
  108. package/dist/types/nodefony/src/migrator/appSchema.d.ts +223 -0
  109. package/dist/types/nodefony/src/migrator/catalog.d.ts +100 -0
  110. package/dist/types/nodefony/src/migrator/destructive.d.ts +123 -0
  111. package/dist/types/nodefony/src/migrator/divergence.d.ts +61 -0
  112. package/dist/types/nodefony/src/migrator/drivers/index.d.ts +26 -0
  113. package/dist/types/nodefony/src/migrator/drivers/mysqlDriver.d.ts +83 -0
  114. package/dist/types/nodefony/src/migrator/drivers/postgresDriver.d.ts +81 -0
  115. package/dist/types/nodefony/src/migrator/drivers/sqliteDriver.d.ts +53 -0
  116. package/dist/types/nodefony/src/migrator/explain.d.ts +424 -0
  117. package/dist/types/nodefony/src/migrator/hash.d.ts +39 -0
  118. package/dist/types/nodefony/src/migrator/history.d.ts +139 -0
  119. package/dist/types/nodefony/src/migrator/index.d.ts +20 -0
  120. package/dist/types/nodefony/src/migrator/kit.d.ts +141 -0
  121. package/dist/types/nodefony/src/migrator/name.d.ts +52 -0
  122. package/dist/types/nodefony/src/migrator/paths.d.ts +54 -0
  123. package/dist/types/nodefony/src/migrator/refusals.d.ts +170 -0
  124. package/dist/types/nodefony/src/migrator/resolve.d.ts +201 -0
  125. package/dist/types/nodefony/src/migrator/schemaDiff.d.ts +112 -0
  126. package/dist/types/nodefony/src/migrator/sources.d.ts +119 -0
  127. package/dist/types/nodefony/src/migrator/status.d.ts +132 -0
  128. package/dist/types/nodefony/src/migrator/types.d.ts +273 -0
  129. package/dist/types/nodefony/src/orm-core/DrizzleOrm.d.ts +241 -0
  130. package/dist/types/nodefony/src/orm-core/DrizzleRepository.d.ts +98 -0
  131. package/dist/types/nodefony/src/orm-core/DrizzleTransaction.d.ts +71 -0
  132. package/dist/types/nodefony/src/orm-core/index.d.ts +11 -0
  133. package/dist/types/nodefony/src/queryKit.d.ts +136 -0
  134. package/dist/types/nodefony/src/safeTarget.d.ts +42 -0
  135. package/docs/index.md +954 -0
  136. package/docs/migrations.md +691 -0
  137. package/migrations/mysql/0000_framework_init.sql +137 -0
  138. package/migrations/mysql/meta/_journal.json +13 -0
  139. package/migrations/postgres/0000_framework_init.sql +128 -0
  140. package/migrations/postgres/meta/_journal.json +13 -0
  141. package/migrations/sqlite/0000_framework_init.sql +127 -0
  142. package/migrations/sqlite/meta/_journal.json +13 -0
  143. 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 };