better-convex 0.6.3 → 0.7.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/dist/aggregate/index.d.ts +388 -0
- package/dist/aggregate/index.js +37 -0
- package/dist/{auth-client → auth/client}/index.js +1 -1
- package/dist/auth/http/index.d.ts +63 -0
- package/dist/auth/http/index.js +429 -0
- package/dist/auth/index.d.ts +19001 -185
- package/dist/auth/index.js +373 -686
- package/dist/{auth-nextjs → auth/nextjs}/index.d.ts +3 -4
- package/dist/{auth-nextjs → auth/nextjs}/index.js +3 -5
- package/dist/{caller-factory-B1FvYSKr.js → caller-factory-Dmgv8MLS.js} +15 -12
- package/dist/cli.mjs +2601 -13
- package/dist/codegen-Cz1idI3-.mjs +969 -0
- package/dist/{create-schema-orm-DplxTtYj.js → create-schema-orm-69VF4CFV.js} +4 -3
- package/dist/crpc/index.d.ts +2 -2
- package/dist/crpc/index.js +3 -3
- package/dist/{http-types-BRLY10NX.d.ts → http-types-BCf2wCgp.d.ts} +25 -25
- package/dist/meta-utils-DDVYp9Xf.js +117 -0
- package/dist/orm/index.d.ts +4 -3012
- package/dist/orm/index.js +9631 -2
- package/dist/{index-BQkhP2ny.d.ts → procedure-caller-CcjtUFvL.d.ts} +211 -74
- package/dist/query-context-BDSis9rT.js +1518 -0
- package/dist/query-context-DGExXZIV.d.ts +42 -0
- package/dist/react/index.d.ts +31 -35
- package/dist/react/index.js +145 -58
- package/dist/rsc/index.d.ts +4 -7
- package/dist/rsc/index.js +14 -10
- package/dist/runtime-B9xQFY8W.js +2280 -0
- package/dist/server/index.d.ts +3 -4
- package/dist/server/index.js +384 -10
- package/dist/{types-o-5rYcTr.d.ts → types-CIBGEYXq.d.ts} +4 -3
- package/dist/types-DgwvxKbT.d.ts +4 -0
- package/dist/watcher.mjs +8 -8
- package/dist/where-clause-compiler-CRP-i1Qa.d.ts +3463 -0
- package/package.json +14 -10
- package/dist/codegen-DkpPBVPn.mjs +0 -189
- package/dist/context-utils-DSuX99Da.d.ts +0 -17
- package/dist/meta-utils-DCpLSBWB.js +0 -41
- package/dist/orm-CleikBIV.js +0 -8820
- /package/dist/{auth-client → auth/client}/index.d.ts +0 -0
- /package/dist/{auth-config → auth/config}/index.d.ts +0 -0
- /package/dist/{auth-config → auth/config}/index.js +0 -0
- /package/dist/{create-schema-DhWXOhnU.js → create-schema-BdZOL6ns.js} +0 -0
- /package/dist/{customFunctions-C1okqCzL.js → customFunctions-CZnCwoR3.js} +0 -0
- /package/dist/{error-BZUhlhYz.js → error-Be4OcwwD.js} +0 -0
- /package/dist/{query-options-BL1Q0X7q.js → query-options-B0c1b6pZ.js} +0 -0
- /package/dist/{transformer-CTNSPjwp.js → transformer-Dh0w2py0.js} +0 -0
- /package/dist/{types-jftzhhuc.d.ts → types-DwGkkq2s.d.ts} +0 -0
package/dist/cli.mjs
CHANGED
|
@@ -1,13 +1,1909 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as generateMeta } from "./codegen-
|
|
2
|
+
import { n as getConvexConfig, t as generateMeta } from "./codegen-Cz1idI3-.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import path, { dirname, join, resolve } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { execa } from "execa";
|
|
9
|
+
import { createJiti } from "jiti";
|
|
10
|
+
import { v } from "convex/values";
|
|
11
|
+
import { createInterface } from "node:readline/promises";
|
|
12
|
+
import { build } from "esbuild";
|
|
8
13
|
import * as childProcess from "node:child_process";
|
|
9
14
|
import { parse } from "dotenv";
|
|
10
15
|
|
|
16
|
+
//#region src/orm/builders/column-builder.ts
|
|
17
|
+
/**
|
|
18
|
+
* entityKind symbol for runtime type checking
|
|
19
|
+
* Following Drizzle's pattern for type guards
|
|
20
|
+
*/
|
|
21
|
+
const entityKind = Symbol.for("better-convex:entityKind");
|
|
22
|
+
/**
|
|
23
|
+
* Base ColumnBuilder abstract class
|
|
24
|
+
*
|
|
25
|
+
* All column builders inherit from this class.
|
|
26
|
+
* Implements chaining methods and stores runtime config.
|
|
27
|
+
*/
|
|
28
|
+
var ColumnBuilder = class {
|
|
29
|
+
static [entityKind] = "ColumnBuilder";
|
|
30
|
+
[entityKind] = "ColumnBuilder";
|
|
31
|
+
/**
|
|
32
|
+
* Runtime configuration - actual mutable state
|
|
33
|
+
*/
|
|
34
|
+
config;
|
|
35
|
+
constructor(name, dataType, columnType) {
|
|
36
|
+
this.config = {
|
|
37
|
+
name,
|
|
38
|
+
notNull: false,
|
|
39
|
+
default: void 0,
|
|
40
|
+
hasDefault: false,
|
|
41
|
+
primaryKey: false,
|
|
42
|
+
isUnique: false,
|
|
43
|
+
uniqueName: void 0,
|
|
44
|
+
uniqueNulls: void 0,
|
|
45
|
+
foreignKeyConfigs: [],
|
|
46
|
+
dataType,
|
|
47
|
+
columnType
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mark column as NOT NULL
|
|
52
|
+
* Returns type-branded instance with notNull: true
|
|
53
|
+
*/
|
|
54
|
+
notNull() {
|
|
55
|
+
this.config.notNull = true;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Override the TypeScript type for this column.
|
|
60
|
+
* Mirrors Drizzle's $type() (type-only, no runtime validation changes).
|
|
61
|
+
*/
|
|
62
|
+
$type() {
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Set default value for column
|
|
67
|
+
* Makes field optional on insert
|
|
68
|
+
*/
|
|
69
|
+
default(value) {
|
|
70
|
+
this.config.default = value;
|
|
71
|
+
this.config.hasDefault = true;
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Set default function for column (runtime evaluated on insert).
|
|
76
|
+
* Mirrors Drizzle's $defaultFn() / $default().
|
|
77
|
+
*/
|
|
78
|
+
$defaultFn(fn) {
|
|
79
|
+
this.config.defaultFn = fn;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Alias of $defaultFn for Drizzle parity.
|
|
84
|
+
*/
|
|
85
|
+
$default(fn) {
|
|
86
|
+
return this.$defaultFn(fn);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Set on-update function for column (runtime evaluated on update).
|
|
90
|
+
* Mirrors Drizzle's $onUpdateFn() / $onUpdate().
|
|
91
|
+
*/
|
|
92
|
+
$onUpdateFn(fn) {
|
|
93
|
+
this.config.onUpdateFn = fn;
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Alias of $onUpdateFn for Drizzle parity.
|
|
98
|
+
*/
|
|
99
|
+
$onUpdate(fn) {
|
|
100
|
+
return this.$onUpdateFn(fn);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Mark column as primary key
|
|
104
|
+
* Implies NOT NULL
|
|
105
|
+
*/
|
|
106
|
+
primaryKey() {
|
|
107
|
+
this.config.primaryKey = true;
|
|
108
|
+
this.config.notNull = true;
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Mark column as UNIQUE
|
|
113
|
+
* Mirrors Drizzle column unique API
|
|
114
|
+
*/
|
|
115
|
+
unique(name, config) {
|
|
116
|
+
this.config.isUnique = true;
|
|
117
|
+
this.config.uniqueName = name;
|
|
118
|
+
this.config.uniqueNulls = config?.nulls;
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Define a foreign key reference
|
|
123
|
+
* Mirrors Drizzle column references() API
|
|
124
|
+
*/
|
|
125
|
+
references(ref, config = {}) {
|
|
126
|
+
this.config.foreignKeyConfigs.push({
|
|
127
|
+
ref,
|
|
128
|
+
config
|
|
129
|
+
});
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/orm/builders/system-fields.ts
|
|
136
|
+
/**
|
|
137
|
+
* System Fields - Convex-provided fields available on all documents
|
|
138
|
+
*
|
|
139
|
+
* id: Document ID (string, backed by internal Convex _id)
|
|
140
|
+
* createdAt: Creation timestamp alias (backed by internal Convex _creationTime)
|
|
141
|
+
*
|
|
142
|
+
* These are automatically added to every Convex table.
|
|
143
|
+
*/
|
|
144
|
+
var ConvexSystemIdBuilder = class extends ColumnBuilder {
|
|
145
|
+
static [entityKind] = "ConvexSystemIdBuilder";
|
|
146
|
+
[entityKind] = "ConvexSystemIdBuilder";
|
|
147
|
+
constructor() {
|
|
148
|
+
super("_id", "string", "ConvexSystemId");
|
|
149
|
+
this.config.notNull = true;
|
|
150
|
+
}
|
|
151
|
+
build() {
|
|
152
|
+
return v.string();
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Convex validator - runtime access
|
|
156
|
+
* System fields use v.string() for _id
|
|
157
|
+
*/
|
|
158
|
+
get convexValidator() {
|
|
159
|
+
return this.build();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var ConvexSystemCreationTimeBuilder = class extends ColumnBuilder {
|
|
163
|
+
static [entityKind] = "ConvexSystemCreationTimeBuilder";
|
|
164
|
+
[entityKind] = "ConvexSystemCreationTimeBuilder";
|
|
165
|
+
constructor() {
|
|
166
|
+
super("_creationTime", "number", "ConvexSystemCreationTime");
|
|
167
|
+
this.config.notNull = true;
|
|
168
|
+
}
|
|
169
|
+
build() {
|
|
170
|
+
return v.number();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Convex validator - runtime access
|
|
174
|
+
* System fields use v.number() for _creationTime
|
|
175
|
+
*/
|
|
176
|
+
get convexValidator() {
|
|
177
|
+
return this.build();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
var ConvexSystemCreatedAtBuilder = class extends ColumnBuilder {
|
|
181
|
+
static [entityKind] = "ConvexSystemCreatedAtBuilder";
|
|
182
|
+
[entityKind] = "ConvexSystemCreatedAtBuilder";
|
|
183
|
+
constructor() {
|
|
184
|
+
super("_creationTime", "number", "ConvexSystemCreatedAt");
|
|
185
|
+
this.config.notNull = true;
|
|
186
|
+
}
|
|
187
|
+
build() {
|
|
188
|
+
return v.number();
|
|
189
|
+
}
|
|
190
|
+
get convexValidator() {
|
|
191
|
+
return this.build();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
function createSystemFields(tableName) {
|
|
195
|
+
const id = new ConvexSystemIdBuilder();
|
|
196
|
+
const creationTime = new ConvexSystemCreationTimeBuilder();
|
|
197
|
+
const createdAt = new ConvexSystemCreatedAtBuilder();
|
|
198
|
+
id.config.tableName = tableName;
|
|
199
|
+
creationTime.config.tableName = tableName;
|
|
200
|
+
createdAt.config.tableName = tableName;
|
|
201
|
+
return {
|
|
202
|
+
id,
|
|
203
|
+
_creationTime: creationTime,
|
|
204
|
+
createdAt
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/orm/index-utils.ts
|
|
210
|
+
function getIndexes(table) {
|
|
211
|
+
const indexes = table.getIndexes?.();
|
|
212
|
+
return Array.isArray(indexes) ? indexes : [];
|
|
213
|
+
}
|
|
214
|
+
function getAggregateIndexes(table) {
|
|
215
|
+
const indexes = table.getAggregateIndexes?.();
|
|
216
|
+
return Array.isArray(indexes) ? indexes : [];
|
|
217
|
+
}
|
|
218
|
+
function getRankIndexes(table) {
|
|
219
|
+
const indexes = table.getRankIndexes?.();
|
|
220
|
+
return Array.isArray(indexes) ? indexes : [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
//#endregion
|
|
224
|
+
//#region src/orm/symbols.ts
|
|
225
|
+
const TableName = Symbol.for("better-convex:TableName");
|
|
226
|
+
const Columns = Symbol.for("better-convex:Columns");
|
|
227
|
+
const Brand = Symbol.for("better-convex:Brand");
|
|
228
|
+
const Relations = Symbol.for("better-convex:Relations");
|
|
229
|
+
const OrmContext = Symbol.for("better-convex:OrmContext");
|
|
230
|
+
const RlsPolicies = Symbol.for("better-convex:RlsPolicies");
|
|
231
|
+
const EnableRLS = Symbol.for("better-convex:EnableRLS");
|
|
232
|
+
const TableDeleteConfig = Symbol.for("better-convex:TableDeleteConfig");
|
|
233
|
+
const OrmSchemaOptions = Symbol.for("better-convex:OrmSchemaOptions");
|
|
234
|
+
const OrmSchemaDefinition = Symbol.for("better-convex:OrmSchemaDefinition");
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/orm/mutation-utils.ts
|
|
238
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
239
|
+
function getTableName(table) {
|
|
240
|
+
const name = table.tableName ?? table[TableName] ?? table?._?.name;
|
|
241
|
+
if (!name) throw new Error("Table is missing a name");
|
|
242
|
+
return name;
|
|
243
|
+
}
|
|
244
|
+
function getUniqueIndexes(table) {
|
|
245
|
+
const fromMethod = table.getUniqueIndexes?.();
|
|
246
|
+
if (Array.isArray(fromMethod)) return fromMethod;
|
|
247
|
+
const fromField = table.uniqueIndexes;
|
|
248
|
+
return Array.isArray(fromField) ? fromField : [];
|
|
249
|
+
}
|
|
250
|
+
function getChecks(table) {
|
|
251
|
+
const fromMethod = table.getChecks?.();
|
|
252
|
+
if (Array.isArray(fromMethod)) return fromMethod;
|
|
253
|
+
const fromField = table.checks;
|
|
254
|
+
return Array.isArray(fromField) ? fromField : [];
|
|
255
|
+
}
|
|
256
|
+
function getForeignKeys(table) {
|
|
257
|
+
const fromMethod = table.getForeignKeys?.();
|
|
258
|
+
if (Array.isArray(fromMethod)) return fromMethod;
|
|
259
|
+
const fromField = table.foreignKeys;
|
|
260
|
+
return Array.isArray(fromField) ? fromField : [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/orm/introspection.ts
|
|
265
|
+
function getSystemFields(table) {
|
|
266
|
+
if (table.id && table._creationTime) return {
|
|
267
|
+
id: table.id,
|
|
268
|
+
createdAt: table._creationTime ?? table.createdAt
|
|
269
|
+
};
|
|
270
|
+
const system = createSystemFields(getTableName(table));
|
|
271
|
+
for (const builder of Object.values(system)) builder.config.table = table;
|
|
272
|
+
return {
|
|
273
|
+
id: system.id,
|
|
274
|
+
createdAt: system.createdAt
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function getTableColumns(table) {
|
|
278
|
+
return {
|
|
279
|
+
...table[Columns] ?? {},
|
|
280
|
+
...getSystemFields(table)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function getTableConfig(table) {
|
|
284
|
+
const policies = table.getRlsPolicies?.() ?? table[RlsPolicies] ?? [];
|
|
285
|
+
const enabled = table.isRlsEnabled?.() ?? table[EnableRLS] ?? false;
|
|
286
|
+
return {
|
|
287
|
+
name: getTableName(table),
|
|
288
|
+
columns: getTableColumns(table),
|
|
289
|
+
indexes: getIndexes(table),
|
|
290
|
+
aggregateIndexes: getAggregateIndexes(table),
|
|
291
|
+
rankIndexes: getRankIndexes(table),
|
|
292
|
+
uniqueIndexes: getUniqueIndexes(table),
|
|
293
|
+
foreignKeys: getForeignKeys(table),
|
|
294
|
+
checks: getChecks(table),
|
|
295
|
+
rls: {
|
|
296
|
+
enabled,
|
|
297
|
+
policies
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/cli/analyze.ts
|
|
304
|
+
const MB = 1024 * 1024;
|
|
305
|
+
const DEFAULT_WARNING_MB = 6;
|
|
306
|
+
const DEFAULT_DANGER_MB = 8;
|
|
307
|
+
const DEFAULT_TOP_INPUTS = 12;
|
|
308
|
+
const DEFAULT_TOP_PACKAGES = 12;
|
|
309
|
+
const DEFAULT_DETAIL_ENTRIES = 20;
|
|
310
|
+
const DEFAULT_OUTPUT_WIDTH = 120;
|
|
311
|
+
const SMALL_INPUT_MIN_BYTES = 8 * 1024;
|
|
312
|
+
const SMALL_INPUT_MIN_SHARE = .002;
|
|
313
|
+
const WHITESPACE_SPLIT_REGEX = /\s+/;
|
|
314
|
+
const NEWLINE_SPLIT_REGEX = /\r?\n/;
|
|
315
|
+
const ENTRY_POINT_EXTENSIONS = new Set([
|
|
316
|
+
".ts",
|
|
317
|
+
".tsx",
|
|
318
|
+
".mts",
|
|
319
|
+
".cts",
|
|
320
|
+
".js",
|
|
321
|
+
".jsx",
|
|
322
|
+
".mjs",
|
|
323
|
+
".cjs"
|
|
324
|
+
]);
|
|
325
|
+
const SCHEMA_RESOLVE_FILTER = /^\.{1,2}\/schema(\.ts|\.js)?$/;
|
|
326
|
+
const USE_NODE_DIRECTIVE_REGEX = /^\s*("|')use node\1;?\s*$/;
|
|
327
|
+
const VALID_IDENTIFIER_REGEX = /^[a-zA-Z_$][\w$]*$/;
|
|
328
|
+
const EXPORTED_CONST_CAPTURE_REGEX = /export\s+const\s+([a-zA-Z_$][\w$]*)\s*=/g;
|
|
329
|
+
const CHAINED_PROCEDURE_CAPTURE_REGEX = /\.\s*(?:query|mutation|action)\s*\(/;
|
|
330
|
+
const EXPORTED_NATIVE_HANDLER_CAPTURE_REGEX = /export\s+const\s+([a-zA-Z_$][\w$]*)\s*=\s*(?:[\w$]+\.)?(?:query|mutation|action|internalQuery|internalMutation|internalAction)\s*\(/g;
|
|
331
|
+
const EXPORTED_ORM_API_DESTRUCTURE_CAPTURE_REGEX = /export\s+const\s*\{([^}]+)\}\s*=\s*orm\.api\s*\(\s*\)\s*;?/g;
|
|
332
|
+
const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
333
|
+
const isInteractiveTerminal = process.stdin.isTTY && process.stdout.isTTY;
|
|
334
|
+
let colorEnabled = supportsColor;
|
|
335
|
+
let outputWidth = process.stdout.columns ?? DEFAULT_OUTPUT_WIDTH;
|
|
336
|
+
const ANSI = {
|
|
337
|
+
reset: "\x1B[0m",
|
|
338
|
+
bold: "\x1B[1m",
|
|
339
|
+
dim: "\x1B[2m",
|
|
340
|
+
red: "\x1B[31m",
|
|
341
|
+
yellow: "\x1B[33m",
|
|
342
|
+
green: "\x1B[32m",
|
|
343
|
+
cyan: "\x1B[36m",
|
|
344
|
+
magenta: "\x1B[35m",
|
|
345
|
+
gray: "\x1B[90m"
|
|
346
|
+
};
|
|
347
|
+
const dedupe = (values) => Array.from(new Set(values));
|
|
348
|
+
const formatBytes = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
|
|
349
|
+
const toMB = (bytes) => (bytes / MB).toFixed(2);
|
|
350
|
+
const truncate = (value, maxWidth) => {
|
|
351
|
+
if (value.length <= maxWidth) return value;
|
|
352
|
+
if (maxWidth <= 3) return value.slice(0, maxWidth);
|
|
353
|
+
return `${value.slice(0, maxWidth - 3)}...`;
|
|
354
|
+
};
|
|
355
|
+
const pad = (value, width, align = "left") => {
|
|
356
|
+
if (align === "right") return value.padStart(width, " ");
|
|
357
|
+
return value.padEnd(width, " ");
|
|
358
|
+
};
|
|
359
|
+
const colorize = (value, color) => {
|
|
360
|
+
if (!colorEnabled) return value;
|
|
361
|
+
return `${color}${value}${ANSI.reset}`;
|
|
362
|
+
};
|
|
363
|
+
const bold = (value) => colorize(value, ANSI.bold);
|
|
364
|
+
const dim = (value) => colorize(value, ANSI.dim);
|
|
365
|
+
const shareColor = (sharePercent) => {
|
|
366
|
+
if (sharePercent >= 25) return ANSI.red;
|
|
367
|
+
if (sharePercent >= 10) return ANSI.yellow;
|
|
368
|
+
if (sharePercent >= 3) return ANSI.cyan;
|
|
369
|
+
return ANSI.gray;
|
|
370
|
+
};
|
|
371
|
+
const severityColor = (severity) => {
|
|
372
|
+
if (severity === "DANGER") return ANSI.red;
|
|
373
|
+
if (severity === "WARN") return ANSI.yellow;
|
|
374
|
+
return ANSI.green;
|
|
375
|
+
};
|
|
376
|
+
const makeShareBar = (sharePercent, width = 16) => {
|
|
377
|
+
const clamped = Math.max(0, Math.min(100, sharePercent));
|
|
378
|
+
const filled = Math.round(clamped / 100 * width);
|
|
379
|
+
return colorize(`${"#".repeat(filled)}${".".repeat(Math.max(0, width - filled))}`, shareColor(sharePercent));
|
|
380
|
+
};
|
|
381
|
+
const colorizePadded = (value, width, align, color) => colorize(pad(value, width, align), color);
|
|
382
|
+
const ANSI_PATTERN = new RegExp(`\\x1b\\[[0-9;]*m`, "g");
|
|
383
|
+
const visibleLength = (value) => value.replace(ANSI_PATTERN, "").length;
|
|
384
|
+
const wrapPlain = (text, width) => {
|
|
385
|
+
const maxWidth = Math.max(16, width);
|
|
386
|
+
const words = text.split(WHITESPACE_SPLIT_REGEX).filter(Boolean);
|
|
387
|
+
if (words.length === 0) return [""];
|
|
388
|
+
const lines = [];
|
|
389
|
+
let current = "";
|
|
390
|
+
for (const word of words) {
|
|
391
|
+
if (!current) {
|
|
392
|
+
current = word;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (current.length + 1 + word.length <= maxWidth) {
|
|
396
|
+
current += ` ${word}`;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
lines.push(current);
|
|
400
|
+
current = word;
|
|
401
|
+
}
|
|
402
|
+
if (current) lines.push(current);
|
|
403
|
+
return lines;
|
|
404
|
+
};
|
|
405
|
+
const printWrapped = ({ indent = 0, prefix = "", text, color = null }) => {
|
|
406
|
+
const indentStr = " ".repeat(Math.max(0, indent));
|
|
407
|
+
const prefixLen = visibleLength(prefix);
|
|
408
|
+
wrapPlain(text, Math.max(16, outputWidth - indent - prefixLen)).forEach((line, index) => {
|
|
409
|
+
const leader = index === 0 ? prefix : " ".repeat(prefixLen);
|
|
410
|
+
const body = color ? colorize(line, color) : line;
|
|
411
|
+
console.log(`${indentStr}${leader}${body}`);
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
const firstLine = (value) => value.split("\n").at(0) ?? value;
|
|
415
|
+
const normalizeOutputPath = (filePath) => filePath.split(path.sep).join("/");
|
|
416
|
+
const pathHasMultipleDots = (base) => (base.match(/\./g) ?? []).length > 1;
|
|
417
|
+
const shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
418
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
419
|
+
const walkDeployEntryPoints = (dir, options) => {
|
|
420
|
+
const files = [];
|
|
421
|
+
const includeMultiDot = options?.includeMultiDot ?? false;
|
|
422
|
+
const includeGeneratedDir = options?.includeGeneratedDir ?? false;
|
|
423
|
+
const visit = (currentDir) => {
|
|
424
|
+
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
425
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
426
|
+
if (entry.isDirectory()) {
|
|
427
|
+
if (entry.name === "_generated") continue;
|
|
428
|
+
if (entry.name === "generated" && !includeGeneratedDir) continue;
|
|
429
|
+
visit(fullPath);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (!entry.isFile()) continue;
|
|
433
|
+
const normalizedRelPath = normalizeOutputPath(path.relative(dir, fullPath));
|
|
434
|
+
const parsed = path.parse(fullPath);
|
|
435
|
+
const base = parsed.base;
|
|
436
|
+
const ext = parsed.ext.toLowerCase();
|
|
437
|
+
if (normalizedRelPath.startsWith("_deps/")) continue;
|
|
438
|
+
if (!ENTRY_POINT_EXTENSIONS.has(ext)) continue;
|
|
439
|
+
if (base.startsWith(".") || base.startsWith("#")) continue;
|
|
440
|
+
if (base === "schema.ts" || base === "schema.js") continue;
|
|
441
|
+
if (!includeMultiDot && pathHasMultipleDots(base)) continue;
|
|
442
|
+
if (normalizedRelPath.includes(" ")) continue;
|
|
443
|
+
files.push(fullPath);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
visit(dir);
|
|
447
|
+
return files;
|
|
448
|
+
};
|
|
449
|
+
const detectProjectRoots = () => {
|
|
450
|
+
const projectRoot = process.cwd();
|
|
451
|
+
const preferredFunctionsRoot = path.join(projectRoot, "convex", "functions");
|
|
452
|
+
const fallbackFunctionsRoot = path.join(projectRoot, "convex");
|
|
453
|
+
if (fs.existsSync(preferredFunctionsRoot)) return {
|
|
454
|
+
projectRoot,
|
|
455
|
+
functionsRoot: preferredFunctionsRoot
|
|
456
|
+
};
|
|
457
|
+
if (fs.existsSync(fallbackFunctionsRoot)) return {
|
|
458
|
+
projectRoot,
|
|
459
|
+
functionsRoot: fallbackFunctionsRoot
|
|
460
|
+
};
|
|
461
|
+
throw new Error(`Missing Convex functions directory. Expected one of:\n- ${preferredFunctionsRoot}\n- ${fallbackFunctionsRoot}`);
|
|
462
|
+
};
|
|
463
|
+
const hasUseNodeDirective = (source) => {
|
|
464
|
+
if (!source.includes("use node")) return false;
|
|
465
|
+
const lines = source.split(NEWLINE_SPLIT_REGEX);
|
|
466
|
+
for (const line of lines) {
|
|
467
|
+
const trimmed = line.trim();
|
|
468
|
+
if (!trimmed) continue;
|
|
469
|
+
if (trimmed.startsWith("//")) continue;
|
|
470
|
+
if (trimmed.startsWith("/*")) continue;
|
|
471
|
+
return USE_NODE_DIRECTIVE_REGEX.test(trimmed);
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
};
|
|
475
|
+
const isNodeEntryPoint = (entryPoint, functionsRoot) => {
|
|
476
|
+
if (normalizeOutputPath(path.relative(functionsRoot, entryPoint)).startsWith("actions/")) return true;
|
|
477
|
+
return hasUseNodeDirective(fs.readFileSync(entryPoint, "utf8"));
|
|
478
|
+
};
|
|
479
|
+
const hasRuntimeProcedureType = (value) => value === "query" || value === "mutation" || value === "action";
|
|
480
|
+
const getNativeHandlerExportNames = (source) => {
|
|
481
|
+
const exportNames = new Set(Array.from(source.matchAll(EXPORTED_NATIVE_HANDLER_CAPTURE_REGEX)).map((match) => match[1]).filter((name) => !!name));
|
|
482
|
+
const exportConstMatches = Array.from(source.matchAll(EXPORTED_CONST_CAPTURE_REGEX));
|
|
483
|
+
for (const [index, match] of exportConstMatches.entries()) {
|
|
484
|
+
const exportName = match[1];
|
|
485
|
+
if (!exportName) continue;
|
|
486
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
487
|
+
const end = exportConstMatches[index + 1]?.index ?? source.length;
|
|
488
|
+
const initializerSlice = source.slice(start, end);
|
|
489
|
+
if (CHAINED_PROCEDURE_CAPTURE_REGEX.test(initializerSlice)) exportNames.add(exportName);
|
|
490
|
+
}
|
|
491
|
+
for (const match of source.matchAll(EXPORTED_ORM_API_DESTRUCTURE_CAPTURE_REGEX)) {
|
|
492
|
+
const bindings = match[1];
|
|
493
|
+
for (const binding of bindings.split(",")) {
|
|
494
|
+
const trimmed = binding.trim();
|
|
495
|
+
if (!trimmed || trimmed.startsWith("...")) continue;
|
|
496
|
+
const withoutDefault = trimmed.split("=")[0]?.trim() ?? "";
|
|
497
|
+
const localBinding = withoutDefault.split(":")[1]?.trim() ?? withoutDefault;
|
|
498
|
+
if (VALID_IDENTIFIER_REGEX.test(localBinding)) exportNames.add(localBinding);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return Array.from(exportNames);
|
|
502
|
+
};
|
|
503
|
+
const listConvexHandlerExports = async (entryPoint, jitiInstance) => {
|
|
504
|
+
const exportNames = /* @__PURE__ */ new Set();
|
|
505
|
+
const source = fs.readFileSync(entryPoint, "utf8");
|
|
506
|
+
for (const exportName of getNativeHandlerExportNames(source)) exportNames.add(exportName);
|
|
507
|
+
try {
|
|
508
|
+
const module = await jitiInstance.import(entryPoint);
|
|
509
|
+
if (module && typeof module === "object") for (const [name, value] of Object.entries(module)) {
|
|
510
|
+
if (name.startsWith("_")) continue;
|
|
511
|
+
const meta = value._crpcMeta;
|
|
512
|
+
if (hasRuntimeProcedureType(meta?.type)) exportNames.add(name);
|
|
513
|
+
}
|
|
514
|
+
} catch {}
|
|
515
|
+
return Array.from(exportNames).sort((a, b) => a.localeCompare(b));
|
|
516
|
+
};
|
|
517
|
+
const scanHandlerExportsByEntry = async (entryPoints) => {
|
|
518
|
+
const jitiInstance = createJiti(process.cwd(), {
|
|
519
|
+
interopDefault: true,
|
|
520
|
+
moduleCache: false
|
|
521
|
+
});
|
|
522
|
+
const results = await Promise.all(entryPoints.map(async (entryPoint) => ({
|
|
523
|
+
entryPoint,
|
|
524
|
+
exportNames: await listConvexHandlerExports(entryPoint, jitiInstance)
|
|
525
|
+
})));
|
|
526
|
+
const byEntry = /* @__PURE__ */ new Map();
|
|
527
|
+
for (const result of results) if (result.exportNames.length > 0) byEntry.set(result.entryPoint, result.exportNames);
|
|
528
|
+
return byEntry;
|
|
529
|
+
};
|
|
530
|
+
const parseArgs$1 = (argv) => {
|
|
531
|
+
const options = {
|
|
532
|
+
mode: "hotspot",
|
|
533
|
+
entryPattern: null,
|
|
534
|
+
details: false,
|
|
535
|
+
showInputs: false,
|
|
536
|
+
interactive: "never",
|
|
537
|
+
includeGenerated: false,
|
|
538
|
+
showSmall: false,
|
|
539
|
+
width: null,
|
|
540
|
+
topInputs: DEFAULT_TOP_INPUTS,
|
|
541
|
+
topPackages: DEFAULT_TOP_PACKAGES,
|
|
542
|
+
detailEntries: DEFAULT_DETAIL_ENTRIES,
|
|
543
|
+
warningMb: DEFAULT_WARNING_MB,
|
|
544
|
+
dangerMb: DEFAULT_DANGER_MB,
|
|
545
|
+
failMb: null
|
|
546
|
+
};
|
|
547
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
548
|
+
const arg = argv[i];
|
|
549
|
+
const next = argv[i + 1];
|
|
550
|
+
if (arg === "--hotspot") throw new Error("`--hotspot` was removed. Hotspot is the default mode, so just run `better-convex analyze`.");
|
|
551
|
+
if (arg === "--deploy") {
|
|
552
|
+
options.mode = "deploy";
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (arg === "--entry" || arg.startsWith("--entry=")) throw new Error("`--entry` was removed. Pass the entry regex as the first positional argument, e.g. `better-convex analyze polar.*`.");
|
|
556
|
+
if (arg === "--detail-entries" || arg.startsWith("--detail-entries=") || arg === "--top" || arg.startsWith("--top=")) throw new Error("`--top` and `--detail-entries` were removed. The analyzer now uses built-in detail defaults.");
|
|
557
|
+
if (arg === "--details") {
|
|
558
|
+
options.details = true;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (arg === "--input") {
|
|
562
|
+
options.showInputs = true;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (arg === "--interactive" || arg === "-i") {
|
|
566
|
+
options.interactive = "always";
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (arg === "--no-interactive" || arg === "-I") throw new Error("`--no-interactive` was removed. Non-interactive is already the default.");
|
|
570
|
+
if (arg === "--all" || arg === "-a") {
|
|
571
|
+
options.includeGenerated = true;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (!arg.startsWith("-")) {
|
|
575
|
+
if (options.entryPattern !== null) throw new Error(`Only one positional entry regex is allowed. Received "${options.entryPattern}" and "${arg}".`);
|
|
576
|
+
options.entryPattern = arg;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (arg === "--show-small") {
|
|
580
|
+
options.showSmall = true;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (arg === "--width" && next) {
|
|
584
|
+
const parsed = Number.parseInt(next, 10);
|
|
585
|
+
if (Number.isFinite(parsed) && parsed >= 60) options.width = parsed;
|
|
586
|
+
i += 1;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (arg === "--top-inputs" && next) {
|
|
590
|
+
const parsed = Number.parseInt(next, 10);
|
|
591
|
+
if (Number.isFinite(parsed) && parsed > 0) options.topInputs = parsed;
|
|
592
|
+
i += 1;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (arg === "--top-packages" && next) {
|
|
596
|
+
const parsed = Number.parseInt(next, 10);
|
|
597
|
+
if (Number.isFinite(parsed) && parsed > 0) options.topPackages = parsed;
|
|
598
|
+
i += 1;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (arg === "--warn-mb" && next) {
|
|
602
|
+
const parsed = Number.parseFloat(next);
|
|
603
|
+
if (Number.isFinite(parsed) && parsed > 0) options.warningMb = parsed;
|
|
604
|
+
i += 1;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (arg === "--danger-mb" && next) {
|
|
608
|
+
const parsed = Number.parseFloat(next);
|
|
609
|
+
if (Number.isFinite(parsed) && parsed > 0) options.dangerMb = parsed;
|
|
610
|
+
i += 1;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (arg === "--fail-mb" && next) {
|
|
614
|
+
const parsed = Number.parseFloat(next);
|
|
615
|
+
if (Number.isFinite(parsed) && parsed > 0) options.failMb = parsed;
|
|
616
|
+
i += 1;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (arg === "--help" || arg === "-h") {
|
|
620
|
+
console.log(`Usage:
|
|
621
|
+
better-convex analyze [entryRegex]
|
|
622
|
+
better-convex analyze --deploy
|
|
623
|
+
better-convex analyze '^convex/functions/auth\\.ts$' --details
|
|
624
|
+
|
|
625
|
+
Modes:
|
|
626
|
+
(default) Hotspot analysis (per-function isolate ranking)
|
|
627
|
+
--deploy Deploy analysis (single isolate bundle, Convex-like)
|
|
628
|
+
|
|
629
|
+
Flags:
|
|
630
|
+
--details Show extra per-entry/package details
|
|
631
|
+
--input Include top internal inputs in detail output
|
|
632
|
+
--interactive, -i Enable interactive hotspot UI (default: off, TTY only)
|
|
633
|
+
--all, -a Include all Convex-ignored entries (multi-dot files + generated/, even without handlers)
|
|
634
|
+
--show-small Include tiny dependencies (hidden by default)
|
|
635
|
+
--width <n> Force output width (min 60)
|
|
636
|
+
--top-inputs <n> Rows for input tables (default ${DEFAULT_TOP_INPUTS})
|
|
637
|
+
--top-packages <n> Rows for package tables (default ${DEFAULT_TOP_PACKAGES})
|
|
638
|
+
--warn-mb <n> WARN threshold per entry/chunk (default ${DEFAULT_WARNING_MB})
|
|
639
|
+
--danger-mb <n> DANGER threshold per entry/chunk (default ${DEFAULT_DANGER_MB})
|
|
640
|
+
--fail-mb <n> Exit 1 if largest entry/chunk >= n MB`);
|
|
641
|
+
process.exit(0);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (options.mode === "deploy" && options.interactive === "always") throw new Error("`--interactive` is hotspot-only. Remove it when using `--deploy`.");
|
|
645
|
+
return options;
|
|
646
|
+
};
|
|
647
|
+
const schemaExternalFallbackPlugin = {
|
|
648
|
+
name: "schema-external-fallback",
|
|
649
|
+
setup(buildCtx) {
|
|
650
|
+
buildCtx.onResolve({ filter: SCHEMA_RESOLVE_FILTER }, (args) => ({
|
|
651
|
+
path: args.path,
|
|
652
|
+
external: true
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
const shouldRetryWithSchemaExternalized = (error) => {
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
return message.includes("No matching export") && (message.includes("schema.ts") || message.includes("/schema"));
|
|
659
|
+
};
|
|
660
|
+
const severityForBytes = (bytes, options) => {
|
|
661
|
+
const outputMb = bytes / MB;
|
|
662
|
+
if (outputMb >= options.dangerMb) return "DANGER";
|
|
663
|
+
if (outputMb >= options.warningMb) return "WARN";
|
|
664
|
+
return "OK";
|
|
665
|
+
};
|
|
666
|
+
const smallInputThreshold = (outputBytes) => Math.max(SMALL_INPUT_MIN_BYTES, Math.floor(outputBytes * SMALL_INPUT_MIN_SHARE));
|
|
667
|
+
const isSmallInput = (bytesInOutput, outputBytes, showSmall) => !showSmall && bytesInOutput < smallInputThreshold(outputBytes);
|
|
668
|
+
const shortPath = (inputPath) => {
|
|
669
|
+
if (inputPath.startsWith("convex/functions/")) return `fn/${inputPath.slice(17)}`;
|
|
670
|
+
if (inputPath.startsWith("convex/lib/")) return `lib/${inputPath.slice(11)}`;
|
|
671
|
+
if (inputPath.startsWith("../packages/better-convex/dist/")) return `bcx/${inputPath.slice(31)}`;
|
|
672
|
+
if (inputPath.startsWith("../node_modules/")) return `nm/${inputPath.slice(16)}`;
|
|
673
|
+
if (inputPath.startsWith("example/convex/")) return `convex/${inputPath.slice(15)}`;
|
|
674
|
+
return inputPath;
|
|
675
|
+
};
|
|
676
|
+
const compactPath = (inputPath, maxWidth) => truncate(shortPath(inputPath), Math.max(16, maxWidth));
|
|
677
|
+
const isNodeModulesInputPath = (inputPath) => inputPath.includes("node_modules/");
|
|
678
|
+
const packageFromInputPath = (inputPath) => {
|
|
679
|
+
const idx = inputPath.lastIndexOf("node_modules/");
|
|
680
|
+
if (idx >= 0) {
|
|
681
|
+
const [first, second] = inputPath.slice(idx + 13).split("/");
|
|
682
|
+
if (!first) return "(node_modules)";
|
|
683
|
+
if (first.startsWith("@") && second) return `${first}/${second}`;
|
|
684
|
+
return first;
|
|
685
|
+
}
|
|
686
|
+
if (inputPath.startsWith("convex/")) return "workspace:convex";
|
|
687
|
+
if (inputPath.startsWith("../packages/")) {
|
|
688
|
+
const parts = inputPath.replace("../", "").split("/");
|
|
689
|
+
return parts.length >= 2 ? `workspace:${parts[0]}/${parts[1]}` : "workspace:packages";
|
|
690
|
+
}
|
|
691
|
+
if (inputPath.startsWith("packages/")) {
|
|
692
|
+
const parts = inputPath.split("/");
|
|
693
|
+
return parts.length >= 2 ? `workspace:${parts[0]}/${parts[1]}` : "workspace:packages";
|
|
694
|
+
}
|
|
695
|
+
return "(other)";
|
|
696
|
+
};
|
|
697
|
+
const buildHotspotEntry = (entryPoint, externalizeSchema) => build({
|
|
698
|
+
bundle: true,
|
|
699
|
+
entryPoints: [entryPoint],
|
|
700
|
+
external: ["convex", "convex/*"],
|
|
701
|
+
format: "esm",
|
|
702
|
+
logLevel: "silent",
|
|
703
|
+
metafile: true,
|
|
704
|
+
platform: "browser",
|
|
705
|
+
target: ["esnext"],
|
|
706
|
+
conditions: ["convex", "module"],
|
|
707
|
+
minifySyntax: true,
|
|
708
|
+
minifyIdentifiers: true,
|
|
709
|
+
minifyWhitespace: false,
|
|
710
|
+
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
711
|
+
write: false,
|
|
712
|
+
plugins: externalizeSchema ? [schemaExternalFallbackPlugin] : []
|
|
713
|
+
});
|
|
714
|
+
const analyzeHotspotEntry = async (entryPoint, projectRoot, includeDeepData) => {
|
|
715
|
+
let result;
|
|
716
|
+
let schemaExternalized = false;
|
|
717
|
+
try {
|
|
718
|
+
result = await buildHotspotEntry(entryPoint, false);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (!shouldRetryWithSchemaExternalized(error)) throw error;
|
|
721
|
+
result = await buildHotspotEntry(entryPoint, true);
|
|
722
|
+
schemaExternalized = true;
|
|
723
|
+
}
|
|
724
|
+
const meta = result.metafile;
|
|
725
|
+
if (!meta) throw new Error(`No metafile generated for ${entryPoint}`);
|
|
726
|
+
const output = Object.values(meta.outputs).at(0);
|
|
727
|
+
if (!output) throw new Error(`No output generated for ${entryPoint}`);
|
|
728
|
+
const inputEntries = Object.entries(meta.inputs);
|
|
729
|
+
const totalInputBytes = inputEntries.reduce((sum, [, value]) => sum + (value.bytes ?? 0), 0);
|
|
730
|
+
const isLocalInput = (inputPath) => inputPath.startsWith("convex/") || inputPath.startsWith("example/convex/") || inputPath.includes("/example/convex/");
|
|
731
|
+
const localInputBytes = inputEntries.filter(([inputPath]) => isLocalInput(inputPath)).reduce((sum, [, value]) => sum + (value.bytes ?? 0), 0);
|
|
732
|
+
const row = {
|
|
733
|
+
entry: path.relative(projectRoot, entryPoint),
|
|
734
|
+
inputCount: inputEntries.length,
|
|
735
|
+
localInputBytes,
|
|
736
|
+
dependencyInputBytes: Math.max(0, totalInputBytes - localInputBytes),
|
|
737
|
+
totalInputBytes,
|
|
738
|
+
outputBytes: output.bytes,
|
|
739
|
+
schemaExternalized
|
|
740
|
+
};
|
|
741
|
+
if (!includeDeepData) return row;
|
|
742
|
+
const bytesByInput = new Map(inputEntries.map(([inputPath, value]) => [inputPath, value.bytes ?? 0]));
|
|
743
|
+
const outputInputs = Object.entries(output.inputs ?? {}).map(([inputPath, value]) => ({
|
|
744
|
+
path: inputPath,
|
|
745
|
+
bytesInOutput: value.bytesInOutput ?? 0,
|
|
746
|
+
sourceBytes: bytesByInput.get(inputPath) ?? 0
|
|
747
|
+
})).sort((a, b) => b.bytesInOutput - a.bytesInOutput);
|
|
748
|
+
const inputSet = new Set(inputEntries.map(([inputPath]) => inputPath));
|
|
749
|
+
const importsByInput = Object.fromEntries(inputEntries.map(([inputPath, value]) => [inputPath, Array.from(new Set((value.imports ?? []).map((entry) => entry.path).filter((importPath) => typeof importPath === "string" && inputSet.has(importPath))))]));
|
|
750
|
+
return {
|
|
751
|
+
...row,
|
|
752
|
+
deep: {
|
|
753
|
+
importsByInput,
|
|
754
|
+
outputInputs
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
};
|
|
758
|
+
const printHotspotTopInputs = (row, options) => {
|
|
759
|
+
if (!row.deep) return;
|
|
760
|
+
const internalInputs = row.deep.outputInputs.filter((input) => input.bytesInOutput > 0 && !isNodeModulesInputPath(input.path));
|
|
761
|
+
const externalBytes = row.deep.outputInputs.filter((input) => input.bytesInOutput > 0 && isNodeModulesInputPath(input.path)).reduce((sum, input) => sum + input.bytesInOutput, 0);
|
|
762
|
+
const topInputs = internalInputs.filter((input) => !isSmallInput(input.bytesInOutput, row.outputBytes, options.showSmall)).slice(0, options.topInputs);
|
|
763
|
+
const hiddenInputs = internalInputs.filter((input) => isSmallInput(input.bytesInOutput, row.outputBytes, options.showSmall));
|
|
764
|
+
const hiddenBytes = hiddenInputs.reduce((sum, input) => sum + input.bytesInOutput, 0);
|
|
765
|
+
if (topInputs.length === 0) {
|
|
766
|
+
if (!options.showSmall && hiddenInputs.length > 0) {
|
|
767
|
+
console.log("");
|
|
768
|
+
console.log(bold(`Top internal inputs: ${row.entry}`));
|
|
769
|
+
console.log(dim(`(all visible internal inputs were small; hidden ${hiddenInputs.length} inputs / ${toMB(hiddenBytes)} MB)`));
|
|
770
|
+
if (externalBytes > 0) console.log(dim(`External deps are summarized in Top packages (${toMB(externalBytes)} MB).`));
|
|
771
|
+
}
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
console.log("");
|
|
775
|
+
console.log(bold(`Top internal inputs: ${row.entry}`));
|
|
776
|
+
const inputPathWidth = Math.max(18, outputWidth - 57);
|
|
777
|
+
console.log(dim(`bytesInOutput sourceBytes share impact ${pad("path", inputPathWidth)}`));
|
|
778
|
+
console.log(dim(`------------- ---------- ---------- ---------------- ${"-".repeat(inputPathWidth)}`));
|
|
779
|
+
for (const input of topInputs) {
|
|
780
|
+
const share = row.outputBytes > 0 ? input.bytesInOutput / row.outputBytes * 100 : 0;
|
|
781
|
+
const bytesInOutput = input.bytesInOutput.toString().padStart(13);
|
|
782
|
+
const sourceBytes = input.sourceBytes.toString().padStart(10);
|
|
783
|
+
const sharePct = colorize(`${share.toFixed(2)}%`.padStart(10), shareColor(share));
|
|
784
|
+
const bar = makeShareBar(share);
|
|
785
|
+
console.log(`${bytesInOutput} ${sourceBytes} ${sharePct} ${bar} ${compactPath(input.path, inputPathWidth)}`);
|
|
786
|
+
}
|
|
787
|
+
if (!options.showSmall && hiddenInputs.length > 0) {
|
|
788
|
+
const share = row.outputBytes > 0 ? hiddenBytes / row.outputBytes * 100 : 0;
|
|
789
|
+
const hiddenLabel = dim(`(small: ${hiddenInputs.length} hidden inputs)`);
|
|
790
|
+
console.log(`${hiddenBytes.toString().padStart(13)} ${"".padStart(10)} ${colorize(`${share.toFixed(2)}%`.padStart(10), ANSI.gray)} ${makeShareBar(share)} ${hiddenLabel}`);
|
|
791
|
+
}
|
|
792
|
+
if (externalBytes > 0) {
|
|
793
|
+
const share = row.outputBytes > 0 ? externalBytes / row.outputBytes * 100 : 0;
|
|
794
|
+
console.log(dim(`External deps: ${toMB(externalBytes)} MB (${share.toFixed(2)}%), see Top packages.`));
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
const printHotspotPackages = (row, options) => {
|
|
798
|
+
if (!row.deep) return;
|
|
799
|
+
const packageRows = buildPackageImportGraphRows(row, Number.POSITIVE_INFINITY);
|
|
800
|
+
const visibleRows = packageRows.filter((item) => !isSmallInput(item.bytesInOutput, row.outputBytes, options.showSmall));
|
|
801
|
+
const hiddenRows = packageRows.filter((item) => isSmallInput(item.bytesInOutput, row.outputBytes, options.showSmall));
|
|
802
|
+
const hiddenBytes = hiddenRows.reduce((sum, item) => sum + item.bytesInOutput, 0);
|
|
803
|
+
const topRows = visibleRows.slice(0, options.topPackages);
|
|
804
|
+
console.log("");
|
|
805
|
+
console.log(bold(`Package graph: ${row.entry}`));
|
|
806
|
+
if (topRows.length === 0) {
|
|
807
|
+
console.log(" (no packages above small-dependency threshold)");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const packageColWidth = Math.max(16, Math.min(30, outputWidth - 58));
|
|
811
|
+
const barWidth = Math.max(8, Math.min(16, outputWidth - (packageColWidth + 52)));
|
|
812
|
+
for (const [index, item] of topRows.entries()) {
|
|
813
|
+
const share = row.outputBytes > 0 ? item.bytesInOutput / row.outputBytes * 100 : 0;
|
|
814
|
+
const shareStr = colorize(`${share.toFixed(2)}%`, shareColor(share));
|
|
815
|
+
const sizeStr = colorize(`${toMB(item.bytesInOutput)} MB`, shareColor(share));
|
|
816
|
+
const bar = makeShareBar(share, barWidth);
|
|
817
|
+
const packageLabel = truncate(item.packageName, packageColWidth);
|
|
818
|
+
const targetPrefix = dim(" imports -> ");
|
|
819
|
+
const maxTargetWidth = Math.max(18, outputWidth - visibleLength(targetPrefix) - 2);
|
|
820
|
+
console.log(`${bold(`${index + 1}.`)} ${pad(packageLabel, packageColWidth)} ${pad(sizeStr, 18)} ${pad(shareStr, 10)} ${bar}`);
|
|
821
|
+
if (item.topTargets.length > 0) {
|
|
822
|
+
const targetLabel = item.topTargets.join(", ");
|
|
823
|
+
console.log(`${targetPrefix}${truncate(targetLabel, maxTargetWidth)}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (!options.showSmall && hiddenRows.length > 0) {
|
|
827
|
+
const share = row.outputBytes > 0 ? hiddenBytes / row.outputBytes * 100 : 0;
|
|
828
|
+
console.log(` ${dim(`(small packages hidden: ${hiddenRows.length}, ${toMB(hiddenBytes)} MB, ${share.toFixed(2)}%)`)}`);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
const shouldPromptForHotspotPick = (options) => {
|
|
832
|
+
if (options.interactive === "never") return false;
|
|
833
|
+
if (!isInteractiveTerminal) return false;
|
|
834
|
+
if (options.details || options.entryPattern) return false;
|
|
835
|
+
return options.interactive === "always";
|
|
836
|
+
};
|
|
837
|
+
const HOTSPOT_SORT_ORDER = [
|
|
838
|
+
"out",
|
|
839
|
+
"dep",
|
|
840
|
+
"fns"
|
|
841
|
+
];
|
|
842
|
+
const HOTSPOT_DETAIL_ORDER = [
|
|
843
|
+
"handlers",
|
|
844
|
+
"packages",
|
|
845
|
+
"inputs"
|
|
846
|
+
];
|
|
847
|
+
const HOTSPOT_MIN_SPLIT_COLUMNS = 120;
|
|
848
|
+
const HOTSPOT_LEFT_MIN_WIDTH = 30;
|
|
849
|
+
const HOTSPOT_LEFT_MAX_WIDTH = 56;
|
|
850
|
+
const HOTSPOT_HEADER_LINES = 2;
|
|
851
|
+
const HOTSPOT_BOTTOM_LINES = 2;
|
|
852
|
+
const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
853
|
+
const cycleHotspotSort = (sortKey) => {
|
|
854
|
+
return HOTSPOT_SORT_ORDER[(HOTSPOT_SORT_ORDER.indexOf(sortKey) + 1) % HOTSPOT_SORT_ORDER.length];
|
|
855
|
+
};
|
|
856
|
+
const cycleHotspotDetailPane = (detailPane) => {
|
|
857
|
+
return HOTSPOT_DETAIL_ORDER[(HOTSPOT_DETAIL_ORDER.indexOf(detailPane) + 1) % HOTSPOT_DETAIL_ORDER.length];
|
|
858
|
+
};
|
|
859
|
+
const cycleHotspotDetailPaneBackward = (detailPane) => {
|
|
860
|
+
return HOTSPOT_DETAIL_ORDER[(HOTSPOT_DETAIL_ORDER.indexOf(detailPane) - 1 + HOTSPOT_DETAIL_ORDER.length) % HOTSPOT_DETAIL_ORDER.length];
|
|
861
|
+
};
|
|
862
|
+
const fitListViewport = (totalRows, selectedIndex, viewportHeight, topIndex) => {
|
|
863
|
+
if (totalRows <= 0 || viewportHeight <= 0) return 0;
|
|
864
|
+
const maxTop = Math.max(0, totalRows - viewportHeight);
|
|
865
|
+
const clampedSelected = clampNumber(selectedIndex, 0, totalRows - 1);
|
|
866
|
+
let nextTop = clampNumber(topIndex, 0, maxTop);
|
|
867
|
+
if (clampedSelected < nextTop) nextTop = clampedSelected;
|
|
868
|
+
else if (clampedSelected >= nextTop + viewportHeight) nextTop = clampedSelected - viewportHeight + 1;
|
|
869
|
+
return clampNumber(nextTop, 0, maxTop);
|
|
870
|
+
};
|
|
871
|
+
const pickSelectedIndex = (rows, preferredEntry, fallbackIndex) => {
|
|
872
|
+
if (rows.length === 0) return 0;
|
|
873
|
+
if (preferredEntry) {
|
|
874
|
+
const preferredIndex = rows.findIndex((row) => row.entry === preferredEntry);
|
|
875
|
+
if (preferredIndex >= 0) return preferredIndex;
|
|
876
|
+
}
|
|
877
|
+
return clampNumber(fallbackIndex, 0, rows.length - 1);
|
|
878
|
+
};
|
|
879
|
+
const resolveInteractiveLayout = (columns, rows) => {
|
|
880
|
+
const safeColumns = Math.max(60, columns);
|
|
881
|
+
const safeRows = Math.max(12, rows);
|
|
882
|
+
const bodyHeight = Math.max(6, safeRows - HOTSPOT_HEADER_LINES - HOTSPOT_BOTTOM_LINES);
|
|
883
|
+
if (safeColumns >= HOTSPOT_MIN_SPLIT_COLUMNS) {
|
|
884
|
+
const leftWidth = clampNumber(Math.floor(safeColumns * .33), HOTSPOT_LEFT_MIN_WIDTH, HOTSPOT_LEFT_MAX_WIDTH);
|
|
885
|
+
const rightWidth = Math.max(24, safeColumns - leftWidth - 3);
|
|
886
|
+
const viewportHeight = Math.max(1, bodyHeight - 1);
|
|
887
|
+
return {
|
|
888
|
+
mode: "split",
|
|
889
|
+
columns: safeColumns,
|
|
890
|
+
rows: safeRows,
|
|
891
|
+
bodyHeight,
|
|
892
|
+
leftWidth,
|
|
893
|
+
rightWidth,
|
|
894
|
+
listViewportHeight: viewportHeight,
|
|
895
|
+
detailViewportHeight: viewportHeight
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const stackBody = Math.max(5, bodyHeight);
|
|
899
|
+
const listHeight = Math.max(2, Math.floor((stackBody - 1) * .45));
|
|
900
|
+
let detailHeight = Math.max(2, stackBody - listHeight - 1);
|
|
901
|
+
if (listHeight + detailHeight + 1 > stackBody) detailHeight = Math.max(2, stackBody - listHeight - 1);
|
|
902
|
+
return {
|
|
903
|
+
mode: "stacked",
|
|
904
|
+
columns: safeColumns,
|
|
905
|
+
rows: safeRows,
|
|
906
|
+
bodyHeight: stackBody,
|
|
907
|
+
listHeight,
|
|
908
|
+
detailHeight,
|
|
909
|
+
listViewportHeight: Math.max(1, listHeight - 1),
|
|
910
|
+
detailViewportHeight: Math.max(1, detailHeight - 1)
|
|
911
|
+
};
|
|
912
|
+
};
|
|
913
|
+
const reduceInteractiveState = (state, action) => {
|
|
914
|
+
switch (action.type) {
|
|
915
|
+
case "moveSelection": {
|
|
916
|
+
if (action.rowCount <= 0) return {
|
|
917
|
+
...state,
|
|
918
|
+
selectedIndex: 0,
|
|
919
|
+
topIndex: 0
|
|
920
|
+
};
|
|
921
|
+
const nextIndex = clampNumber(state.selectedIndex + action.delta, 0, action.rowCount - 1);
|
|
922
|
+
return {
|
|
923
|
+
...state,
|
|
924
|
+
selectedIndex: nextIndex
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
case "setFilter": return {
|
|
928
|
+
...state,
|
|
929
|
+
filterQuery: action.query
|
|
930
|
+
};
|
|
931
|
+
case "cycleSort": return {
|
|
932
|
+
...state,
|
|
933
|
+
sortKey: cycleHotspotSort(state.sortKey)
|
|
934
|
+
};
|
|
935
|
+
case "cyclePane": return {
|
|
936
|
+
...state,
|
|
937
|
+
detailPane: action.direction === 1 ? cycleHotspotDetailPane(state.detailPane) : cycleHotspotDetailPaneBackward(state.detailPane)
|
|
938
|
+
};
|
|
939
|
+
case "toggleGenerated": return {
|
|
940
|
+
...state,
|
|
941
|
+
includeGenerated: !state.includeGenerated
|
|
942
|
+
};
|
|
943
|
+
case "toggleWatch": return {
|
|
944
|
+
...state,
|
|
945
|
+
watchEnabled: !state.watchEnabled
|
|
946
|
+
};
|
|
947
|
+
case "toggleHelp": return {
|
|
948
|
+
...state,
|
|
949
|
+
showHelp: !state.showHelp
|
|
950
|
+
};
|
|
951
|
+
case "requestRefresh": return {
|
|
952
|
+
...state,
|
|
953
|
+
statusMessage: "Refreshing analysis..."
|
|
954
|
+
};
|
|
955
|
+
case "setStatus": return {
|
|
956
|
+
...state,
|
|
957
|
+
statusMessage: action.message
|
|
958
|
+
};
|
|
959
|
+
case "setTopIndex": return {
|
|
960
|
+
...state,
|
|
961
|
+
topIndex: action.topIndex
|
|
962
|
+
};
|
|
963
|
+
default: return state;
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
const sortHotspotRows = (rows, sortKey) => {
|
|
967
|
+
const clone = [...rows];
|
|
968
|
+
if (sortKey === "dep") {
|
|
969
|
+
clone.sort((a, b) => b.dependencyInputBytes - a.dependencyInputBytes);
|
|
970
|
+
return clone;
|
|
971
|
+
}
|
|
972
|
+
if (sortKey === "fns") {
|
|
973
|
+
clone.sort((a, b) => b.handlerExports.length - a.handlerExports.length);
|
|
974
|
+
return clone;
|
|
975
|
+
}
|
|
976
|
+
clone.sort((a, b) => b.outputBytes - a.outputBytes);
|
|
977
|
+
return clone;
|
|
978
|
+
};
|
|
979
|
+
const filterHotspotRows = (rows, query) => {
|
|
980
|
+
const trimmed = query.trim().toLowerCase();
|
|
981
|
+
if (!trimmed) return rows;
|
|
982
|
+
return rows.filter((row) => {
|
|
983
|
+
if (row.entry.toLowerCase().includes(trimmed)) return true;
|
|
984
|
+
return row.handlerExports.some((name) => name.toLowerCase().includes(trimmed));
|
|
985
|
+
});
|
|
986
|
+
};
|
|
987
|
+
const sortLabel = (sortKey) => {
|
|
988
|
+
if (sortKey === "dep") return "DepMB";
|
|
989
|
+
if (sortKey === "fns") return "Fns";
|
|
990
|
+
return "OutMB";
|
|
991
|
+
};
|
|
992
|
+
const selectHotspotEntryPoints = (params) => {
|
|
993
|
+
const { baseCandidateEntries, allCandidateEntries, handlerExportsByEntry, includeGenerated } = params;
|
|
994
|
+
const isolateEntries = baseCandidateEntries.filter((entryPoint) => handlerExportsByEntry.has(entryPoint));
|
|
995
|
+
const baseEntrySet = new Set(baseCandidateEntries);
|
|
996
|
+
const ignoredEntries = allCandidateEntries.filter((entryPoint) => !baseEntrySet.has(entryPoint));
|
|
997
|
+
const generatedEntries = includeGenerated ? ignoredEntries : ignoredEntries.filter((entryPoint) => handlerExportsByEntry.has(entryPoint));
|
|
998
|
+
return {
|
|
999
|
+
isolateEntries,
|
|
1000
|
+
generatedEntries,
|
|
1001
|
+
entryPoints: dedupe([...isolateEntries, ...generatedEntries])
|
|
1002
|
+
};
|
|
1003
|
+
};
|
|
1004
|
+
const filterEntryPointsByPattern = (entryPoints, roots, entryPattern) => {
|
|
1005
|
+
if (!entryPattern) return entryPoints;
|
|
1006
|
+
let regex;
|
|
1007
|
+
try {
|
|
1008
|
+
regex = new RegExp(entryPattern, "i");
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
const reason = error instanceof Error ? firstLine(error.message) : "invalid regex";
|
|
1011
|
+
throw new Error(`Invalid entry regex "${entryPattern}": ${reason}`);
|
|
1012
|
+
}
|
|
1013
|
+
return entryPoints.filter((entryPoint) => regex.test(path.relative(roots.projectRoot, entryPoint)));
|
|
1014
|
+
};
|
|
1015
|
+
const collectAnalyzeEntrySelection = async (roots, options) => {
|
|
1016
|
+
const allCandidateWithNode = walkDeployEntryPoints(roots.functionsRoot, {
|
|
1017
|
+
includeMultiDot: true,
|
|
1018
|
+
includeGeneratedDir: true
|
|
1019
|
+
});
|
|
1020
|
+
const nodeEntryPoints = allCandidateWithNode.filter((entryPoint) => isNodeEntryPoint(entryPoint, roots.functionsRoot));
|
|
1021
|
+
const nodeEntrySet = new Set(nodeEntryPoints);
|
|
1022
|
+
const allCandidateEntries = allCandidateWithNode.filter((entryPoint) => !nodeEntrySet.has(entryPoint));
|
|
1023
|
+
const baseCandidateEntries = walkDeployEntryPoints(roots.functionsRoot).filter((entryPoint) => !nodeEntrySet.has(entryPoint));
|
|
1024
|
+
const handlerExportsByEntry = await scanHandlerExportsByEntry(allCandidateEntries);
|
|
1025
|
+
const { isolateEntries, generatedEntries, entryPoints } = selectHotspotEntryPoints({
|
|
1026
|
+
baseCandidateEntries,
|
|
1027
|
+
allCandidateEntries,
|
|
1028
|
+
handlerExportsByEntry,
|
|
1029
|
+
includeGenerated: options.includeGenerated
|
|
1030
|
+
});
|
|
1031
|
+
return {
|
|
1032
|
+
nodeEntryPoints,
|
|
1033
|
+
isolateEntries,
|
|
1034
|
+
generatedEntries,
|
|
1035
|
+
entryPoints: filterEntryPointsByPattern(entryPoints, roots, options.entryPattern),
|
|
1036
|
+
handlerExportsByEntry
|
|
1037
|
+
};
|
|
1038
|
+
};
|
|
1039
|
+
const collectHotspotRows = async (roots, options, includeDeepData) => {
|
|
1040
|
+
const { isolateEntries, generatedEntries, entryPoints, handlerExportsByEntry } = await collectAnalyzeEntrySelection(roots, options);
|
|
1041
|
+
const rows = [];
|
|
1042
|
+
for (const entryPoint of entryPoints) try {
|
|
1043
|
+
rows.push({
|
|
1044
|
+
...await analyzeHotspotEntry(entryPoint, roots.projectRoot, includeDeepData),
|
|
1045
|
+
handlerExports: handlerExportsByEntry.get(entryPoint) ?? []
|
|
1046
|
+
});
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
rows.push({
|
|
1049
|
+
entry: path.relative(roots.projectRoot, entryPoint),
|
|
1050
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
isolateEntries,
|
|
1055
|
+
generatedEntries,
|
|
1056
|
+
entryPoints,
|
|
1057
|
+
successRows: rows.filter((row) => !("error" in row)).sort((a, b) => b.outputBytes - a.outputBytes),
|
|
1058
|
+
failedRows: rows.filter((row) => "error" in row),
|
|
1059
|
+
handlerExportsByEntry
|
|
1060
|
+
};
|
|
1061
|
+
};
|
|
1062
|
+
const buildPackageImportGraphRows = (row, limit = 12) => {
|
|
1063
|
+
if (!row.deep) return [];
|
|
1064
|
+
const packageBytes = /* @__PURE__ */ new Map();
|
|
1065
|
+
const packageTargets = /* @__PURE__ */ new Map();
|
|
1066
|
+
for (const input of row.deep.outputInputs) {
|
|
1067
|
+
if (input.bytesInOutput <= 0) continue;
|
|
1068
|
+
const sourcePackage = packageFromInputPath(input.path);
|
|
1069
|
+
packageBytes.set(sourcePackage, (packageBytes.get(sourcePackage) ?? 0) + input.bytesInOutput);
|
|
1070
|
+
const targets = row.deep.importsByInput[input.path] ?? [];
|
|
1071
|
+
if (targets.length === 0) continue;
|
|
1072
|
+
const targetCounts = packageTargets.get(sourcePackage) ?? /* @__PURE__ */ new Map();
|
|
1073
|
+
for (const targetPath of targets) {
|
|
1074
|
+
const targetPackage = packageFromInputPath(targetPath);
|
|
1075
|
+
if (targetPackage === sourcePackage) continue;
|
|
1076
|
+
targetCounts.set(targetPackage, (targetCounts.get(targetPackage) ?? 0) + 1);
|
|
1077
|
+
}
|
|
1078
|
+
packageTargets.set(sourcePackage, targetCounts);
|
|
1079
|
+
}
|
|
1080
|
+
const sorted = Array.from(packageBytes.entries()).map(([packageName, bytesInOutput]) => {
|
|
1081
|
+
const targetMap = packageTargets.get(packageName) ?? /* @__PURE__ */ new Map();
|
|
1082
|
+
return {
|
|
1083
|
+
packageName,
|
|
1084
|
+
bytesInOutput,
|
|
1085
|
+
topTargets: Array.from(targetMap.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3).map(([target]) => target)
|
|
1086
|
+
};
|
|
1087
|
+
}).sort((a, b) => b.bytesInOutput - a.bytesInOutput);
|
|
1088
|
+
if (!Number.isFinite(limit)) return sorted;
|
|
1089
|
+
return sorted.slice(0, limit);
|
|
1090
|
+
};
|
|
1091
|
+
const padVisible = (value, width) => {
|
|
1092
|
+
const missing = width - visibleLength(value);
|
|
1093
|
+
if (missing <= 0) return value;
|
|
1094
|
+
return `${value}${" ".repeat(missing)}`;
|
|
1095
|
+
};
|
|
1096
|
+
const fillPane = (lines, height) => {
|
|
1097
|
+
const next = [...lines];
|
|
1098
|
+
while (next.length < height) next.push("");
|
|
1099
|
+
return next.slice(0, height);
|
|
1100
|
+
};
|
|
1101
|
+
const buildHandlersPaneLines = (row, width) => {
|
|
1102
|
+
if (!row) return [dim("No selected entry.")];
|
|
1103
|
+
if (row.handlerExports.length === 0) return [dim("No handler exports detected.")];
|
|
1104
|
+
return row.handlerExports.map((name) => `- ${truncate(name, width - 2)}`);
|
|
1105
|
+
};
|
|
1106
|
+
const buildPackagesPaneLines = (row, options, width) => {
|
|
1107
|
+
if (!row) return [dim("No selected entry.")];
|
|
1108
|
+
if (!row.deep) return [dim("No package data loaded.")];
|
|
1109
|
+
const packageRows = buildPackageImportGraphRows(row, Number.POSITIVE_INFINITY);
|
|
1110
|
+
const visibleRows = packageRows.filter((item) => !isSmallInput(item.bytesInOutput, row.outputBytes, options.showSmall));
|
|
1111
|
+
const hiddenCount = packageRows.length - visibleRows.length;
|
|
1112
|
+
const lines = [];
|
|
1113
|
+
if (visibleRows.length === 0) {
|
|
1114
|
+
lines.push(dim("No package rows above small-input threshold."));
|
|
1115
|
+
return lines;
|
|
1116
|
+
}
|
|
1117
|
+
const topRows = visibleRows.slice(0, Math.min(12, options.topPackages));
|
|
1118
|
+
const packageWidth = Math.max(10, width - 26);
|
|
1119
|
+
for (const [index, pkg] of topRows.entries()) {
|
|
1120
|
+
const share = row.outputBytes > 0 ? pkg.bytesInOutput / row.outputBytes * 100 : 0;
|
|
1121
|
+
lines.push(`${pad(String(index + 1), 2, "right")} ${pad(truncate(pkg.packageName, packageWidth), packageWidth)} ${pad(`${toMB(pkg.bytesInOutput)}MB`, 9, "right")} ${pad(`${share.toFixed(2)}%`, 8, "right")}`);
|
|
1122
|
+
if (pkg.topTargets.length > 0) {
|
|
1123
|
+
const targetLabel = pkg.topTargets.join(", ");
|
|
1124
|
+
lines.push(dim(` -> ${truncate(targetLabel, Math.max(12, width - 7))}`));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (!options.showSmall && hiddenCount > 0) lines.push(dim(`hidden small packages: ${hiddenCount}`));
|
|
1128
|
+
return lines;
|
|
1129
|
+
};
|
|
1130
|
+
const buildInputsPaneLines = (row, options, width) => {
|
|
1131
|
+
if (!row) return [dim("No selected entry.")];
|
|
1132
|
+
if (!row.deep) return [dim("No input data loaded.")];
|
|
1133
|
+
const internal = row.deep.outputInputs.filter((input) => input.bytesInOutput > 0 && !isNodeModulesInputPath(input.path));
|
|
1134
|
+
const externalBytes = row.deep.outputInputs.filter((input) => input.bytesInOutput > 0 && isNodeModulesInputPath(input.path)).reduce((sum, input) => sum + input.bytesInOutput, 0);
|
|
1135
|
+
const visibleInputs = internal.filter((input) => !isSmallInput(input.bytesInOutput, row.outputBytes, options.showSmall)).slice(0, Math.min(12, options.topInputs));
|
|
1136
|
+
if (visibleInputs.length === 0) {
|
|
1137
|
+
const lines = [dim("No internal inputs above small-input threshold.")];
|
|
1138
|
+
if (externalBytes > 0) {
|
|
1139
|
+
const share = row.outputBytes > 0 ? externalBytes / row.outputBytes * 100 : 0;
|
|
1140
|
+
lines.push(dim(`external deps: ${toMB(externalBytes)}MB (${share.toFixed(2)}%)`));
|
|
1141
|
+
}
|
|
1142
|
+
return lines;
|
|
1143
|
+
}
|
|
1144
|
+
const pathWidth = Math.max(12, width - 24);
|
|
1145
|
+
const lines = visibleInputs.map((input) => {
|
|
1146
|
+
const share = row.outputBytes > 0 ? input.bytesInOutput / row.outputBytes * 100 : 0;
|
|
1147
|
+
return `${pad(`${toMB(input.bytesInOutput)}MB`, 9, "right")} ${pad(`${share.toFixed(2)}%`, 8, "right")} ${truncate(shortPath(input.path), pathWidth)}`;
|
|
1148
|
+
});
|
|
1149
|
+
if (externalBytes > 0) {
|
|
1150
|
+
const share = row.outputBytes > 0 ? externalBytes / row.outputBytes * 100 : 0;
|
|
1151
|
+
lines.push(dim(`external deps: ${toMB(externalBytes)}MB (${share.toFixed(2)}%)`));
|
|
1152
|
+
}
|
|
1153
|
+
return lines;
|
|
1154
|
+
};
|
|
1155
|
+
const buildHelpPaneLines = () => [
|
|
1156
|
+
"j/k move selection",
|
|
1157
|
+
"left/right arrow cycle detail pane",
|
|
1158
|
+
"/ filter entries",
|
|
1159
|
+
"s sort cycle (OutMB, DepMB, Fns)",
|
|
1160
|
+
"g toggle all entries",
|
|
1161
|
+
"r refresh analysis",
|
|
1162
|
+
"w toggle watch mode",
|
|
1163
|
+
"? toggle help overlay",
|
|
1164
|
+
"q quit"
|
|
1165
|
+
];
|
|
1166
|
+
const runHotspotInteractive = async (roots, options) => {
|
|
1167
|
+
const stdin = process.stdin;
|
|
1168
|
+
const stdout = process.stdout;
|
|
1169
|
+
let state = {
|
|
1170
|
+
selectedIndex: 0,
|
|
1171
|
+
topIndex: 0,
|
|
1172
|
+
filterQuery: "",
|
|
1173
|
+
sortKey: "out",
|
|
1174
|
+
detailPane: "packages",
|
|
1175
|
+
includeGenerated: options.includeGenerated,
|
|
1176
|
+
watchEnabled: false,
|
|
1177
|
+
showHelp: false,
|
|
1178
|
+
statusMessage: ""
|
|
1179
|
+
};
|
|
1180
|
+
let snapshot = await collectHotspotRows(roots, {
|
|
1181
|
+
...options,
|
|
1182
|
+
includeGenerated: state.includeGenerated,
|
|
1183
|
+
entryPattern: null
|
|
1184
|
+
}, false);
|
|
1185
|
+
let visibleRows = sortHotspotRows(filterHotspotRows(snapshot.successRows, state.filterQuery), state.sortKey);
|
|
1186
|
+
let watcher = null;
|
|
1187
|
+
let watchTimer = null;
|
|
1188
|
+
let refreshing = false;
|
|
1189
|
+
let refreshQueued = false;
|
|
1190
|
+
let shouldQuit = false;
|
|
1191
|
+
let listViewportHeight = 10;
|
|
1192
|
+
const deepRowCache = /* @__PURE__ */ new Map();
|
|
1193
|
+
const getSelectedEntry = () => visibleRows.at(state.selectedIndex)?.entry ?? null;
|
|
1194
|
+
const findRowByEntry = (entry) => {
|
|
1195
|
+
if (!entry) return null;
|
|
1196
|
+
return snapshot.successRows.find((row) => row.entry === entry) ?? null;
|
|
1197
|
+
};
|
|
1198
|
+
const syncVisibleRows = (preferredEntry) => {
|
|
1199
|
+
visibleRows = sortHotspotRows(filterHotspotRows(snapshot.successRows, state.filterQuery), state.sortKey);
|
|
1200
|
+
const nextSelectedIndex = pickSelectedIndex(visibleRows, preferredEntry, state.selectedIndex);
|
|
1201
|
+
const nextTopIndex = fitListViewport(visibleRows.length, nextSelectedIndex, listViewportHeight, state.topIndex);
|
|
1202
|
+
state = reduceInteractiveState(state, {
|
|
1203
|
+
type: "setTopIndex",
|
|
1204
|
+
topIndex: nextTopIndex
|
|
1205
|
+
});
|
|
1206
|
+
state = {
|
|
1207
|
+
...state,
|
|
1208
|
+
selectedIndex: nextSelectedIndex
|
|
1209
|
+
};
|
|
1210
|
+
};
|
|
1211
|
+
const refreshSnapshot = async (reason) => {
|
|
1212
|
+
if (refreshing) {
|
|
1213
|
+
refreshQueued = true;
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
refreshing = true;
|
|
1217
|
+
state = reduceInteractiveState(state, {
|
|
1218
|
+
type: "setStatus",
|
|
1219
|
+
message: reason
|
|
1220
|
+
});
|
|
1221
|
+
const preferredEntry = getSelectedEntry();
|
|
1222
|
+
try {
|
|
1223
|
+
snapshot = await collectHotspotRows(roots, {
|
|
1224
|
+
...options,
|
|
1225
|
+
includeGenerated: state.includeGenerated,
|
|
1226
|
+
entryPattern: null
|
|
1227
|
+
}, false);
|
|
1228
|
+
deepRowCache.clear();
|
|
1229
|
+
syncVisibleRows(preferredEntry);
|
|
1230
|
+
state = reduceInteractiveState(state, {
|
|
1231
|
+
type: "setStatus",
|
|
1232
|
+
message: `Refreshed (${snapshot.successRows.length} entries).`
|
|
1233
|
+
});
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
state = reduceInteractiveState(state, {
|
|
1236
|
+
type: "setStatus",
|
|
1237
|
+
message: `Refresh failed: ${firstLine(error instanceof Error ? error.message : String(error))}`
|
|
1238
|
+
});
|
|
1239
|
+
} finally {
|
|
1240
|
+
refreshing = false;
|
|
1241
|
+
if (refreshQueued) {
|
|
1242
|
+
refreshQueued = false;
|
|
1243
|
+
await refreshSnapshot("Refreshing (queued)...");
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
const stopWatcher = () => {
|
|
1248
|
+
if (watcher) {
|
|
1249
|
+
watcher.close();
|
|
1250
|
+
watcher = null;
|
|
1251
|
+
}
|
|
1252
|
+
if (watchTimer) {
|
|
1253
|
+
clearTimeout(watchTimer);
|
|
1254
|
+
watchTimer = null;
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
const applyWatchMode = () => {
|
|
1258
|
+
if (!state.watchEnabled) {
|
|
1259
|
+
stopWatcher();
|
|
1260
|
+
state = reduceInteractiveState(state, {
|
|
1261
|
+
type: "setStatus",
|
|
1262
|
+
message: "Watch mode disabled."
|
|
1263
|
+
});
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
try {
|
|
1267
|
+
watcher = fs.watch(roots.functionsRoot, { recursive: true }, () => {
|
|
1268
|
+
if (watchTimer) clearTimeout(watchTimer);
|
|
1269
|
+
watchTimer = setTimeout(() => {
|
|
1270
|
+
refreshSnapshot("Auto-refresh (file change)...");
|
|
1271
|
+
}, 200);
|
|
1272
|
+
});
|
|
1273
|
+
state = reduceInteractiveState(state, {
|
|
1274
|
+
type: "setStatus",
|
|
1275
|
+
message: "Watch mode enabled."
|
|
1276
|
+
});
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
state = reduceInteractiveState(state, { type: "toggleWatch" });
|
|
1279
|
+
state = reduceInteractiveState(state, {
|
|
1280
|
+
type: "setStatus",
|
|
1281
|
+
message: `Watch unavailable: ${firstLine(error instanceof Error ? error.message : String(error))}`
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
const promptFilter = async () => {
|
|
1286
|
+
if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
|
|
1287
|
+
const rl = createInterface({
|
|
1288
|
+
input: stdin,
|
|
1289
|
+
output: stdout
|
|
1290
|
+
});
|
|
1291
|
+
try {
|
|
1292
|
+
const value = await rl.question("Filter entries (empty clears): ");
|
|
1293
|
+
const preferredEntry = getSelectedEntry();
|
|
1294
|
+
state = reduceInteractiveState(state, {
|
|
1295
|
+
type: "setFilter",
|
|
1296
|
+
query: value.trim()
|
|
1297
|
+
});
|
|
1298
|
+
syncVisibleRows(preferredEntry);
|
|
1299
|
+
state = reduceInteractiveState(state, {
|
|
1300
|
+
type: "setStatus",
|
|
1301
|
+
message: state.filterQuery ? `Filter applied: "${state.filterQuery}"` : "Filter cleared."
|
|
1302
|
+
});
|
|
1303
|
+
} finally {
|
|
1304
|
+
rl.close();
|
|
1305
|
+
if (typeof stdin.setRawMode === "function") stdin.setRawMode(true);
|
|
1306
|
+
stdin.resume();
|
|
1307
|
+
stdin.setEncoding("utf8");
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
const ensureDeepRow = async (entry) => {
|
|
1311
|
+
if (deepRowCache.has(entry)) return deepRowCache.get(entry) ?? null;
|
|
1312
|
+
const entryPoint = path.join(roots.projectRoot, entry);
|
|
1313
|
+
try {
|
|
1314
|
+
const analyzed = await analyzeHotspotEntry(entryPoint, roots.projectRoot, true);
|
|
1315
|
+
const fallbackHandlers = findRowByEntry(entry)?.handlerExports ?? [];
|
|
1316
|
+
const mergedRow = {
|
|
1317
|
+
...analyzed,
|
|
1318
|
+
handlerExports: snapshot.handlerExportsByEntry.get(entryPoint) ?? fallbackHandlers
|
|
1319
|
+
};
|
|
1320
|
+
deepRowCache.set(entry, mergedRow);
|
|
1321
|
+
return mergedRow;
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
deepRowCache.set(entry, null);
|
|
1324
|
+
state = reduceInteractiveState(state, {
|
|
1325
|
+
type: "setStatus",
|
|
1326
|
+
message: `Detail load failed for ${entry}: ${firstLine(error instanceof Error ? error.message : String(error))}`
|
|
1327
|
+
});
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
const renderInteractive = async () => {
|
|
1332
|
+
const layout = resolveInteractiveLayout(stdout.columns ?? outputWidth, stdout.rows ?? 30);
|
|
1333
|
+
listViewportHeight = layout.listViewportHeight;
|
|
1334
|
+
state = reduceInteractiveState(state, {
|
|
1335
|
+
type: "setTopIndex",
|
|
1336
|
+
topIndex: fitListViewport(visibleRows.length, state.selectedIndex, layout.listViewportHeight, state.topIndex)
|
|
1337
|
+
});
|
|
1338
|
+
const activeEntry = (visibleRows.at(state.selectedIndex) ?? null)?.entry ?? null;
|
|
1339
|
+
let detailRow = findRowByEntry(activeEntry);
|
|
1340
|
+
if (!state.showHelp && activeEntry && state.detailPane !== "handlers") detailRow = await ensureDeepRow(activeEntry);
|
|
1341
|
+
const statusLine = state.statusMessage || "Ready";
|
|
1342
|
+
const headerLine = `entries=${visibleRows.length}/${snapshot.successRows.length} sort=${sortLabel(state.sortKey)} filter=${state.filterQuery || "∅"} all=${state.includeGenerated ? "on" : "off"} pane=${state.detailPane} watch=${state.watchEnabled ? "on" : "off"}`;
|
|
1343
|
+
const listLines = (() => {
|
|
1344
|
+
const lines = [bold(truncate(`Entries (${visibleRows.length})${state.filterQuery ? ` · filter="${state.filterQuery}"` : ""}`, layout.mode === "split" ? layout.leftWidth : layout.columns))];
|
|
1345
|
+
if (visibleRows.length === 0) lines.push(dim("No entries. Adjust filter or refresh."));
|
|
1346
|
+
else {
|
|
1347
|
+
const start = state.topIndex;
|
|
1348
|
+
const end = Math.min(visibleRows.length, start + layout.listViewportHeight);
|
|
1349
|
+
const labelWidth = (layout.mode === "split" ? layout.leftWidth : layout.columns) - 4;
|
|
1350
|
+
for (let i = start; i < end; i += 1) {
|
|
1351
|
+
const row = visibleRows[i];
|
|
1352
|
+
const marker = i === state.selectedIndex ? colorize("›", ANSI.cyan) : " ";
|
|
1353
|
+
const label = truncate(`${row.entry} · ${toMB(row.outputBytes)}MB · ${row.handlerExports.length} fn`, Math.max(16, labelWidth));
|
|
1354
|
+
lines.push(`${marker} ${label}`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return lines;
|
|
1358
|
+
})();
|
|
1359
|
+
const detailTitle = `Detail (${state.detailPane}) · ${state.showHelp ? "help" : activeEntry ? activeEntry : "none"}`;
|
|
1360
|
+
const detailBodyWidth = (layout.mode === "split" ? layout.rightWidth : layout.columns) - 1;
|
|
1361
|
+
const detailBody = state.showHelp ? buildHelpPaneLines() : state.detailPane === "handlers" ? buildHandlersPaneLines(detailRow, detailBodyWidth) : state.detailPane === "packages" ? buildPackagesPaneLines(detailRow, options, detailBodyWidth) : buildInputsPaneLines(detailRow, options, detailBodyWidth);
|
|
1362
|
+
const detailLines = [bold(truncate(detailTitle, detailBodyWidth)), ...detailBody];
|
|
1363
|
+
const keyHints = "j/k move ←/→ pane / filter s sort g all r refresh w watch ? help q quit";
|
|
1364
|
+
stdout.write("\x1B[2J\x1B[H");
|
|
1365
|
+
console.log(bold("better-convex analyze · interactive"));
|
|
1366
|
+
console.log(dim(truncate(headerLine, layout.columns)));
|
|
1367
|
+
if (layout.mode === "split") {
|
|
1368
|
+
const leftPane = fillPane(listLines, layout.bodyHeight);
|
|
1369
|
+
const rightPane = fillPane(detailLines, layout.bodyHeight);
|
|
1370
|
+
for (let i = 0; i < layout.bodyHeight; i += 1) {
|
|
1371
|
+
const left = padVisible(truncate(leftPane[i] ?? "", layout.leftWidth), layout.leftWidth);
|
|
1372
|
+
const right = truncate(rightPane[i] ?? "", layout.rightWidth);
|
|
1373
|
+
console.log(`${left} │ ${right}`);
|
|
1374
|
+
}
|
|
1375
|
+
} else {
|
|
1376
|
+
const listPane = fillPane(listLines, layout.listHeight);
|
|
1377
|
+
const detailPane = fillPane(detailLines, layout.detailHeight);
|
|
1378
|
+
for (const line of listPane) console.log(truncate(line, layout.columns));
|
|
1379
|
+
console.log(dim("-".repeat(layout.columns)));
|
|
1380
|
+
for (const line of detailPane) console.log(truncate(line, layout.columns));
|
|
1381
|
+
}
|
|
1382
|
+
console.log(dim(truncate(keyHints, layout.columns)));
|
|
1383
|
+
console.log(dim(truncate(statusLine, layout.columns)));
|
|
1384
|
+
};
|
|
1385
|
+
const readKey = () => new Promise((resolve) => {
|
|
1386
|
+
stdin.once("data", (data) => resolve(String(data)));
|
|
1387
|
+
});
|
|
1388
|
+
syncVisibleRows(null);
|
|
1389
|
+
if (typeof stdin.setRawMode === "function") stdin.setRawMode(true);
|
|
1390
|
+
stdin.resume();
|
|
1391
|
+
stdin.setEncoding("utf8");
|
|
1392
|
+
try {
|
|
1393
|
+
while (!shouldQuit) {
|
|
1394
|
+
await renderInteractive();
|
|
1395
|
+
const key = await readKey();
|
|
1396
|
+
if (key === "" || key === "q") {
|
|
1397
|
+
shouldQuit = true;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (key === "j" || key === "\x1B[B") {
|
|
1401
|
+
state = reduceInteractiveState(state, {
|
|
1402
|
+
type: "moveSelection",
|
|
1403
|
+
delta: 1,
|
|
1404
|
+
rowCount: visibleRows.length
|
|
1405
|
+
});
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (key === "k" || key === "\x1B[A") {
|
|
1409
|
+
state = reduceInteractiveState(state, {
|
|
1410
|
+
type: "moveSelection",
|
|
1411
|
+
delta: -1,
|
|
1412
|
+
rowCount: visibleRows.length
|
|
1413
|
+
});
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (key === "/") {
|
|
1417
|
+
await promptFilter();
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (key === "s") {
|
|
1421
|
+
const preferredEntry = getSelectedEntry();
|
|
1422
|
+
state = reduceInteractiveState(state, { type: "cycleSort" });
|
|
1423
|
+
syncVisibleRows(preferredEntry);
|
|
1424
|
+
state = reduceInteractiveState(state, {
|
|
1425
|
+
type: "setStatus",
|
|
1426
|
+
message: `Sort: ${sortLabel(state.sortKey)}`
|
|
1427
|
+
});
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (key === "g") {
|
|
1431
|
+
state = reduceInteractiveState(state, { type: "toggleGenerated" });
|
|
1432
|
+
await refreshSnapshot(state.includeGenerated ? "Refreshing with all entries..." : "Refreshing with function entries only...");
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (key === "\x1B[C") {
|
|
1436
|
+
state = reduceInteractiveState(state, {
|
|
1437
|
+
type: "cyclePane",
|
|
1438
|
+
direction: 1
|
|
1439
|
+
});
|
|
1440
|
+
state = reduceInteractiveState(state, {
|
|
1441
|
+
type: "setStatus",
|
|
1442
|
+
message: `Detail pane: ${state.detailPane}`
|
|
1443
|
+
});
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (key === "\x1B[D") {
|
|
1447
|
+
state = reduceInteractiveState(state, {
|
|
1448
|
+
type: "cyclePane",
|
|
1449
|
+
direction: -1
|
|
1450
|
+
});
|
|
1451
|
+
state = reduceInteractiveState(state, {
|
|
1452
|
+
type: "setStatus",
|
|
1453
|
+
message: `Detail pane: ${state.detailPane}`
|
|
1454
|
+
});
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
if (key === "r") {
|
|
1458
|
+
state = reduceInteractiveState(state, { type: "requestRefresh" });
|
|
1459
|
+
await refreshSnapshot(state.statusMessage);
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (key === "w") {
|
|
1463
|
+
state = reduceInteractiveState(state, { type: "toggleWatch" });
|
|
1464
|
+
applyWatchMode();
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (key === "?") state = reduceInteractiveState(state, { type: "toggleHelp" });
|
|
1468
|
+
}
|
|
1469
|
+
} finally {
|
|
1470
|
+
stopWatcher();
|
|
1471
|
+
if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
|
|
1472
|
+
stdin.pause();
|
|
1473
|
+
stdout.write("\x1B[2J\x1B[H");
|
|
1474
|
+
}
|
|
1475
|
+
return 0;
|
|
1476
|
+
};
|
|
1477
|
+
const runHotspotAnalysis = async (roots, options) => {
|
|
1478
|
+
if (shouldPromptForHotspotPick(options)) return runHotspotInteractive(roots, options);
|
|
1479
|
+
const includeDeepData = options.details;
|
|
1480
|
+
const { isolateEntries, generatedEntries, entryPoints, successRows, failedRows } = await collectHotspotRows(roots, options, includeDeepData);
|
|
1481
|
+
if (entryPoints.length === 0) {
|
|
1482
|
+
if (options.includeGenerated) console.log("No matching entries found for the provided regex.");
|
|
1483
|
+
else console.log("No matching Convex handler entries found (files exporting query/mutation/action, including internal variants).");
|
|
1484
|
+
return 0;
|
|
1485
|
+
}
|
|
1486
|
+
const outputTotal = successRows.reduce((sum, row) => sum + row.outputBytes, 0);
|
|
1487
|
+
const outputAverage = successRows.length > 0 ? outputTotal / successRows.length : 0;
|
|
1488
|
+
const totalHandlers = successRows.reduce((sum, row) => sum + row.handlerExports.length, 0);
|
|
1489
|
+
const largest = successRows.at(0);
|
|
1490
|
+
const buildHotspotDetailCommand = (entry, mode) => {
|
|
1491
|
+
const parts = [
|
|
1492
|
+
"better-convex",
|
|
1493
|
+
"analyze",
|
|
1494
|
+
shellQuote(`^${escapeRegex(entry)}$`),
|
|
1495
|
+
"--details"
|
|
1496
|
+
];
|
|
1497
|
+
if (options.showInputs) parts.push("--input");
|
|
1498
|
+
if (options.includeGenerated) parts.push("--all");
|
|
1499
|
+
if (options.detailEntries !== DEFAULT_DETAIL_ENTRIES) parts.push("--top", String(options.detailEntries));
|
|
1500
|
+
if (mode === "agent") parts.push("--top-inputs", "30", "--top-packages", "20");
|
|
1501
|
+
return parts.join(" ");
|
|
1502
|
+
};
|
|
1503
|
+
const actionRows = successRows;
|
|
1504
|
+
console.log(bold("Runtime hotspot analysis (least optimized functions)"));
|
|
1505
|
+
console.log(`isolateEntries=${isolateEntries.length} handlers=${totalHandlers} extraAll=${generatedEntries.length} selected=${entryPoints.length} ok=${successRows.length} failed=${failedRows.length} avg=${formatBytes(outputAverage)}${largest ? ` largest=${largest.entry} (${formatBytes(largest.outputBytes)})` : ""}`);
|
|
1506
|
+
console.log("");
|
|
1507
|
+
if (actionRows.length > 0) {
|
|
1508
|
+
console.log(bold("Agent queue (run top-down):"));
|
|
1509
|
+
for (const [index, row] of actionRows.entries()) console.log(`${index + 1}. ${row.entry} (${toMB(row.outputBytes)} MB, ${row.handlerExports.length} handlers) -> ${buildHotspotDetailCommand(row.entry, "agent")}`);
|
|
1510
|
+
if (options.interactive === "always" && !isInteractiveTerminal) {
|
|
1511
|
+
console.log("");
|
|
1512
|
+
console.log(dim("Interactive picker requires a TTY. Falling back to command list."));
|
|
1513
|
+
}
|
|
1514
|
+
console.log("");
|
|
1515
|
+
}
|
|
1516
|
+
if (successRows.length > 0) {
|
|
1517
|
+
const maxEntryWidth = Math.max(16, outputWidth - 42 - 14);
|
|
1518
|
+
const widths = {
|
|
1519
|
+
rank: 4,
|
|
1520
|
+
sev: 7,
|
|
1521
|
+
output: 7,
|
|
1522
|
+
deps: 7,
|
|
1523
|
+
local: 7,
|
|
1524
|
+
inputCount: 6,
|
|
1525
|
+
handlerCount: 4,
|
|
1526
|
+
entry: Math.max(16, Math.min(maxEntryWidth, Math.max(...successRows.map((row) => row.entry.length), 5)))
|
|
1527
|
+
};
|
|
1528
|
+
const header = [
|
|
1529
|
+
pad("Rank", widths.rank, "right"),
|
|
1530
|
+
pad("Level", widths.sev),
|
|
1531
|
+
pad("OutMB", widths.output, "right"),
|
|
1532
|
+
pad("DepMB", widths.deps, "right"),
|
|
1533
|
+
pad("LocMB", widths.local, "right"),
|
|
1534
|
+
pad("Files", widths.inputCount, "right"),
|
|
1535
|
+
pad("Fns", widths.handlerCount, "right"),
|
|
1536
|
+
pad("Entry", widths.entry)
|
|
1537
|
+
].join(" ");
|
|
1538
|
+
const divider = [
|
|
1539
|
+
"-".repeat(widths.rank),
|
|
1540
|
+
"-".repeat(widths.sev),
|
|
1541
|
+
"-".repeat(widths.output),
|
|
1542
|
+
"-".repeat(widths.deps),
|
|
1543
|
+
"-".repeat(widths.local),
|
|
1544
|
+
"-".repeat(widths.inputCount),
|
|
1545
|
+
"-".repeat(widths.handlerCount),
|
|
1546
|
+
"-".repeat(widths.entry)
|
|
1547
|
+
].join(" ");
|
|
1548
|
+
console.log(dim(header));
|
|
1549
|
+
console.log(dim(divider));
|
|
1550
|
+
for (const [index, row] of successRows.entries()) {
|
|
1551
|
+
const severity = severityForBytes(row.outputBytes, options);
|
|
1552
|
+
const outputMbValue = Number.parseFloat(toMB(row.outputBytes));
|
|
1553
|
+
console.log([
|
|
1554
|
+
pad(String(index + 1), widths.rank, "right"),
|
|
1555
|
+
colorizePadded(severity, widths.sev, "left", severityColor(severity)),
|
|
1556
|
+
colorizePadded(toMB(row.outputBytes), widths.output, "right", shareColor(outputMbValue / options.dangerMb * 100)),
|
|
1557
|
+
pad(toMB(row.dependencyInputBytes), widths.deps, "right"),
|
|
1558
|
+
pad(toMB(row.localInputBytes), widths.local, "right"),
|
|
1559
|
+
pad(String(row.inputCount), widths.inputCount, "right"),
|
|
1560
|
+
pad(String(row.handlerExports.length), widths.handlerCount, "right"),
|
|
1561
|
+
pad(truncate(row.entry, widths.entry), widths.entry)
|
|
1562
|
+
].join(" "));
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
if (failedRows.length > 0) {
|
|
1566
|
+
console.log(`\n${colorize("Failed entries:", ANSI.red)}`);
|
|
1567
|
+
for (const row of failedRows) {
|
|
1568
|
+
const errorLine = firstLine(row.error);
|
|
1569
|
+
const available = Math.max(24, outputWidth - row.entry.length - 4);
|
|
1570
|
+
console.log(`- ${row.entry}: ${truncate(errorLine, available)}`);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (includeDeepData) {
|
|
1574
|
+
const detailRows = options.entryPattern ? successRows : successRows.slice(0, options.detailEntries);
|
|
1575
|
+
if (!options.entryPattern && successRows.length > detailRows.length) {
|
|
1576
|
+
console.log("");
|
|
1577
|
+
console.log(dim(`Expanded details for top ${detailRows.length} entries. Pass an entry regex as first arg for a specific module.`));
|
|
1578
|
+
}
|
|
1579
|
+
for (const row of detailRows) {
|
|
1580
|
+
if (row.handlerExports.length > 0) {
|
|
1581
|
+
console.log("");
|
|
1582
|
+
printWrapped({
|
|
1583
|
+
prefix: `${bold("Handlers:")} `,
|
|
1584
|
+
text: row.handlerExports.join(", ")
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
printHotspotPackages(row, options);
|
|
1588
|
+
if (options.showInputs) printHotspotTopInputs(row, options);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
let exitCode = 0;
|
|
1592
|
+
if (failedRows.length > 0) exitCode = 1;
|
|
1593
|
+
if (options.failMb !== null && largest && largest.outputBytes / MB >= options.failMb) {
|
|
1594
|
+
console.log("");
|
|
1595
|
+
console.log(colorize(`Fail threshold reached: largest output is ${toMB(largest.outputBytes)} MB (>= ${options.failMb.toFixed(2)} MB).`, ANSI.red));
|
|
1596
|
+
exitCode = 1;
|
|
1597
|
+
}
|
|
1598
|
+
return exitCode;
|
|
1599
|
+
};
|
|
1600
|
+
const buildDeployBundle = async (entryPoints, externalizeSchema) => build({
|
|
1601
|
+
bundle: true,
|
|
1602
|
+
entryPoints,
|
|
1603
|
+
format: "esm",
|
|
1604
|
+
platform: "browser",
|
|
1605
|
+
target: ["esnext"],
|
|
1606
|
+
write: false,
|
|
1607
|
+
metafile: true,
|
|
1608
|
+
logLevel: "silent",
|
|
1609
|
+
outdir: "out",
|
|
1610
|
+
splitting: true,
|
|
1611
|
+
jsx: "automatic",
|
|
1612
|
+
conditions: ["convex", "module"],
|
|
1613
|
+
minifySyntax: true,
|
|
1614
|
+
minifyIdentifiers: true,
|
|
1615
|
+
minifyWhitespace: false,
|
|
1616
|
+
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
1617
|
+
plugins: externalizeSchema ? [schemaExternalFallbackPlugin] : []
|
|
1618
|
+
});
|
|
1619
|
+
const printDeployTopInputs = (outputBytes, aggregateInputBytes, options) => {
|
|
1620
|
+
const topInputs = aggregateInputBytes.filter((input) => !isSmallInput(input.bytes, outputBytes, options.showSmall)).slice(0, options.topInputs);
|
|
1621
|
+
if (topInputs.length === 0) return;
|
|
1622
|
+
console.log("");
|
|
1623
|
+
console.log(bold("Top inputs (deploy bundle):"));
|
|
1624
|
+
const inputPathWidth = Math.max(18, outputWidth - 46);
|
|
1625
|
+
console.log(dim(`bytesInOutput share impact ${pad("path", inputPathWidth)}`));
|
|
1626
|
+
console.log(dim(`------------- ---------- ---------------- ${"-".repeat(inputPathWidth)}`));
|
|
1627
|
+
for (const input of topInputs) {
|
|
1628
|
+
const share = outputBytes > 0 ? input.bytes / outputBytes * 100 : 0;
|
|
1629
|
+
const sharePct = colorize(`${share.toFixed(2)}%`.padStart(10), shareColor(share));
|
|
1630
|
+
const bar = makeShareBar(share);
|
|
1631
|
+
console.log(`${input.bytes.toString().padStart(13)} ${sharePct} ${bar} ${compactPath(input.inputPath, inputPathWidth)}`);
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
const printDeployTopPackages = (outputBytes, aggregateInputBytes, options) => {
|
|
1635
|
+
const packageMap = /* @__PURE__ */ new Map();
|
|
1636
|
+
for (const item of aggregateInputBytes) {
|
|
1637
|
+
const packageName = packageFromInputPath(item.inputPath);
|
|
1638
|
+
const current = packageMap.get(packageName) ?? { bytes: 0 };
|
|
1639
|
+
current.bytes += item.bytes;
|
|
1640
|
+
packageMap.set(packageName, current);
|
|
1641
|
+
}
|
|
1642
|
+
const topRows = Array.from(packageMap.entries()).map(([packageName, value]) => ({
|
|
1643
|
+
packageName,
|
|
1644
|
+
bytes: value.bytes
|
|
1645
|
+
})).sort((a, b) => b.bytes - a.bytes).filter((item) => !isSmallInput(item.bytes, outputBytes, options.showSmall)).slice(0, options.topPackages);
|
|
1646
|
+
if (topRows.length === 0) return;
|
|
1647
|
+
console.log("");
|
|
1648
|
+
console.log(bold("Top packages (deploy bundle):"));
|
|
1649
|
+
const packageColWidth = Math.max(16, Math.min(30, outputWidth - 58));
|
|
1650
|
+
const barWidth = Math.max(8, Math.min(16, outputWidth - (packageColWidth + 52)));
|
|
1651
|
+
for (const [index, item] of topRows.entries()) {
|
|
1652
|
+
const share = outputBytes > 0 ? item.bytes / outputBytes * 100 : 0;
|
|
1653
|
+
const shareStr = colorize(`${share.toFixed(2)}%`, shareColor(share));
|
|
1654
|
+
const sizeStr = colorize(`${toMB(item.bytes)} MB`, shareColor(share));
|
|
1655
|
+
const bar = makeShareBar(share, barWidth);
|
|
1656
|
+
const packageLabel = truncate(item.packageName, packageColWidth);
|
|
1657
|
+
console.log(`${bold(`${index + 1}.`)} ${pad(packageLabel, packageColWidth)} ${pad(sizeStr, 18)} ${pad(shareStr, 10)} ${bar}`);
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
const runDeployAnalysis = async (roots, options) => {
|
|
1661
|
+
const { entryPoints, nodeEntryPoints } = await collectAnalyzeEntrySelection(roots, options);
|
|
1662
|
+
if (entryPoints.length === 0) {
|
|
1663
|
+
console.log("No Convex handler entries found to analyze (files exporting query/mutation/action, including internal variants).");
|
|
1664
|
+
return 0;
|
|
1665
|
+
}
|
|
1666
|
+
let result;
|
|
1667
|
+
let schemaExternalized = false;
|
|
1668
|
+
try {
|
|
1669
|
+
result = await buildDeployBundle(entryPoints, false);
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
if (!shouldRetryWithSchemaExternalized(error)) throw error;
|
|
1672
|
+
result = await buildDeployBundle(entryPoints, true);
|
|
1673
|
+
schemaExternalized = true;
|
|
1674
|
+
}
|
|
1675
|
+
const meta = result.metafile;
|
|
1676
|
+
if (!meta) throw new Error("No metafile generated for deploy analysis.");
|
|
1677
|
+
const jsOutputs = Object.entries(meta.outputs).filter(([outputPath]) => !outputPath.endsWith(".map")).map(([outputPath, value]) => ({
|
|
1678
|
+
outputPath,
|
|
1679
|
+
...value
|
|
1680
|
+
})).filter((output) => output.outputPath.endsWith(".js"));
|
|
1681
|
+
const totalOutputBytes = jsOutputs.reduce((sum, output) => sum + output.bytes, 0);
|
|
1682
|
+
const entryOutputs = jsOutputs.filter((output) => !!output.entryPoint).map((output) => ({
|
|
1683
|
+
outputPath: output.outputPath,
|
|
1684
|
+
entryPoint: output.entryPoint,
|
|
1685
|
+
bytes: output.bytes,
|
|
1686
|
+
inputCount: Object.keys(output.inputs ?? {}).length
|
|
1687
|
+
})).sort((a, b) => b.bytes - a.bytes);
|
|
1688
|
+
const sharedChunks = jsOutputs.filter((output) => !output.entryPoint).map((output) => ({
|
|
1689
|
+
outputPath: output.outputPath,
|
|
1690
|
+
bytes: output.bytes
|
|
1691
|
+
})).sort((a, b) => b.bytes - a.bytes);
|
|
1692
|
+
const aggregateInputs = /* @__PURE__ */ new Map();
|
|
1693
|
+
for (const output of jsOutputs) for (const [inputPath, value] of Object.entries(output.inputs ?? {})) {
|
|
1694
|
+
const bytesInOutput = value.bytesInOutput ?? 0;
|
|
1695
|
+
aggregateInputs.set(inputPath, (aggregateInputs.get(inputPath) ?? 0) + bytesInOutput);
|
|
1696
|
+
}
|
|
1697
|
+
const aggregateInputRows = Array.from(aggregateInputs.entries()).map(([inputPath, bytes]) => ({
|
|
1698
|
+
inputPath,
|
|
1699
|
+
bytes
|
|
1700
|
+
})).sort((a, b) => b.bytes - a.bytes);
|
|
1701
|
+
const largestEntry = entryOutputs.at(0);
|
|
1702
|
+
const largestChunk = sharedChunks.at(0);
|
|
1703
|
+
console.log(bold("Runtime deploy analysis (isolate bundle)"));
|
|
1704
|
+
console.log(`entries=${entryPoints.length} nodeEntriesSkipped=${nodeEntryPoints.length} outputs=${jsOutputs.length} total=${formatBytes(totalOutputBytes)}`);
|
|
1705
|
+
if (schemaExternalized) console.log(colorize("note: schema imports were externalized after a build error; dependency sizes are approximate.", ANSI.yellow));
|
|
1706
|
+
if (largestEntry) console.log(`largestEntry=${shortPath(largestEntry.entryPoint)} (${formatBytes(largestEntry.bytes)})`);
|
|
1707
|
+
if (largestChunk) console.log(`largestSharedChunk=${shortPath(largestChunk.outputPath)} (${formatBytes(largestChunk.bytes)})`);
|
|
1708
|
+
console.log("");
|
|
1709
|
+
const topEntries = entryOutputs.slice(0, 15);
|
|
1710
|
+
if (topEntries.length > 0) {
|
|
1711
|
+
const widths = {
|
|
1712
|
+
rank: 4,
|
|
1713
|
+
sev: 7,
|
|
1714
|
+
output: 7,
|
|
1715
|
+
inputs: 6,
|
|
1716
|
+
entry: Math.max(24, Math.min(72, outputWidth - 34))
|
|
1717
|
+
};
|
|
1718
|
+
console.log(dim(`${pad("Rank", widths.rank, "right")} ${pad("Level", widths.sev)} ${pad("OutMB", widths.output, "right")} ${pad("In", widths.inputs, "right")} ${pad("Entry", widths.entry)}`));
|
|
1719
|
+
console.log(dim(`${"-".repeat(widths.rank)} ${"-".repeat(widths.sev)} ${"-".repeat(widths.output)} ${"-".repeat(widths.inputs)} ${"-".repeat(widths.entry)}`));
|
|
1720
|
+
for (const [index, row] of topEntries.entries()) {
|
|
1721
|
+
const severity = severityForBytes(row.bytes, options);
|
|
1722
|
+
console.log(`${pad(String(index + 1), widths.rank, "right")} ${colorizePadded(severity, widths.sev, "left", severityColor(severity))} ${colorizePadded(toMB(row.bytes), widths.output, "right", shareColor(row.bytes / MB / options.dangerMb * 100))} ${pad(String(row.inputCount), widths.inputs, "right")} ${pad(truncate(shortPath(row.entryPoint), widths.entry), widths.entry)}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
const topChunks = sharedChunks.slice(0, 8);
|
|
1726
|
+
if (topChunks.length > 0) {
|
|
1727
|
+
console.log("");
|
|
1728
|
+
console.log(bold("Top shared chunks:"));
|
|
1729
|
+
for (const chunk of topChunks) {
|
|
1730
|
+
const share = totalOutputBytes > 0 ? chunk.bytes / totalOutputBytes * 100 : 0;
|
|
1731
|
+
const severity = severityForBytes(chunk.bytes, options);
|
|
1732
|
+
console.log(`${colorize(`[${severity}]`, severityColor(severity))} ${pad(`${toMB(chunk.bytes)} MB`, 10, "right")} ${pad(`${share.toFixed(2)}%`, 8, "right")} ${shortPath(chunk.outputPath)}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (options.details) {
|
|
1736
|
+
printDeployTopPackages(totalOutputBytes, aggregateInputRows, options);
|
|
1737
|
+
printDeployTopInputs(totalOutputBytes, aggregateInputRows, options);
|
|
1738
|
+
}
|
|
1739
|
+
const largestBytes = Math.max(largestEntry?.bytes ?? 0, largestChunk?.bytes ?? 0);
|
|
1740
|
+
if (options.failMb !== null && largestBytes / MB >= options.failMb) {
|
|
1741
|
+
console.log("");
|
|
1742
|
+
printWrapped({
|
|
1743
|
+
text: `Fail threshold reached: largest output is ${toMB(largestBytes)} MB (>= ${options.failMb.toFixed(2)} MB).`,
|
|
1744
|
+
color: ANSI.red
|
|
1745
|
+
});
|
|
1746
|
+
return 1;
|
|
1747
|
+
}
|
|
1748
|
+
return 0;
|
|
1749
|
+
};
|
|
1750
|
+
async function runAnalyze(argv) {
|
|
1751
|
+
const options = parseArgs$1(argv);
|
|
1752
|
+
colorEnabled = supportsColor;
|
|
1753
|
+
outputWidth = options.width ?? process.stdout.columns ?? DEFAULT_OUTPUT_WIDTH;
|
|
1754
|
+
const roots = detectProjectRoots();
|
|
1755
|
+
if (options.mode === "hotspot") return runHotspotAnalysis(roots, options);
|
|
1756
|
+
return runDeployAnalysis(roots, options);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
//#endregion
|
|
1760
|
+
//#region src/cli/config.ts
|
|
1761
|
+
const DEFAULT_CONFIG_PATH = "better-convex.json";
|
|
1762
|
+
const CODEGEN_SCOPES = new Set([
|
|
1763
|
+
"all",
|
|
1764
|
+
"auth",
|
|
1765
|
+
"orm"
|
|
1766
|
+
]);
|
|
1767
|
+
const BACKFILL_ENABLED_VALUES = new Set([
|
|
1768
|
+
"auto",
|
|
1769
|
+
"on",
|
|
1770
|
+
"off"
|
|
1771
|
+
]);
|
|
1772
|
+
function createDefaultConfig() {
|
|
1773
|
+
return {
|
|
1774
|
+
api: true,
|
|
1775
|
+
auth: true,
|
|
1776
|
+
outputDir: "convex/shared",
|
|
1777
|
+
dev: {
|
|
1778
|
+
debug: false,
|
|
1779
|
+
convexArgs: [],
|
|
1780
|
+
aggregateBackfill: {
|
|
1781
|
+
enabled: "auto",
|
|
1782
|
+
wait: true,
|
|
1783
|
+
batchSize: 1e3,
|
|
1784
|
+
pollIntervalMs: 1e3,
|
|
1785
|
+
timeoutMs: 9e5,
|
|
1786
|
+
strict: false
|
|
1787
|
+
}
|
|
1788
|
+
},
|
|
1789
|
+
codegen: {
|
|
1790
|
+
debug: false,
|
|
1791
|
+
convexArgs: []
|
|
1792
|
+
},
|
|
1793
|
+
deploy: {
|
|
1794
|
+
convexArgs: [],
|
|
1795
|
+
aggregateBackfill: {
|
|
1796
|
+
enabled: "auto",
|
|
1797
|
+
wait: true,
|
|
1798
|
+
batchSize: 1e3,
|
|
1799
|
+
pollIntervalMs: 1e3,
|
|
1800
|
+
timeoutMs: 9e5,
|
|
1801
|
+
strict: true
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
function isRecord(value) {
|
|
1807
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1808
|
+
}
|
|
1809
|
+
function parseBoolean(value, fieldName, configPath) {
|
|
1810
|
+
if (typeof value === "boolean") return value;
|
|
1811
|
+
throw new Error(`Invalid ${fieldName} in ${configPath}: expected boolean, got ${typeof value}.`);
|
|
1812
|
+
}
|
|
1813
|
+
function parseString(value, fieldName, configPath) {
|
|
1814
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
1815
|
+
throw new Error(`Invalid ${fieldName} in ${configPath}: expected non-empty string.`);
|
|
1816
|
+
}
|
|
1817
|
+
function parseStringArray(value, fieldName, configPath) {
|
|
1818
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`Invalid ${fieldName} in ${configPath}: expected string array.`);
|
|
1819
|
+
return [...value];
|
|
1820
|
+
}
|
|
1821
|
+
function parsePositiveInteger(value, fieldName, configPath) {
|
|
1822
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
|
1823
|
+
throw new Error(`Invalid ${fieldName} in ${configPath}: expected a positive integer.`);
|
|
1824
|
+
}
|
|
1825
|
+
function parseScope(value, fieldName, configPath) {
|
|
1826
|
+
if (typeof value === "string" && CODEGEN_SCOPES.has(value)) return value;
|
|
1827
|
+
throw new Error(`Invalid ${fieldName} in ${configPath}: expected one of all, auth, orm.`);
|
|
1828
|
+
}
|
|
1829
|
+
function parseBackfillEnabled(value, fieldName, configPath) {
|
|
1830
|
+
if (value === true) return "on";
|
|
1831
|
+
if (value === false) return "off";
|
|
1832
|
+
if (typeof value === "string" && BACKFILL_ENABLED_VALUES.has(value)) return value;
|
|
1833
|
+
throw new Error(`Invalid ${fieldName} in ${configPath}: expected boolean or one of auto, on, off.`);
|
|
1834
|
+
}
|
|
1835
|
+
function parseAggregateBackfillConfig(value, fieldName, configPath) {
|
|
1836
|
+
if (!isRecord(value)) throw new Error(`Invalid ${fieldName} in ${configPath}: expected object.`);
|
|
1837
|
+
const parsed = {};
|
|
1838
|
+
if ("enabled" in value) parsed.enabled = parseBackfillEnabled(value.enabled, `${fieldName}.enabled`, configPath);
|
|
1839
|
+
if ("wait" in value) parsed.wait = parseBoolean(value.wait, `${fieldName}.wait`, configPath);
|
|
1840
|
+
if ("batchSize" in value) parsed.batchSize = parsePositiveInteger(value.batchSize, `${fieldName}.batchSize`, configPath);
|
|
1841
|
+
if ("pollIntervalMs" in value) parsed.pollIntervalMs = parsePositiveInteger(value.pollIntervalMs, `${fieldName}.pollIntervalMs`, configPath);
|
|
1842
|
+
if ("timeoutMs" in value) parsed.timeoutMs = parsePositiveInteger(value.timeoutMs, `${fieldName}.timeoutMs`, configPath);
|
|
1843
|
+
if ("strict" in value) parsed.strict = parseBoolean(value.strict, `${fieldName}.strict`, configPath);
|
|
1844
|
+
return parsed;
|
|
1845
|
+
}
|
|
1846
|
+
function parseCommandConfig(value, fieldName, configPath) {
|
|
1847
|
+
if (!isRecord(value)) throw new Error(`Invalid ${fieldName} in ${configPath}: expected object.`);
|
|
1848
|
+
const parsed = {};
|
|
1849
|
+
if ("debug" in value) parsed.debug = parseBoolean(value.debug, `${fieldName}.debug`, configPath);
|
|
1850
|
+
if ("convexArgs" in value) parsed.convexArgs = parseStringArray(value.convexArgs, `${fieldName}.convexArgs`, configPath);
|
|
1851
|
+
if (fieldName === "codegen" && "scope" in value && value.scope !== void 0) parsed.scope = parseScope(value.scope, `${fieldName}.scope`, configPath);
|
|
1852
|
+
if (fieldName === "dev" && "aggregateBackfill" in value && value.aggregateBackfill !== void 0) parsed.aggregateBackfill = parseAggregateBackfillConfig(value.aggregateBackfill, `${fieldName}.aggregateBackfill`, configPath);
|
|
1853
|
+
return parsed;
|
|
1854
|
+
}
|
|
1855
|
+
function parseDeployConfig(value, configPath) {
|
|
1856
|
+
if (!isRecord(value)) throw new Error(`Invalid deploy in ${configPath}: expected object.`);
|
|
1857
|
+
const parsed = {};
|
|
1858
|
+
if ("convexArgs" in value) parsed.convexArgs = parseStringArray(value.convexArgs, "deploy.convexArgs", configPath);
|
|
1859
|
+
if ("aggregateBackfill" in value && value.aggregateBackfill !== void 0) parsed.aggregateBackfill = parseAggregateBackfillConfig(value.aggregateBackfill, "deploy.aggregateBackfill", configPath);
|
|
1860
|
+
return parsed;
|
|
1861
|
+
}
|
|
1862
|
+
function loadBetterConvexConfig(configPathArg) {
|
|
1863
|
+
const resolvedConfigPath = path.resolve(process.cwd(), configPathArg ?? DEFAULT_CONFIG_PATH);
|
|
1864
|
+
const hasExplicitConfigPath = typeof configPathArg === "string";
|
|
1865
|
+
if (!fs.existsSync(resolvedConfigPath)) {
|
|
1866
|
+
if (hasExplicitConfigPath) throw new Error(`Config file not found: ${resolvedConfigPath}`);
|
|
1867
|
+
return createDefaultConfig();
|
|
1868
|
+
}
|
|
1869
|
+
let rawConfig;
|
|
1870
|
+
try {
|
|
1871
|
+
rawConfig = JSON.parse(fs.readFileSync(resolvedConfigPath, "utf-8"));
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
throw new Error(`Failed to parse config file ${resolvedConfigPath}: ${error.message}`);
|
|
1874
|
+
}
|
|
1875
|
+
if (!isRecord(rawConfig)) throw new Error(`Invalid config file ${resolvedConfigPath}: expected top-level object.`);
|
|
1876
|
+
const config = createDefaultConfig();
|
|
1877
|
+
if ("api" in rawConfig) config.api = parseBoolean(rawConfig.api, "api", resolvedConfigPath);
|
|
1878
|
+
if ("auth" in rawConfig) config.auth = parseBoolean(rawConfig.auth, "auth", resolvedConfigPath);
|
|
1879
|
+
if ("outputDir" in rawConfig) config.outputDir = parseString(rawConfig.outputDir, "outputDir", resolvedConfigPath);
|
|
1880
|
+
if ("dev" in rawConfig) {
|
|
1881
|
+
const parsed = parseCommandConfig(rawConfig.dev, "dev", resolvedConfigPath);
|
|
1882
|
+
if (parsed.debug !== void 0) config.dev.debug = parsed.debug;
|
|
1883
|
+
if (parsed.convexArgs !== void 0) config.dev.convexArgs = parsed.convexArgs;
|
|
1884
|
+
if (parsed.aggregateBackfill !== void 0) config.dev.aggregateBackfill = {
|
|
1885
|
+
...config.dev.aggregateBackfill,
|
|
1886
|
+
...parsed.aggregateBackfill
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
if ("codegen" in rawConfig) {
|
|
1890
|
+
const parsed = parseCommandConfig(rawConfig.codegen, "codegen", resolvedConfigPath);
|
|
1891
|
+
if (parsed.debug !== void 0) config.codegen.debug = parsed.debug;
|
|
1892
|
+
if (parsed.convexArgs !== void 0) config.codegen.convexArgs = parsed.convexArgs;
|
|
1893
|
+
if (parsed.scope !== void 0) config.codegen.scope = parsed.scope;
|
|
1894
|
+
}
|
|
1895
|
+
if ("deploy" in rawConfig) {
|
|
1896
|
+
const parsed = parseDeployConfig(rawConfig.deploy, resolvedConfigPath);
|
|
1897
|
+
if (parsed.convexArgs !== void 0) config.deploy.convexArgs = parsed.convexArgs;
|
|
1898
|
+
if (parsed.aggregateBackfill !== void 0) config.deploy.aggregateBackfill = {
|
|
1899
|
+
...config.deploy.aggregateBackfill,
|
|
1900
|
+
...parsed.aggregateBackfill
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
return config;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
//#endregion
|
|
11
1907
|
//#region src/cli/env.ts
|
|
12
1908
|
async function syncEnv(options = {}) {
|
|
13
1909
|
const { auth = false, force = false, prod = false } = options;
|
|
@@ -111,9 +2007,20 @@ async function syncEnv(options = {}) {
|
|
|
111
2007
|
const __filename = fileURLToPath(import.meta.url);
|
|
112
2008
|
const __dirname = dirname(__filename);
|
|
113
2009
|
const realConvex = join(dirname(createRequire(import.meta.url).resolve("convex/package.json")), "bin/main.js");
|
|
2010
|
+
const MISSING_BACKFILL_FUNCTION_RE = /could not find function|function .* was not found|unknown function/i;
|
|
2011
|
+
const GITIGNORE_CONVEX_ENTRY_RE = /(^|\r?\n)\.convex\/?\s*(\r?\n|$)/m;
|
|
2012
|
+
const AGGREGATE_STATE_RELATIVE_PATH = join(".convex", "better-convex", "aggregate-backfill-state.json");
|
|
2013
|
+
const AGGREGATE_STATE_VERSION = 1;
|
|
2014
|
+
const VALID_SCOPES = new Set([
|
|
2015
|
+
"all",
|
|
2016
|
+
"auth",
|
|
2017
|
+
"orm"
|
|
2018
|
+
]);
|
|
114
2019
|
function parseArgs(argv) {
|
|
115
2020
|
let debug = false;
|
|
116
2021
|
let outputDir;
|
|
2022
|
+
let scope;
|
|
2023
|
+
let configPath;
|
|
117
2024
|
const filtered = [];
|
|
118
2025
|
for (let i = 0; i < argv.length; i++) {
|
|
119
2026
|
const a = argv[i];
|
|
@@ -121,8 +2028,24 @@ function parseArgs(argv) {
|
|
|
121
2028
|
debug = true;
|
|
122
2029
|
continue;
|
|
123
2030
|
}
|
|
124
|
-
if (a === "--
|
|
125
|
-
|
|
2031
|
+
if (a === "--api") {
|
|
2032
|
+
const value = argv[i + 1];
|
|
2033
|
+
if (!value) throw new Error("Missing value for --api.");
|
|
2034
|
+
outputDir = value;
|
|
2035
|
+
i += 1;
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
if (a === "--scope") {
|
|
2039
|
+
const value = argv[i + 1];
|
|
2040
|
+
if (!value || !VALID_SCOPES.has(value)) throw new Error(`Invalid --scope value "${value ?? ""}". Expected one of: all, auth, orm.`);
|
|
2041
|
+
scope = value;
|
|
2042
|
+
i += 1;
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
if (a === "--config") {
|
|
2046
|
+
const value = argv[i + 1];
|
|
2047
|
+
if (!value) throw new Error("Missing value for --config.");
|
|
2048
|
+
configPath = value;
|
|
126
2049
|
i += 1;
|
|
127
2050
|
continue;
|
|
128
2051
|
}
|
|
@@ -135,24 +2058,519 @@ function parseArgs(argv) {
|
|
|
135
2058
|
restArgs,
|
|
136
2059
|
convexArgs: restArgs,
|
|
137
2060
|
debug,
|
|
138
|
-
outputDir
|
|
2061
|
+
outputDir,
|
|
2062
|
+
scope,
|
|
2063
|
+
configPath
|
|
139
2064
|
};
|
|
140
2065
|
}
|
|
2066
|
+
const DEFAULT_AGGREGATE_FINGERPRINT_STATE = {
|
|
2067
|
+
version: AGGREGATE_STATE_VERSION,
|
|
2068
|
+
entries: {}
|
|
2069
|
+
};
|
|
2070
|
+
function normalizeStringList(values) {
|
|
2071
|
+
if (!Array.isArray(values)) return [];
|
|
2072
|
+
return [...new Set(values.filter((value) => typeof value === "string"))].sort();
|
|
2073
|
+
}
|
|
2074
|
+
function readOptionalCliFlagValue(args, flag) {
|
|
2075
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2076
|
+
const arg = args[i];
|
|
2077
|
+
if (arg === flag) {
|
|
2078
|
+
const value = args[i + 1];
|
|
2079
|
+
if (value) return value;
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
const withEquals = `${flag}=`;
|
|
2083
|
+
if (arg.startsWith(withEquals)) {
|
|
2084
|
+
const value = arg.slice(withEquals.length);
|
|
2085
|
+
if (value) return value;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
function collectSchemaTables(schemaModule) {
|
|
2090
|
+
const allTables = /* @__PURE__ */ new Set();
|
|
2091
|
+
const relations = schemaModule.relations;
|
|
2092
|
+
if (relations && typeof relations === "object" && !Array.isArray(relations)) for (const relationConfig of Object.values(relations)) {
|
|
2093
|
+
const table = relationConfig?.table;
|
|
2094
|
+
if (table) allTables.add(table);
|
|
2095
|
+
}
|
|
2096
|
+
const tables = schemaModule.tables;
|
|
2097
|
+
if (tables && typeof tables === "object" && !Array.isArray(tables)) {
|
|
2098
|
+
for (const table of Object.values(tables)) if (table) allTables.add(table);
|
|
2099
|
+
}
|
|
2100
|
+
return [...allTables];
|
|
2101
|
+
}
|
|
2102
|
+
function buildAggregateFingerprintPayload(tables) {
|
|
2103
|
+
return tables.map((table) => getTableConfig(table)).map((tableConfig) => ({
|
|
2104
|
+
tableName: tableConfig.name,
|
|
2105
|
+
aggregateIndexes: tableConfig.aggregateIndexes.map((index) => ({
|
|
2106
|
+
name: index.name,
|
|
2107
|
+
fields: normalizeStringList(index.fields),
|
|
2108
|
+
countFields: normalizeStringList(index.countFields),
|
|
2109
|
+
sumFields: normalizeStringList(index.sumFields),
|
|
2110
|
+
avgFields: normalizeStringList(index.avgFields),
|
|
2111
|
+
minFields: normalizeStringList(index.minFields),
|
|
2112
|
+
maxFields: normalizeStringList(index.maxFields)
|
|
2113
|
+
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
2114
|
+
})).sort((a, b) => a.tableName.localeCompare(b.tableName));
|
|
2115
|
+
}
|
|
2116
|
+
async function computeAggregateIndexFingerprint(functionsDir) {
|
|
2117
|
+
const schemaPath = join(functionsDir, "schema.ts");
|
|
2118
|
+
if (!fs.existsSync(schemaPath)) return null;
|
|
2119
|
+
const schemaModule = await createJiti(process.cwd(), {
|
|
2120
|
+
interopDefault: true,
|
|
2121
|
+
moduleCache: false
|
|
2122
|
+
}).import(schemaPath);
|
|
2123
|
+
if (!schemaModule || typeof schemaModule !== "object") return null;
|
|
2124
|
+
const tables = collectSchemaTables(schemaModule);
|
|
2125
|
+
if (tables.length === 0) return null;
|
|
2126
|
+
const payload = buildAggregateFingerprintPayload(tables);
|
|
2127
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
2128
|
+
}
|
|
2129
|
+
function getDevAggregateBackfillStatePath(cwd = process.cwd()) {
|
|
2130
|
+
return join(cwd, AGGREGATE_STATE_RELATIVE_PATH);
|
|
2131
|
+
}
|
|
2132
|
+
function readAggregateFingerprintState(statePath) {
|
|
2133
|
+
if (!fs.existsSync(statePath)) return {
|
|
2134
|
+
...DEFAULT_AGGREGATE_FINGERPRINT_STATE,
|
|
2135
|
+
entries: {}
|
|
2136
|
+
};
|
|
2137
|
+
try {
|
|
2138
|
+
const raw = fs.readFileSync(statePath, "utf8");
|
|
2139
|
+
const parsed = JSON.parse(raw);
|
|
2140
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.entries !== "object" || parsed.entries === null) return {
|
|
2141
|
+
...DEFAULT_AGGREGATE_FINGERPRINT_STATE,
|
|
2142
|
+
entries: {}
|
|
2143
|
+
};
|
|
2144
|
+
return {
|
|
2145
|
+
version: AGGREGATE_STATE_VERSION,
|
|
2146
|
+
entries: Object.fromEntries(Object.entries(parsed.entries).filter(([, value]) => typeof value === "object" && value !== null && typeof value.fingerprint === "string"))
|
|
2147
|
+
};
|
|
2148
|
+
} catch {
|
|
2149
|
+
return {
|
|
2150
|
+
...DEFAULT_AGGREGATE_FINGERPRINT_STATE,
|
|
2151
|
+
entries: {}
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function writeAggregateFingerprintState(statePath, state) {
|
|
2156
|
+
fs.mkdirSync(dirname(statePath), { recursive: true });
|
|
2157
|
+
const tmpPath = `${statePath}.tmp`;
|
|
2158
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
|
|
2159
|
+
fs.renameSync(tmpPath, statePath);
|
|
2160
|
+
}
|
|
2161
|
+
function getAggregateBackfillDeploymentKey(args) {
|
|
2162
|
+
if (args.includes("--prod")) return "prod";
|
|
2163
|
+
const deploymentName = readOptionalCliFlagValue(args, "--deployment-name");
|
|
2164
|
+
if (deploymentName) return `deployment:${deploymentName}`;
|
|
2165
|
+
const previewName = readOptionalCliFlagValue(args, "--preview-name");
|
|
2166
|
+
if (previewName) return `preview:${previewName}`;
|
|
2167
|
+
return "local";
|
|
2168
|
+
}
|
|
2169
|
+
function ensureConvexGitignoreEntry(cwd = process.cwd()) {
|
|
2170
|
+
let currentDir = resolve(cwd);
|
|
2171
|
+
let gitRoot = null;
|
|
2172
|
+
while (true) {
|
|
2173
|
+
if (fs.existsSync(join(currentDir, ".git"))) {
|
|
2174
|
+
gitRoot = currentDir;
|
|
2175
|
+
break;
|
|
2176
|
+
}
|
|
2177
|
+
const parent = dirname(currentDir);
|
|
2178
|
+
if (parent === currentDir) break;
|
|
2179
|
+
currentDir = parent;
|
|
2180
|
+
}
|
|
2181
|
+
if (!gitRoot) return;
|
|
2182
|
+
const gitignorePath = join(gitRoot, ".gitignore");
|
|
2183
|
+
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
|
|
2184
|
+
if (GITIGNORE_CONVEX_ENTRY_RE.test(existing)) return;
|
|
2185
|
+
const normalized = existing.endsWith("\n") || existing.length === 0 ? existing : `${existing}\n`;
|
|
2186
|
+
fs.writeFileSync(gitignorePath, `${normalized}.convex/\n`);
|
|
2187
|
+
}
|
|
141
2188
|
const processes = [];
|
|
142
2189
|
function cleanup() {
|
|
143
2190
|
for (const proc of processes) if (proc && !proc.killed) proc.kill("SIGTERM");
|
|
144
2191
|
}
|
|
2192
|
+
function deriveScopeFromToggles(api, auth) {
|
|
2193
|
+
if (api && auth) return "all";
|
|
2194
|
+
if (!api && auth) return "auth";
|
|
2195
|
+
if (!api && !auth) return "orm";
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
const VALID_BACKFILL_ENABLED = new Set([
|
|
2199
|
+
"auto",
|
|
2200
|
+
"on",
|
|
2201
|
+
"off"
|
|
2202
|
+
]);
|
|
2203
|
+
function parsePositiveIntegerArg(flag, raw) {
|
|
2204
|
+
const parsed = Number(raw);
|
|
2205
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${flag} expects a positive integer.`);
|
|
2206
|
+
return parsed;
|
|
2207
|
+
}
|
|
2208
|
+
function readFlagValue(args, index, flag) {
|
|
2209
|
+
const value = args[index + 1];
|
|
2210
|
+
if (!value) throw new Error(`Missing value for ${flag}.`);
|
|
2211
|
+
return {
|
|
2212
|
+
value,
|
|
2213
|
+
nextIndex: index + 1
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
function extractBackfillCliOptions(args) {
|
|
2217
|
+
const remainingArgs = [];
|
|
2218
|
+
const overrides = {};
|
|
2219
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2220
|
+
const arg = args[i];
|
|
2221
|
+
if (arg === "--force") continue;
|
|
2222
|
+
if (arg === "--backfill") {
|
|
2223
|
+
const { value, nextIndex } = readFlagValue(args, i, "--backfill");
|
|
2224
|
+
if (!VALID_BACKFILL_ENABLED.has(value)) throw new Error("Invalid --backfill value. Expected auto, on, or off.");
|
|
2225
|
+
overrides.enabled = value;
|
|
2226
|
+
i = nextIndex;
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
if (arg.startsWith("--backfill=")) {
|
|
2230
|
+
const value = arg.slice(11);
|
|
2231
|
+
if (!VALID_BACKFILL_ENABLED.has(value)) throw new Error("Invalid --backfill value. Expected auto, on, or off.");
|
|
2232
|
+
overrides.enabled = value;
|
|
2233
|
+
continue;
|
|
2234
|
+
}
|
|
2235
|
+
if (arg === "--backfill-mode") {
|
|
2236
|
+
readFlagValue(args, i, "--backfill-mode");
|
|
2237
|
+
throw new Error("`--backfill-mode` was removed. Use `better-convex aggregate rebuild`.");
|
|
2238
|
+
}
|
|
2239
|
+
if (arg.startsWith("--backfill-mode=")) throw new Error("`--backfill-mode` was removed. Use `better-convex aggregate rebuild`.");
|
|
2240
|
+
if (arg === "--backfill-wait") {
|
|
2241
|
+
overrides.wait = true;
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
if (arg === "--no-backfill-wait") {
|
|
2245
|
+
overrides.wait = false;
|
|
2246
|
+
continue;
|
|
2247
|
+
}
|
|
2248
|
+
if (arg === "--backfill-strict") {
|
|
2249
|
+
overrides.strict = true;
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
if (arg === "--no-backfill-strict") {
|
|
2253
|
+
overrides.strict = false;
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
if (arg === "--backfill-batch-size") {
|
|
2257
|
+
const { value, nextIndex } = readFlagValue(args, i, "--backfill-batch-size");
|
|
2258
|
+
overrides.batchSize = parsePositiveIntegerArg("--backfill-batch-size", value);
|
|
2259
|
+
i = nextIndex;
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
if (arg.startsWith("--backfill-batch-size=")) {
|
|
2263
|
+
overrides.batchSize = parsePositiveIntegerArg("--backfill-batch-size", arg.slice(22));
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
if (arg === "--backfill-timeout-ms") {
|
|
2267
|
+
const { value, nextIndex } = readFlagValue(args, i, "--backfill-timeout-ms");
|
|
2268
|
+
overrides.timeoutMs = parsePositiveIntegerArg("--backfill-timeout-ms", value);
|
|
2269
|
+
i = nextIndex;
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
2272
|
+
if (arg.startsWith("--backfill-timeout-ms=")) {
|
|
2273
|
+
overrides.timeoutMs = parsePositiveIntegerArg("--backfill-timeout-ms", arg.slice(22));
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
if (arg === "--backfill-poll-ms") {
|
|
2277
|
+
const { value, nextIndex } = readFlagValue(args, i, "--backfill-poll-ms");
|
|
2278
|
+
overrides.pollIntervalMs = parsePositiveIntegerArg("--backfill-poll-ms", value);
|
|
2279
|
+
i = nextIndex;
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
if (arg.startsWith("--backfill-poll-ms=")) {
|
|
2283
|
+
overrides.pollIntervalMs = parsePositiveIntegerArg("--backfill-poll-ms", arg.slice(19));
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
remainingArgs.push(arg);
|
|
2287
|
+
}
|
|
2288
|
+
return {
|
|
2289
|
+
remainingArgs,
|
|
2290
|
+
overrides
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function extractResetCliOptions(args) {
|
|
2294
|
+
const remainingArgs = [];
|
|
2295
|
+
let confirmed = false;
|
|
2296
|
+
let beforeHook;
|
|
2297
|
+
let afterHook;
|
|
2298
|
+
const isBackfillFlag = (arg) => arg === "--backfill" || arg.startsWith("--backfill=") || arg.startsWith("--backfill-") || arg.startsWith("--no-backfill-");
|
|
2299
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2300
|
+
const arg = args[i];
|
|
2301
|
+
if (isBackfillFlag(arg)) throw new Error("`better-convex reset` does not accept backfill flags. It always runs aggregateBackfill in resume mode.");
|
|
2302
|
+
if (arg === "--yes") {
|
|
2303
|
+
confirmed = true;
|
|
2304
|
+
continue;
|
|
2305
|
+
}
|
|
2306
|
+
if (arg === "--before") {
|
|
2307
|
+
const { value, nextIndex } = readFlagValue(args, i, "--before");
|
|
2308
|
+
beforeHook = value;
|
|
2309
|
+
i = nextIndex;
|
|
2310
|
+
continue;
|
|
2311
|
+
}
|
|
2312
|
+
if (arg.startsWith("--before=")) {
|
|
2313
|
+
const value = arg.slice(9);
|
|
2314
|
+
if (!value) throw new Error("Missing value for --before.");
|
|
2315
|
+
beforeHook = value;
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
if (arg === "--after") {
|
|
2319
|
+
const { value, nextIndex } = readFlagValue(args, i, "--after");
|
|
2320
|
+
afterHook = value;
|
|
2321
|
+
i = nextIndex;
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
if (arg.startsWith("--after=")) {
|
|
2325
|
+
const value = arg.slice(8);
|
|
2326
|
+
if (!value) throw new Error("Missing value for --after.");
|
|
2327
|
+
afterHook = value;
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
remainingArgs.push(arg);
|
|
2331
|
+
}
|
|
2332
|
+
return {
|
|
2333
|
+
confirmed,
|
|
2334
|
+
beforeHook,
|
|
2335
|
+
afterHook,
|
|
2336
|
+
remainingArgs
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
function resolveBackfillConfig(base, overrides) {
|
|
2340
|
+
const resolvedBase = base ?? {
|
|
2341
|
+
enabled: "auto",
|
|
2342
|
+
wait: true,
|
|
2343
|
+
batchSize: 1e3,
|
|
2344
|
+
timeoutMs: 9e5,
|
|
2345
|
+
pollIntervalMs: 1e3,
|
|
2346
|
+
strict: false
|
|
2347
|
+
};
|
|
2348
|
+
return {
|
|
2349
|
+
...resolvedBase,
|
|
2350
|
+
enabled: overrides.enabled ?? resolvedBase.enabled,
|
|
2351
|
+
wait: overrides.wait ?? resolvedBase.wait,
|
|
2352
|
+
batchSize: overrides.batchSize ?? resolvedBase.batchSize,
|
|
2353
|
+
timeoutMs: overrides.timeoutMs ?? resolvedBase.timeoutMs,
|
|
2354
|
+
pollIntervalMs: overrides.pollIntervalMs ?? resolvedBase.pollIntervalMs,
|
|
2355
|
+
strict: overrides.strict ?? resolvedBase.strict
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
function extractRunDeploymentArgs(args) {
|
|
2359
|
+
const deploymentArgs = [];
|
|
2360
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2361
|
+
const arg = args[i];
|
|
2362
|
+
if (arg === "--prod") {
|
|
2363
|
+
deploymentArgs.push(arg);
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
if (arg === "--preview-name" || arg === "--deployment-name" || arg === "--env-file" || arg === "--component") {
|
|
2367
|
+
const value = args[i + 1];
|
|
2368
|
+
if (!value) throw new Error(`Missing value for ${arg}.`);
|
|
2369
|
+
deploymentArgs.push(arg, value);
|
|
2370
|
+
i += 1;
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
if (arg.startsWith("--preview-name=") || arg.startsWith("--deployment-name=") || arg.startsWith("--env-file=") || arg.startsWith("--component=")) deploymentArgs.push(arg);
|
|
2374
|
+
}
|
|
2375
|
+
return deploymentArgs;
|
|
2376
|
+
}
|
|
2377
|
+
function isMissingBackfillFunctionOutput(output) {
|
|
2378
|
+
return MISSING_BACKFILL_FUNCTION_RE.test(output);
|
|
2379
|
+
}
|
|
2380
|
+
function parseConvexRunJson(stdout) {
|
|
2381
|
+
const trimmed = stdout.trim();
|
|
2382
|
+
if (trimmed.length === 0) return [];
|
|
2383
|
+
try {
|
|
2384
|
+
return JSON.parse(trimmed);
|
|
2385
|
+
} catch {
|
|
2386
|
+
const lines = trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2387
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
2388
|
+
const line = lines[i];
|
|
2389
|
+
try {
|
|
2390
|
+
return JSON.parse(line);
|
|
2391
|
+
} catch {}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
throw new Error(`Failed to parse convex run output as JSON.\nOutput:\n${stdout.trim()}`);
|
|
2395
|
+
}
|
|
2396
|
+
function sleep(ms, signal) {
|
|
2397
|
+
if (signal?.aborted) return Promise.resolve();
|
|
2398
|
+
return new Promise((resolve) => {
|
|
2399
|
+
const timer = setTimeout(() => {
|
|
2400
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2401
|
+
resolve();
|
|
2402
|
+
}, ms);
|
|
2403
|
+
const onAbort = () => {
|
|
2404
|
+
clearTimeout(timer);
|
|
2405
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2406
|
+
resolve();
|
|
2407
|
+
};
|
|
2408
|
+
signal?.addEventListener("abort", onAbort);
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
async function runConvexFunction(execaFn, realConvexPath, functionName, args, deploymentArgs, options) {
|
|
2412
|
+
const result = await execaFn("node", [
|
|
2413
|
+
realConvexPath,
|
|
2414
|
+
"run",
|
|
2415
|
+
...deploymentArgs,
|
|
2416
|
+
functionName,
|
|
2417
|
+
JSON.stringify(args)
|
|
2418
|
+
], {
|
|
2419
|
+
cwd: process.cwd(),
|
|
2420
|
+
reject: false,
|
|
2421
|
+
stdio: "pipe"
|
|
2422
|
+
});
|
|
2423
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
2424
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
2425
|
+
if (options?.echoOutput !== false) {
|
|
2426
|
+
if (stdout) process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
|
|
2427
|
+
if (stderr) process.stderr.write(stderr.endsWith("\n") ? stderr : `${stderr}\n`);
|
|
2428
|
+
}
|
|
2429
|
+
return {
|
|
2430
|
+
exitCode: result.exitCode ?? 1,
|
|
2431
|
+
stdout,
|
|
2432
|
+
stderr
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
async function runAggregateBackfillFlow(params) {
|
|
2436
|
+
const { execaFn, realConvexPath, backfillConfig, mode, deploymentArgs, signal, context } = params;
|
|
2437
|
+
if (signal?.aborted) return 0;
|
|
2438
|
+
if (backfillConfig.enabled === "off") return 0;
|
|
2439
|
+
const kickoff = await runConvexFunction(execaFn, realConvexPath, "generated/server:aggregateBackfill", {
|
|
2440
|
+
mode,
|
|
2441
|
+
batchSize: backfillConfig.batchSize
|
|
2442
|
+
}, deploymentArgs, { echoOutput: false });
|
|
2443
|
+
if (kickoff.exitCode !== 0) {
|
|
2444
|
+
const combinedOutput = `${kickoff.stdout}\n${kickoff.stderr}`;
|
|
2445
|
+
if (backfillConfig.enabled === "auto" && isMissingBackfillFunctionOutput(combinedOutput)) {
|
|
2446
|
+
if (context === "deploy") console.info("ℹ️ aggregateBackfill not found in this deployment; skipping post-deploy backfill (auto mode).");
|
|
2447
|
+
return 0;
|
|
2448
|
+
}
|
|
2449
|
+
return kickoff.exitCode;
|
|
2450
|
+
}
|
|
2451
|
+
const kickoffPayload = parseConvexRunJson(kickoff.stdout);
|
|
2452
|
+
const needsRebuild = typeof kickoffPayload === "object" && kickoffPayload !== null && !Array.isArray(kickoffPayload) && typeof kickoffPayload.needsRebuild === "number" ? kickoffPayload.needsRebuild : 0;
|
|
2453
|
+
const scheduled = typeof kickoffPayload === "object" && kickoffPayload !== null && !Array.isArray(kickoffPayload) && typeof kickoffPayload.scheduled === "number" ? kickoffPayload.scheduled : 0;
|
|
2454
|
+
const targets = typeof kickoffPayload === "object" && kickoffPayload !== null && !Array.isArray(kickoffPayload) && typeof kickoffPayload.targets === "number" ? kickoffPayload.targets : 0;
|
|
2455
|
+
const pruned = typeof kickoffPayload === "object" && kickoffPayload !== null && !Array.isArray(kickoffPayload) && typeof kickoffPayload.pruned === "number" ? kickoffPayload.pruned : 0;
|
|
2456
|
+
if (pruned > 0) console.info(`ℹ️ aggregateBackfill pruned ${pruned} removed indexes`);
|
|
2457
|
+
if (mode === "resume" && needsRebuild > 0) {
|
|
2458
|
+
const message = `Aggregate backfill found ${needsRebuild} index definitions that require rebuild. Run \`better-convex aggregate rebuild\` for this deployment.`;
|
|
2459
|
+
if (backfillConfig.strict) {
|
|
2460
|
+
console.error(`❌ ${message}`);
|
|
2461
|
+
return 1;
|
|
2462
|
+
}
|
|
2463
|
+
console.warn(`⚠️ ${message}`);
|
|
2464
|
+
} else if (scheduled > 0) console.info(`ℹ️ aggregateBackfill scheduled ${scheduled}/${targets} target indexes`);
|
|
2465
|
+
if (!backfillConfig.wait || signal?.aborted) return 0;
|
|
2466
|
+
const deadline = Date.now() + backfillConfig.timeoutMs;
|
|
2467
|
+
let lastProgress = "";
|
|
2468
|
+
while (!signal?.aborted) {
|
|
2469
|
+
const statusResult = await runConvexFunction(execaFn, realConvexPath, "generated/server:aggregateBackfillStatus", {}, deploymentArgs, { echoOutput: false });
|
|
2470
|
+
if (statusResult.exitCode !== 0) return statusResult.exitCode;
|
|
2471
|
+
const statuses = parseConvexRunJson(statusResult.stdout);
|
|
2472
|
+
const failed = statuses.find((entry) => Boolean(entry.lastError));
|
|
2473
|
+
if (failed) {
|
|
2474
|
+
console.error(`❌ Aggregate backfill failed for ${failed.tableName}.${failed.indexName}: ${failed.lastError}`);
|
|
2475
|
+
return backfillConfig.strict ? 1 : 0;
|
|
2476
|
+
}
|
|
2477
|
+
const total = statuses.length;
|
|
2478
|
+
const ready = statuses.filter((entry) => entry.status === "READY").length;
|
|
2479
|
+
const progress = `${ready}/${total}`;
|
|
2480
|
+
if (progress !== lastProgress) {
|
|
2481
|
+
lastProgress = progress;
|
|
2482
|
+
if (total > 0) console.info(`ℹ️ aggregateBackfill progress ${ready}/${total} READY`);
|
|
2483
|
+
}
|
|
2484
|
+
if (total === 0 || ready === total) return 0;
|
|
2485
|
+
if (Date.now() > deadline) {
|
|
2486
|
+
const timeoutMessage = `Aggregate backfill timed out after ${backfillConfig.timeoutMs}ms (${ready}/${total} READY).`;
|
|
2487
|
+
if (backfillConfig.strict) {
|
|
2488
|
+
console.error(`❌ ${timeoutMessage}`);
|
|
2489
|
+
return 1;
|
|
2490
|
+
}
|
|
2491
|
+
console.warn(`⚠️ ${timeoutMessage}`);
|
|
2492
|
+
return 0;
|
|
2493
|
+
}
|
|
2494
|
+
await sleep(backfillConfig.pollIntervalMs, signal);
|
|
2495
|
+
}
|
|
2496
|
+
return 0;
|
|
2497
|
+
}
|
|
2498
|
+
async function runAggregatePruneFlow(params) {
|
|
2499
|
+
const { execaFn, realConvexPath, deploymentArgs } = params;
|
|
2500
|
+
const result = await runConvexFunction(execaFn, realConvexPath, "generated/server:aggregateBackfill", { mode: "prune" }, deploymentArgs, { echoOutput: false });
|
|
2501
|
+
if (result.exitCode !== 0) return result.exitCode;
|
|
2502
|
+
const payload = parseConvexRunJson(result.stdout);
|
|
2503
|
+
const pruned = typeof payload === "object" && payload !== null && !Array.isArray(payload) && typeof payload.pruned === "number" ? payload.pruned : 0;
|
|
2504
|
+
if (pruned > 0) console.info(`ℹ️ aggregateBackfill pruned ${pruned} removed indexes`);
|
|
2505
|
+
else console.info("ℹ️ aggregateBackfill prune no-op");
|
|
2506
|
+
return 0;
|
|
2507
|
+
}
|
|
2508
|
+
async function runDevSchemaBackfillIfNeeded(params) {
|
|
2509
|
+
const { execaFn, realConvexPath, backfillConfig, functionsDir, deploymentArgs, signal } = params;
|
|
2510
|
+
const fingerprint = await computeAggregateIndexFingerprint(functionsDir);
|
|
2511
|
+
if (!fingerprint) return 0;
|
|
2512
|
+
const deploymentKey = getAggregateBackfillDeploymentKey(deploymentArgs);
|
|
2513
|
+
const statePath = getDevAggregateBackfillStatePath();
|
|
2514
|
+
const state = readAggregateFingerprintState(statePath);
|
|
2515
|
+
if (state.entries[deploymentKey]?.fingerprint === fingerprint) return 0;
|
|
2516
|
+
console.info(`ℹ️ aggregateBackfill resume (${deploymentKey} schema change)`);
|
|
2517
|
+
const exitCode = await runAggregateBackfillFlow({
|
|
2518
|
+
execaFn,
|
|
2519
|
+
realConvexPath,
|
|
2520
|
+
backfillConfig: {
|
|
2521
|
+
...backfillConfig,
|
|
2522
|
+
enabled: "on"
|
|
2523
|
+
},
|
|
2524
|
+
mode: "resume",
|
|
2525
|
+
deploymentArgs,
|
|
2526
|
+
signal,
|
|
2527
|
+
context: "dev"
|
|
2528
|
+
});
|
|
2529
|
+
if (exitCode !== 0 || signal.aborted) return exitCode;
|
|
2530
|
+
state.entries[deploymentKey] = {
|
|
2531
|
+
fingerprint,
|
|
2532
|
+
updatedAt: Date.now()
|
|
2533
|
+
};
|
|
2534
|
+
writeAggregateFingerprintState(statePath, state);
|
|
2535
|
+
return 0;
|
|
2536
|
+
}
|
|
145
2537
|
async function run(argv, deps) {
|
|
146
|
-
const { execa: execaFn, generateMeta: generateMetaFn, syncEnv: syncEnvFn, realConvex: realConvexPath } = {
|
|
2538
|
+
const { execa: execaFn, runAnalyze: runAnalyzeFn, generateMeta: generateMetaFn, getConvexConfig: getConvexConfigFn, syncEnv: syncEnvFn, loadBetterConvexConfig: loadBetterConvexConfigFn, ensureConvexGitignoreEntry: ensureConvexGitignoreEntryFn, enableDevSchemaWatch, realConvex: realConvexPath } = {
|
|
147
2539
|
execa,
|
|
2540
|
+
runAnalyze,
|
|
148
2541
|
generateMeta,
|
|
2542
|
+
getConvexConfig,
|
|
149
2543
|
syncEnv,
|
|
2544
|
+
loadBetterConvexConfig,
|
|
2545
|
+
ensureConvexGitignoreEntry,
|
|
2546
|
+
enableDevSchemaWatch: !deps,
|
|
150
2547
|
realConvex,
|
|
151
2548
|
...deps
|
|
152
2549
|
};
|
|
153
|
-
const { command, restArgs, convexArgs, debug, outputDir } = parseArgs(argv);
|
|
2550
|
+
const { command, restArgs, convexArgs, debug: cliDebug, outputDir: cliOutputDir, scope: cliScope, configPath } = parseArgs(argv);
|
|
154
2551
|
if (command === "dev") {
|
|
155
|
-
|
|
2552
|
+
if (cliScope) throw new Error("`--scope` is not supported for `better-convex dev`. Use `better-convex codegen --scope <all|auth|orm>` for scoped generation.");
|
|
2553
|
+
const config = loadBetterConvexConfigFn(configPath);
|
|
2554
|
+
const { remainingArgs: devCommandArgs, overrides: devBackfillOverrides } = extractBackfillCliOptions(convexArgs);
|
|
2555
|
+
const outputDir = cliOutputDir ?? config.outputDir;
|
|
2556
|
+
const debug = cliDebug || config.dev.debug;
|
|
2557
|
+
const generateApi = config.api;
|
|
2558
|
+
const generateAuth = config.auth;
|
|
2559
|
+
const convexDevArgs = [...config.dev.convexArgs, ...devCommandArgs];
|
|
2560
|
+
const devBackfillConfig = resolveBackfillConfig(config.dev.aggregateBackfill, devBackfillOverrides);
|
|
2561
|
+
const { functionsDir } = getConvexConfigFn(outputDir);
|
|
2562
|
+
const schemaPath = join(functionsDir, "schema.ts");
|
|
2563
|
+
const deploymentArgs = extractRunDeploymentArgs(convexDevArgs);
|
|
2564
|
+
if (!deps) try {
|
|
2565
|
+
ensureConvexGitignoreEntryFn(process.cwd());
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
console.warn(`⚠️ Failed to ensure .convex/ is ignored in .gitignore: ${error.message}`);
|
|
2568
|
+
}
|
|
2569
|
+
await generateMetaFn(outputDir, {
|
|
2570
|
+
debug,
|
|
2571
|
+
api: generateApi,
|
|
2572
|
+
auth: generateAuth
|
|
2573
|
+
});
|
|
156
2574
|
const isTs = __filename.endsWith(".ts");
|
|
157
2575
|
const watcherPath = isTs ? join(__dirname, "watcher.ts") : join(__dirname, "watcher.mjs");
|
|
158
2576
|
const watcherProcess = execaFn(isTs ? "bun" : process.execPath, [watcherPath], {
|
|
@@ -160,40 +2578,131 @@ async function run(argv, deps) {
|
|
|
160
2578
|
cwd: process.cwd(),
|
|
161
2579
|
env: {
|
|
162
2580
|
...process.env,
|
|
163
|
-
|
|
164
|
-
BETTER_CONVEX_DEBUG: debug ? "1" : ""
|
|
2581
|
+
BETTER_CONVEX_API_OUTPUT_DIR: outputDir || "",
|
|
2582
|
+
BETTER_CONVEX_DEBUG: debug ? "1" : "",
|
|
2583
|
+
BETTER_CONVEX_GENERATE_API: generateApi ? "1" : "0",
|
|
2584
|
+
BETTER_CONVEX_GENERATE_AUTH: generateAuth ? "1" : "0"
|
|
165
2585
|
}
|
|
166
2586
|
});
|
|
167
2587
|
processes.push(watcherProcess);
|
|
168
2588
|
const convexProcess = execaFn("node", [
|
|
169
2589
|
realConvexPath,
|
|
170
2590
|
"dev",
|
|
171
|
-
...
|
|
2591
|
+
...convexDevArgs
|
|
172
2592
|
], {
|
|
173
2593
|
stdio: "inherit",
|
|
174
2594
|
cwd: process.cwd(),
|
|
175
2595
|
reject: false
|
|
176
2596
|
});
|
|
177
2597
|
processes.push(convexProcess);
|
|
2598
|
+
const backfillAbortController = new AbortController();
|
|
2599
|
+
let schemaWatcher = null;
|
|
2600
|
+
let schemaDebounceTimer = null;
|
|
2601
|
+
let schemaBackfillInFlight = null;
|
|
2602
|
+
let schemaBackfillQueued = false;
|
|
2603
|
+
const maybeRunSchemaBackfill = async () => {
|
|
2604
|
+
try {
|
|
2605
|
+
if (await runDevSchemaBackfillIfNeeded({
|
|
2606
|
+
execaFn,
|
|
2607
|
+
realConvexPath,
|
|
2608
|
+
backfillConfig: devBackfillConfig,
|
|
2609
|
+
functionsDir,
|
|
2610
|
+
deploymentArgs,
|
|
2611
|
+
signal: backfillAbortController.signal
|
|
2612
|
+
}) !== 0 && !backfillAbortController.signal.aborted) console.warn("⚠️ aggregateBackfill on schema update failed in dev (continuing without blocking).");
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
if (!backfillAbortController.signal.aborted) console.warn(`⚠️ aggregateBackfill on schema update errored in dev: ${error.message}`);
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
const queueSchemaBackfill = () => {
|
|
2618
|
+
if (backfillAbortController.signal.aborted) return;
|
|
2619
|
+
schemaBackfillQueued = true;
|
|
2620
|
+
if (schemaBackfillInFlight) return;
|
|
2621
|
+
schemaBackfillInFlight = (async () => {
|
|
2622
|
+
while (schemaBackfillQueued && !backfillAbortController.signal.aborted) {
|
|
2623
|
+
schemaBackfillQueued = false;
|
|
2624
|
+
await maybeRunSchemaBackfill();
|
|
2625
|
+
}
|
|
2626
|
+
})().finally(() => {
|
|
2627
|
+
schemaBackfillInFlight = null;
|
|
2628
|
+
});
|
|
2629
|
+
};
|
|
2630
|
+
if (devBackfillConfig.enabled !== "off") (async () => {
|
|
2631
|
+
try {
|
|
2632
|
+
if (await runAggregateBackfillFlow({
|
|
2633
|
+
execaFn,
|
|
2634
|
+
realConvexPath,
|
|
2635
|
+
backfillConfig: devBackfillConfig,
|
|
2636
|
+
mode: "resume",
|
|
2637
|
+
deploymentArgs,
|
|
2638
|
+
signal: backfillAbortController.signal,
|
|
2639
|
+
context: "dev"
|
|
2640
|
+
}) !== 0 && !backfillAbortController.signal.aborted) console.warn("⚠️ aggregateBackfill kickoff failed in dev (continuing without blocking).");
|
|
2641
|
+
} catch (error) {
|
|
2642
|
+
if (!backfillAbortController.signal.aborted) console.warn(`⚠️ aggregateBackfill kickoff errored in dev: ${error.message}`);
|
|
2643
|
+
}
|
|
2644
|
+
})();
|
|
2645
|
+
if (enableDevSchemaWatch && devBackfillConfig.enabled !== "off" && fs.existsSync(schemaPath)) {
|
|
2646
|
+
const { watch } = await import("chokidar");
|
|
2647
|
+
const watchedSchema = watch(schemaPath, { ignoreInitial: true });
|
|
2648
|
+
schemaWatcher = watchedSchema;
|
|
2649
|
+
watchedSchema.on("change", () => {
|
|
2650
|
+
if (schemaDebounceTimer) clearTimeout(schemaDebounceTimer);
|
|
2651
|
+
schemaDebounceTimer = setTimeout(() => {
|
|
2652
|
+
queueSchemaBackfill();
|
|
2653
|
+
}, 200);
|
|
2654
|
+
}).on("error", (error) => {
|
|
2655
|
+
if (!backfillAbortController.signal.aborted) console.warn(`⚠️ schema watch error (aggregate backfill): ${error.message}`);
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
178
2658
|
process.on("exit", cleanup);
|
|
179
2659
|
process.on("SIGINT", () => {
|
|
2660
|
+
backfillAbortController.abort();
|
|
2661
|
+
if (schemaDebounceTimer) clearTimeout(schemaDebounceTimer);
|
|
2662
|
+
schemaWatcher?.close();
|
|
180
2663
|
cleanup();
|
|
181
2664
|
process.exit(0);
|
|
182
2665
|
});
|
|
183
2666
|
process.on("SIGTERM", () => {
|
|
2667
|
+
backfillAbortController.abort();
|
|
2668
|
+
if (schemaDebounceTimer) clearTimeout(schemaDebounceTimer);
|
|
2669
|
+
schemaWatcher?.close();
|
|
184
2670
|
cleanup();
|
|
185
2671
|
process.exit(0);
|
|
186
2672
|
});
|
|
187
2673
|
const result = await Promise.race([watcherProcess.catch(() => ({ exitCode: 1 })), convexProcess]);
|
|
2674
|
+
backfillAbortController.abort();
|
|
2675
|
+
if (schemaDebounceTimer) clearTimeout(schemaDebounceTimer);
|
|
2676
|
+
await schemaWatcher?.close();
|
|
188
2677
|
cleanup();
|
|
189
2678
|
return result.exitCode ?? 0;
|
|
190
2679
|
}
|
|
191
2680
|
if (command === "codegen") {
|
|
192
|
-
|
|
2681
|
+
const config = loadBetterConvexConfigFn(configPath);
|
|
2682
|
+
const outputDir = cliOutputDir ?? config.outputDir;
|
|
2683
|
+
const debug = cliDebug || config.codegen.debug;
|
|
2684
|
+
const convexCodegenArgs = [...config.codegen.convexArgs, ...convexArgs];
|
|
2685
|
+
const scope = cliScope ?? config.codegen.scope;
|
|
2686
|
+
if (scope) await generateMetaFn(outputDir, {
|
|
2687
|
+
debug,
|
|
2688
|
+
scope
|
|
2689
|
+
});
|
|
2690
|
+
else {
|
|
2691
|
+
const derivedScope = deriveScopeFromToggles(config.api, config.auth);
|
|
2692
|
+
if (derivedScope) await generateMetaFn(outputDir, {
|
|
2693
|
+
debug,
|
|
2694
|
+
scope: derivedScope
|
|
2695
|
+
});
|
|
2696
|
+
else await generateMetaFn(outputDir, {
|
|
2697
|
+
debug,
|
|
2698
|
+
api: config.api,
|
|
2699
|
+
auth: config.auth
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
193
2702
|
return (await execaFn("node", [
|
|
194
2703
|
realConvexPath,
|
|
195
2704
|
"codegen",
|
|
196
|
-
...
|
|
2705
|
+
...convexCodegenArgs
|
|
197
2706
|
], {
|
|
198
2707
|
stdio: "inherit",
|
|
199
2708
|
cwd: process.cwd()
|
|
@@ -218,6 +2727,85 @@ async function run(argv, deps) {
|
|
|
218
2727
|
reject: false
|
|
219
2728
|
})).exitCode ?? 0;
|
|
220
2729
|
}
|
|
2730
|
+
if (command === "analyze") return runAnalyzeFn(restArgs);
|
|
2731
|
+
if (command === "reset") {
|
|
2732
|
+
const { confirmed, beforeHook, afterHook, remainingArgs: resetCommandArgs } = extractResetCliOptions(convexArgs);
|
|
2733
|
+
if (!confirmed) throw new Error("`better-convex reset` is destructive. Re-run with `--yes`.");
|
|
2734
|
+
const deploymentArgs = extractRunDeploymentArgs([...loadBetterConvexConfigFn(configPath).deploy.convexArgs, ...resetCommandArgs]);
|
|
2735
|
+
const runOptionalHook = async (functionName) => {
|
|
2736
|
+
if (!functionName) return 0;
|
|
2737
|
+
return (await runConvexFunction(execaFn, realConvexPath, functionName, {}, deploymentArgs)).exitCode;
|
|
2738
|
+
};
|
|
2739
|
+
const beforeExitCode = await runOptionalHook(beforeHook);
|
|
2740
|
+
if (beforeExitCode !== 0) return beforeExitCode;
|
|
2741
|
+
const resetResult = await runConvexFunction(execaFn, realConvexPath, "generated/server:reset", {}, deploymentArgs);
|
|
2742
|
+
if (resetResult.exitCode !== 0) return resetResult.exitCode;
|
|
2743
|
+
const backfillExitCode = await runAggregateBackfillFlow({
|
|
2744
|
+
execaFn,
|
|
2745
|
+
realConvexPath,
|
|
2746
|
+
backfillConfig: {
|
|
2747
|
+
enabled: "on",
|
|
2748
|
+
wait: true,
|
|
2749
|
+
batchSize: 1e3,
|
|
2750
|
+
pollIntervalMs: 1e3,
|
|
2751
|
+
timeoutMs: 9e5,
|
|
2752
|
+
strict: false
|
|
2753
|
+
},
|
|
2754
|
+
mode: "resume",
|
|
2755
|
+
deploymentArgs,
|
|
2756
|
+
context: "aggregate"
|
|
2757
|
+
});
|
|
2758
|
+
if (backfillExitCode !== 0) return backfillExitCode;
|
|
2759
|
+
return runOptionalHook(afterHook);
|
|
2760
|
+
}
|
|
2761
|
+
if (command === "deploy") {
|
|
2762
|
+
const config = loadBetterConvexConfigFn(configPath);
|
|
2763
|
+
const { remainingArgs: deployCommandArgs, overrides: deployBackfillOverrides } = extractBackfillCliOptions(convexArgs);
|
|
2764
|
+
const deployArgs = [...config.deploy.convexArgs, ...deployCommandArgs];
|
|
2765
|
+
const deployResult = await execaFn("node", [
|
|
2766
|
+
realConvexPath,
|
|
2767
|
+
"deploy",
|
|
2768
|
+
...deployArgs
|
|
2769
|
+
], {
|
|
2770
|
+
stdio: "inherit",
|
|
2771
|
+
cwd: process.cwd(),
|
|
2772
|
+
reject: false
|
|
2773
|
+
});
|
|
2774
|
+
if ((deployResult.exitCode ?? 1) !== 0) return deployResult.exitCode ?? 1;
|
|
2775
|
+
return runAggregateBackfillFlow({
|
|
2776
|
+
execaFn,
|
|
2777
|
+
realConvexPath,
|
|
2778
|
+
backfillConfig: resolveBackfillConfig(config.deploy.aggregateBackfill, deployBackfillOverrides),
|
|
2779
|
+
mode: "resume",
|
|
2780
|
+
deploymentArgs: extractRunDeploymentArgs(deployArgs),
|
|
2781
|
+
context: "deploy"
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
if (command === "aggregate") {
|
|
2785
|
+
const subcommand = restArgs[0];
|
|
2786
|
+
if (subcommand !== "rebuild" && subcommand !== "backfill" && subcommand !== "prune") throw new Error("Unknown aggregate command. Use: `better-convex aggregate backfill`, `better-convex aggregate rebuild`, or `better-convex aggregate prune`.");
|
|
2787
|
+
const config = loadBetterConvexConfigFn(configPath);
|
|
2788
|
+
const { remainingArgs: aggregateCommandArgs, overrides: aggregateBackfillOverrides } = extractBackfillCliOptions(restArgs.slice(1));
|
|
2789
|
+
const aggregateArgs = [...config.deploy.convexArgs, ...aggregateCommandArgs];
|
|
2790
|
+
const backfillConfig = {
|
|
2791
|
+
...resolveBackfillConfig(config.deploy.aggregateBackfill, aggregateBackfillOverrides),
|
|
2792
|
+
enabled: "on"
|
|
2793
|
+
};
|
|
2794
|
+
const deploymentArgs = extractRunDeploymentArgs(aggregateArgs);
|
|
2795
|
+
if (subcommand === "prune") return runAggregatePruneFlow({
|
|
2796
|
+
execaFn,
|
|
2797
|
+
realConvexPath,
|
|
2798
|
+
deploymentArgs
|
|
2799
|
+
});
|
|
2800
|
+
return runAggregateBackfillFlow({
|
|
2801
|
+
execaFn,
|
|
2802
|
+
realConvexPath,
|
|
2803
|
+
backfillConfig,
|
|
2804
|
+
mode: subcommand === "rebuild" ? "rebuild" : "resume",
|
|
2805
|
+
deploymentArgs,
|
|
2806
|
+
context: "aggregate"
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
221
2809
|
return (await execaFn("node", [
|
|
222
2810
|
realConvexPath,
|
|
223
2811
|
command,
|
|
@@ -243,4 +2831,4 @@ if (isEntryPoint(process.argv[1], __filename)) run(process.argv.slice(2)).then((
|
|
|
243
2831
|
});
|
|
244
2832
|
|
|
245
2833
|
//#endregion
|
|
246
|
-
export { isEntryPoint, parseArgs, run };
|
|
2834
|
+
export { ensureConvexGitignoreEntry, getAggregateBackfillDeploymentKey, getDevAggregateBackfillStatePath, isEntryPoint, parseArgs, run };
|