@socialgouv/matomo-postgres 2.1.0 → 2.2.0
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/bin/index.js +13 -13
- package/dist/PiwikClient.js +8 -4
- package/dist/__tests__/importDate.test.js +16 -14
- package/dist/__tests__/importEvent.test.js +5 -6
- package/dist/__tests__/run.test.js +31 -31
- package/dist/config.js +8 -7
- package/dist/db.js +7 -7
- package/dist/importDate.js +16 -16
- package/dist/importEvent.js +89 -33
- package/dist/index.js +55 -21
- package/dist/migrate-down.js +6 -6
- package/dist/migrate-latest.js +9 -9
- package/dist/migrations/20230301-01-initial.js +44 -44
- package/dist/migrations/20230301-02-indexes.js +37 -37
- package/dist/migrations/20250425-01-add-resolution.js +9 -3
- package/dist/migrations/20250715-01-weekly-partitioning.js +362 -0
- package/package.json +15 -2
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.down = exports.up = void 0;
|
|
13
|
+
const kysely_1 = require("kysely");
|
|
14
|
+
const PARTITIONED_MATOMO_TABLE_NAME = process.env.PARTITIONED_MATOMO_TABLE_NAME || 'matomo_partitioned';
|
|
15
|
+
function up(db) {
|
|
16
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
17
|
+
// First, create the partitioned table structure as a partitioned table
|
|
18
|
+
yield (0, kysely_1.sql) `
|
|
19
|
+
CREATE TABLE ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}`)} (
|
|
20
|
+
action_id text NOT NULL,
|
|
21
|
+
idsite text,
|
|
22
|
+
idvisit text,
|
|
23
|
+
actions text,
|
|
24
|
+
country text,
|
|
25
|
+
region text,
|
|
26
|
+
city text,
|
|
27
|
+
operatingsystemname text,
|
|
28
|
+
devicemodel text,
|
|
29
|
+
devicebrand text,
|
|
30
|
+
visitduration text,
|
|
31
|
+
dayssincefirstvisit text,
|
|
32
|
+
visitortype text,
|
|
33
|
+
sitename text,
|
|
34
|
+
userid text,
|
|
35
|
+
serverdateprettyfirstaction date,
|
|
36
|
+
action_type text,
|
|
37
|
+
action_eventcategory text,
|
|
38
|
+
action_eventaction text,
|
|
39
|
+
action_eventname text,
|
|
40
|
+
action_eventvalue numeric,
|
|
41
|
+
action_timespent text,
|
|
42
|
+
action_timestamp timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
usercustomproperties json,
|
|
44
|
+
usercustomdimensions json,
|
|
45
|
+
dimension1 text,
|
|
46
|
+
dimension2 text,
|
|
47
|
+
dimension3 text,
|
|
48
|
+
dimension4 text,
|
|
49
|
+
dimension5 text,
|
|
50
|
+
dimension6 text,
|
|
51
|
+
dimension7 text,
|
|
52
|
+
dimension8 text,
|
|
53
|
+
dimension9 text,
|
|
54
|
+
dimension10 text,
|
|
55
|
+
action_url text,
|
|
56
|
+
sitesearchkeyword text,
|
|
57
|
+
action_title text,
|
|
58
|
+
visitorid text,
|
|
59
|
+
referrertype text,
|
|
60
|
+
referrername text,
|
|
61
|
+
resolution text
|
|
62
|
+
) PARTITION BY RANGE (action_timestamp);
|
|
63
|
+
`.execute(db);
|
|
64
|
+
// Add unique constraint that includes partition key
|
|
65
|
+
yield (0, kysely_1.sql) `
|
|
66
|
+
ALTER TABLE ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}`)}
|
|
67
|
+
ADD CONSTRAINT ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}_action_id_timestamp_unique`)}
|
|
68
|
+
UNIQUE (action_id, action_timestamp)
|
|
69
|
+
`.execute(db);
|
|
70
|
+
// Create function for automatic weekly partition creation
|
|
71
|
+
yield (0, kysely_1.sql) `
|
|
72
|
+
CREATE OR REPLACE FUNCTION create_weekly_partition_if_not_exists(table_name text, partition_date timestamptz)
|
|
73
|
+
RETURNS void AS $$
|
|
74
|
+
DECLARE
|
|
75
|
+
partition_name text;
|
|
76
|
+
start_date timestamptz;
|
|
77
|
+
end_date timestamptz;
|
|
78
|
+
year_week text;
|
|
79
|
+
BEGIN
|
|
80
|
+
-- Calculate the start of the week (Monday)
|
|
81
|
+
start_date := date_trunc('week', partition_date);
|
|
82
|
+
end_date := start_date + interval '1 week';
|
|
83
|
+
|
|
84
|
+
-- Generate partition name using ISO week format (YYYY-WW)
|
|
85
|
+
year_week := to_char(start_date, 'IYYY') || 'w' || to_char(start_date, 'IW');
|
|
86
|
+
partition_name := table_name || '_' || year_week;
|
|
87
|
+
|
|
88
|
+
-- Check if partition already exists
|
|
89
|
+
IF NOT EXISTS (
|
|
90
|
+
SELECT 1 FROM pg_class c
|
|
91
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
92
|
+
WHERE c.relname = partition_name
|
|
93
|
+
AND n.nspname = current_schema()
|
|
94
|
+
) THEN
|
|
95
|
+
-- Create the partition
|
|
96
|
+
EXECUTE format('CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
|
|
97
|
+
partition_name, table_name, start_date, end_date);
|
|
98
|
+
|
|
99
|
+
RAISE NOTICE 'Created partition % for range % to %', partition_name, start_date, end_date;
|
|
100
|
+
END IF;
|
|
101
|
+
END;
|
|
102
|
+
$$ LANGUAGE plpgsql;
|
|
103
|
+
`.execute(db);
|
|
104
|
+
// Create trigger function that automatically creates partitions on insert
|
|
105
|
+
yield (0, kysely_1.sql) `
|
|
106
|
+
CREATE OR REPLACE FUNCTION ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}_partition_trigger`)}()
|
|
107
|
+
RETURNS trigger AS $$
|
|
108
|
+
BEGIN
|
|
109
|
+
PERFORM create_weekly_partition_if_not_exists('${kysely_1.sql.raw(PARTITIONED_MATOMO_TABLE_NAME)}', NEW.action_timestamp);
|
|
110
|
+
RETURN NEW;
|
|
111
|
+
END;
|
|
112
|
+
$$ LANGUAGE plpgsql;
|
|
113
|
+
`.execute(db);
|
|
114
|
+
// Create trigger that fires before insert
|
|
115
|
+
yield (0, kysely_1.sql) `
|
|
116
|
+
CREATE TRIGGER ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}_auto_partition`)}
|
|
117
|
+
BEFORE INSERT ON ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}`)}
|
|
118
|
+
FOR EACH ROW EXECUTE FUNCTION ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}_partition_trigger`)}();
|
|
119
|
+
`.execute(db);
|
|
120
|
+
// Create stored procedure for safe insertion with automatic partition creation
|
|
121
|
+
yield (0, kysely_1.sql) `
|
|
122
|
+
CREATE OR REPLACE FUNCTION insert_into_matomo_partitioned(
|
|
123
|
+
p_action_id text,
|
|
124
|
+
p_action_timestamp timestamptz,
|
|
125
|
+
p_idsite text DEFAULT '',
|
|
126
|
+
p_idvisit text DEFAULT '',
|
|
127
|
+
p_actions text DEFAULT NULL,
|
|
128
|
+
p_country text DEFAULT NULL,
|
|
129
|
+
p_region text DEFAULT NULL,
|
|
130
|
+
p_city text DEFAULT NULL,
|
|
131
|
+
p_operatingsystemname text DEFAULT NULL,
|
|
132
|
+
p_devicemodel text DEFAULT NULL,
|
|
133
|
+
p_devicebrand text DEFAULT NULL,
|
|
134
|
+
p_visitduration text DEFAULT NULL,
|
|
135
|
+
p_dayssincefirstvisit text DEFAULT NULL,
|
|
136
|
+
p_visitortype text DEFAULT NULL,
|
|
137
|
+
p_sitename text DEFAULT NULL,
|
|
138
|
+
p_userid text DEFAULT NULL,
|
|
139
|
+
p_serverdateprettyfirstaction date DEFAULT NULL,
|
|
140
|
+
p_action_type text DEFAULT '',
|
|
141
|
+
p_action_eventcategory text DEFAULT '',
|
|
142
|
+
p_action_eventaction text DEFAULT '',
|
|
143
|
+
p_action_eventname text DEFAULT '',
|
|
144
|
+
p_action_eventvalue numeric DEFAULT 0,
|
|
145
|
+
p_action_timespent text DEFAULT '0',
|
|
146
|
+
p_usercustomproperties json DEFAULT NULL,
|
|
147
|
+
p_usercustomdimensions json DEFAULT NULL,
|
|
148
|
+
p_dimension1 text DEFAULT NULL,
|
|
149
|
+
p_dimension2 text DEFAULT NULL,
|
|
150
|
+
p_dimension3 text DEFAULT NULL,
|
|
151
|
+
p_dimension4 text DEFAULT NULL,
|
|
152
|
+
p_dimension5 text DEFAULT NULL,
|
|
153
|
+
p_dimension6 text DEFAULT NULL,
|
|
154
|
+
p_dimension7 text DEFAULT NULL,
|
|
155
|
+
p_dimension8 text DEFAULT NULL,
|
|
156
|
+
p_dimension9 text DEFAULT NULL,
|
|
157
|
+
p_dimension10 text DEFAULT NULL,
|
|
158
|
+
p_action_url text DEFAULT NULL,
|
|
159
|
+
p_sitesearchkeyword text DEFAULT NULL,
|
|
160
|
+
p_action_title text DEFAULT NULL,
|
|
161
|
+
p_visitorid text DEFAULT NULL,
|
|
162
|
+
p_referrertype text DEFAULT NULL,
|
|
163
|
+
p_referrername text DEFAULT NULL,
|
|
164
|
+
p_resolution text DEFAULT NULL
|
|
165
|
+
)
|
|
166
|
+
RETURNS void
|
|
167
|
+
LANGUAGE plpgsql
|
|
168
|
+
SECURITY DEFINER
|
|
169
|
+
AS $$
|
|
170
|
+
BEGIN
|
|
171
|
+
-- Ensure partition exists for the given timestamp
|
|
172
|
+
PERFORM create_weekly_partition_if_not_exists('${kysely_1.sql.raw(PARTITIONED_MATOMO_TABLE_NAME)}', p_action_timestamp);
|
|
173
|
+
|
|
174
|
+
-- Insert the data with conflict handling
|
|
175
|
+
INSERT INTO ${kysely_1.sql.id(PARTITIONED_MATOMO_TABLE_NAME)} (
|
|
176
|
+
action_id,
|
|
177
|
+
action_timestamp,
|
|
178
|
+
idsite,
|
|
179
|
+
idvisit,
|
|
180
|
+
actions,
|
|
181
|
+
country,
|
|
182
|
+
region,
|
|
183
|
+
city,
|
|
184
|
+
operatingsystemname,
|
|
185
|
+
devicemodel,
|
|
186
|
+
devicebrand,
|
|
187
|
+
visitduration,
|
|
188
|
+
dayssincefirstvisit,
|
|
189
|
+
visitortype,
|
|
190
|
+
sitename,
|
|
191
|
+
userid,
|
|
192
|
+
serverdateprettyfirstaction,
|
|
193
|
+
action_type,
|
|
194
|
+
action_eventcategory,
|
|
195
|
+
action_eventaction,
|
|
196
|
+
action_eventname,
|
|
197
|
+
action_eventvalue,
|
|
198
|
+
action_timespent,
|
|
199
|
+
usercustomproperties,
|
|
200
|
+
usercustomdimensions,
|
|
201
|
+
dimension1,
|
|
202
|
+
dimension2,
|
|
203
|
+
dimension3,
|
|
204
|
+
dimension4,
|
|
205
|
+
dimension5,
|
|
206
|
+
dimension6,
|
|
207
|
+
dimension7,
|
|
208
|
+
dimension8,
|
|
209
|
+
dimension9,
|
|
210
|
+
dimension10,
|
|
211
|
+
action_url,
|
|
212
|
+
sitesearchkeyword,
|
|
213
|
+
action_title,
|
|
214
|
+
visitorid,
|
|
215
|
+
referrertype,
|
|
216
|
+
referrername,
|
|
217
|
+
resolution
|
|
218
|
+
) VALUES (
|
|
219
|
+
p_action_id,
|
|
220
|
+
p_action_timestamp,
|
|
221
|
+
p_idsite,
|
|
222
|
+
p_idvisit,
|
|
223
|
+
p_actions,
|
|
224
|
+
p_country,
|
|
225
|
+
p_region,
|
|
226
|
+
p_city,
|
|
227
|
+
p_operatingsystemname,
|
|
228
|
+
p_devicemodel,
|
|
229
|
+
p_devicebrand,
|
|
230
|
+
p_visitduration,
|
|
231
|
+
p_dayssincefirstvisit,
|
|
232
|
+
p_visitortype,
|
|
233
|
+
p_sitename,
|
|
234
|
+
p_userid,
|
|
235
|
+
p_serverdateprettyfirstaction,
|
|
236
|
+
p_action_type,
|
|
237
|
+
p_action_eventcategory,
|
|
238
|
+
p_action_eventaction,
|
|
239
|
+
p_action_eventname,
|
|
240
|
+
p_action_eventvalue,
|
|
241
|
+
p_action_timespent,
|
|
242
|
+
p_usercustomproperties,
|
|
243
|
+
p_usercustomdimensions,
|
|
244
|
+
p_dimension1,
|
|
245
|
+
p_dimension2,
|
|
246
|
+
p_dimension3,
|
|
247
|
+
p_dimension4,
|
|
248
|
+
p_dimension5,
|
|
249
|
+
p_dimension6,
|
|
250
|
+
p_dimension7,
|
|
251
|
+
p_dimension8,
|
|
252
|
+
p_dimension9,
|
|
253
|
+
p_dimension10,
|
|
254
|
+
p_action_url,
|
|
255
|
+
p_sitesearchkeyword,
|
|
256
|
+
p_action_title,
|
|
257
|
+
p_visitorid,
|
|
258
|
+
p_referrertype,
|
|
259
|
+
p_referrername,
|
|
260
|
+
p_resolution
|
|
261
|
+
)
|
|
262
|
+
ON CONFLICT (action_id, action_timestamp) DO NOTHING;
|
|
263
|
+
END;
|
|
264
|
+
$$;
|
|
265
|
+
`.execute(db);
|
|
266
|
+
// Create indexes on the partitioned table
|
|
267
|
+
const indexes = [
|
|
268
|
+
{
|
|
269
|
+
name: `idx_action_eventaction_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
270
|
+
columns: ['action_eventaction']
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: `idx_action_eventcategory_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
274
|
+
columns: ['action_eventcategory']
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: `idx_action_id_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
278
|
+
columns: ['action_id']
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: `idx_action_timestamp_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
282
|
+
columns: ['action_timestamp']
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: `idx_action_type_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
286
|
+
columns: ['action_type']
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: `idx_actionurl_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
290
|
+
columns: ['action_url']
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: `idx_dimension1_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
294
|
+
columns: ['dimension1']
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: `idx_dimension2_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
298
|
+
columns: ['dimension2']
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: `idx_dimension3_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
302
|
+
columns: ['dimension3']
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: `idx_dimension4_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
306
|
+
columns: ['dimension4']
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: `idx_dimension5_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
310
|
+
columns: ['dimension5']
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: `idx_idvisit_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
314
|
+
columns: ['idvisit']
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: `idx_region_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
318
|
+
columns: ['region']
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: `idx_userid_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
322
|
+
columns: ['userid']
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: `idx_visitorid_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
326
|
+
columns: ['visitorid']
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: `idx_category_timestamp_${PARTITIONED_MATOMO_TABLE_NAME}`,
|
|
330
|
+
columns: ['action_eventcategory', 'action_timestamp']
|
|
331
|
+
}
|
|
332
|
+
];
|
|
333
|
+
// Create indexes
|
|
334
|
+
for (const index of indexes) {
|
|
335
|
+
yield db.schema
|
|
336
|
+
.createIndex(index.name)
|
|
337
|
+
.on(PARTITIONED_MATOMO_TABLE_NAME)
|
|
338
|
+
.using('btree')
|
|
339
|
+
.columns(index.columns)
|
|
340
|
+
.execute();
|
|
341
|
+
}
|
|
342
|
+
// Create the date-based index
|
|
343
|
+
yield db.schema
|
|
344
|
+
.createIndex(`actions_day_${PARTITIONED_MATOMO_TABLE_NAME}`)
|
|
345
|
+
.on(PARTITIONED_MATOMO_TABLE_NAME)
|
|
346
|
+
.expression((0, kysely_1.sql) `date(timezone('UTC', action_timestamp))`)
|
|
347
|
+
.execute();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
exports.up = up;
|
|
351
|
+
function down(db) {
|
|
352
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
353
|
+
// Drop trigger and function
|
|
354
|
+
yield (0, kysely_1.sql) `DROP TRIGGER IF EXISTS ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}_auto_partition`)} ON ${kysely_1.sql.id(PARTITIONED_MATOMO_TABLE_NAME)}`.execute(db);
|
|
355
|
+
yield (0, kysely_1.sql) `DROP FUNCTION IF EXISTS ${kysely_1.sql.id(`${PARTITIONED_MATOMO_TABLE_NAME}_partition_trigger`)}()`.execute(db);
|
|
356
|
+
yield (0, kysely_1.sql) `DROP FUNCTION IF EXISTS create_weekly_partition_if_not_exists(text, timestamptz)`.execute(db);
|
|
357
|
+
yield (0, kysely_1.sql) `DROP FUNCTION IF EXISTS insert_into_matomo_partitioned`.execute(db);
|
|
358
|
+
// Drop the partitioned table (this will also drop all partitions)
|
|
359
|
+
yield db.schema.dropTable(PARTITIONED_MATOMO_TABLE_NAME).ifExists().execute();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
exports.down = down;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@socialgouv/matomo-postgres",
|
|
3
3
|
"description": "Extract visitor events from Matomo API and push to Postgres",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"types": "types/index.d.ts",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"build": "tsc",
|
|
22
22
|
"prepublish": "yarn build",
|
|
23
23
|
"migrate": "node ./dist/migrate-latest.js",
|
|
24
|
-
"test": "jest --verbose"
|
|
24
|
+
"test": "jest --verbose",
|
|
25
|
+
"lint": "eslint .",
|
|
26
|
+
"lint:fix": "eslint . --fix"
|
|
25
27
|
},
|
|
26
28
|
"prettier": {
|
|
27
29
|
"printWidth": 120
|
|
@@ -35,11 +37,22 @@
|
|
|
35
37
|
"pg": "^8.9.0"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"@eslint/eslintrc": "^3.3.1",
|
|
41
|
+
"@eslint/js": "^9.31.0",
|
|
38
42
|
"@types/debug": "^4.1.7",
|
|
39
43
|
"@types/jest": "^29.4.0",
|
|
40
44
|
"@types/node": "^18.14.4",
|
|
41
45
|
"@types/pg": "^8.6.6",
|
|
46
|
+
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
|
47
|
+
"@typescript-eslint/parser": "^8.37.0",
|
|
48
|
+
"eslint": "^9.31.0",
|
|
49
|
+
"eslint-config-prettier": "^10.1.5",
|
|
50
|
+
"eslint-plugin-prettier": "^5.5.1",
|
|
51
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
52
|
+
"globals": "^16.3.0",
|
|
42
53
|
"jest": "^29.4.3",
|
|
54
|
+
"knip": "^5.61.3",
|
|
55
|
+
"prettier": "^3.6.2",
|
|
43
56
|
"ts-jest": "^29.0.5",
|
|
44
57
|
"ts-node": "^10.9.1",
|
|
45
58
|
"typescript": "^4.9.5"
|