@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,203 @@
|
|
|
1
|
+
import { execTable } from "./orm-core/DrizzleRepository.js";
|
|
2
|
+
import { auditEventTable, createAuditEventTable } from "../entity/auditEventEntity.js";
|
|
3
|
+
import { assertPageQuery } from "nodefony";
|
|
4
|
+
import { and, count, desc, eq, gte, lt, lte, or } from "drizzle-orm";
|
|
5
|
+
//#region nodefony/src/DrizzleAuditStore.ts
|
|
6
|
+
/** Rétention par défaut d'un événement : 365 jours (aligné `MemoryAuditStore`). */
|
|
7
|
+
const DEFAULT_RETENTION_MS = 31536e6;
|
|
8
|
+
/** Taille de page par défaut / plafond (alignés `MemoryAuditStore`). */
|
|
9
|
+
const DEFAULT_LIMIT = 100;
|
|
10
|
+
const MAX_LIMIT = 500;
|
|
11
|
+
/** Séparateur du curseur composite `<ts>:<id>` (cf `#parseCursor`). */
|
|
12
|
+
const CURSOR_SEPARATOR = ":";
|
|
13
|
+
/**
|
|
14
|
+
* Journal d'audit **Drizzle** (driver `better-sqlite3` en test, Postgres/MySQL en
|
|
15
|
+
* prod) — implémentation SQL d'{@link IAuditStore} (append-only, tamper-evident)
|
|
16
|
+
* pour la **rétention longue** et le **partage cross-pod**, là où le store mémoire
|
|
17
|
+
* par défaut reste affine à un pod et volatile.
|
|
18
|
+
*
|
|
19
|
+
* **Append-only** : `append` est la seule écriture (INSERT) ; aucune mutation ni
|
|
20
|
+
* suppression ciblée d'un événement — seul {@link DrizzleAuditStore.gc} (rétention)
|
|
21
|
+
* retire des lignes. L'immuabilité EST la garantie d'audit.
|
|
22
|
+
*
|
|
23
|
+
* **Pagination curseur EXACTE (`listPage`)** — via le query builder Drizzle
|
|
24
|
+
* (dialect-agnostique), l'ordre total est `(ts DESC, id DESC)` : `ts` porte l'ordre
|
|
25
|
+
* chronologique, `id` casse les collisions à la milliseconde (rafales de login). Le
|
|
26
|
+
* curseur transporte **les deux** (`<ts>:<id>`) et se compare en **composite**
|
|
27
|
+
* `(ts, id) < (cursorTs, cursorId)` — non exprimable en critère `IRepository`
|
|
28
|
+
* AND-only, d'où la trappe native (ADR-0003, comme `DrizzleIdempotencyStore`). Une
|
|
29
|
+
* ligne de garde (`limit + 1`) détermine `hasNext` sans page vide parasite.
|
|
30
|
+
*
|
|
31
|
+
* **Résolution LAZY + dégradation gracieuse** (calqué sur les stores frères) : le
|
|
32
|
+
* handle Drizzle est résolu à CHAQUE appel, pas capturé à la construction — l'ordre
|
|
33
|
+
* de boot n'est pas garanti (l'app câble à `onKernelBoot`, l'ORM peut n'être pas
|
|
34
|
+
* encore connecté) et l'ORM se déconnecte au shutdown avant le drain des serveurs.
|
|
35
|
+
* Si le handle est `null` : `append` est un no-op **best-effort** (l'audit ne
|
|
36
|
+
* bloque ni ne fait échouer le flux métier — un événement au boot/shutdown est
|
|
37
|
+
* perdu plutôt que de crasher un login), `listPage` rend une page vide et `gc` rend 0.
|
|
38
|
+
*
|
|
39
|
+
* Horloge injectable (`now`) pour des tests déterministes.
|
|
40
|
+
*/
|
|
41
|
+
var DrizzleAuditStore = class DrizzleAuditStore {
|
|
42
|
+
#resolveDb;
|
|
43
|
+
#table;
|
|
44
|
+
/** Colonnes par nom logique, vue canonique (les specs colKit partagent les
|
|
45
|
+
* NOMS entre dialectes → le query builder reste dialecte-agnostique). */
|
|
46
|
+
#c;
|
|
47
|
+
#now;
|
|
48
|
+
#retentionMs;
|
|
49
|
+
#location;
|
|
50
|
+
/**
|
|
51
|
+
* @param resolveDb - résolveur **lazy** du handle Drizzle (`null` = ORM non
|
|
52
|
+
* connecté → dégradation gracieuse).
|
|
53
|
+
* @param now - horloge (epoch ms) injectable pour des tests déterministes.
|
|
54
|
+
* @param retentionMs - fenêtre de rétention (ms) avant purge par `gc`.
|
|
55
|
+
* @param table - variante de table à utiliser (dialecte). Défaut = SQLite.
|
|
56
|
+
* @param location - emplacement physique de la base (fichier SQLite) pour Studio
|
|
57
|
+
* ({@link DrizzleOrm.location}) ; `undefined` pour un backend réseau/`:memory:`.
|
|
58
|
+
*/
|
|
59
|
+
constructor(resolveDb, now = Date.now, retentionMs = DEFAULT_RETENTION_MS, table = auditEventTable, location) {
|
|
60
|
+
this.#resolveDb = resolveDb;
|
|
61
|
+
this.#table = table;
|
|
62
|
+
this.#c = execTable(table);
|
|
63
|
+
this.#now = now;
|
|
64
|
+
this.#retentionMs = retentionMs;
|
|
65
|
+
this.#location = location;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
69
|
+
* — lu par `readStoreLocation`. `undefined` = backend réseau ou `:memory:`.
|
|
70
|
+
*/
|
|
71
|
+
get location() {
|
|
72
|
+
return this.#location;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Construit le store depuis un {@link DrizzleOrm}. Le handle est résolu **lazy**
|
|
76
|
+
* (gardé par `isConnected()` → `null` tant que l'ORM n'est pas/plus connecté).
|
|
77
|
+
* L'entité (`registerAuditEntities`) doit avoir été enregistrée **avant**
|
|
78
|
+
* `orm.connect()` (la table est créée au connect) — sur la **variante de table
|
|
79
|
+
* du dialecte de l'ORM** (S3 multi-dialecte).
|
|
80
|
+
*
|
|
81
|
+
* @param orm - ORM Drizzle hébergeant la table `audit_event`.
|
|
82
|
+
* @param now - horloge injectable (tests).
|
|
83
|
+
* @param retentionMs - fenêtre de rétention (ms).
|
|
84
|
+
*/
|
|
85
|
+
static from(orm, now, retentionMs) {
|
|
86
|
+
return new DrizzleAuditStore(() => orm.isConnected() ? orm.getNativeConnection() : null, now, retentionMs, createAuditEventTable(orm.dialect), orm.location);
|
|
87
|
+
}
|
|
88
|
+
async append(event) {
|
|
89
|
+
const db = this.#resolveDb();
|
|
90
|
+
if (!db) return;
|
|
91
|
+
await db.insert(execTable(this.#table)).values({
|
|
92
|
+
id: event.id,
|
|
93
|
+
ts: event.ts,
|
|
94
|
+
category: event.category,
|
|
95
|
+
action: event.action,
|
|
96
|
+
outcome: event.outcome,
|
|
97
|
+
actor: event.actor ?? null,
|
|
98
|
+
resource: event.resource ?? null,
|
|
99
|
+
reason: event.reason ?? null,
|
|
100
|
+
ip: event.ip ?? null,
|
|
101
|
+
userAgent: event.userAgent ?? null,
|
|
102
|
+
requestId: event.requestId ?? null,
|
|
103
|
+
flags: event.flags ?? null,
|
|
104
|
+
metadata: event.metadata ?? null
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async listPage(query) {
|
|
108
|
+
assertPageQuery(query, "cursor");
|
|
109
|
+
const limit = Math.min(Math.max(1, query.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
110
|
+
const db = this.#resolveDb();
|
|
111
|
+
if (!db) return {
|
|
112
|
+
items: [],
|
|
113
|
+
limit,
|
|
114
|
+
hasNext: false,
|
|
115
|
+
nextCursor: null,
|
|
116
|
+
total: 0
|
|
117
|
+
};
|
|
118
|
+
const table = execTable(this.#table);
|
|
119
|
+
const c = this.#c;
|
|
120
|
+
const filterWhere = this.#buildFilter(query);
|
|
121
|
+
let total;
|
|
122
|
+
if (query.withTotal !== false) {
|
|
123
|
+
const totalRows = await db.select({ n: count() }).from(table).where(filterWhere);
|
|
124
|
+
total = Number(totalRows[0]?.n ?? 0);
|
|
125
|
+
}
|
|
126
|
+
let where = filterWhere;
|
|
127
|
+
const cursor = this.#parseCursor(query.cursor);
|
|
128
|
+
if (cursor) {
|
|
129
|
+
const cursorCond = or(lt(c.ts, cursor.ts), and(eq(c.ts, cursor.ts), lt(c.id, cursor.id)));
|
|
130
|
+
where = filterWhere ? and(filterWhere, cursorCond) : cursorCond;
|
|
131
|
+
}
|
|
132
|
+
const rows = await db.select().from(table).where(where).orderBy(desc(c.ts), desc(c.id)).limit(limit + 1);
|
|
133
|
+
const hasNext = rows.length > limit;
|
|
134
|
+
const pageRows = hasNext ? rows.slice(0, limit) : rows;
|
|
135
|
+
const items = pageRows.map((row) => this.#toEvent(row));
|
|
136
|
+
const last = pageRows[pageRows.length - 1];
|
|
137
|
+
return {
|
|
138
|
+
items,
|
|
139
|
+
limit,
|
|
140
|
+
hasNext,
|
|
141
|
+
nextCursor: hasNext && last ? `${last.ts}${CURSOR_SEPARATOR}${last.id}` : null,
|
|
142
|
+
...total !== void 0 ? { total } : {}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Décode le curseur composite `<ts>:<id>` (format privé au store, comme son
|
|
147
|
+
* pendant mémoire). Jeton absent ou malformé → `null` : la lecture repart de
|
|
148
|
+
* la page la plus récente plutôt que d'échouer sur une consultation.
|
|
149
|
+
*/
|
|
150
|
+
#parseCursor(cursor) {
|
|
151
|
+
if (cursor === void 0) return null;
|
|
152
|
+
const sep = cursor.indexOf(CURSOR_SEPARATOR);
|
|
153
|
+
if (sep <= 0) return null;
|
|
154
|
+
const ts = Number(cursor.slice(0, sep));
|
|
155
|
+
return Number.isFinite(ts) ? {
|
|
156
|
+
ts,
|
|
157
|
+
id: cursor.slice(sep + 1)
|
|
158
|
+
} : null;
|
|
159
|
+
}
|
|
160
|
+
async gc(now = this.#now()) {
|
|
161
|
+
const db = this.#resolveDb();
|
|
162
|
+
if (!db) return 0;
|
|
163
|
+
const threshold = now - this.#retentionMs;
|
|
164
|
+
const result = await db.delete(execTable(this.#table)).where(lt(this.#c.ts, threshold));
|
|
165
|
+
if (Array.isArray(result)) return result[0]?.affectedRows ?? 0;
|
|
166
|
+
const r = result;
|
|
167
|
+
return r.changes ?? r.rowCount ?? 0;
|
|
168
|
+
}
|
|
169
|
+
/** Compose la clause `WHERE` des filtres AND ; `undefined` si aucun (= tout). */
|
|
170
|
+
#buildFilter(filter) {
|
|
171
|
+
const c = this.#c;
|
|
172
|
+
const clauses = [];
|
|
173
|
+
if (filter.category !== void 0) clauses.push(eq(c.category, filter.category));
|
|
174
|
+
if (filter.outcome !== void 0) clauses.push(eq(c.outcome, filter.outcome));
|
|
175
|
+
if (filter.actor !== void 0) clauses.push(eq(c.actor, filter.actor));
|
|
176
|
+
if (filter.action !== void 0) clauses.push(eq(c.action, filter.action));
|
|
177
|
+
if (filter.requestId !== void 0) clauses.push(eq(c.requestId, filter.requestId));
|
|
178
|
+
if (filter.since !== void 0) clauses.push(gte(c.ts, filter.since));
|
|
179
|
+
if (filter.until !== void 0) clauses.push(lte(c.ts, filter.until));
|
|
180
|
+
return clauses.length > 0 ? and(...clauses) : void 0;
|
|
181
|
+
}
|
|
182
|
+
/** Mappe une ligne SQL vers un événement (NULL → optionnel absent). */
|
|
183
|
+
#toEvent(row) {
|
|
184
|
+
const event = {
|
|
185
|
+
id: row.id,
|
|
186
|
+
ts: row.ts,
|
|
187
|
+
category: row.category,
|
|
188
|
+
action: row.action,
|
|
189
|
+
outcome: row.outcome,
|
|
190
|
+
actor: row.actor,
|
|
191
|
+
resource: row.resource,
|
|
192
|
+
reason: row.reason,
|
|
193
|
+
ip: row.ip,
|
|
194
|
+
userAgent: row.userAgent,
|
|
195
|
+
requestId: row.requestId
|
|
196
|
+
};
|
|
197
|
+
if (row.flags) event.flags = row.flags;
|
|
198
|
+
if (row.metadata) event.metadata = row.metadata;
|
|
199
|
+
return event;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
//#endregion
|
|
203
|
+
export { DrizzleAuditStore };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { execTable } from "./orm-core/DrizzleRepository.js";
|
|
2
|
+
import { createIdempotencyTable, idempotencyKeyTable } from "../entity/idempotencyEntity.js";
|
|
3
|
+
import { reserveIdempotencyKeyMysql } from "./queryKit.js";
|
|
4
|
+
import { assertPageQuery } from "nodefony";
|
|
5
|
+
import { and, asc, count, eq, gt, is, like, lt, lte } from "drizzle-orm";
|
|
6
|
+
import { MySqlTable } from "drizzle-orm/mysql-core";
|
|
7
|
+
//#region nodefony/src/DrizzleIdempotencyStore.ts
|
|
8
|
+
/**
|
|
9
|
+
* Lignes affectées d'une mutation builder, normalisées par driver :
|
|
10
|
+
* better-sqlite3 `{changes}` / pg `{rowCount}` / mysql2 tuple
|
|
11
|
+
* `[ResultSetHeader{affectedRows}, fields]`.
|
|
12
|
+
*/
|
|
13
|
+
function affectedOf(result) {
|
|
14
|
+
if (Array.isArray(result)) return result[0]?.affectedRows ?? 0;
|
|
15
|
+
const r = result;
|
|
16
|
+
return r.changes ?? r.rowCount ?? 0;
|
|
17
|
+
}
|
|
18
|
+
/** Bail par défaut d'une entrée *in-flight* : 60 s (au-delà = exécution abandonnée). */
|
|
19
|
+
const DEFAULT_LEASE_MS = 6e4;
|
|
20
|
+
/** Rétention par défaut d'une réponse mémorisée : 10 min (rejeu plausible). */
|
|
21
|
+
const DEFAULT_TTL_MS = 6e5;
|
|
22
|
+
/**
|
|
23
|
+
* Store d'idempotence **Drizzle** (driver `better-sqlite3` en test, Postgres/MySQL
|
|
24
|
+
* en prod) — implémentation SQL d'{@link IIdempotencyStore} (contrat au CORE) pour
|
|
25
|
+
* dédoublonner les mutations rejouées PARTAGÉ cross-pod, là où le store mémoire par
|
|
26
|
+
* défaut reste affine à un pod.
|
|
27
|
+
*
|
|
28
|
+
* **Pourquoi SQL plutôt que Redis** : un cluster qui possède déjà une base
|
|
29
|
+
* (Postgres) mais pas de Redis obtient la dédup cross-pod sans nouvelle infra.
|
|
30
|
+
*
|
|
31
|
+
* **Réservation atomique (`begin`)** — clé de voûte. Un `INSERT … ON CONFLICT(key)
|
|
32
|
+
* DO UPDATE SET … WHERE expiresAt < now RETURNING` = l'équivalent SQL du `SET … NX
|
|
33
|
+
* PX` Redis, en **une seule instruction atomique** côté serveur :
|
|
34
|
+
* - clé absente → l'`INSERT` passe → 1 ligne retournée → `fresh` ;
|
|
35
|
+
* - clé présente mais **morte** (bail/rétention expirés) → le `DO UPDATE … WHERE
|
|
36
|
+
* expirée` la **vole** atomiquement → 1 ligne retournée → `fresh` ;
|
|
37
|
+
* - clé présente et **vivante** → le `WHERE` du `DO UPDATE` échoue → 0 ligne
|
|
38
|
+
* retournée → on lit l'état (`in-flight` / `replayed` / `mismatch`).
|
|
39
|
+
*
|
|
40
|
+
* Cette atomicité au niveau de l'INSTRUCTION est ce qui rend la dédup correcte
|
|
41
|
+
* sous concurrence inter-pods (deux `begin` simultanés sur deux pods : un seul
|
|
42
|
+
* gagne la réservation). Le store ne renvoie JAMAIS `fresh` sur le chemin de
|
|
43
|
+
* contention → anti double-effet garanti (l'invariant capital d'un store
|
|
44
|
+
* d'idempotence).
|
|
45
|
+
*
|
|
46
|
+
* **Pas de TTL natif** (≠ Redis `PX`) → un {@link DrizzleIdempotencyStore.gc}
|
|
47
|
+
* applicatif purge les entrées expirées. À mutualiser avec la maintenance du store
|
|
48
|
+
* de session (chantier « GC moderne »).
|
|
49
|
+
*
|
|
50
|
+
* **Empreinte préservée à la complétion** : `complete()` ne touche pas la colonne
|
|
51
|
+
* `fingerprint` (UPDATE ciblé) → un rejeu de la clé avec un AUTRE payload après
|
|
52
|
+
* complétion est toujours détecté (`mismatch` 422, draft §2.7).
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ **SQLite = banc de test de la sémantique** : un fichier SQLite est
|
|
55
|
+
* mono-machine (lock d'écriture) → aucun intérêt multi-pod. La cible RÉELLE est
|
|
56
|
+
* **Postgres/MySQL** (changement de driver). La preuve cross-pod réelle passe par
|
|
57
|
+
* un e2e Postgres (≠ test SQLite, qui valide la sémantique séquentielle).
|
|
58
|
+
*
|
|
59
|
+
* **Résolution LAZY + dégradation gracieuse** (calqué sur `RedisIdempotencyStore`,
|
|
60
|
+
* le frère idempotence) : le store ne capture PAS le handle Drizzle à la
|
|
61
|
+
* construction mais le résout à CHAQUE appel — l'ordre de boot n'est pas garanti
|
|
62
|
+
* (le framework résout ce store à `onKernelBoot`, quand l'ORM peut ne pas être
|
|
63
|
+
* encore connecté), et l'ORM se déconnecte au shutdown avant le drain des
|
|
64
|
+
* serveurs (cf le gotcha `SessionStorage`). Si le handle est `null` (ORM non
|
|
65
|
+
* connecté), `begin` renvoie `fresh` (la mutation s'exécute SANS dédup) et
|
|
66
|
+
* `complete`/`abort`/`gc` sont des no-op — l'idempotence est temporairement
|
|
67
|
+
* inactive plutôt que de crasher une mutation en vol. Trade-off identique à Redis
|
|
68
|
+
* (un rejeu pendant la fenêtre peut ré-exécuter) ; en pratique la base
|
|
69
|
+
* d'idempotence = la base applicative → « connectée » est vrai sur tout le service.
|
|
70
|
+
*
|
|
71
|
+
* **Câblage** : la classe reste PURE (`import type` du contrat core), mais le
|
|
72
|
+
* module drizzle s'enregistre LUI-MÊME au boot — `registerStores.ts:311-316` pose
|
|
73
|
+
* l'entité (`registerIdempotencyEntities`) puis la fabrique
|
|
74
|
+
* (`registerIdempotencyStore("drizzle", …)`, registre `@nodefony/framework`), sans
|
|
75
|
+
* rien demander à l'application. Celle-ci choisit seulement le store à employer
|
|
76
|
+
* (`idempotency.store`). L'enregistrement est idempotent : une fabrique déjà
|
|
77
|
+
* posée n'est pas écrasée, donc une app peut fournir la sienne avant le boot.
|
|
78
|
+
*/
|
|
79
|
+
var DrizzleIdempotencyStore = class DrizzleIdempotencyStore {
|
|
80
|
+
#resolveDb;
|
|
81
|
+
#table;
|
|
82
|
+
/** Colonnes par nom logique, vue canonique (les specs colKit partagent les
|
|
83
|
+
* NOMS entre dialectes → les builders restent dialecte-agnostiques). */
|
|
84
|
+
#c;
|
|
85
|
+
/** `true` si la table injectée est la variante MySQL → `begin` route sur la
|
|
86
|
+
* réservation `ON DUPLICATE KEY UPDATE` du queryKit (pas de RETURNING). */
|
|
87
|
+
#mysql;
|
|
88
|
+
#now;
|
|
89
|
+
#leaseMs;
|
|
90
|
+
#ttlMs;
|
|
91
|
+
#resolveLocation;
|
|
92
|
+
/** Compteur LOCAL best-effort des réservations faites par CE pod (cf {@link size}). */
|
|
93
|
+
#pending = 0;
|
|
94
|
+
/**
|
|
95
|
+
* @param resolveDb - résolveur **lazy** du handle Drizzle (`null` = ORM non
|
|
96
|
+
* connecté → dégradation gracieuse). Lazy car l'ordre de boot/shutdown n'est
|
|
97
|
+
* pas garanti à la construction.
|
|
98
|
+
* @param now - horloge (epoch ms) injectable pour des tests déterministes.
|
|
99
|
+
* @param leaseMs - bail d'une entrée *in-flight* (ms).
|
|
100
|
+
* @param ttlMs - rétention d'une réponse mémorisée (ms).
|
|
101
|
+
* @param table - variante de table à utiliser (dialecte). Défaut = variante
|
|
102
|
+
* SQLite ; `from()` injecte la variante du dialecte de l'ORM.
|
|
103
|
+
* @param resolveLocation - résolveur **lazy** de l'emplacement physique de la
|
|
104
|
+
* base ({@link DrizzleOrm.location}) pour Studio. Lazy (comme `resolveDb`) car
|
|
105
|
+
* le store est fabriqué AVANT le connect de l'ORM → l'emplacement n'est lisible
|
|
106
|
+
* qu'une fois l'ORM enregistré (lu au `onReady`, pas à la construction).
|
|
107
|
+
*/
|
|
108
|
+
constructor(resolveDb, now = Date.now, leaseMs = DEFAULT_LEASE_MS, ttlMs = DEFAULT_TTL_MS, table = idempotencyKeyTable, resolveLocation) {
|
|
109
|
+
this.#resolveDb = resolveDb;
|
|
110
|
+
this.#table = table;
|
|
111
|
+
this.#c = execTable(table);
|
|
112
|
+
this.#mysql = is(table, MySqlTable);
|
|
113
|
+
this.#now = now;
|
|
114
|
+
this.#leaseMs = leaseMs;
|
|
115
|
+
this.#ttlMs = ttlMs;
|
|
116
|
+
this.#resolveLocation = resolveLocation;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Emplacement physique de la base (fichier SQLite) pour l'écran Studio « Stores »
|
|
120
|
+
* — lu par `readStoreLocation`. Résolu **lazy** (l'ORM n'existe pas à la
|
|
121
|
+
* construction) : `undefined` tant que l'ORM n'est pas enregistré, en `:memory:`,
|
|
122
|
+
* ou sur un backend réseau (pg/mysql → voir l'infra déclarée).
|
|
123
|
+
*/
|
|
124
|
+
get location() {
|
|
125
|
+
return this.#resolveLocation?.();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Construit le store depuis un {@link DrizzleOrm}. Le handle est résolu **lazy**
|
|
129
|
+
* (gardé par `isConnected()` → `null` tant que l'ORM n'est pas/plus connecté).
|
|
130
|
+
* L'entité (`registerIdempotencyEntities`) doit avoir été enregistrée **avant**
|
|
131
|
+
* `orm.connect()` (la table est créée au connect).
|
|
132
|
+
*
|
|
133
|
+
* @param orm - ORM Drizzle hébergeant la table `idempotency_key` (dialecte
|
|
134
|
+
* sélectionne la variante de table — `orm.dialect`).
|
|
135
|
+
* @param now - horloge injectable (tests).
|
|
136
|
+
* @param leaseMs - bail *in-flight* (ms).
|
|
137
|
+
* @param ttlMs - rétention d'une réponse mémorisée (ms).
|
|
138
|
+
*/
|
|
139
|
+
static from(orm, now, leaseMs, ttlMs) {
|
|
140
|
+
return new DrizzleIdempotencyStore(() => orm.isConnected() ? orm.getNativeConnection() : null, now, leaseMs, ttlMs, createIdempotencyTable(orm.dialect), () => orm.location);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Approximation **per-pod, best-effort** : compteur local des réservations
|
|
144
|
+
* faites par CE pod (incrémenté au `fresh`, décrémenté au `complete`/`abort`),
|
|
145
|
+
* non décrémenté si le bail expire sans complétion, et désaligné cross-pod. La
|
|
146
|
+
* vérité cluster passe par un `COUNT(*)`, jamais ce getter (sync). Borné à ≥ 0.
|
|
147
|
+
*/
|
|
148
|
+
get size() {
|
|
149
|
+
return this.#pending < 0 ? 0 : this.#pending;
|
|
150
|
+
}
|
|
151
|
+
async begin(key, fingerprint) {
|
|
152
|
+
const db = this.#resolveDb();
|
|
153
|
+
if (!db) return { state: "fresh" };
|
|
154
|
+
const now = this.#now();
|
|
155
|
+
const leaseExpiresAt = now + this.#leaseMs;
|
|
156
|
+
let reserved;
|
|
157
|
+
if (this.#mysql) reserved = await reserveIdempotencyKeyMysql(db, {
|
|
158
|
+
key,
|
|
159
|
+
fingerprint,
|
|
160
|
+
now,
|
|
161
|
+
leaseExpiresAt
|
|
162
|
+
});
|
|
163
|
+
else reserved = (await db.insert(execTable(this.#table)).values({
|
|
164
|
+
key,
|
|
165
|
+
fingerprint,
|
|
166
|
+
state: "if",
|
|
167
|
+
response: null,
|
|
168
|
+
expiresAt: leaseExpiresAt
|
|
169
|
+
}).onConflictDoUpdate({
|
|
170
|
+
target: this.#c.key,
|
|
171
|
+
set: {
|
|
172
|
+
fingerprint,
|
|
173
|
+
state: "if",
|
|
174
|
+
response: null,
|
|
175
|
+
expiresAt: leaseExpiresAt
|
|
176
|
+
},
|
|
177
|
+
setWhere: lt(this.#c.expiresAt, now)
|
|
178
|
+
}).returning({ key: this.#c.key })).length > 0;
|
|
179
|
+
if (reserved) {
|
|
180
|
+
this.#pending++;
|
|
181
|
+
return { state: "fresh" };
|
|
182
|
+
}
|
|
183
|
+
const existing = (await db.select().from(execTable(this.#table)).where(eq(this.#c.key, key)))[0];
|
|
184
|
+
if (existing === void 0) return { state: "in-flight" };
|
|
185
|
+
if (existing.fingerprint !== fingerprint) return { state: "mismatch" };
|
|
186
|
+
if (existing.state === "done" && existing.response !== null) return {
|
|
187
|
+
state: "replayed",
|
|
188
|
+
response: existing.response
|
|
189
|
+
};
|
|
190
|
+
return { state: "in-flight" };
|
|
191
|
+
}
|
|
192
|
+
async complete(key, response) {
|
|
193
|
+
const db = this.#resolveDb();
|
|
194
|
+
if (!db) return;
|
|
195
|
+
if (affectedOf(await db.update(execTable(this.#table)).set({
|
|
196
|
+
state: "done",
|
|
197
|
+
response,
|
|
198
|
+
expiresAt: this.#now() + this.#ttlMs
|
|
199
|
+
}).where(and(eq(this.#c.key, key), eq(this.#c.state, "if")))) > 0) this.#dec();
|
|
200
|
+
}
|
|
201
|
+
async abort(key) {
|
|
202
|
+
const db = this.#resolveDb();
|
|
203
|
+
if (!db) return;
|
|
204
|
+
if (affectedOf(await db.delete(execTable(this.#table)).where(and(eq(this.#c.key, key), eq(this.#c.state, "if")))) > 0) this.#dec();
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Purge les entrées mortes (`expiresAt <= now`) — supplée l'absence de TTL natif
|
|
208
|
+
* SQL (≠ Redis `PX`). À déclencher périodiquement (timer de maintenance, à
|
|
209
|
+
* mutualiser avec le GC du store de session).
|
|
210
|
+
*
|
|
211
|
+
* @param now - horloge de purge (défaut : horloge injectée).
|
|
212
|
+
* @returns le nombre d'entrées supprimées.
|
|
213
|
+
*/
|
|
214
|
+
async gc(now = this.#now()) {
|
|
215
|
+
const db = this.#resolveDb();
|
|
216
|
+
if (!db) return 0;
|
|
217
|
+
return affectedOf(await db.delete(execTable(this.#table)).where(lte(this.#c.expiresAt, now)));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* {@inheritDoc IIdempotencyStore.listPage}
|
|
221
|
+
*
|
|
222
|
+
* Query builder dialect-agnostique (comme `gc`) : `LIMIT/OFFSET` + `COUNT`
|
|
223
|
+
* filtré. On ne SELECTe QUE les colonnes exposées — la réponse mémorisée
|
|
224
|
+
* (`response`) ne quitte jamais la base par ce chemin, quelle que soit la
|
|
225
|
+
* taille de la page.
|
|
226
|
+
*
|
|
227
|
+
* Les entrées expirées sont exclues (`expiresAt > now`) : le GC applicatif
|
|
228
|
+
* passe plus tard, mais une clé échue n'est déjà plus opposable.
|
|
229
|
+
*/
|
|
230
|
+
async listPage(query) {
|
|
231
|
+
assertPageQuery(query, "offset");
|
|
232
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
233
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
234
|
+
const db = this.#resolveDb();
|
|
235
|
+
if (!db) return {
|
|
236
|
+
items: [],
|
|
237
|
+
total: 0,
|
|
238
|
+
limit,
|
|
239
|
+
offset,
|
|
240
|
+
hasNext: false
|
|
241
|
+
};
|
|
242
|
+
const table = execTable(this.#table);
|
|
243
|
+
const conds = [gt(this.#c.expiresAt, this.#now())];
|
|
244
|
+
if (query.state !== void 0) conds.push(eq(this.#c.state, query.state === "in-flight" ? "if" : "done"));
|
|
245
|
+
if (query.q !== void 0 && query.q.length > 0) conds.push(like(this.#c.key, `${query.q}%`));
|
|
246
|
+
const where = conds.length === 1 ? conds[0] : and(...conds);
|
|
247
|
+
const rows = await db.select({
|
|
248
|
+
key: this.#c.key,
|
|
249
|
+
state: this.#c.state,
|
|
250
|
+
expiresAt: this.#c.expiresAt
|
|
251
|
+
}).from(table).where(where).orderBy(asc(this.#c.expiresAt), asc(this.#c.key)).limit(limit + 1).offset(offset);
|
|
252
|
+
const hasNext = rows.length > limit;
|
|
253
|
+
const page = hasNext ? rows.slice(0, limit) : rows;
|
|
254
|
+
let total;
|
|
255
|
+
if (query.withTotal !== false) {
|
|
256
|
+
const counted = await db.select({ cnt: count() }).from(table).where(where);
|
|
257
|
+
total = Number(counted[0]?.cnt ?? 0);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
items: page.map((row) => ({
|
|
261
|
+
key: row.key,
|
|
262
|
+
state: row.state === "if" ? "in-flight" : "done",
|
|
263
|
+
expiresAtMs: Number(row.expiresAt),
|
|
264
|
+
hasResponse: row.state !== "if"
|
|
265
|
+
})),
|
|
266
|
+
total,
|
|
267
|
+
limit,
|
|
268
|
+
offset,
|
|
269
|
+
hasNext
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** Décrémente le compteur local borné à 0. */
|
|
273
|
+
#dec() {
|
|
274
|
+
if (this.#pending > 0) this.#pending--;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
//#endregion
|
|
278
|
+
export { DrizzleIdempotencyStore };
|