naro-cli 0.12.0 → 0.13.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/README.md +69 -1
- package/dist/{chunk-GFCG5C7D.js → chunk-O4NJRDJE.js} +1584 -195
- package/dist/deploy.js +1 -1
- package/dist/index.js +3564 -210
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
BACKEND_STEPS,
|
|
4
4
|
BOOKMARK_FORMAT_MESSAGE,
|
|
5
5
|
INDEX_SQL,
|
|
6
|
+
MAX_SINGLE_UPLOAD_BYTES,
|
|
6
7
|
NAMED_POLICY_SQL,
|
|
7
8
|
NAMED_POLICY_TABLE,
|
|
8
9
|
NaroClient,
|
|
@@ -12,6 +13,9 @@ import {
|
|
|
12
13
|
RESET_BACKEND_OUTDATED,
|
|
13
14
|
RESTORE_OUTCOME_UNKNOWN_CODES,
|
|
14
15
|
SCHEMA_SQL,
|
|
16
|
+
SPEED_INSIGHTS_METRICS,
|
|
17
|
+
SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
|
|
18
|
+
STORAGE_OBJECTS_TABLE,
|
|
15
19
|
buildResetPlan,
|
|
16
20
|
cellText,
|
|
17
21
|
clearSession,
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
controlPlaneUrl,
|
|
21
25
|
countLintedTables,
|
|
22
26
|
createAuthClient,
|
|
27
|
+
dataError,
|
|
23
28
|
deployDirectory,
|
|
24
29
|
detectGitInfo,
|
|
25
30
|
ensureGitignoreHasNaro,
|
|
@@ -40,6 +45,7 @@ import {
|
|
|
40
45
|
migrationsPendingError,
|
|
41
46
|
nextMigrationFileName,
|
|
42
47
|
openBrowser,
|
|
48
|
+
parseCacheControl,
|
|
43
49
|
parseGitHubRemote,
|
|
44
50
|
parsePolicyNode,
|
|
45
51
|
parsePolicyStatement,
|
|
@@ -75,7 +81,7 @@ import {
|
|
|
75
81
|
toNaroError,
|
|
76
82
|
unreadablePolicyComment,
|
|
77
83
|
writeProjectLink
|
|
78
|
-
} from "./chunk-
|
|
84
|
+
} from "./chunk-O4NJRDJE.js";
|
|
79
85
|
import "./chunk-K3NQKI34.js";
|
|
80
86
|
|
|
81
87
|
// src/args.ts
|
|
@@ -108,7 +114,13 @@ var BOOLEAN_FLAGS = {
|
|
|
108
114
|
"--allow-unindexed": "allowUnindexed",
|
|
109
115
|
"--api-key-stdin": "apiKeyStdin",
|
|
110
116
|
"--client-secret-stdin": "clientSecretStdin",
|
|
111
|
-
"--password-stdin": "passwordStdin"
|
|
117
|
+
"--password-stdin": "passwordStdin",
|
|
118
|
+
"--public": "public",
|
|
119
|
+
"--private": "private",
|
|
120
|
+
"--empty": "empty",
|
|
121
|
+
"--recursive": "recursive",
|
|
122
|
+
"-r": "recursive",
|
|
123
|
+
"--upsert": "upsert"
|
|
112
124
|
};
|
|
113
125
|
var REPEATED_FLAGS = {
|
|
114
126
|
"--column": "columns"
|
|
@@ -124,6 +136,9 @@ var STRING_FLAGS = {
|
|
|
124
136
|
"--type": "type",
|
|
125
137
|
"--env": "env",
|
|
126
138
|
"--level": "level",
|
|
139
|
+
"--metric": "metric",
|
|
140
|
+
"--range": "range",
|
|
141
|
+
"--route": "route",
|
|
127
142
|
"--file": "file",
|
|
128
143
|
"--output": "output",
|
|
129
144
|
"--lang": "lang",
|
|
@@ -149,7 +164,11 @@ var STRING_FLAGS = {
|
|
|
149
164
|
"--username": "username",
|
|
150
165
|
"--email": "email",
|
|
151
166
|
"--password": "password",
|
|
152
|
-
"--min-length": "minLength"
|
|
167
|
+
"--min-length": "minLength",
|
|
168
|
+
"--file-size-limit": "fileSizeLimit",
|
|
169
|
+
"--allowed-mime-types": "allowedMimeTypes",
|
|
170
|
+
"--content-type": "contentType",
|
|
171
|
+
"--cache-control": "cacheControl"
|
|
153
172
|
};
|
|
154
173
|
var SECRET_FLAGS = {
|
|
155
174
|
"--api-key": "--api-key-stdin",
|
|
@@ -335,7 +354,7 @@ var TtyPrompter = class {
|
|
|
335
354
|
});
|
|
336
355
|
try {
|
|
337
356
|
return await new Promise(
|
|
338
|
-
(
|
|
357
|
+
(resolve6) => rl.question(question, resolve6)
|
|
339
358
|
);
|
|
340
359
|
} finally {
|
|
341
360
|
rl.close();
|
|
@@ -356,7 +375,7 @@ var TtyPrompter = class {
|
|
|
356
375
|
});
|
|
357
376
|
try {
|
|
358
377
|
const answer = new Promise(
|
|
359
|
-
(
|
|
378
|
+
(resolve6) => rl.question(question, resolve6)
|
|
360
379
|
);
|
|
361
380
|
muted = true;
|
|
362
381
|
const value = await answer;
|
|
@@ -438,10 +457,10 @@ function createContext(flags, positionals = []) {
|
|
|
438
457
|
positionals,
|
|
439
458
|
output,
|
|
440
459
|
prompter,
|
|
441
|
-
positionalOrThrow: (
|
|
442
|
-
if (positionals.length <
|
|
460
|
+
positionalOrThrow: (count2, usage) => {
|
|
461
|
+
if (positionals.length < count2)
|
|
443
462
|
throw new NaroError("VALIDATION_FAILED", usage);
|
|
444
|
-
return positionals.slice(0,
|
|
463
|
+
return positionals.slice(0, count2);
|
|
445
464
|
},
|
|
446
465
|
auth,
|
|
447
466
|
client: async () => {
|
|
@@ -468,7 +487,7 @@ function createContext(flags, positionals = []) {
|
|
|
468
487
|
|
|
469
488
|
// src/router.ts
|
|
470
489
|
import { readFileSync as readFileSync5 } from "fs";
|
|
471
|
-
import { dirname as
|
|
490
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
472
491
|
import { fileURLToPath } from "url";
|
|
473
492
|
|
|
474
493
|
// src/commands/auth.ts
|
|
@@ -553,9 +572,12 @@ async function runToken(context) {
|
|
|
553
572
|
}
|
|
554
573
|
|
|
555
574
|
// src/commands/confirm.ts
|
|
575
|
+
function cannotAsk(context) {
|
|
576
|
+
return context.flags.json || !isInteractive();
|
|
577
|
+
}
|
|
556
578
|
async function confirmOrAbort(context, question) {
|
|
557
579
|
if (context.flags.yes) return;
|
|
558
|
-
if (context
|
|
580
|
+
if (cannotAsk(context)) {
|
|
559
581
|
throw new NaroError(
|
|
560
582
|
"CONFIRMATION_REQUIRED",
|
|
561
583
|
`${question} Re-run with --yes to confirm.`
|
|
@@ -591,14 +613,9 @@ async function appliedMigrations(client, projectId) {
|
|
|
591
613
|
throw error;
|
|
592
614
|
}
|
|
593
615
|
}
|
|
616
|
+
var UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
594
617
|
function printable(text) {
|
|
595
|
-
|
|
596
|
-
for (const char of text) {
|
|
597
|
-
const code = char.charCodeAt(0);
|
|
598
|
-
const control = code < 32 || code >= 127 && code <= 159;
|
|
599
|
-
out += control ? "?" : char;
|
|
600
|
-
}
|
|
601
|
-
return out;
|
|
618
|
+
return text.replace(UNPRINTABLE, "?");
|
|
602
619
|
}
|
|
603
620
|
function isPrintable(text) {
|
|
604
621
|
return printable(text) === text;
|
|
@@ -1112,9 +1129,9 @@ function adviceLines2(auth) {
|
|
|
1112
1129
|
"Redirect URIs to register with each provider \u2014 the one for the origin your app passes to createClient({ url }):"
|
|
1113
1130
|
);
|
|
1114
1131
|
for (const provider of ["google", "github"]) {
|
|
1115
|
-
const
|
|
1132
|
+
const label3 = ` ${LABELS[provider]}: `;
|
|
1116
1133
|
callbacksOf(auth, provider).forEach(
|
|
1117
|
-
(url, index) => lines.push(`${index === 0 ?
|
|
1134
|
+
(url, index) => lines.push(`${index === 0 ? label3 : " ".repeat(label3.length)}${url}`)
|
|
1118
1135
|
);
|
|
1119
1136
|
}
|
|
1120
1137
|
return lines;
|
|
@@ -1141,11 +1158,11 @@ async function readSecret(context, input, given, flag, what, usage) {
|
|
|
1141
1158
|
throw new NaroError("VALIDATION_FAILED", `No ${what} came in on stdin.`);
|
|
1142
1159
|
return secret;
|
|
1143
1160
|
}
|
|
1144
|
-
async function change(context,
|
|
1161
|
+
async function change(context, send2, done, after) {
|
|
1145
1162
|
const projectId = await context.resolveProjectId();
|
|
1146
1163
|
let answer;
|
|
1147
1164
|
try {
|
|
1148
|
-
answer = await
|
|
1165
|
+
answer = await send2(projectId);
|
|
1149
1166
|
} catch (error) {
|
|
1150
1167
|
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
1151
1168
|
throw new NaroError(
|
|
@@ -1410,9 +1427,9 @@ var processStdin3 = {
|
|
|
1410
1427
|
stdinIsTty: () => process.stdin.isTTY === true,
|
|
1411
1428
|
readStdin
|
|
1412
1429
|
};
|
|
1413
|
-
async function usersCall(
|
|
1430
|
+
async function usersCall(call2) {
|
|
1414
1431
|
try {
|
|
1415
|
-
return await
|
|
1432
|
+
return await call2();
|
|
1416
1433
|
} catch (error) {
|
|
1417
1434
|
if (isNaroError(error) && error.code === "BACKEND_OUTDATED")
|
|
1418
1435
|
throw new NaroError("BACKEND_OUTDATED", OUTDATED_MESSAGE, {
|
|
@@ -1969,7 +1986,7 @@ async function runDbRestore(context) {
|
|
|
1969
1986
|
target = resolved.bookmark;
|
|
1970
1987
|
at = resolved.timestamp ?? given.timestamp;
|
|
1971
1988
|
}
|
|
1972
|
-
const
|
|
1989
|
+
const label3 = at === null ? `bookmark ${target}` : `${at} (bookmark ${target})`;
|
|
1973
1990
|
if (context.flags.dryRun) {
|
|
1974
1991
|
context.output.result(
|
|
1975
1992
|
{
|
|
@@ -1977,13 +1994,13 @@ async function runDbRestore(context) {
|
|
|
1977
1994
|
bookmark: target,
|
|
1978
1995
|
...at === null ? {} : { timestamp: at }
|
|
1979
1996
|
},
|
|
1980
|
-
[`Would restore to ${
|
|
1997
|
+
[`Would restore to ${label3}. Nothing was changed.`]
|
|
1981
1998
|
);
|
|
1982
1999
|
return;
|
|
1983
2000
|
}
|
|
1984
2001
|
await confirmOrAbort(
|
|
1985
2002
|
context,
|
|
1986
|
-
`Restore the project database to ${
|
|
2003
|
+
`Restore the project database to ${label3}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
|
|
1987
2004
|
);
|
|
1988
2005
|
const before = await bookmarkNow(context, client, projectId);
|
|
1989
2006
|
let restored;
|
|
@@ -2083,11 +2100,12 @@ async function readPolicies(client, projectId, warn) {
|
|
|
2083
2100
|
});
|
|
2084
2101
|
if ((probe.statements[0]?.rows.length ?? 0) === 0) return void 0;
|
|
2085
2102
|
const answer = await client.db.query(projectId, { sql: POLICY_READ_SQL });
|
|
2086
|
-
const [stored, indexes,
|
|
2087
|
-
|
|
2103
|
+
const [stored, indexes, ...schema] = answer.statements;
|
|
2104
|
+
const expected = SCHEMA_SQL.length + 2;
|
|
2105
|
+
if (stored === void 0 || indexes === void 0 || answer.statements.length !== expected)
|
|
2088
2106
|
throw new NaroError(
|
|
2089
2107
|
"DATABASE_QUERY_FAILED",
|
|
2090
|
-
`The policies could not be read: the database answered with ${String(answer.statements.length)} result sets instead of
|
|
2108
|
+
`The policies could not be read: the database answered with ${String(answer.statements.length)} result sets instead of ${String(expected)}.`
|
|
2091
2109
|
);
|
|
2092
2110
|
if (answer.truncated) {
|
|
2093
2111
|
warn(POLICIES_TRUNCATED_NOTICE);
|
|
@@ -2116,10 +2134,7 @@ async function readPolicies(client, projectId, warn) {
|
|
|
2116
2134
|
owner: policy.owner
|
|
2117
2135
|
});
|
|
2118
2136
|
}
|
|
2119
|
-
const snapshot = snapshotFromRows(
|
|
2120
|
-
...objectsOf(columns),
|
|
2121
|
-
...objectsOf(definitions)
|
|
2122
|
-
]);
|
|
2137
|
+
const snapshot = snapshotFromRows(schema.flatMap(objectsOf));
|
|
2123
2138
|
return {
|
|
2124
2139
|
tables: tableNames(snapshot),
|
|
2125
2140
|
policies,
|
|
@@ -2155,17 +2170,17 @@ async function runDbLint(context) {
|
|
|
2155
2170
|
const projectId = await context.resolveProjectId();
|
|
2156
2171
|
const tables = await client.db.tables(projectId);
|
|
2157
2172
|
const answer = await client.db.query(projectId, { sql: DIAGNOSTICS_SQL });
|
|
2158
|
-
const [triggers,
|
|
2159
|
-
if (triggers === void 0 ||
|
|
2173
|
+
const [triggers, objects2, quick, broken] = answer.statements;
|
|
2174
|
+
if (triggers === void 0 || objects2 === void 0 || quick === void 0 || broken === void 0)
|
|
2160
2175
|
throw new NaroError(
|
|
2161
2176
|
"DATABASE_QUERY_FAILED",
|
|
2162
2177
|
`The diagnostics could not be read: the database answered with ${String(answer.statements.length)} result sets instead of 4.`
|
|
2163
2178
|
);
|
|
2164
2179
|
const virtualTables = [];
|
|
2165
2180
|
const shadow = /* @__PURE__ */ new Set();
|
|
2166
|
-
for (const row of
|
|
2167
|
-
const name = cellText(
|
|
2168
|
-
if (cellText(
|
|
2181
|
+
for (const row of objects2.rows) {
|
|
2182
|
+
const name = cellText(objects2, row, "name");
|
|
2183
|
+
if (cellText(objects2, row, "type") === "shadow") shadow.add(name);
|
|
2169
2184
|
else virtualTables.push(name);
|
|
2170
2185
|
}
|
|
2171
2186
|
const foreignKeyCheck = broken.rows.map((row) => ({
|
|
@@ -2179,7 +2194,7 @@ async function runDbLint(context) {
|
|
|
2179
2194
|
(message) => context.output.warn(message)
|
|
2180
2195
|
);
|
|
2181
2196
|
const input = {
|
|
2182
|
-
tables: tables.filter((
|
|
2197
|
+
tables: tables.filter((table2) => !shadow.has(table2.name)),
|
|
2183
2198
|
foreignKeyCheck,
|
|
2184
2199
|
quickCheck: quick.rows.map(firstCell),
|
|
2185
2200
|
triggers: triggers.rows.map((row) => cellText(triggers, row, "name")),
|
|
@@ -2207,15 +2222,15 @@ var SCHEMA_FILTER = `${systemTablePredicate("name")} AND ${systemTablePredicate(
|
|
|
2207
2222
|
var SCHEMA_SQL2 = [
|
|
2208
2223
|
`SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view', 'trigger') AND ${SCHEMA_FILTER} ORDER BY name;`,
|
|
2209
2224
|
"SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'virtual';",
|
|
2210
|
-
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations', '${NAMED_POLICY_TABLE}');`
|
|
2225
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations', '${NAMED_POLICY_TABLE}', '${STORAGE_OBJECTS_TABLE}');`
|
|
2211
2226
|
].join("\n");
|
|
2212
2227
|
function literal(value) {
|
|
2213
2228
|
return `'${value.replace(/'/g, "''")}'`;
|
|
2214
2229
|
}
|
|
2215
|
-
function chunk(
|
|
2230
|
+
function chunk(items3, size) {
|
|
2216
2231
|
const pieces = [];
|
|
2217
|
-
for (let index = 0; index <
|
|
2218
|
-
pieces.push(
|
|
2232
|
+
for (let index = 0; index < items3.length; index += size)
|
|
2233
|
+
pieces.push(items3.slice(index, index + size));
|
|
2219
2234
|
return pieces;
|
|
2220
2235
|
}
|
|
2221
2236
|
async function readSchema(client, projectId) {
|
|
@@ -2232,7 +2247,7 @@ async function readSchema(client, projectId) {
|
|
|
2232
2247
|
const virtualNames = new Set(
|
|
2233
2248
|
virtual.rows.map((row) => cellText(virtual, row, "name").toLowerCase())
|
|
2234
2249
|
);
|
|
2235
|
-
const
|
|
2250
|
+
const objects2 = listed.rows.map((row) => {
|
|
2236
2251
|
const name = cellText(listed, row, "name");
|
|
2237
2252
|
const type = cellText(listed, row, "type");
|
|
2238
2253
|
if (type === "view" || type === "trigger") return { type, name };
|
|
@@ -2241,7 +2256,7 @@ async function readSchema(client, projectId) {
|
|
|
2241
2256
|
name
|
|
2242
2257
|
};
|
|
2243
2258
|
});
|
|
2244
|
-
const lookups =
|
|
2259
|
+
const lookups = objects2.filter((object) => object.type === "table" || object.type === "virtual").map(
|
|
2245
2260
|
(object) => `SELECT ${literal(object.name)} AS child, "table" AS parent FROM pragma_foreign_key_list(${literal(object.name)});`
|
|
2246
2261
|
);
|
|
2247
2262
|
const foreignKeys = [];
|
|
@@ -2260,13 +2275,14 @@ async function readSchema(client, projectId) {
|
|
|
2260
2275
|
});
|
|
2261
2276
|
}
|
|
2262
2277
|
return {
|
|
2263
|
-
objects,
|
|
2278
|
+
objects: objects2,
|
|
2264
2279
|
foreignKeys,
|
|
2265
2280
|
internal: {
|
|
2266
2281
|
sqliteSequence: internalNames.has("sqlite_sequence"),
|
|
2267
2282
|
ledger: internalNames.has("d1_migrations")
|
|
2268
2283
|
},
|
|
2269
|
-
policyTable: internalNames.has(NAMED_POLICY_TABLE)
|
|
2284
|
+
policyTable: internalNames.has(NAMED_POLICY_TABLE),
|
|
2285
|
+
storageTable: internalNames.has(STORAGE_OBJECTS_TABLE)
|
|
2270
2286
|
};
|
|
2271
2287
|
}
|
|
2272
2288
|
async function hasBackend(client, projectId) {
|
|
@@ -2311,7 +2327,10 @@ async function policyPlan(client, projectId, schema, dir, files) {
|
|
|
2311
2327
|
...policiesLostByReset({
|
|
2312
2328
|
current,
|
|
2313
2329
|
tables: schema.tables,
|
|
2314
|
-
files: files.map((name) => readFileSync3(join3(dir, name), "utf8"))
|
|
2330
|
+
files: files.map((name) => readFileSync3(join3(dir, name), "utf8")),
|
|
2331
|
+
// Storage's table stays; its policies are emptied with the rest (PR
|
|
2332
|
+
// #85 리뷰).
|
|
2333
|
+
storage: schema.storageTable
|
|
2315
2334
|
})
|
|
2316
2335
|
};
|
|
2317
2336
|
}
|
|
@@ -2348,7 +2367,10 @@ ${problemLines.join("\n")}`,
|
|
|
2348
2367
|
const client = await context.client();
|
|
2349
2368
|
const projectId = await context.resolveProjectId();
|
|
2350
2369
|
const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
|
|
2351
|
-
const { policyTable: policyTable2, ...input } = await readSchema(
|
|
2370
|
+
const { policyTable: policyTable2, storageTable, ...input } = await readSchema(
|
|
2371
|
+
client,
|
|
2372
|
+
projectId
|
|
2373
|
+
);
|
|
2352
2374
|
const plan = buildResetPlan(input, { clearPolicies: policyTable2 });
|
|
2353
2375
|
const { tables, views, triggers } = plan.dropped;
|
|
2354
2376
|
const counts = `${String(tables.length)} table(s), ${String(views.length)} view(s) and ${String(triggers.length)} trigger(s)`;
|
|
@@ -2356,7 +2378,7 @@ ${problemLines.join("\n")}`,
|
|
|
2356
2378
|
const policies = await policyPlan(
|
|
2357
2379
|
client,
|
|
2358
2380
|
projectId,
|
|
2359
|
-
{ policyTable: policyTable2, tables },
|
|
2381
|
+
{ policyTable: policyTable2, storageTable, tables },
|
|
2360
2382
|
dir,
|
|
2361
2383
|
files
|
|
2362
2384
|
);
|
|
@@ -2789,9 +2811,9 @@ function keyword(text, at) {
|
|
|
2789
2811
|
`needs null, true or false after is at character ${String(at + 1)}`
|
|
2790
2812
|
);
|
|
2791
2813
|
}
|
|
2792
|
-
function attempt(
|
|
2814
|
+
function attempt(parse2) {
|
|
2793
2815
|
try {
|
|
2794
|
-
return { ok: true, node:
|
|
2816
|
+
return { ok: true, node: parse2() };
|
|
2795
2817
|
} catch (error) {
|
|
2796
2818
|
if (error instanceof Refusal) return { ok: false, reason: error.message };
|
|
2797
2819
|
throw error;
|
|
@@ -2898,7 +2920,7 @@ function conditionsFrom(flags) {
|
|
|
2898
2920
|
);
|
|
2899
2921
|
return conditions;
|
|
2900
2922
|
}
|
|
2901
|
-
function patchFromFile(context, path,
|
|
2923
|
+
function patchFromFile(context, path, table2) {
|
|
2902
2924
|
let text;
|
|
2903
2925
|
try {
|
|
2904
2926
|
text = readFileSync4(resolve3(context.cwd, path), "utf8");
|
|
@@ -2920,9 +2942,9 @@ function patchFromFile(context, path, table) {
|
|
|
2920
2942
|
if (Object.hasOwn(body, "table")) {
|
|
2921
2943
|
if (typeof named !== "string")
|
|
2922
2944
|
refuse2(`${path}: table must be a string, the table's name.`);
|
|
2923
|
-
if (
|
|
2945
|
+
if (table2 !== void 0 && asciiLower(named) !== asciiLower(table2))
|
|
2924
2946
|
refuse2(
|
|
2925
|
-
`${path} is the policy of ${printable(named)}, not ${printable(
|
|
2947
|
+
`${path} is the policy of ${printable(named)}, not ${printable(table2)}. Remove its "table" to set it on ${printable(table2)}.`
|
|
2926
2948
|
);
|
|
2927
2949
|
}
|
|
2928
2950
|
const allowed = /* @__PURE__ */ new Set([...POLICY_ACTIONS, "ownerColumn"]);
|
|
@@ -2935,7 +2957,7 @@ function patchFromFile(context, path, table) {
|
|
|
2935
2957
|
function asciiLower(text) {
|
|
2936
2958
|
return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
2937
2959
|
}
|
|
2938
|
-
function policyPatch(flags, context,
|
|
2960
|
+
function policyPatch(flags, context, table2) {
|
|
2939
2961
|
if (flags.fromFile !== void 0) {
|
|
2940
2962
|
if (OTHER_FLAGS.some(
|
|
2941
2963
|
(key) => flags[key] !== void 0 && flags[key] !== false
|
|
@@ -2944,7 +2966,7 @@ function policyPatch(flags, context, table) {
|
|
|
2944
2966
|
"--from-file takes the whole policy: leave out the other policy options."
|
|
2945
2967
|
);
|
|
2946
2968
|
if (!context) refuse2(SET_USAGE);
|
|
2947
|
-
return patchFromFile(context, flags.fromFile,
|
|
2969
|
+
return patchFromFile(context, flags.fromFile, table2);
|
|
2948
2970
|
}
|
|
2949
2971
|
const patch = {};
|
|
2950
2972
|
for (const action of POLICY_ACTIONS) {
|
|
@@ -3017,9 +3039,9 @@ function leafText(leaf, negated) {
|
|
|
3017
3039
|
if (leaf.operator === "in" || short !== void 0) {
|
|
3018
3040
|
if (!Array.isArray(leaf.value)) return void 0;
|
|
3019
3041
|
const slot = short === void 0 ? "in" : "array";
|
|
3020
|
-
const
|
|
3021
|
-
if (
|
|
3022
|
-
const list =
|
|
3042
|
+
const items3 = leaf.value.map((item) => valueText(item, slot));
|
|
3043
|
+
if (items3.some((item) => item === void 0)) return void 0;
|
|
3044
|
+
const list = items3.join(",");
|
|
3023
3045
|
return slot === "in" ? `${prefix}in.(${list})` : `${prefix}${String(short)}.{${list}}`;
|
|
3024
3046
|
}
|
|
3025
3047
|
if (Array.isArray(leaf.value)) return void 0;
|
|
@@ -3083,7 +3105,7 @@ function policyTable(policies) {
|
|
|
3083
3105
|
const widths = header.map(
|
|
3084
3106
|
(title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0))
|
|
3085
3107
|
);
|
|
3086
|
-
const
|
|
3108
|
+
const table2 = [header, ...rows].map(
|
|
3087
3109
|
(cells) => cells.map(
|
|
3088
3110
|
(cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)
|
|
3089
3111
|
).join(" ").trimEnd()
|
|
@@ -3100,10 +3122,10 @@ function policyTable(policies) {
|
|
|
3100
3122
|
}
|
|
3101
3123
|
)
|
|
3102
3124
|
);
|
|
3103
|
-
if (lines.length === 0) return
|
|
3125
|
+
if (lines.length === 0) return table2;
|
|
3104
3126
|
const width = Math.max(...lines.map((line) => line.label.length));
|
|
3105
3127
|
return [
|
|
3106
|
-
...
|
|
3128
|
+
...table2,
|
|
3107
3129
|
"",
|
|
3108
3130
|
...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
|
|
3109
3131
|
];
|
|
@@ -3120,39 +3142,39 @@ function statementLine(policy) {
|
|
|
3120
3142
|
);
|
|
3121
3143
|
return shownStatement(policy, policy.table, "naro policy list --json");
|
|
3122
3144
|
}
|
|
3123
|
-
function policyNamed(name,
|
|
3124
|
-
return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(
|
|
3145
|
+
function policyNamed(name, table2) {
|
|
3146
|
+
return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(table2))}`;
|
|
3125
3147
|
}
|
|
3126
|
-
function shownStatement(statement,
|
|
3148
|
+
function shownStatement(statement, table2, exact) {
|
|
3127
3149
|
if (isPrintable(statement.sql)) return statement.sql;
|
|
3128
3150
|
return printable(
|
|
3129
|
-
`-- ${policyNamed(statement.name,
|
|
3151
|
+
`-- ${policyNamed(statement.name, table2)} ${UNSHOWN}, so it is not printed as CREATE POLICY: ${exact} has its exact text.`
|
|
3130
3152
|
);
|
|
3131
3153
|
}
|
|
3132
3154
|
function policyStatements(policies) {
|
|
3133
3155
|
const lines = [];
|
|
3134
|
-
let
|
|
3156
|
+
let table2;
|
|
3135
3157
|
for (const policy of policies) {
|
|
3136
|
-
if (
|
|
3137
|
-
|
|
3158
|
+
if (table2 !== void 0 && policy.table !== table2) lines.push("");
|
|
3159
|
+
table2 = policy.table;
|
|
3138
3160
|
lines.push(statementLine(policy));
|
|
3139
3161
|
}
|
|
3140
3162
|
return lines;
|
|
3141
3163
|
}
|
|
3142
3164
|
async function runPolicyList(context) {
|
|
3143
|
-
const
|
|
3165
|
+
const table2 = context.flags.table;
|
|
3144
3166
|
const client = await context.client();
|
|
3145
3167
|
const projectId = await context.resolveProjectId();
|
|
3146
3168
|
const policies = await client.backend.rlsPolicies(
|
|
3147
3169
|
projectId,
|
|
3148
|
-
|
|
3170
|
+
table2 === void 0 ? {} : { table: table2 }
|
|
3149
3171
|
);
|
|
3150
3172
|
let lines;
|
|
3151
3173
|
if (policies.length > 0)
|
|
3152
3174
|
lines = [...policyStatements(policies), "", DEFAULT_DENY];
|
|
3153
|
-
else if (
|
|
3175
|
+
else if (table2 !== void 0)
|
|
3154
3176
|
lines = [
|
|
3155
|
-
`No policies on ${printable(
|
|
3177
|
+
`No policies on ${printable(table2)}: anon and signed-in requests to it are denied.`
|
|
3156
3178
|
];
|
|
3157
3179
|
else
|
|
3158
3180
|
lines = [
|
|
@@ -3248,17 +3270,17 @@ async function runPolicyCreate(context) {
|
|
|
3248
3270
|
}
|
|
3249
3271
|
async function runPolicyDrop(context) {
|
|
3250
3272
|
const [name] = context.positionalOrThrow(1, DROP_USAGE);
|
|
3251
|
-
const
|
|
3252
|
-
if (
|
|
3273
|
+
const table2 = context.flags.table;
|
|
3274
|
+
if (table2 === void 0 || table2 === "") refuse2(DROP_USAGE);
|
|
3253
3275
|
await confirmOrAbort(
|
|
3254
3276
|
context,
|
|
3255
|
-
`Drop policy ${printable(name)} on ${printable(
|
|
3277
|
+
`Drop policy ${printable(name)} on ${printable(table2)}? It stops applying within about 10 seconds.`
|
|
3256
3278
|
);
|
|
3257
3279
|
const client = await context.client();
|
|
3258
3280
|
const projectId = await context.resolveProjectId();
|
|
3259
3281
|
const dropped = await client.backend.dropRlsPolicy(
|
|
3260
3282
|
projectId,
|
|
3261
|
-
|
|
3283
|
+
table2,
|
|
3262
3284
|
name
|
|
3263
3285
|
);
|
|
3264
3286
|
context.output.result({ projectId, dropped }, [
|
|
@@ -3266,13 +3288,13 @@ async function runPolicyDrop(context) {
|
|
|
3266
3288
|
]);
|
|
3267
3289
|
}
|
|
3268
3290
|
async function runPolicySet(context) {
|
|
3269
|
-
const [
|
|
3270
|
-
const patch = policyPatch(context.flags, context,
|
|
3291
|
+
const [table2] = context.positionalOrThrow(1, SET_USAGE);
|
|
3292
|
+
const patch = policyPatch(context.flags, context, table2);
|
|
3271
3293
|
const client = await context.client();
|
|
3272
3294
|
const projectId = await context.resolveProjectId();
|
|
3273
3295
|
const policy = await client.backend.setPolicy(
|
|
3274
3296
|
projectId,
|
|
3275
|
-
|
|
3297
|
+
table2,
|
|
3276
3298
|
patch
|
|
3277
3299
|
);
|
|
3278
3300
|
let named;
|
|
@@ -3293,14 +3315,14 @@ async function runPolicySet(context) {
|
|
|
3293
3315
|
);
|
|
3294
3316
|
}
|
|
3295
3317
|
async function runPolicyRemove(context) {
|
|
3296
|
-
const [
|
|
3318
|
+
const [table2] = context.positionalOrThrow(1, REMOVE_USAGE);
|
|
3297
3319
|
await confirmOrAbort(
|
|
3298
3320
|
context,
|
|
3299
|
-
`Remove every policy on ${printable(
|
|
3321
|
+
`Remove every policy on ${printable(table2)}? Anon and signed-in requests to it are denied from then on.`
|
|
3300
3322
|
);
|
|
3301
3323
|
const client = await context.client();
|
|
3302
3324
|
const projectId = await context.resolveProjectId();
|
|
3303
|
-
const policy = await client.backend.removePolicy(projectId,
|
|
3325
|
+
const policy = await client.backend.removePolicy(projectId, table2);
|
|
3304
3326
|
const names = policy.policies ?? [];
|
|
3305
3327
|
context.output.result({ projectId, removed: true, policy }, [
|
|
3306
3328
|
names.length === 0 ? `\u2705 Removed the policy for ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.` : `\u2705 Removed ${names.length === 1 ? "policy" : "policies"} ${names.map(printable).join(", ")} on ${printable(policy.table)}. Anon and signed-in requests to it are denied within about 10 seconds.`
|
|
@@ -3387,14 +3409,14 @@ function parseColumnSpec(spec) {
|
|
|
3387
3409
|
column.default = defaultValue(value);
|
|
3388
3410
|
else if (value !== void 0 && key === "references") {
|
|
3389
3411
|
const dot = value.lastIndexOf(".");
|
|
3390
|
-
const
|
|
3412
|
+
const table2 = dot < 0 ? "" : value.slice(0, dot);
|
|
3391
3413
|
const target = dot < 0 ? "" : value.slice(dot + 1);
|
|
3392
|
-
if (!
|
|
3414
|
+
if (!table2 || !target)
|
|
3393
3415
|
throw new NaroError(
|
|
3394
3416
|
"VALIDATION_FAILED",
|
|
3395
3417
|
`${shown}: references needs <table>.<column>.`
|
|
3396
3418
|
);
|
|
3397
|
-
column.references = { table, column: target };
|
|
3419
|
+
column.references = { table: table2, column: target };
|
|
3398
3420
|
} else if (value !== void 0 && key === "ondelete") {
|
|
3399
3421
|
onDelete = ON_DELETE[value.toLowerCase()];
|
|
3400
3422
|
if (!onDelete)
|
|
@@ -3573,16 +3595,16 @@ async function runDbIndexUsage() {
|
|
|
3573
3595
|
throw new NaroError("VALIDATION_FAILED", USAGE2.index);
|
|
3574
3596
|
}
|
|
3575
3597
|
async function runDbTableCreate(context) {
|
|
3576
|
-
const [
|
|
3598
|
+
const [table2] = context.positionals;
|
|
3577
3599
|
const specs = columnsFlag(context);
|
|
3578
|
-
if (!
|
|
3600
|
+
if (!table2 || specs.length === 0)
|
|
3579
3601
|
throw new NaroError("VALIDATION_FAILED", USAGE2.create);
|
|
3580
3602
|
const columns = specs.map(parseColumnSpec);
|
|
3581
3603
|
const owner = context.flags.ownerColumn;
|
|
3582
3604
|
await runSchemaChange(context, [
|
|
3583
3605
|
{
|
|
3584
3606
|
op: "createTable",
|
|
3585
|
-
table,
|
|
3607
|
+
table: table2,
|
|
3586
3608
|
columns,
|
|
3587
3609
|
...context.flags.ifNotExists ? { ifNotExists: true } : {},
|
|
3588
3610
|
...owner === void 0 ? {} : { owner: { column: owner } }
|
|
@@ -3590,56 +3612,56 @@ async function runDbTableCreate(context) {
|
|
|
3590
3612
|
]);
|
|
3591
3613
|
}
|
|
3592
3614
|
async function runDbTableAddColumn(context) {
|
|
3593
|
-
const [
|
|
3615
|
+
const [table2] = context.positionals;
|
|
3594
3616
|
const specs = columnsFlag(context);
|
|
3595
3617
|
const [spec] = specs;
|
|
3596
|
-
if (!
|
|
3618
|
+
if (!table2 || spec === void 0 || specs.length !== 1)
|
|
3597
3619
|
throw new NaroError("VALIDATION_FAILED", USAGE2.addColumn);
|
|
3598
3620
|
await runSchemaChange(context, [
|
|
3599
|
-
{ op: "addColumn", table, column: parseColumnSpec(spec) }
|
|
3621
|
+
{ op: "addColumn", table: table2, column: parseColumnSpec(spec) }
|
|
3600
3622
|
]);
|
|
3601
3623
|
}
|
|
3602
3624
|
async function runDbTableRenameColumn(context) {
|
|
3603
|
-
const [
|
|
3604
|
-
if (!
|
|
3625
|
+
const [table2, column, to] = context.positionals;
|
|
3626
|
+
if (!table2 || !column || !to)
|
|
3605
3627
|
throw new NaroError("VALIDATION_FAILED", USAGE2.renameColumn);
|
|
3606
|
-
await runSchemaChange(context, [{ op: "renameColumn", table, column, to }]);
|
|
3628
|
+
await runSchemaChange(context, [{ op: "renameColumn", table: table2, column, to }]);
|
|
3607
3629
|
}
|
|
3608
3630
|
async function runDbTableRename(context) {
|
|
3609
|
-
const [
|
|
3610
|
-
if (!
|
|
3611
|
-
await runSchemaChange(context, [{ op: "renameTable", table, to }]);
|
|
3631
|
+
const [table2, to] = context.positionals;
|
|
3632
|
+
if (!table2 || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
|
|
3633
|
+
await runSchemaChange(context, [{ op: "renameTable", table: table2, to }]);
|
|
3612
3634
|
}
|
|
3613
3635
|
async function runDbTableDropColumn(context) {
|
|
3614
|
-
const [
|
|
3615
|
-
if (!
|
|
3636
|
+
const [table2, column] = context.positionals;
|
|
3637
|
+
if (!table2 || !column)
|
|
3616
3638
|
throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
|
|
3617
|
-
const target = `${
|
|
3639
|
+
const target = `${table2}.${column}`;
|
|
3618
3640
|
await runSchemaChange(
|
|
3619
3641
|
context,
|
|
3620
|
-
[{ op: "dropColumn", table, column, confirm: target }],
|
|
3642
|
+
[{ op: "dropColumn", table: table2, column, confirm: target }],
|
|
3621
3643
|
`Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
|
|
3622
3644
|
);
|
|
3623
3645
|
}
|
|
3624
3646
|
async function runDbTableDrop(context) {
|
|
3625
|
-
const [
|
|
3626
|
-
if (!
|
|
3647
|
+
const [table2] = context.positionals;
|
|
3648
|
+
if (!table2) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
|
|
3627
3649
|
await runSchemaChange(
|
|
3628
3650
|
context,
|
|
3629
|
-
[{ op: "dropTable", table, confirm:
|
|
3630
|
-
`Drop table ${printable(
|
|
3651
|
+
[{ op: "dropTable", table: table2, confirm: table2 }],
|
|
3652
|
+
`Drop table ${printable(table2)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
|
|
3631
3653
|
);
|
|
3632
3654
|
}
|
|
3633
3655
|
async function runDbIndexCreate(context) {
|
|
3634
|
-
const [
|
|
3656
|
+
const [table2, list] = context.positionals;
|
|
3635
3657
|
const columns = listFlag(list);
|
|
3636
|
-
if (!
|
|
3658
|
+
if (!table2 || columns.length === 0)
|
|
3637
3659
|
throw new NaroError("VALIDATION_FAILED", USAGE2.indexCreate);
|
|
3638
3660
|
const name = context.flags.name;
|
|
3639
3661
|
await runSchemaChange(context, [
|
|
3640
3662
|
{
|
|
3641
3663
|
op: "createIndex",
|
|
3642
|
-
table,
|
|
3664
|
+
table: table2,
|
|
3643
3665
|
columns,
|
|
3644
3666
|
...context.flags.unique ? { unique: true } : {},
|
|
3645
3667
|
...name === void 0 ? {} : { name }
|
|
@@ -3656,7 +3678,7 @@ async function runDbIndexDrop(context) {
|
|
|
3656
3678
|
var defaults2 = {
|
|
3657
3679
|
detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
|
|
3658
3680
|
openBrowser,
|
|
3659
|
-
sleep: (ms) => new Promise((
|
|
3681
|
+
sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
|
|
3660
3682
|
};
|
|
3661
3683
|
function connected(context, project) {
|
|
3662
3684
|
const branch = project.defaultBranch;
|
|
@@ -3726,7 +3748,7 @@ async function waitForInstall(context, projectId, fullName, deps) {
|
|
|
3726
3748
|
if (teamSlug) {
|
|
3727
3749
|
const attempts = deps.pollAttempts ?? 60;
|
|
3728
3750
|
const interval = deps.pollIntervalMs ?? 3e3;
|
|
3729
|
-
for (let
|
|
3751
|
+
for (let attempt3 = 0; attempt3 < attempts; attempt3 += 1) {
|
|
3730
3752
|
await deps.sleep(interval);
|
|
3731
3753
|
let reachable = false;
|
|
3732
3754
|
try {
|
|
@@ -3853,9 +3875,9 @@ async function resolveTeam(context, teams, deps = {}) {
|
|
|
3853
3875
|
);
|
|
3854
3876
|
const account = deps.accountLabel ? await deps.accountLabel() : "this account";
|
|
3855
3877
|
if (only) {
|
|
3856
|
-
const
|
|
3878
|
+
const label3 = only.isPersonal ? `${only.name} (personal)` : only.name;
|
|
3857
3879
|
context.output.log(`
|
|
3858
|
-
${account} \u2192 ${
|
|
3880
|
+
${account} \u2192 ${label3}`);
|
|
3859
3881
|
const ok = await context.prompter.confirm(
|
|
3860
3882
|
"Deploy to this account and team?"
|
|
3861
3883
|
);
|
|
@@ -4144,7 +4166,7 @@ async function runInspect(context) {
|
|
|
4144
4166
|
|
|
4145
4167
|
// src/commands/domains.ts
|
|
4146
4168
|
var defaults3 = {
|
|
4147
|
-
sleep: (ms) => new Promise((
|
|
4169
|
+
sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)),
|
|
4148
4170
|
now: () => Date.now()
|
|
4149
4171
|
};
|
|
4150
4172
|
var FIRST_DELAY_MS = 5e3;
|
|
@@ -4525,7 +4547,7 @@ async function runGenTypes(context) {
|
|
|
4525
4547
|
read.policies === void 0 ? {} : { policies: read.policies }
|
|
4526
4548
|
);
|
|
4527
4549
|
const undescribed = tables.flatMap(
|
|
4528
|
-
(
|
|
4550
|
+
(table2) => table2.error === void 0 ? [] : [{ name: table2.name, error: table2.error }]
|
|
4529
4551
|
);
|
|
4530
4552
|
for (const { name, error } of undescribed)
|
|
4531
4553
|
context.output.warn(
|
|
@@ -4561,10 +4583,242 @@ async function runGenTypes(context) {
|
|
|
4561
4583
|
]);
|
|
4562
4584
|
}
|
|
4563
4585
|
|
|
4586
|
+
// src/commands/insights-format.ts
|
|
4587
|
+
var OFF_LINE = "Speed Insights is off for this project. Turn it on: naro insights enable";
|
|
4588
|
+
var TURNED_ON = "Speed Insights is on. Measuring starts within about a minute; values arrive when visitors leave a page.";
|
|
4589
|
+
var TURNED_OFF = "Speed Insights is off. Measuring stops within about a minute; turning it back on shows past measurements again.";
|
|
4590
|
+
var RANGE_TEXT = {
|
|
4591
|
+
"24h": "last 24 hours",
|
|
4592
|
+
"7d": "last 7 days",
|
|
4593
|
+
"30d": "last 30 days"
|
|
4594
|
+
};
|
|
4595
|
+
var PART_TEXT = {
|
|
4596
|
+
ttfb: "TTFB",
|
|
4597
|
+
loadDelay: "load delay",
|
|
4598
|
+
loadDuration: "load duration",
|
|
4599
|
+
renderDelay: "render delay",
|
|
4600
|
+
inputDelay: "input delay",
|
|
4601
|
+
processing: "processing",
|
|
4602
|
+
presentation: "presentation"
|
|
4603
|
+
};
|
|
4604
|
+
function rangeText(range) {
|
|
4605
|
+
const known = RANGE_TEXT;
|
|
4606
|
+
return known[range] ?? printable(range);
|
|
4607
|
+
}
|
|
4608
|
+
function partText(name) {
|
|
4609
|
+
const known = PART_TEXT;
|
|
4610
|
+
return known[name] ?? printable(name);
|
|
4611
|
+
}
|
|
4612
|
+
var ATTRIBUTED = /* @__PURE__ */ new Set([
|
|
4613
|
+
"lcp",
|
|
4614
|
+
"inp",
|
|
4615
|
+
"cls"
|
|
4616
|
+
]);
|
|
4617
|
+
var DEPLOYMENT_ROWS = 10;
|
|
4618
|
+
var SUBJECT_LENGTH = 50;
|
|
4619
|
+
function formatMs(ms) {
|
|
4620
|
+
const rounded = Math.round(ms);
|
|
4621
|
+
return rounded < 1e3 ? `${String(rounded)} ms` : `${(rounded / 1e3).toFixed(2)} s`;
|
|
4622
|
+
}
|
|
4623
|
+
function formatValue(metric, value) {
|
|
4624
|
+
return metric === "cls" ? value.toFixed(2) : formatMs(value);
|
|
4625
|
+
}
|
|
4626
|
+
function formatChange(metric, change2) {
|
|
4627
|
+
const size = formatValue(metric, Math.abs(change2));
|
|
4628
|
+
if (Number.parseFloat(size) === 0) return "\xB10";
|
|
4629
|
+
return change2 > 0 ? `\u25B2 +${size}` : `\u25BC \u2212${size}`;
|
|
4630
|
+
}
|
|
4631
|
+
function formatCount(count2) {
|
|
4632
|
+
return Math.round(count2).toLocaleString("en-US");
|
|
4633
|
+
}
|
|
4634
|
+
function percent(part, total) {
|
|
4635
|
+
return `${String(Math.round(part / total * 100))}%`;
|
|
4636
|
+
}
|
|
4637
|
+
function label2(metric) {
|
|
4638
|
+
return metric.toUpperCase();
|
|
4639
|
+
}
|
|
4640
|
+
function environmentSuffix(environment) {
|
|
4641
|
+
return environment === "preview" ? ", preview" : "";
|
|
4642
|
+
}
|
|
4643
|
+
function environmentWord(environment) {
|
|
4644
|
+
return environment === "preview" ? "preview " : "";
|
|
4645
|
+
}
|
|
4646
|
+
function table(rows) {
|
|
4647
|
+
const widths = [];
|
|
4648
|
+
for (const row of rows)
|
|
4649
|
+
row.forEach((cell, i) => {
|
|
4650
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
4651
|
+
});
|
|
4652
|
+
return rows.map(
|
|
4653
|
+
(row) => ` ${row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.trimEnd()
|
|
4654
|
+
);
|
|
4655
|
+
}
|
|
4656
|
+
function summaryLines(report) {
|
|
4657
|
+
return table([
|
|
4658
|
+
["Metric", "p75", "Good", "Needs improvement", "Poor", "Visits"],
|
|
4659
|
+
...SPEED_INSIGHTS_METRICS.map((metric) => {
|
|
4660
|
+
const summary = report.summary[metric];
|
|
4661
|
+
if (summary.p75 === null || summary.samples <= 0)
|
|
4662
|
+
return [label2(metric), "\u2014", "\u2014", "\u2014", "\u2014", "0"];
|
|
4663
|
+
return [
|
|
4664
|
+
label2(metric),
|
|
4665
|
+
formatValue(metric, summary.p75),
|
|
4666
|
+
percent(summary.good, summary.samples),
|
|
4667
|
+
percent(summary.needsImprovement, summary.samples),
|
|
4668
|
+
percent(summary.poor, summary.samples),
|
|
4669
|
+
formatCount(summary.samples)
|
|
4670
|
+
];
|
|
4671
|
+
})
|
|
4672
|
+
]);
|
|
4673
|
+
}
|
|
4674
|
+
function breakdownTable(heading, metric, rows) {
|
|
4675
|
+
if (rows.length === 0) return [" (none)"];
|
|
4676
|
+
return table([
|
|
4677
|
+
["p75", "Visits", heading],
|
|
4678
|
+
...rows.map((row) => [
|
|
4679
|
+
formatValue(metric, row.p75),
|
|
4680
|
+
formatCount(row.samples),
|
|
4681
|
+
printable(row.name)
|
|
4682
|
+
])
|
|
4683
|
+
]);
|
|
4684
|
+
}
|
|
4685
|
+
function elementLines(metric, elements) {
|
|
4686
|
+
const lines = ["", `${label2(metric)} elements behind slow visits`];
|
|
4687
|
+
if (elements.length === 0) return [...lines, " (none)"];
|
|
4688
|
+
for (const row of elements) {
|
|
4689
|
+
lines.push(
|
|
4690
|
+
` ${printable(row.element)} on ${printable(row.route)}: ${formatCount(row.slow)} slow visits, average ${formatValue(metric, row.average)}`
|
|
4691
|
+
);
|
|
4692
|
+
if (row.parts && row.parts.length > 0)
|
|
4693
|
+
lines.push(
|
|
4694
|
+
` ${row.parts.map((part) => `${partText(part.name)} ${formatMs(part.ms)}`).join(" \xB7 ")}`
|
|
4695
|
+
);
|
|
4696
|
+
if (row.url) lines.push(` image ${printable(row.url)}`);
|
|
4697
|
+
}
|
|
4698
|
+
return lines;
|
|
4699
|
+
}
|
|
4700
|
+
function deploymentName(row) {
|
|
4701
|
+
if (row.source === "cli" || !row.commitSha) return row.id.slice(0, 8);
|
|
4702
|
+
const sha = printable(row.commitSha.slice(0, 7));
|
|
4703
|
+
const subject = [
|
|
4704
|
+
...printable(row.commitMessage?.split("\n")[0]?.trim() ?? "")
|
|
4705
|
+
];
|
|
4706
|
+
if (subject.length === 0) return sha;
|
|
4707
|
+
return subject.length > SUBJECT_LENGTH ? `${sha} ${subject.slice(0, SUBJECT_LENGTH - 1).join("")}\u2026` : `${sha} ${subject.join("")}`;
|
|
4708
|
+
}
|
|
4709
|
+
function changeText(metric, row) {
|
|
4710
|
+
if (row.samples < SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES)
|
|
4711
|
+
return "too few visits";
|
|
4712
|
+
if (row.change === null) return "\u2013";
|
|
4713
|
+
return formatChange(metric, row.change);
|
|
4714
|
+
}
|
|
4715
|
+
function deploymentLines(metric, deployments, environment) {
|
|
4716
|
+
if (deployments === null) return [];
|
|
4717
|
+
const lines = [
|
|
4718
|
+
"",
|
|
4719
|
+
`${label2(metric)} by ${environmentWord(environment)}deployment, newest first`
|
|
4720
|
+
];
|
|
4721
|
+
const measured = deployments.filter((row) => row.samples > 0).slice(0, DEPLOYMENT_ROWS);
|
|
4722
|
+
if (measured.length === 0) return [...lines, " (none)"];
|
|
4723
|
+
return [
|
|
4724
|
+
...lines,
|
|
4725
|
+
...table([
|
|
4726
|
+
["", "p75", "Visits", "Change", "Deployment"],
|
|
4727
|
+
...measured.map((row) => [
|
|
4728
|
+
row.liveUntil === null ? "Live" : "",
|
|
4729
|
+
row.p75 === null ? "\u2014" : formatValue(metric, row.p75),
|
|
4730
|
+
formatCount(row.samples),
|
|
4731
|
+
changeText(metric, row),
|
|
4732
|
+
deploymentName(row)
|
|
4733
|
+
])
|
|
4734
|
+
])
|
|
4735
|
+
];
|
|
4736
|
+
}
|
|
4737
|
+
function reportLines(report, deployments) {
|
|
4738
|
+
if (!report.enabled) return [OFF_LINE];
|
|
4739
|
+
const { metric, range, environment } = report;
|
|
4740
|
+
const head = `Speed Insights \u2014 ${label2(metric)}, ${rangeText(range)}${environmentSuffix(environment)}`;
|
|
4741
|
+
if (SPEED_INSIGHTS_METRICS.every((m) => report.summary[m].samples <= 0))
|
|
4742
|
+
return [
|
|
4743
|
+
head,
|
|
4744
|
+
"",
|
|
4745
|
+
`No ${environmentWord(environment)}measurements in the ${rangeText(range)} yet. Values arrive when visitors leave a page.`
|
|
4746
|
+
];
|
|
4747
|
+
return [
|
|
4748
|
+
head,
|
|
4749
|
+
"",
|
|
4750
|
+
...summaryLines(report),
|
|
4751
|
+
"",
|
|
4752
|
+
`${label2(metric)} by page`,
|
|
4753
|
+
...breakdownTable("Page", metric, report.paths),
|
|
4754
|
+
"",
|
|
4755
|
+
`${label2(metric)} by device`,
|
|
4756
|
+
...breakdownTable("Device", metric, report.devices),
|
|
4757
|
+
...ATTRIBUTED.has(metric) ? elementLines(metric, report.elements) : [],
|
|
4758
|
+
...deploymentLines(metric, deployments, environment)
|
|
4759
|
+
];
|
|
4760
|
+
}
|
|
4761
|
+
function routePathsLines(result) {
|
|
4762
|
+
return [
|
|
4763
|
+
`${label2(result.metric)} p75 under ${printable(result.route)}, ${rangeText(result.range)}${environmentSuffix(result.environment)}`,
|
|
4764
|
+
...breakdownTable("Path", result.metric, result.paths)
|
|
4765
|
+
];
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
// src/commands/insights.ts
|
|
4769
|
+
function queryOf(flags) {
|
|
4770
|
+
return {
|
|
4771
|
+
...flags.range !== void 0 ? { range: flags.range } : {},
|
|
4772
|
+
...flags.metric !== void 0 ? { metric: flags.metric } : {},
|
|
4773
|
+
...flags.env !== void 0 ? { environment: flags.env } : {}
|
|
4774
|
+
};
|
|
4775
|
+
}
|
|
4776
|
+
async function runInsights(context) {
|
|
4777
|
+
const client = await context.client();
|
|
4778
|
+
const projectId = await context.resolveProjectId();
|
|
4779
|
+
const query = queryOf(context.flags);
|
|
4780
|
+
if (context.flags.route !== void 0) {
|
|
4781
|
+
const result = await client.insights.paths(projectId, {
|
|
4782
|
+
...query,
|
|
4783
|
+
route: context.flags.route
|
|
4784
|
+
});
|
|
4785
|
+
context.output.result({ projectId, ...result }, routePathsLines(result));
|
|
4786
|
+
return;
|
|
4787
|
+
}
|
|
4788
|
+
const overview = await client.insights.overview(projectId, query);
|
|
4789
|
+
if (overview.deploymentsError)
|
|
4790
|
+
context.output.warn(
|
|
4791
|
+
`Deployments are unavailable right now: ${printable(overview.deploymentsError.message)}`
|
|
4792
|
+
);
|
|
4793
|
+
context.output.result(
|
|
4794
|
+
{ projectId, ...overview },
|
|
4795
|
+
reportLines(overview.report, overview.deployments)
|
|
4796
|
+
);
|
|
4797
|
+
}
|
|
4798
|
+
async function setInsights(context, enabled) {
|
|
4799
|
+
if (context.flags.env !== void 0)
|
|
4800
|
+
throw new NaroError(
|
|
4801
|
+
"VALIDATION_FAILED",
|
|
4802
|
+
"Speed Insights has one switch for production and preview; drop --env."
|
|
4803
|
+
);
|
|
4804
|
+
const client = await context.client();
|
|
4805
|
+
const projectId = await context.resolveProjectId();
|
|
4806
|
+
const result = await client.insights.setEnabled(projectId, enabled);
|
|
4807
|
+
context.output.result({ projectId, enabled: result.enabled }, [
|
|
4808
|
+
result.enabled ? TURNED_ON : TURNED_OFF
|
|
4809
|
+
]);
|
|
4810
|
+
}
|
|
4811
|
+
function runInsightsEnable(context) {
|
|
4812
|
+
return setInsights(context, true);
|
|
4813
|
+
}
|
|
4814
|
+
function runInsightsDisable(context) {
|
|
4815
|
+
return setInsights(context, false);
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4564
4818
|
// src/commands/logs.ts
|
|
4565
4819
|
var FOLLOW_INTERVAL_MS = 3e3;
|
|
4566
4820
|
function sleep(ms) {
|
|
4567
|
-
return new Promise((
|
|
4821
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
4568
4822
|
}
|
|
4569
4823
|
function formatBuildLog(log) {
|
|
4570
4824
|
return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
|
|
@@ -4812,102 +5066,3084 @@ async function runRollback(context) {
|
|
|
4812
5066
|
await promoteAndWait(context, client, project, target, "rollback");
|
|
4813
5067
|
}
|
|
4814
5068
|
|
|
4815
|
-
// src/
|
|
4816
|
-
var
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
5069
|
+
// src/commands/protection.ts
|
|
5070
|
+
var DISABLE_QUESTION2 = "Open this project's preview to anyone with the link? Visitors no longer need to be a member to see it.";
|
|
5071
|
+
var REPLACE_QUESTION = "Replace the bypass secret? The current one stops working, and automation still sending it can no longer open the preview.";
|
|
5072
|
+
var REMOVE_QUESTION = "Remove the bypass secret? Automation that sends it can no longer open the preview.";
|
|
5073
|
+
var EDGE = "The edge picks the change up within about a minute.";
|
|
5074
|
+
function statusLine(state) {
|
|
5075
|
+
return `Preview protection: ${state.enabled ? "on" : "off"} \xB7 bypass secret: ${state.bypass ? "set" : "none"}`;
|
|
5076
|
+
}
|
|
5077
|
+
async function runProtection(context) {
|
|
5078
|
+
const client = await context.client();
|
|
5079
|
+
const projectId = await context.resolveProjectId();
|
|
5080
|
+
const state = await client.protection.get(projectId);
|
|
5081
|
+
context.output.result({ projectId, ...state }, [statusLine(state)]);
|
|
5082
|
+
}
|
|
5083
|
+
async function setProtection(context, enabled) {
|
|
5084
|
+
const client = await context.client();
|
|
5085
|
+
const projectId = await context.resolveProjectId();
|
|
5086
|
+
const state = await client.protection.set(projectId, enabled);
|
|
5087
|
+
context.output.result({ projectId, ...state }, [
|
|
5088
|
+
state.enabled ? `Preview protection is on: only members of this project can open the preview. ${EDGE}` : `Preview protection is off: anyone with the link can open the preview. ${EDGE}`
|
|
5089
|
+
]);
|
|
5090
|
+
}
|
|
5091
|
+
function runProtectionEnable(context) {
|
|
5092
|
+
return setProtection(context, true);
|
|
5093
|
+
}
|
|
5094
|
+
async function runProtectionDisable(context) {
|
|
5095
|
+
await confirmOrAbort(context, DISABLE_QUESTION2);
|
|
5096
|
+
return setProtection(context, false);
|
|
5097
|
+
}
|
|
5098
|
+
async function runProtectionBypass(context) {
|
|
5099
|
+
const client = await context.client();
|
|
5100
|
+
const projectId = await context.resolveProjectId();
|
|
5101
|
+
if (!context.flags.yes && (await client.protection.get(projectId)).bypass)
|
|
5102
|
+
await confirmOrAbort(context, REPLACE_QUESTION);
|
|
5103
|
+
const result = await client.protection.createBypass(projectId);
|
|
5104
|
+
context.output.result({ projectId, ...result }, [
|
|
5105
|
+
`Bypass secret (shown once): ${result.secret}`,
|
|
5106
|
+
"Send it as the x-naro-protection-bypass header."
|
|
5107
|
+
]);
|
|
5108
|
+
}
|
|
5109
|
+
async function runProtectionBypassRemove(context) {
|
|
5110
|
+
await confirmOrAbort(context, REMOVE_QUESTION);
|
|
5111
|
+
const client = await context.client();
|
|
5112
|
+
const projectId = await context.resolveProjectId();
|
|
5113
|
+
const state = await client.protection.removeBypass(projectId);
|
|
5114
|
+
context.output.result({ projectId, ...state }, ["Bypass secret removed."]);
|
|
5115
|
+
}
|
|
4838
5116
|
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
5117
|
+
// src/commands/storage-units.ts
|
|
5118
|
+
var SIZE_MESSAGE = "--file-size-limit takes a whole number of bytes, or a size like 5MB (1000s) or 5MiB (1024s), or none.";
|
|
5119
|
+
var UNITS = {
|
|
5120
|
+
b: 1,
|
|
5121
|
+
kb: 1e3,
|
|
5122
|
+
mb: 1e6,
|
|
5123
|
+
gb: 1e9,
|
|
5124
|
+
kib: 1024,
|
|
5125
|
+
mib: 1048576,
|
|
5126
|
+
gib: 1073741824
|
|
5127
|
+
};
|
|
5128
|
+
function parseSize(text) {
|
|
5129
|
+
const match = /^(\d{1,16})\s*([a-z]+)?$/i.exec(text.trim());
|
|
5130
|
+
const unit = UNITS[(match?.[2] ?? "b").toLowerCase()];
|
|
5131
|
+
const bytes = match?.[1] !== void 0 && unit !== void 0 ? Number(match[1]) * unit : Number.NaN;
|
|
5132
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1)
|
|
5133
|
+
throw new NaroError("VALIDATION_FAILED", SIZE_MESSAGE);
|
|
5134
|
+
return bytes;
|
|
5135
|
+
}
|
|
5136
|
+
function binarySize(bytes) {
|
|
5137
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
5138
|
+
let value = bytes;
|
|
5139
|
+
let unit = 0;
|
|
5140
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
5141
|
+
value /= 1024;
|
|
5142
|
+
unit += 1;
|
|
5143
|
+
}
|
|
5144
|
+
const shown = unit === 0 ? String(value) : String(Number(value.toFixed(1)));
|
|
5145
|
+
return `${shown} ${units[unit] ?? "B"}`;
|
|
5146
|
+
}
|
|
5147
|
+
function count(n, noun) {
|
|
5148
|
+
return `${n.toLocaleString("en-US")} ${noun}${n === 1 ? "" : "s"}`;
|
|
5149
|
+
}
|
|
4843
5150
|
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
5151
|
+
// src/commands/storage.ts
|
|
5152
|
+
var CREATE_USAGE2 = "Usage: naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]";
|
|
5153
|
+
var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]";
|
|
5154
|
+
var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
|
|
5155
|
+
var REMOVE_USAGE2 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
|
|
5156
|
+
var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
|
|
5157
|
+
var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
|
|
5158
|
+
var IDLE_PAGES = 3;
|
|
5159
|
+
function bucketLine(bucket) {
|
|
5160
|
+
const name = printable(bucket.name);
|
|
5161
|
+
const cells = [
|
|
5162
|
+
name.padEnd(20),
|
|
5163
|
+
(bucket.public ? "public" : "private").padEnd(7),
|
|
5164
|
+
count(bucket.objects, "object").padEnd(12),
|
|
5165
|
+
binarySize(bucket.bytes).padEnd(10),
|
|
5166
|
+
bucket.allowedMimeTypes === null ? "any type" : printable(bucket.allowedMimeTypes.join(",")),
|
|
5167
|
+
bucket.fileSizeLimit === null ? "no size limit" : `up to ${binarySize(bucket.fileSizeLimit)}`
|
|
5168
|
+
];
|
|
5169
|
+
const line = ` ${cells.join(" ")}`;
|
|
5170
|
+
return bucket.deletingAt === null ? line : `${line} (being removed: \`naro storage bucket remove ${name} --empty\` finishes it)`;
|
|
5171
|
+
}
|
|
5172
|
+
function storageLines(info) {
|
|
5173
|
+
if (!info.enabled)
|
|
5174
|
+
return [
|
|
5175
|
+
"Storage: off",
|
|
5176
|
+
"Run `naro storage enable` to turn it on (the project's backend must be on first)."
|
|
5177
|
+
];
|
|
5178
|
+
const { limits, usage } = info;
|
|
5179
|
+
return [
|
|
5180
|
+
`Storage: on (R2 bucket ${printable(info.bucket ?? "unknown")})`,
|
|
5181
|
+
`Limits: ${binarySize(limits.bytes)} in all, ${binarySize(limits.objectBytes)} per object, ${binarySize(limits.singleUploadBytes)} per upload`,
|
|
5182
|
+
usage === null ? "Used: not known yet: the backend is not ready (`naro backend` shows where it got to)" : `Used: ${binarySize(usage.bytes)} in ${count(usage.objects, "object")}`,
|
|
5183
|
+
info.buckets.length === 0 ? "Buckets: none yet (`naro storage bucket create <name>` makes one)" : `Buckets: ${String(info.buckets.length)}`,
|
|
5184
|
+
...info.buckets.map(bucketLine)
|
|
5185
|
+
];
|
|
5186
|
+
}
|
|
5187
|
+
async function enableStorage(client, projectId) {
|
|
5188
|
+
try {
|
|
5189
|
+
return await client.storage.enable(projectId);
|
|
5190
|
+
} catch (error) {
|
|
5191
|
+
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
5192
|
+
throw new NaroError(
|
|
5193
|
+
"TIMEOUT",
|
|
5194
|
+
"Turning Storage on did not answer within 2 minutes. `naro storage` shows where it got to, and `naro storage enable` again picks up where it stopped.",
|
|
5195
|
+
{ cause: error }
|
|
5196
|
+
);
|
|
5197
|
+
throw error;
|
|
5198
|
+
}
|
|
5199
|
+
}
|
|
5200
|
+
function bucketName(context, usage) {
|
|
5201
|
+
if (context.positionals.length !== 1)
|
|
5202
|
+
throw new NaroError("VALIDATION_FAILED", usage);
|
|
5203
|
+
return context.positionals[0];
|
|
5204
|
+
}
|
|
5205
|
+
function bucketFields(flags) {
|
|
5206
|
+
if (flags.public === true && flags.private === true)
|
|
5207
|
+
throw new NaroError(
|
|
5208
|
+
"VALIDATION_FAILED",
|
|
5209
|
+
"Pass --public or --private, not both."
|
|
5210
|
+
);
|
|
5211
|
+
const fields = {};
|
|
5212
|
+
if (flags.public === true) fields.public = true;
|
|
5213
|
+
if (flags.private === true) fields.public = false;
|
|
5214
|
+
if (flags.fileSizeLimit !== void 0)
|
|
5215
|
+
fields.fileSizeLimit = flags.fileSizeLimit.trim().toLowerCase() === "none" ? null : parseSize(flags.fileSizeLimit);
|
|
5216
|
+
if (flags.allowedMimeTypes !== void 0)
|
|
5217
|
+
fields.allowedMimeTypes = flags.allowedMimeTypes.trim().toLowerCase() === "any" ? null : listFlag(flags.allowedMimeTypes);
|
|
5218
|
+
return fields;
|
|
5219
|
+
}
|
|
5220
|
+
async function runStorageStatus(context) {
|
|
5221
|
+
const client = await context.client();
|
|
5222
|
+
const projectId = await context.resolveProjectId();
|
|
5223
|
+
const storage = await client.storage.info(projectId);
|
|
5224
|
+
context.output.result({ projectId, storage }, storageLines(storage));
|
|
5225
|
+
}
|
|
5226
|
+
async function runStorageEnable(context) {
|
|
5227
|
+
const client = await context.client();
|
|
5228
|
+
const projectId = await context.resolveProjectId();
|
|
5229
|
+
const storage = await enableStorage(client, projectId);
|
|
5230
|
+
context.output.result({ projectId, storage }, [
|
|
5231
|
+
`\u2705 Storage is on: R2 bucket ${printable(storage.bucket ?? "unknown")}.`,
|
|
5232
|
+
...storage.usage === null ? ["\u23F3 The backend is still finishing; check `naro backend` in a minute."] : [],
|
|
5233
|
+
...storageLines(storage)
|
|
5234
|
+
]);
|
|
5235
|
+
}
|
|
5236
|
+
async function runStorageBucketList(context) {
|
|
5237
|
+
const client = await context.client();
|
|
5238
|
+
const projectId = await context.resolveProjectId();
|
|
5239
|
+
const buckets = await client.storage.listBuckets(projectId);
|
|
5240
|
+
context.output.result(
|
|
5241
|
+
{ projectId, buckets },
|
|
5242
|
+
buckets.length === 0 ? [NO_BUCKETS] : buckets.map(bucketLine)
|
|
5243
|
+
);
|
|
5244
|
+
}
|
|
5245
|
+
async function runStorageBucketCreate(context) {
|
|
5246
|
+
const name = bucketName(context, CREATE_USAGE2);
|
|
5247
|
+
const input = { name, ...bucketFields(context.flags) };
|
|
5248
|
+
const client = await context.client();
|
|
5249
|
+
const projectId = await context.resolveProjectId();
|
|
5250
|
+
let bucket;
|
|
5251
|
+
try {
|
|
5252
|
+
bucket = await client.storage.createBucket(projectId, input);
|
|
5253
|
+
} catch (error) {
|
|
5254
|
+
if (!isNaroError(error) || error.code !== "STORAGE_NOT_ENABLED")
|
|
5255
|
+
throw error;
|
|
5256
|
+
context.output.log(ENABLE_FIRST);
|
|
5257
|
+
await enableStorage(client, projectId);
|
|
5258
|
+
bucket = await client.storage.createBucket(projectId, input);
|
|
5259
|
+
}
|
|
5260
|
+
context.output.result({ projectId, bucket }, [
|
|
5261
|
+
`\u2705 Created bucket ${printable(bucket.name)}.`,
|
|
5262
|
+
bucketLine(bucket)
|
|
5263
|
+
]);
|
|
5264
|
+
}
|
|
5265
|
+
async function runStorageBucketUpdate(context) {
|
|
5266
|
+
const name = bucketName(context, UPDATE_USAGE);
|
|
5267
|
+
const patch = bucketFields(context.flags);
|
|
5268
|
+
if (patch.public === true)
|
|
5269
|
+
await confirmOrAbort(
|
|
5270
|
+
context,
|
|
5271
|
+
`Make bucket ${name} public? Anyone with an object's URL can download it.`
|
|
5272
|
+
);
|
|
5273
|
+
const client = await context.client();
|
|
5274
|
+
const projectId = await context.resolveProjectId();
|
|
5275
|
+
const bucket = await client.storage.updateBucket(projectId, name, patch);
|
|
5276
|
+
context.output.result({ projectId, bucket }, [
|
|
5277
|
+
`\u2705 Updated bucket ${printable(bucket.name)}. It takes effect within about 10 seconds.`,
|
|
5278
|
+
bucketLine(bucket)
|
|
5279
|
+
]);
|
|
5280
|
+
}
|
|
5281
|
+
async function untilDone(context, bucket, page) {
|
|
5282
|
+
let deleted = 0;
|
|
5283
|
+
let idle = 0;
|
|
5284
|
+
for (; ; ) {
|
|
5285
|
+
const result = await page();
|
|
5286
|
+
deleted += result.deleted;
|
|
5287
|
+
if (result.done) return deleted;
|
|
5288
|
+
idle = result.deleted === 0 ? idle + 1 : 0;
|
|
5289
|
+
if (idle >= IDLE_PAGES)
|
|
5290
|
+
throw new NaroError(
|
|
5291
|
+
"BUCKET_EMPTY_STALLED",
|
|
5292
|
+
`Bucket ${bucket} is not empty yet, but the last calls deleted nothing. Run the same command again in a minute.`,
|
|
5293
|
+
{ details: { deleted } }
|
|
5294
|
+
);
|
|
5295
|
+
context.output.log(`\u2026 ${count(deleted, "object")} deleted so far`);
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
function objectsSentence(objects2) {
|
|
5299
|
+
return `Its ${count(objects2, "object")} ${objects2 === 1 ? "is" : "are"} deleted and cannot be restored.`;
|
|
5300
|
+
}
|
|
5301
|
+
async function runStorageBucketEmpty(context) {
|
|
5302
|
+
const name = bucketName(context, EMPTY_USAGE);
|
|
5303
|
+
const client = await context.client();
|
|
5304
|
+
const projectId = await context.resolveProjectId();
|
|
5305
|
+
const bucket = await client.storage.getBucket(projectId, name);
|
|
5306
|
+
await confirmOrAbort(
|
|
5307
|
+
context,
|
|
5308
|
+
`Empty bucket ${name}? ${objectsSentence(bucket.objects)}`
|
|
5309
|
+
);
|
|
5310
|
+
const deleted = await untilDone(
|
|
5311
|
+
context,
|
|
5312
|
+
name,
|
|
5313
|
+
() => client.storage.emptyBucket(projectId, name)
|
|
5314
|
+
);
|
|
5315
|
+
context.output.result({ projectId, bucket: name, deleted }, [
|
|
5316
|
+
`\u2705 Emptied ${name}: ${count(deleted, "object")} deleted.`
|
|
5317
|
+
]);
|
|
5318
|
+
}
|
|
5319
|
+
async function runStorageBucketRemove(context) {
|
|
5320
|
+
const name = bucketName(context, REMOVE_USAGE2);
|
|
5321
|
+
const empty = context.flags.empty === true;
|
|
5322
|
+
const client = await context.client();
|
|
5323
|
+
const projectId = await context.resolveProjectId();
|
|
5324
|
+
let question = `Remove bucket ${name}?`;
|
|
5325
|
+
if (empty) {
|
|
5326
|
+
const bucket = await client.storage.getBucket(projectId, name);
|
|
5327
|
+
question += ` ${objectsSentence(bucket.objects)}`;
|
|
5328
|
+
}
|
|
5329
|
+
await confirmOrAbort(context, question);
|
|
5330
|
+
await untilDone(context, name, async () => {
|
|
5331
|
+
const removal = await client.storage.removeBucket(
|
|
5332
|
+
projectId,
|
|
5333
|
+
name,
|
|
5334
|
+
empty ? { empty: true } : {}
|
|
5335
|
+
);
|
|
5336
|
+
return "ok" in removal ? { deleted: 0, done: true } : removal;
|
|
5337
|
+
});
|
|
5338
|
+
context.output.result({ projectId, bucket: name, removed: true }, [
|
|
5339
|
+
`\u2705 Removed bucket ${name}.`
|
|
5340
|
+
]);
|
|
5341
|
+
}
|
|
4850
5342
|
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
naro db dump [--output <f>] Download a SQL dump of the database (stdout by default)
|
|
4859
|
-
[--schema-only|--data-only] [--table a,b] Cloudflare holds other queries until it finishes
|
|
4860
|
-
naro db restore --bookmark <id> Roll the database back (Time Travel, last 30 days; asks first)
|
|
4861
|
-
| --timestamp <time> [--dry-run] prints the command that undoes it
|
|
4862
|
-
naro db pull [--dir <d>] Write the remote schema as migrations/0001_remote_schema.sql
|
|
4863
|
-
and record it as applied - a baseline (asks first)
|
|
4864
|
-
naro db reset [--dir <d>] Drop everything and clear the policies and the ledger, then re-apply
|
|
4865
|
-
[--dry-run] [--yes] ./migrations, which create the policies again. Refuses files the server
|
|
4866
|
-
would refuse; lists first the policies the files do not make
|
|
4867
|
-
(asks first; db restore --bookmark undoes it)
|
|
4868
|
-
naro db lint [--strict] Check the schema for what D1 actually trips on, and the RLS policies
|
|
4869
|
-
(rls_no_policy, rls_policy_invalid, rls_policy_text_default,
|
|
4870
|
-
rls_policy_unindexed; exits 1 on an error, --strict on a warning too)
|
|
4871
|
-
naro db table create <name> --column <spec> [--column <spec>\u2026]
|
|
4872
|
-
[--owner <column>] [--if-not-exists] Create a table without SQL. <spec> is name:type[:modifier\u2026]:
|
|
4873
|
-
types integer, real, text, blob, numeric, boolean, datetime, json;
|
|
4874
|
-
modifiers pk, autoincrement, notnull, unique, default=<v>,
|
|
4875
|
-
references=<table>.<column>, ondelete=cascade|set-null|\u2026
|
|
4876
|
-
--owner adds that column (TEXT NOT NULL) and an owner policy
|
|
4877
|
-
naro db table add-column <table> --column <spec>
|
|
4878
|
-
naro db table rename-column <table> <column> <new-name>
|
|
4879
|
-
naro db table rename <table> <new-name>
|
|
4880
|
-
naro db table drop-column <table> <column> Asks first (--yes to skip)
|
|
4881
|
-
naro db table drop <table> Asks first (--yes to skip)
|
|
4882
|
-
naro db index create <table> <col[,col\u2026]> [--unique] [--name <name>]
|
|
4883
|
-
naro db index drop <name>
|
|
4884
|
-
Each runs as one migration in d1_migrations and is written to ./migrations
|
|
4885
|
-
(--no-write skips the file; --dry-run prints the SQL and changes nothing)
|
|
4886
|
-
naro migration new <name> Create migrations/NNNN_<name>.sql (stdin becomes the body when piped)
|
|
4887
|
-
naro migration list Local vs applied migrations
|
|
4888
|
-
naro migration up [--dry-run] Same as db push
|
|
4889
|
-
naro migration repair <name> Fix the ledger alone: mark a migration applied or
|
|
4890
|
-
--status applied|reverted [--yes] remove its row. Its SQL is never run (asks first)
|
|
4891
|
-
naro gen types [--lang typescript] [--output <file>]
|
|
4892
|
-
TypeScript types for the schema, for createClient<Database>()
|
|
4893
|
-
from naro-js (stdout by default)
|
|
5343
|
+
// src/commands/storage-objects.ts
|
|
5344
|
+
import { randomUUID } from "crypto";
|
|
5345
|
+
import { createWriteStream as createWriteStream2, openAsBlob, rmSync as rmSync2 } from "fs";
|
|
5346
|
+
import { rename, rm, stat } from "fs/promises";
|
|
5347
|
+
import { basename as basename2, dirname as dirname3, extname, join as join6, resolve as resolve5 } from "path";
|
|
5348
|
+
import { Readable } from "stream";
|
|
5349
|
+
import { pipeline as pipeline2 } from "stream/promises";
|
|
4894
5350
|
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
5351
|
+
// ../../packages/naro-sdk/dist/index.js
|
|
5352
|
+
var INVALID_QUERY_MESSAGE = "The query is not a valid Naro query.";
|
|
5353
|
+
var INVALID_SESSION_MESSAGE = "The session is expired or not valid. Sign in again.";
|
|
5354
|
+
function networkError(cause) {
|
|
5355
|
+
return {
|
|
5356
|
+
code: "NETWORK_ERROR",
|
|
5357
|
+
message: "The request did not reach the Naro backend. Check the url and the network.",
|
|
5358
|
+
details: { cause: describe(cause) }
|
|
5359
|
+
};
|
|
5360
|
+
}
|
|
5361
|
+
function requestTimeout(ms) {
|
|
5362
|
+
return {
|
|
5363
|
+
code: "TIMEOUT",
|
|
5364
|
+
message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
|
|
5365
|
+
};
|
|
5366
|
+
}
|
|
5367
|
+
function invalidResponse(status) {
|
|
5368
|
+
return {
|
|
5369
|
+
code: "INVALID_RESPONSE",
|
|
5370
|
+
message: `The server did not answer like a Naro backend (HTTP ${String(status)}).`,
|
|
5371
|
+
details: { status }
|
|
5372
|
+
};
|
|
5373
|
+
}
|
|
5374
|
+
function invalidQuery(path, reason) {
|
|
5375
|
+
return {
|
|
5376
|
+
code: "INVALID_QUERY",
|
|
5377
|
+
message: INVALID_QUERY_MESSAGE,
|
|
5378
|
+
details: { path, reason }
|
|
5379
|
+
};
|
|
5380
|
+
}
|
|
5381
|
+
function invalidSession() {
|
|
5382
|
+
return { code: "INVALID_SESSION", message: INVALID_SESSION_MESSAGE };
|
|
5383
|
+
}
|
|
5384
|
+
function codeVerifierMissing() {
|
|
5385
|
+
return {
|
|
5386
|
+
code: "CODE_VERIFIER_MISSING",
|
|
5387
|
+
message: "This browser did not start this sign-in, so it cannot finish it. Sign in again here."
|
|
5388
|
+
};
|
|
5389
|
+
}
|
|
5390
|
+
function passwordRecoveryRequired() {
|
|
5391
|
+
return {
|
|
5392
|
+
code: "PASSWORD_RECOVERY_REQUIRED",
|
|
5393
|
+
message: "updateUser({ password }) works in the password recovery flow: open the link resetPasswordForEmail sent, then call it on that page."
|
|
5394
|
+
};
|
|
5395
|
+
}
|
|
5396
|
+
function invalidProvider() {
|
|
5397
|
+
return {
|
|
5398
|
+
code: "VALIDATION_ERROR",
|
|
5399
|
+
message: "provider must be google or github."
|
|
5400
|
+
};
|
|
5401
|
+
}
|
|
5402
|
+
function serviceKeyRequired() {
|
|
5403
|
+
return {
|
|
5404
|
+
code: "PERMISSION_DENIED",
|
|
5405
|
+
message: "Only the service key can manage users."
|
|
5406
|
+
};
|
|
5407
|
+
}
|
|
5408
|
+
function cryptoUnavailable() {
|
|
5409
|
+
return {
|
|
5410
|
+
code: "CRYPTO_UNAVAILABLE",
|
|
5411
|
+
message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
|
|
5412
|
+
};
|
|
5413
|
+
}
|
|
5414
|
+
function describe(cause) {
|
|
5415
|
+
if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
|
|
5416
|
+
return String(cause);
|
|
5417
|
+
}
|
|
5418
|
+
function isRecord(value) {
|
|
5419
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5420
|
+
}
|
|
5421
|
+
function asError(value) {
|
|
5422
|
+
if (!isRecord(value)) return null;
|
|
5423
|
+
const { code, message } = value;
|
|
5424
|
+
if (typeof code !== "string" || code === "") return null;
|
|
5425
|
+
if (typeof message !== "string") return null;
|
|
5426
|
+
const error = { code, message };
|
|
5427
|
+
if (value.details !== void 0) error.details = value.details;
|
|
5428
|
+
return error;
|
|
5429
|
+
}
|
|
5430
|
+
function errorFromBody(status, body) {
|
|
5431
|
+
if (isRecord(body) && body.error !== void 0 && body.error !== null)
|
|
5432
|
+
return asError(body.error) ?? invalidResponse(status);
|
|
5433
|
+
if (status >= 200 && status < 300) return null;
|
|
5434
|
+
return invalidResponse(status);
|
|
5435
|
+
}
|
|
5436
|
+
async function receive(config, options, init) {
|
|
5437
|
+
const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
|
|
5438
|
+
if (controller) init.signal = controller.signal;
|
|
5439
|
+
const request = (async () => {
|
|
5440
|
+
try {
|
|
5441
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
5442
|
+
const response = await fetchImpl(
|
|
5443
|
+
`${config.baseUrl}/_naro/v1${options.path}`,
|
|
5444
|
+
init
|
|
5445
|
+
);
|
|
5446
|
+
return {
|
|
5447
|
+
status: response.status,
|
|
5448
|
+
headers: response.headers,
|
|
5449
|
+
text: await response.text()
|
|
5450
|
+
};
|
|
5451
|
+
} catch (error) {
|
|
5452
|
+
return { error: networkError(error) };
|
|
5453
|
+
}
|
|
5454
|
+
})();
|
|
5455
|
+
const { timeoutMs } = options;
|
|
5456
|
+
if (timeoutMs === void 0) return request;
|
|
5457
|
+
let timer;
|
|
5458
|
+
const expired = new Promise((resolve6) => {
|
|
5459
|
+
timer = setTimeout(() => {
|
|
5460
|
+
controller?.abort();
|
|
5461
|
+
resolve6({ error: requestTimeout(timeoutMs) });
|
|
5462
|
+
}, timeoutMs);
|
|
5463
|
+
});
|
|
5464
|
+
try {
|
|
5465
|
+
return await Promise.race([request, expired]);
|
|
5466
|
+
} finally {
|
|
5467
|
+
clearTimeout(timer);
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
5470
|
+
async function call(config, options) {
|
|
5471
|
+
let payload;
|
|
5472
|
+
if (options.body !== void 0) {
|
|
5473
|
+
try {
|
|
5474
|
+
payload = JSON.stringify(options.body);
|
|
5475
|
+
} catch {
|
|
5476
|
+
return { error: invalidQuery("", "is not JSON"), answer: null };
|
|
5477
|
+
}
|
|
5478
|
+
}
|
|
5479
|
+
const token = options.withToken ? await config.tokens.get() : null;
|
|
5480
|
+
const headers = { apikey: config.apiKey };
|
|
5481
|
+
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
|
5482
|
+
const init = {
|
|
5483
|
+
method: options.method,
|
|
5484
|
+
headers,
|
|
5485
|
+
// Identity is the bearer token alone. The sign-in routes also set a
|
|
5486
|
+
// session cookie (spec §13); a request that carried it would let any site
|
|
5487
|
+
// act as the user. Workers accept the field (measured on workerd
|
|
5488
|
+
// 1.20260815).
|
|
5489
|
+
credentials: "omit"
|
|
5490
|
+
};
|
|
5491
|
+
if (payload !== void 0) {
|
|
5492
|
+
headers["Content-Type"] = "application/json";
|
|
5493
|
+
init.body = payload;
|
|
5494
|
+
}
|
|
5495
|
+
const received = await receive(config, options, init);
|
|
5496
|
+
if ("error" in received) return { error: received.error, answer: null };
|
|
5497
|
+
const { status, headers: responseHeaders, text } = received;
|
|
5498
|
+
let body;
|
|
5499
|
+
if (text.trim() !== "") {
|
|
5500
|
+
try {
|
|
5501
|
+
body = JSON.parse(text);
|
|
5502
|
+
} catch {
|
|
5503
|
+
return { error: invalidResponse(status), answer: null };
|
|
5504
|
+
}
|
|
5505
|
+
}
|
|
5506
|
+
const answer = { status, headers: responseHeaders, body, token };
|
|
5507
|
+
const error = errorFromBody(status, body);
|
|
5508
|
+
if (error === null) return { error: null, answer };
|
|
5509
|
+
return {
|
|
5510
|
+
error: await settled(config, error, responseHeaders, token),
|
|
5511
|
+
answer
|
|
5512
|
+
};
|
|
5513
|
+
}
|
|
5514
|
+
async function settled(config, error, headers, token) {
|
|
5515
|
+
if (error.code === "INVALID_SESSION" && token !== null)
|
|
5516
|
+
await config.tokens.clearIf(token);
|
|
5517
|
+
return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
|
|
5518
|
+
}
|
|
5519
|
+
async function send(config, options) {
|
|
5520
|
+
const token = options.withToken ? await config.tokens.get() : null;
|
|
5521
|
+
const headers = {
|
|
5522
|
+
...options.headers,
|
|
5523
|
+
apikey: config.apiKey
|
|
5524
|
+
};
|
|
5525
|
+
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
|
5526
|
+
const init = {
|
|
5527
|
+
method: options.method,
|
|
5528
|
+
headers,
|
|
5529
|
+
credentials: "omit"
|
|
5530
|
+
};
|
|
5531
|
+
if (options.body !== void 0) init.body = options.body;
|
|
5532
|
+
let response;
|
|
5533
|
+
try {
|
|
5534
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
5535
|
+
response = await fetchImpl(
|
|
5536
|
+
`${config.baseUrl}/_naro/v1${options.path}`,
|
|
5537
|
+
init
|
|
5538
|
+
);
|
|
5539
|
+
} catch (error2) {
|
|
5540
|
+
return { error: networkError(error2) };
|
|
5541
|
+
}
|
|
5542
|
+
if (response.status >= 200 && response.status < 300)
|
|
5543
|
+
return { error: null, response };
|
|
5544
|
+
let text;
|
|
5545
|
+
try {
|
|
5546
|
+
text = await response.text();
|
|
5547
|
+
} catch (error2) {
|
|
5548
|
+
return { error: networkError(error2) };
|
|
5549
|
+
}
|
|
5550
|
+
let body;
|
|
5551
|
+
if (text.trim() !== "") {
|
|
5552
|
+
try {
|
|
5553
|
+
body = JSON.parse(text);
|
|
5554
|
+
} catch {
|
|
5555
|
+
return { error: invalidResponse(response.status) };
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5558
|
+
const error = errorFromBody(response.status, body) ?? invalidResponse(response.status);
|
|
5559
|
+
return { error: await settled(config, error, response.headers, token) };
|
|
5560
|
+
}
|
|
5561
|
+
var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
5562
|
+
"RATE_LIMITED",
|
|
5563
|
+
"WRITE_QUOTA_EXCEEDED",
|
|
5564
|
+
// Storage (original spec §13): one object written twice within a second
|
|
5565
|
+
// (`x-retry-after: 1`), and the daily operation limit (3-1d).
|
|
5566
|
+
"OBJECT_RATE_LIMITED",
|
|
5567
|
+
"STORAGE_OPS_QUOTA_EXCEEDED"
|
|
5568
|
+
]);
|
|
5569
|
+
function withRetryAfter(error, headers) {
|
|
5570
|
+
const header = headers.get("x-retry-after")?.trim() ?? "";
|
|
5571
|
+
if (!/^\d{1,9}$/.test(header)) return error;
|
|
5572
|
+
const { details } = error;
|
|
5573
|
+
if (details !== void 0 && !isRecord(details)) return error;
|
|
5574
|
+
return {
|
|
5575
|
+
...error,
|
|
5576
|
+
details: { ...details, retryAfter: Number(header) }
|
|
5577
|
+
};
|
|
5578
|
+
}
|
|
5579
|
+
function isBrowser(scope = globalThis) {
|
|
5580
|
+
const g = scope;
|
|
5581
|
+
return g.window !== void 0 && g.document !== void 0;
|
|
5582
|
+
}
|
|
5583
|
+
function isWorkerd(g) {
|
|
5584
|
+
return g.navigator?.userAgent === "Cloudflare-Workers";
|
|
5585
|
+
}
|
|
5586
|
+
function hasServerSignal(g) {
|
|
5587
|
+
return isWorkerd(g) || // Vercel's edge runtime and Next.js's edge (middleware, edge routes).
|
|
5588
|
+
typeof g.EdgeRuntime === "string" || typeof g.Deno?.version?.deno === "string" || typeof g.Bun?.version === "string" || // Node, and so the Next.js server. Browser polyfills of `process` leave
|
|
5589
|
+
// `versions.node` out.
|
|
5590
|
+
typeof g.process?.versions?.node === "string";
|
|
5591
|
+
}
|
|
5592
|
+
function hasDeviceSignal(g) {
|
|
5593
|
+
if (g.window !== void 0 || g.document !== void 0) return true;
|
|
5594
|
+
if (g.navigator?.product === "ReactNative") return true;
|
|
5595
|
+
return !isWorkerd(g) && (g.WorkerGlobalScope !== void 0 || g.ServiceWorkerGlobalScope !== void 0);
|
|
5596
|
+
}
|
|
5597
|
+
function isKnownServer(scope = globalThis) {
|
|
5598
|
+
const g = scope;
|
|
5599
|
+
return hasServerSignal(g) && !hasDeviceSignal(g);
|
|
5600
|
+
}
|
|
5601
|
+
var USERS_PATH = "/auth/admin/users";
|
|
5602
|
+
function dataOf(result) {
|
|
5603
|
+
if (result.error !== null) return null;
|
|
5604
|
+
const { body } = result.answer;
|
|
5605
|
+
return isRecord(body) && isRecord(body.data) ? body.data : null;
|
|
5606
|
+
}
|
|
5607
|
+
function errorOf(result) {
|
|
5608
|
+
if (result.error !== null) return result.error;
|
|
5609
|
+
return invalidResponse(result.answer.status);
|
|
5610
|
+
}
|
|
5611
|
+
function userOf(value) {
|
|
5612
|
+
return isRecord(value) && typeof value.id === "string" ? value : null;
|
|
5613
|
+
}
|
|
5614
|
+
var AuthAdminApi = class {
|
|
5615
|
+
#http;
|
|
5616
|
+
#serviceKey;
|
|
5617
|
+
constructor(http, serviceKey) {
|
|
5618
|
+
this.#http = http;
|
|
5619
|
+
this.#serviceKey = serviceKey;
|
|
5620
|
+
}
|
|
5621
|
+
/**
|
|
5622
|
+
* Creates a user with a password: an email, a username or both. It is
|
|
5623
|
+
* made while sign-ups are closed too, and is confirmed unless
|
|
5624
|
+
* `emailConfirm: false`. A username-only user answers `email: null` and
|
|
5625
|
+
* signs in with `signIn({ username, password })`. Refusals come as the
|
|
5626
|
+
* worker says them: USERNAME_TAKEN (409),
|
|
5627
|
+
* USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (422), PASSWORD_TOO_SHORT ("Use at
|
|
5628
|
+
* least {n} characters."), VALIDATION_ERROR, WRITE_QUOTA_EXCEEDED (429).
|
|
5629
|
+
*/
|
|
5630
|
+
async createUser(attributes) {
|
|
5631
|
+
if (!this.#serviceKey)
|
|
5632
|
+
return { data: { user: null }, error: serviceKeyRequired() };
|
|
5633
|
+
const result = await call(this.#http, {
|
|
5634
|
+
method: "POST",
|
|
5635
|
+
path: USERS_PATH,
|
|
5636
|
+
body: attributes,
|
|
5637
|
+
withToken: false
|
|
5638
|
+
});
|
|
5639
|
+
const user = userOf(dataOf(result)?.user);
|
|
5640
|
+
if (user === null) return { data: { user: null }, error: errorOf(result) };
|
|
5641
|
+
return { data: { user }, error: null };
|
|
5642
|
+
}
|
|
5643
|
+
/** The project's users, newest first: page 1 and 50 a page unless given. */
|
|
5644
|
+
async listUsers(params = {}) {
|
|
5645
|
+
const none = { users: [], total: 0 };
|
|
5646
|
+
if (!this.#serviceKey) return { data: none, error: serviceKeyRequired() };
|
|
5647
|
+
const query = new URLSearchParams();
|
|
5648
|
+
if (params.page !== void 0) query.set("page", String(params.page));
|
|
5649
|
+
if (params.perPage !== void 0)
|
|
5650
|
+
query.set("perPage", String(params.perPage));
|
|
5651
|
+
const search = query.toString();
|
|
5652
|
+
const result = await call(this.#http, {
|
|
5653
|
+
method: "GET",
|
|
5654
|
+
path: search === "" ? USERS_PATH : `${USERS_PATH}?${search}`,
|
|
5655
|
+
withToken: false
|
|
5656
|
+
});
|
|
5657
|
+
const data = dataOf(result);
|
|
5658
|
+
const users = Array.isArray(data?.users) ? data.users.map(userOf) : null;
|
|
5659
|
+
if (!data || users === null || users.includes(null) || typeof data.total !== "number")
|
|
5660
|
+
return { data: none, error: errorOf(result) };
|
|
5661
|
+
return {
|
|
5662
|
+
data: { users, total: data.total },
|
|
5663
|
+
error: null
|
|
5664
|
+
};
|
|
5665
|
+
}
|
|
5666
|
+
/**
|
|
5667
|
+
* Deletes a user and its sign-in methods, and ends its sessions. Answers
|
|
5668
|
+
* the user it was; USER_NOT_FOUND (404) for an id that is no user.
|
|
5669
|
+
*/
|
|
5670
|
+
async deleteUser(id) {
|
|
5671
|
+
if (!this.#serviceKey)
|
|
5672
|
+
return { data: { user: null }, error: serviceKeyRequired() };
|
|
5673
|
+
const result = await call(this.#http, {
|
|
5674
|
+
method: "DELETE",
|
|
5675
|
+
path: `${USERS_PATH}/${encodeURIComponent(String(id))}`,
|
|
5676
|
+
withToken: false
|
|
5677
|
+
});
|
|
5678
|
+
const user = userOf(dataOf(result)?.user);
|
|
5679
|
+
if (user === null) return { data: { user: null }, error: errorOf(result) };
|
|
5680
|
+
return { data: { user }, error: null };
|
|
5681
|
+
}
|
|
5682
|
+
};
|
|
5683
|
+
var CODE = /^[A-Z0-9_]{1,64}$/;
|
|
5684
|
+
var INVALID_CONFIRMATION = "The confirmation link is not valid. Ask for a new one.";
|
|
5685
|
+
var MESSAGES = {
|
|
5686
|
+
INVALID_STATE: "This sign-in link is not valid or has expired. Start signing in again.",
|
|
5687
|
+
OAUTH_BROWSER_MISMATCH: "This sign-in was started in another browser, or this browser did not keep its cookie. Start signing in again here.",
|
|
5688
|
+
OAUTH_ACCESS_DENIED: "Signing in with the provider was cancelled.",
|
|
5689
|
+
OAUTH_PROVIDER_EMAIL_NOT_VERIFIED: "Your email address is not verified with the provider. Verify it there, then sign in again.",
|
|
5690
|
+
OAUTH_ACCOUNT_NOT_LINKED: "An account with this email already exists and is not linked to this provider. Sign in the way you signed up.",
|
|
5691
|
+
PROVIDER_NOT_CONFIGURED: "Signing in with this provider is not set up for this project.",
|
|
5692
|
+
RATE_LIMITED: "Too many sign-in attempts. Wait a few seconds and try again.",
|
|
5693
|
+
// A first Google or GitHub sign-in while the project's sign-ups are
|
|
5694
|
+
// closed (spec 2026-09-26 §2.1): the worker's own words.
|
|
5695
|
+
SIGNUPS_DISABLED: "Sign-ups are closed for this project.",
|
|
5696
|
+
INTERNAL_ERROR: "Something went wrong. Try again.",
|
|
5697
|
+
PASSWORD_RESET_REQUIRED: "Your email is confirmed. Someone else also signed up with it before it was confirmed, so its password was cleared: choose a new one with a password reset.",
|
|
5698
|
+
TOKEN_EXPIRED: "The confirmation link has expired. Ask for a new one.",
|
|
5699
|
+
USER_NOT_FOUND: INVALID_CONFIRMATION
|
|
5700
|
+
};
|
|
5701
|
+
function flowError(type, raw) {
|
|
5702
|
+
if (!CODE.test(raw))
|
|
5703
|
+
return {
|
|
5704
|
+
code: "AUTH_ERROR",
|
|
5705
|
+
message: "Signing in did not finish. Try again."
|
|
5706
|
+
};
|
|
5707
|
+
if (raw === "INVALID_TOKEN")
|
|
5708
|
+
return {
|
|
5709
|
+
code: raw,
|
|
5710
|
+
message: type === "recovery" ? "The password reset link is not valid, has expired or was already used. Ask for a new one." : INVALID_CONFIRMATION
|
|
5711
|
+
};
|
|
5712
|
+
const message = MESSAGES[raw];
|
|
5713
|
+
if (message !== void 0) return { code: raw, message };
|
|
5714
|
+
if (raw.startsWith("OAUTH_"))
|
|
5715
|
+
return {
|
|
5716
|
+
code: raw,
|
|
5717
|
+
message: `Signing in with the provider did not finish (${raw}). Try again.`
|
|
5718
|
+
};
|
|
5719
|
+
return {
|
|
5720
|
+
code: raw,
|
|
5721
|
+
message: `Signing in did not finish (${raw}). Try again.`
|
|
5722
|
+
};
|
|
5723
|
+
}
|
|
5724
|
+
function base64url(bytes) {
|
|
5725
|
+
let binary = "";
|
|
5726
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
5727
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
5728
|
+
}
|
|
5729
|
+
async function createPkcePair() {
|
|
5730
|
+
const subtle = globalThis.crypto?.subtle;
|
|
5731
|
+
if (subtle === void 0) return null;
|
|
5732
|
+
const verifier = base64url(
|
|
5733
|
+
globalThis.crypto.getRandomValues(new Uint8Array(32))
|
|
5734
|
+
);
|
|
5735
|
+
const digest = await subtle.digest(
|
|
5736
|
+
"SHA-256",
|
|
5737
|
+
new TextEncoder().encode(verifier)
|
|
5738
|
+
);
|
|
5739
|
+
return { verifier, challenge: base64url(new Uint8Array(digest)) };
|
|
5740
|
+
}
|
|
5741
|
+
var TOKEN_HEADER = "set-auth-token";
|
|
5742
|
+
var FRAGMENT_PREFIX = "naro_";
|
|
5743
|
+
var OAUTH_PROVIDERS = ["google", "github"];
|
|
5744
|
+
var FLOW_TYPES = [
|
|
5745
|
+
"oauth",
|
|
5746
|
+
"email_verification",
|
|
5747
|
+
"recovery"
|
|
5748
|
+
];
|
|
5749
|
+
function defaultName(email) {
|
|
5750
|
+
if (typeof email !== "string") return "";
|
|
5751
|
+
const at = email.lastIndexOf("@");
|
|
5752
|
+
return at > 0 ? email.slice(0, at) : email;
|
|
5753
|
+
}
|
|
5754
|
+
function nonEmpty(value) {
|
|
5755
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
5756
|
+
}
|
|
5757
|
+
function tokenFrom(answer) {
|
|
5758
|
+
return nonEmpty(answer.headers.get(TOKEN_HEADER));
|
|
5759
|
+
}
|
|
5760
|
+
function userFrom(body) {
|
|
5761
|
+
return isRecord(body) && isRecord(body.user) ? body.user : null;
|
|
5762
|
+
}
|
|
5763
|
+
function currentLocation() {
|
|
5764
|
+
if (!isBrowser()) return null;
|
|
5765
|
+
return globalThis.location ?? null;
|
|
5766
|
+
}
|
|
5767
|
+
function currentPage() {
|
|
5768
|
+
const location = currentLocation();
|
|
5769
|
+
if (!location) return void 0;
|
|
5770
|
+
return `${location.origin}${location.pathname}${location.search}`;
|
|
5771
|
+
}
|
|
5772
|
+
function naroFragment() {
|
|
5773
|
+
const hash = currentLocation()?.hash ?? "";
|
|
5774
|
+
if (!hash.startsWith("#")) return null;
|
|
5775
|
+
const params = new URLSearchParams(hash.slice(1));
|
|
5776
|
+
const found = {};
|
|
5777
|
+
for (const [key, value] of params)
|
|
5778
|
+
if (key.startsWith(FRAGMENT_PREFIX)) found[key] = value;
|
|
5779
|
+
return Object.keys(found).length > 0 ? found : null;
|
|
5780
|
+
}
|
|
5781
|
+
function clearFragment() {
|
|
5782
|
+
const location = currentLocation();
|
|
5783
|
+
const history = globalThis.history;
|
|
5784
|
+
if (!location || !history?.replaceState) return;
|
|
5785
|
+
try {
|
|
5786
|
+
history.replaceState(
|
|
5787
|
+
history.state,
|
|
5788
|
+
"",
|
|
5789
|
+
`${location.pathname}${location.search}`
|
|
5790
|
+
);
|
|
5791
|
+
} catch {
|
|
5792
|
+
}
|
|
5793
|
+
}
|
|
5794
|
+
var EXCHANGE_TIMEOUT_MS = 15e3;
|
|
5795
|
+
var inFlight = /* @__PURE__ */ new WeakMap();
|
|
5796
|
+
function sharedInFlight(shared) {
|
|
5797
|
+
return inFlight.get(shared.storage)?.get(shared.key) ?? null;
|
|
5798
|
+
}
|
|
5799
|
+
function share(shared, pending) {
|
|
5800
|
+
let entries = inFlight.get(shared.storage);
|
|
5801
|
+
if (!entries) {
|
|
5802
|
+
entries = /* @__PURE__ */ new Map();
|
|
5803
|
+
inFlight.set(shared.storage, entries);
|
|
5804
|
+
}
|
|
5805
|
+
entries.set(shared.key, pending);
|
|
5806
|
+
void pending.finally(() => {
|
|
5807
|
+
if (entries.get(shared.key) === pending) entries.delete(shared.key);
|
|
5808
|
+
});
|
|
5809
|
+
}
|
|
5810
|
+
var AuthClient = class {
|
|
5811
|
+
/**
|
|
5812
|
+
* The service key's user management — createUser, listUsers, deleteUser,
|
|
5813
|
+
* as in supabase-js (spec 2026-09-26 §2.2). Only on a client made with
|
|
5814
|
+
* `serviceKey`, on a server.
|
|
5815
|
+
*/
|
|
5816
|
+
admin;
|
|
5817
|
+
#http;
|
|
5818
|
+
#flow;
|
|
5819
|
+
/** The last `getSessionFromUrl()`, for callers after detection ran it. */
|
|
5820
|
+
#fromUrl = null;
|
|
5821
|
+
constructor(http, flow) {
|
|
5822
|
+
this.#http = http;
|
|
5823
|
+
this.#flow = flow;
|
|
5824
|
+
this.admin = new AuthAdminApi(http, flow.serviceKey);
|
|
5825
|
+
if (flow.detectSessionInUrl) {
|
|
5826
|
+
if (naroFragment() !== null) void this.getSessionFromUrl();
|
|
5827
|
+
else this.#fromUrl = sharedInFlight(flow.shared);
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
/**
|
|
5831
|
+
* Creates the account and signs in; the token is stored for later calls.
|
|
5832
|
+
* When the project requires a confirmed email there is no session yet —
|
|
5833
|
+
* `user` comes back, and after the confirmation link the user signs in.
|
|
5834
|
+
* While the project's sign-ups are closed every sign-up answers 403
|
|
5835
|
+
* SIGNUPS_DISABLED; while they are open, a taken `username` answers 409
|
|
5836
|
+
* USERNAME_TAKEN.
|
|
5837
|
+
*/
|
|
5838
|
+
async signUp(credentials) {
|
|
5839
|
+
const { email, password, username } = credentials;
|
|
5840
|
+
const name = credentials.name ?? username ?? defaultName(email);
|
|
5841
|
+
const body = { email, password, name };
|
|
5842
|
+
if (username !== void 0) body.username = username;
|
|
5843
|
+
if (credentials.redirectTo !== void 0)
|
|
5844
|
+
body.callbackURL = credentials.redirectTo;
|
|
5845
|
+
return this.#signIn("/auth/sign-up/email", body, false);
|
|
5846
|
+
}
|
|
5847
|
+
/**
|
|
5848
|
+
* Signs in with an email and password, or a username and password (spec
|
|
5849
|
+
* 2026-09-26 §2.3); the token is stored for later calls. A wrong password
|
|
5850
|
+
* answers 401 INVALID_EMAIL_OR_PASSWORD, or INVALID_USERNAME_OR_PASSWORD.
|
|
5851
|
+
*/
|
|
5852
|
+
async signIn(credentials) {
|
|
5853
|
+
const { password } = credentials;
|
|
5854
|
+
if (credentials.username !== void 0)
|
|
5855
|
+
return this.#signIn(
|
|
5856
|
+
"/auth/sign-in/username",
|
|
5857
|
+
{ username: credentials.username, password },
|
|
5858
|
+
true
|
|
5859
|
+
);
|
|
5860
|
+
return this.#signIn(
|
|
5861
|
+
"/auth/sign-in/email",
|
|
5862
|
+
{ email: credentials.email, password },
|
|
5863
|
+
true
|
|
5864
|
+
);
|
|
5865
|
+
}
|
|
5866
|
+
/**
|
|
5867
|
+
* Starts a Google or GitHub sign-in: sends the browser, with a top-level
|
|
5868
|
+
* navigation, to the worker's `GET /_naro/v1/auth/authorize/<provider>`,
|
|
5869
|
+
* which checks `redirectTo`, binds the flow to this browser with a cookie
|
|
5870
|
+
* and goes on to the provider (adversarial review I1). The provider sends
|
|
5871
|
+
* the browser back to `redirectTo`, where `getSessionFromUrl()` (run by
|
|
5872
|
+
* `createClient` on its own) finishes the sign-in. No request is made
|
|
5873
|
+
* here: anything that goes wrong comes back in that page's URL.
|
|
5874
|
+
*/
|
|
5875
|
+
async signInWithOAuth(options) {
|
|
5876
|
+
const { provider } = options;
|
|
5877
|
+
const failed = (error) => ({
|
|
5878
|
+
data: { provider, url: null },
|
|
5879
|
+
error
|
|
5880
|
+
});
|
|
5881
|
+
if (!OAUTH_PROVIDERS.includes(provider)) return failed(invalidProvider());
|
|
5882
|
+
await this.#flow.recovery.forget();
|
|
5883
|
+
const pair = await createPkcePair();
|
|
5884
|
+
if (!pair) return failed(cryptoUnavailable());
|
|
5885
|
+
await this.#flow.verifiers.add(pair.challenge, pair.verifier);
|
|
5886
|
+
const redirectTo = options.redirectTo ?? currentPage();
|
|
5887
|
+
const url = new URL(
|
|
5888
|
+
`${this.#http.baseUrl}/_naro/v1/auth/authorize/${provider}`
|
|
5889
|
+
);
|
|
5890
|
+
if (redirectTo !== void 0)
|
|
5891
|
+
url.searchParams.set("redirectTo", redirectTo);
|
|
5892
|
+
url.searchParams.set("code_challenge", pair.challenge);
|
|
5893
|
+
const location = currentLocation();
|
|
5894
|
+
if (location && !options.skipBrowserRedirect) location.assign(url.href);
|
|
5895
|
+
return { data: { provider, url: url.href }, error: null };
|
|
5896
|
+
}
|
|
5897
|
+
/**
|
|
5898
|
+
* Trades the one-time code a sign-in redirect brought for the session,
|
|
5899
|
+
* with the verifier this browser kept when it began that flow — `flow` is
|
|
5900
|
+
* the redirect's `naro_flow`; without it, the newest flow's. Most apps
|
|
5901
|
+
* never call it: `getSessionFromUrl()` does.
|
|
5902
|
+
*/
|
|
5903
|
+
async exchangeCodeForSession(code, flow) {
|
|
5904
|
+
const signedOut = { session: null, user: null };
|
|
5905
|
+
const verifier = await this.#flow.verifiers.take(flow);
|
|
5906
|
+
if (verifier === null)
|
|
5907
|
+
return { data: signedOut, error: codeVerifierMissing() };
|
|
5908
|
+
const result = await call(this.#http, {
|
|
5909
|
+
method: "POST",
|
|
5910
|
+
path: "/auth/exchange-code",
|
|
5911
|
+
body: { code, codeVerifier: verifier },
|
|
5912
|
+
withToken: false,
|
|
5913
|
+
// The page's queries wait on this; they must not wait forever.
|
|
5914
|
+
timeoutMs: EXCHANGE_TIMEOUT_MS
|
|
5915
|
+
});
|
|
5916
|
+
if (result.error !== null) return { data: signedOut, error: result.error };
|
|
5917
|
+
const { body } = result.answer;
|
|
5918
|
+
const token = tokenFrom(result.answer);
|
|
5919
|
+
const user = userFrom(body);
|
|
5920
|
+
if (token === null || user === null || !isRecord(body) || !isRecord(body.session))
|
|
5921
|
+
return { data: signedOut, error: invalidResponse(result.answer.status) };
|
|
5922
|
+
await this.#http.tokens.set(token);
|
|
5923
|
+
await this.#flow.recovery.forget();
|
|
5924
|
+
return { data: { session: body.session, user }, error: null };
|
|
5925
|
+
}
|
|
5926
|
+
/**
|
|
5927
|
+
* Reads what a sign-in redirect left in this page's URL fragment, then
|
|
5928
|
+
* clears it: a provider sign-in or a confirmation link hands over a
|
|
5929
|
+
* session (traded here), a reset link a recovery token (kept for
|
|
5930
|
+
* `updateUser`), and a failure its `code` and `message`. With nothing of
|
|
5931
|
+
* Naro's in the URL, `type` is null — or, after `createClient` already
|
|
5932
|
+
* read it, the answer it got.
|
|
5933
|
+
*/
|
|
5934
|
+
getSessionFromUrl() {
|
|
5935
|
+
const fragment = naroFragment();
|
|
5936
|
+
if (fragment === null)
|
|
5937
|
+
return this.#fromUrl ?? Promise.resolve({
|
|
5938
|
+
data: { session: null, user: null, type: null },
|
|
5939
|
+
error: null
|
|
5940
|
+
});
|
|
5941
|
+
clearFragment();
|
|
5942
|
+
const answer = this.#answerOf(fragment);
|
|
5943
|
+
this.#fromUrl = answer;
|
|
5944
|
+
share(this.#flow.shared, answer);
|
|
5945
|
+
return answer;
|
|
5946
|
+
}
|
|
5947
|
+
async #answerOf(fragment) {
|
|
5948
|
+
const named = fragment.naro_type;
|
|
5949
|
+
const type = named && FLOW_TYPES.includes(named) ? named : null;
|
|
5950
|
+
const none = { session: null, user: null, type };
|
|
5951
|
+
if (fragment.naro_error) {
|
|
5952
|
+
if (fragment.naro_flow)
|
|
5953
|
+
await this.#flow.verifiers.take(fragment.naro_flow);
|
|
5954
|
+
return { data: none, error: flowError(type, fragment.naro_error) };
|
|
5955
|
+
}
|
|
5956
|
+
if (type === "recovery" && fragment.naro_token) {
|
|
5957
|
+
await this.#http.tokens.clear();
|
|
5958
|
+
await this.#flow.recovery.keep(fragment.naro_token);
|
|
5959
|
+
return { data: none, error: null };
|
|
5960
|
+
}
|
|
5961
|
+
if (fragment.naro_code) {
|
|
5962
|
+
const traded = await this.exchangeCodeForSession(
|
|
5963
|
+
fragment.naro_code,
|
|
5964
|
+
fragment.naro_flow
|
|
5965
|
+
);
|
|
5966
|
+
return { data: { ...traded.data, type }, error: traded.error };
|
|
5967
|
+
}
|
|
5968
|
+
return { data: none, error: null };
|
|
5969
|
+
}
|
|
5970
|
+
/**
|
|
5971
|
+
* Emails a password reset link, when the address has an account. The
|
|
5972
|
+
* answer is the same either way — it does not say whether it had one.
|
|
5973
|
+
*/
|
|
5974
|
+
async resetPasswordForEmail(email, options = {}) {
|
|
5975
|
+
const body = { email };
|
|
5976
|
+
if (options.redirectTo !== void 0) body.redirectTo = options.redirectTo;
|
|
5977
|
+
const result = await call(this.#http, {
|
|
5978
|
+
method: "POST",
|
|
5979
|
+
path: "/auth/request-password-reset",
|
|
5980
|
+
body,
|
|
5981
|
+
withToken: false
|
|
5982
|
+
});
|
|
5983
|
+
return { data: null, error: result.error };
|
|
5984
|
+
}
|
|
5985
|
+
/**
|
|
5986
|
+
* Sets a new password in the recovery flow: on the page the reset link
|
|
5987
|
+
* opened, after `getSessionFromUrl()` read its token. Signing in with the
|
|
5988
|
+
* new password comes after — a reset ends every session of the account.
|
|
5989
|
+
* (Changing the password of a signed-in user is not offered: spec §14
|
|
5990
|
+
* "(P1-2 리뷰 반영)".)
|
|
5991
|
+
*
|
|
5992
|
+
* Like supabase-js, it acts on the recovery link's account, which took the
|
|
5993
|
+
* place of any signed-in session when the link was read — so the token is
|
|
5994
|
+
* used only while no session is signed in on this browser, for 15 minutes
|
|
5995
|
+
* after the link was opened, and never after a sign-in, sign-up or
|
|
5996
|
+
* sign-out forgot it (PR #68 review 6). Otherwise PASSWORD_RECOVERY_REQUIRED,
|
|
5997
|
+
* with no request.
|
|
5998
|
+
*/
|
|
5999
|
+
async updateUser(attributes) {
|
|
6000
|
+
await this.#pendingUrl();
|
|
6001
|
+
if (await this.#http.tokens.get() !== null)
|
|
6002
|
+
return { data: null, error: passwordRecoveryRequired() };
|
|
6003
|
+
const token = await this.#flow.recovery.current();
|
|
6004
|
+
if (token === null)
|
|
6005
|
+
return { data: null, error: passwordRecoveryRequired() };
|
|
6006
|
+
const result = await call(this.#http, {
|
|
6007
|
+
method: "POST",
|
|
6008
|
+
path: "/auth/reset-password",
|
|
6009
|
+
body: { newPassword: attributes.password, token },
|
|
6010
|
+
withToken: false
|
|
6011
|
+
});
|
|
6012
|
+
if (result.error === null || result.error.code === "INVALID_TOKEN")
|
|
6013
|
+
await this.#flow.recovery.forgetIf(token);
|
|
6014
|
+
return { data: null, error: result.error };
|
|
6015
|
+
}
|
|
6016
|
+
/**
|
|
6017
|
+
* Emails the confirmation link again, when the address has an account that
|
|
6018
|
+
* is not confirmed yet. The answer does not say which.
|
|
6019
|
+
*/
|
|
6020
|
+
async resendVerification(email, options = {}) {
|
|
6021
|
+
const body = { email };
|
|
6022
|
+
if (options.redirectTo !== void 0) body.callbackURL = options.redirectTo;
|
|
6023
|
+
const result = await call(this.#http, {
|
|
6024
|
+
method: "POST",
|
|
6025
|
+
path: "/auth/send-verification-email",
|
|
6026
|
+
body,
|
|
6027
|
+
withToken: false
|
|
6028
|
+
});
|
|
6029
|
+
return { data: null, error: result.error };
|
|
6030
|
+
}
|
|
6031
|
+
/**
|
|
6032
|
+
* Ends the session on the worker and forgets the token. The token is
|
|
6033
|
+
* forgotten even when the worker cannot be reached — the error still comes
|
|
6034
|
+
* back, since the session then lives on until it expires.
|
|
6035
|
+
*/
|
|
6036
|
+
async signOut() {
|
|
6037
|
+
await this.#pendingUrl();
|
|
6038
|
+
await this.#flow.recovery.forget();
|
|
6039
|
+
const tokens = this.#http.tokens;
|
|
6040
|
+
const token = await tokens.get();
|
|
6041
|
+
if (token === null) return { data: null, error: null };
|
|
6042
|
+
const result = await call(this.#http, {
|
|
6043
|
+
method: "POST",
|
|
6044
|
+
path: "/auth/sign-out",
|
|
6045
|
+
withToken: true
|
|
6046
|
+
});
|
|
6047
|
+
await tokens.clearIf(result.answer?.token ?? token);
|
|
6048
|
+
return { data: null, error: result.error };
|
|
6049
|
+
}
|
|
6050
|
+
/**
|
|
6051
|
+
* The signed-in session and user. Signed out: both null, no request. A
|
|
6052
|
+
* stored token the worker no longer accepts is forgotten and reported as
|
|
6053
|
+
* INVALID_SESSION once (spec §11.1: a session never quietly turns anon).
|
|
6054
|
+
* Waits for a sign-in the page URL carried, if `createClient` found one.
|
|
6055
|
+
*/
|
|
6056
|
+
async getSession() {
|
|
6057
|
+
await this.#pendingUrl();
|
|
6058
|
+
const signedOut = { session: null, user: null };
|
|
6059
|
+
const tokens = this.#http.tokens;
|
|
6060
|
+
if (await tokens.get() === null) return { data: signedOut, error: null };
|
|
6061
|
+
const result = await call(this.#http, {
|
|
6062
|
+
method: "GET",
|
|
6063
|
+
path: "/auth/get-session",
|
|
6064
|
+
withToken: true
|
|
6065
|
+
});
|
|
6066
|
+
if (result.error !== null) return { data: signedOut, error: result.error };
|
|
6067
|
+
const { body, token } = result.answer;
|
|
6068
|
+
if (body === null) {
|
|
6069
|
+
if (token !== null) await tokens.clearIf(token);
|
|
6070
|
+
return { data: signedOut, error: invalidSession() };
|
|
6071
|
+
}
|
|
6072
|
+
const user = userFrom(body);
|
|
6073
|
+
if (!isRecord(body) || !isRecord(body.session) || user === null)
|
|
6074
|
+
return { data: signedOut, error: invalidResponse(result.answer.status) };
|
|
6075
|
+
const renewed = tokenFrom(result.answer);
|
|
6076
|
+
if (renewed !== null && token !== null && renewed !== token)
|
|
6077
|
+
await tokens.replaceIf(token, renewed);
|
|
6078
|
+
return { data: { session: body.session, user }, error: null };
|
|
6079
|
+
}
|
|
6080
|
+
/** `{ data: { user } }`; `user` is null when signed out. */
|
|
6081
|
+
async getUser() {
|
|
6082
|
+
const { data, error } = await this.getSession();
|
|
6083
|
+
return { data: { user: data.user }, error };
|
|
6084
|
+
}
|
|
6085
|
+
/**
|
|
6086
|
+
* Resolves once a sign-in the page URL carried has been traded for its
|
|
6087
|
+
* session (or failed), with its error. Queries wait for it, so the first
|
|
6088
|
+
* one after a provider's redirect goes out signed in rather than as anon.
|
|
6089
|
+
* The exchange gives up after 15 seconds (TIMEOUT, PR #68 review 10):
|
|
6090
|
+
* then the queries go out as anon rather than wait forever.
|
|
6091
|
+
*/
|
|
6092
|
+
async ready() {
|
|
6093
|
+
if (!this.#fromUrl) return { error: null };
|
|
6094
|
+
return { error: (await this.#fromUrl).error };
|
|
6095
|
+
}
|
|
6096
|
+
/** Waits for a URL sign-in in flight; its outcome is not this call's. */
|
|
6097
|
+
async #pendingUrl() {
|
|
6098
|
+
if (this.#fromUrl) await this.#fromUrl;
|
|
6099
|
+
}
|
|
6100
|
+
async #signIn(path, body, needsToken) {
|
|
6101
|
+
await this.#flow.recovery.forget();
|
|
6102
|
+
const result = await call(this.#http, {
|
|
6103
|
+
method: "POST",
|
|
6104
|
+
path,
|
|
6105
|
+
body,
|
|
6106
|
+
withToken: false
|
|
6107
|
+
});
|
|
6108
|
+
if (result.error !== null)
|
|
6109
|
+
return { data: { user: null }, error: result.error };
|
|
6110
|
+
const { body: answerBody } = result.answer;
|
|
6111
|
+
const user = userFrom(answerBody);
|
|
6112
|
+
const token = tokenFrom(result.answer);
|
|
6113
|
+
const opened = isRecord(answerBody) && nonEmpty(answerBody.token) !== null;
|
|
6114
|
+
if (user === null || (needsToken || opened) && token === null)
|
|
6115
|
+
return {
|
|
6116
|
+
data: { user: null },
|
|
6117
|
+
error: invalidResponse(result.answer.status)
|
|
6118
|
+
};
|
|
6119
|
+
if (token !== null) await this.#http.tokens.set(token);
|
|
6120
|
+
return { data: { user }, error: null };
|
|
6121
|
+
}
|
|
6122
|
+
};
|
|
6123
|
+
var FLOW_TTL_MS = 10 * 6e4;
|
|
6124
|
+
var MAX_FLOWS = 10;
|
|
6125
|
+
function isEntry(value) {
|
|
6126
|
+
return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
|
|
6127
|
+
}
|
|
6128
|
+
function live(text, now) {
|
|
6129
|
+
if (text === null) return {};
|
|
6130
|
+
let parsed;
|
|
6131
|
+
try {
|
|
6132
|
+
parsed = JSON.parse(text);
|
|
6133
|
+
} catch {
|
|
6134
|
+
return {};
|
|
6135
|
+
}
|
|
6136
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
6137
|
+
const entries = {};
|
|
6138
|
+
for (const [flow, entry] of Object.entries(parsed))
|
|
6139
|
+
if (isEntry(entry) && entry.expiresAt > now) entries[flow] = entry;
|
|
6140
|
+
return entries;
|
|
6141
|
+
}
|
|
6142
|
+
function pick(entries, oldest) {
|
|
6143
|
+
let found;
|
|
6144
|
+
for (const pair of Object.entries(entries))
|
|
6145
|
+
if (found === void 0 || (oldest ? pair[1].expiresAt < found[1].expiresAt : pair[1].expiresAt > found[1].expiresAt))
|
|
6146
|
+
found = pair;
|
|
6147
|
+
return found?.[0];
|
|
6148
|
+
}
|
|
6149
|
+
function serialize(entries) {
|
|
6150
|
+
return Object.keys(entries).length > 0 ? JSON.stringify(entries) : null;
|
|
6151
|
+
}
|
|
6152
|
+
var FlowVerifiers = class {
|
|
6153
|
+
#store;
|
|
6154
|
+
#now;
|
|
6155
|
+
constructor(store, now = Date.now) {
|
|
6156
|
+
this.#store = store;
|
|
6157
|
+
this.#now = now;
|
|
6158
|
+
}
|
|
6159
|
+
/** Keeps `verifier` for the flow `flow`, for ten minutes. */
|
|
6160
|
+
async add(flow, verifier) {
|
|
6161
|
+
const now = this.#now();
|
|
6162
|
+
await this.#store.update((text) => {
|
|
6163
|
+
const entries = live(text, now);
|
|
6164
|
+
entries[flow] = { verifier, expiresAt: now + FLOW_TTL_MS };
|
|
6165
|
+
while (Object.keys(entries).length > MAX_FLOWS) {
|
|
6166
|
+
const oldest = pick(entries, true);
|
|
6167
|
+
if (oldest === void 0) break;
|
|
6168
|
+
delete entries[oldest];
|
|
6169
|
+
}
|
|
6170
|
+
return serialize(entries);
|
|
6171
|
+
});
|
|
6172
|
+
}
|
|
6173
|
+
/**
|
|
6174
|
+
* The verifier of `flow`, taken — one flow, one use. Without a flow id
|
|
6175
|
+
* (a code traded by hand), the newest one. Null when there is none, or it
|
|
6176
|
+
* expired.
|
|
6177
|
+
*/
|
|
6178
|
+
async take(flow) {
|
|
6179
|
+
const now = this.#now();
|
|
6180
|
+
let taken = null;
|
|
6181
|
+
await this.#store.update((text) => {
|
|
6182
|
+
const entries = live(text, now);
|
|
6183
|
+
const id = flow ?? pick(entries, false);
|
|
6184
|
+
if (id !== void 0 && Object.hasOwn(entries, id)) {
|
|
6185
|
+
taken = entries[id]?.verifier ?? null;
|
|
6186
|
+
delete entries[id];
|
|
6187
|
+
}
|
|
6188
|
+
return serialize(entries);
|
|
6189
|
+
});
|
|
6190
|
+
return taken;
|
|
6191
|
+
}
|
|
6192
|
+
};
|
|
6193
|
+
var RECOVERY_TTL_MS = 15 * 6e4;
|
|
6194
|
+
function parse(text) {
|
|
6195
|
+
if (text === null) return null;
|
|
6196
|
+
try {
|
|
6197
|
+
const value = JSON.parse(text);
|
|
6198
|
+
if (typeof value === "object" && value !== null && typeof value.token === "string" && value.token !== "" && typeof value.expiresAt === "number")
|
|
6199
|
+
return value;
|
|
6200
|
+
} catch {
|
|
6201
|
+
}
|
|
6202
|
+
return null;
|
|
6203
|
+
}
|
|
6204
|
+
var RecoveryToken = class {
|
|
6205
|
+
#store;
|
|
6206
|
+
#now;
|
|
6207
|
+
constructor(store, now = () => Date.now()) {
|
|
6208
|
+
this.#store = store;
|
|
6209
|
+
this.#now = now;
|
|
6210
|
+
}
|
|
6211
|
+
/** Keeps the token a recovery link brought, for 15 minutes. */
|
|
6212
|
+
async keep(token) {
|
|
6213
|
+
const kept = { token, expiresAt: this.#now() + RECOVERY_TTL_MS };
|
|
6214
|
+
await this.#store.set(JSON.stringify(kept));
|
|
6215
|
+
}
|
|
6216
|
+
/** The token, while it lasts; an expired or unreadable one is cleared. */
|
|
6217
|
+
async current() {
|
|
6218
|
+
const now = this.#now();
|
|
6219
|
+
let token = null;
|
|
6220
|
+
await this.#store.update((text) => {
|
|
6221
|
+
const kept = parse(text);
|
|
6222
|
+
if (kept === null || kept.expiresAt <= now) return null;
|
|
6223
|
+
token = kept.token;
|
|
6224
|
+
return text;
|
|
6225
|
+
});
|
|
6226
|
+
return token;
|
|
6227
|
+
}
|
|
6228
|
+
/** Forgets it: a sign-in, a sign-up, a sign-out. */
|
|
6229
|
+
async forget() {
|
|
6230
|
+
await this.#store.clear();
|
|
6231
|
+
}
|
|
6232
|
+
/** Forgets it if it is still `token` — used, or refused as used. */
|
|
6233
|
+
async forgetIf(token) {
|
|
6234
|
+
await this.#store.update(
|
|
6235
|
+
(text) => parse(text)?.token === token ? null : text
|
|
6236
|
+
);
|
|
6237
|
+
}
|
|
6238
|
+
};
|
|
6239
|
+
function memoryStorage() {
|
|
6240
|
+
const items22 = /* @__PURE__ */ new Map();
|
|
6241
|
+
return {
|
|
6242
|
+
getItem: (key) => items22.get(key) ?? null,
|
|
6243
|
+
setItem: (key, value) => {
|
|
6244
|
+
items22.set(key, value);
|
|
6245
|
+
},
|
|
6246
|
+
removeItem: (key) => {
|
|
6247
|
+
items22.delete(key);
|
|
6248
|
+
}
|
|
6249
|
+
};
|
|
6250
|
+
}
|
|
6251
|
+
function defaultStorage() {
|
|
6252
|
+
if (!isBrowser()) return memoryStorage();
|
|
6253
|
+
try {
|
|
6254
|
+
const storage = globalThis.localStorage;
|
|
6255
|
+
if (storage === void 0 || storage === null) return memoryStorage();
|
|
6256
|
+
const probe = "naro-storage-probe";
|
|
6257
|
+
storage.setItem(probe, probe);
|
|
6258
|
+
storage.removeItem(probe);
|
|
6259
|
+
return storage;
|
|
6260
|
+
} catch {
|
|
6261
|
+
return memoryStorage();
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
function storageKey(baseUrl) {
|
|
6265
|
+
return `naro-auth-token:${baseUrl}`;
|
|
6266
|
+
}
|
|
6267
|
+
function verifierKey(baseUrl) {
|
|
6268
|
+
return `naro-auth-code-verifiers:${baseUrl}`;
|
|
6269
|
+
}
|
|
6270
|
+
function recoveryKey(baseUrl) {
|
|
6271
|
+
return `naro-auth-recovery:${baseUrl}`;
|
|
6272
|
+
}
|
|
6273
|
+
var TokenStore = class {
|
|
6274
|
+
#storage;
|
|
6275
|
+
#key;
|
|
6276
|
+
#fallback = null;
|
|
6277
|
+
/** Settles when the last queued operation has finished. */
|
|
6278
|
+
#queue = Promise.resolve();
|
|
6279
|
+
constructor(storage, key) {
|
|
6280
|
+
this.#storage = storage;
|
|
6281
|
+
this.#key = key;
|
|
6282
|
+
}
|
|
6283
|
+
get() {
|
|
6284
|
+
return this.#serial(() => this.#get());
|
|
6285
|
+
}
|
|
6286
|
+
set(token) {
|
|
6287
|
+
return this.#serial(() => this.#set(token));
|
|
6288
|
+
}
|
|
6289
|
+
clear() {
|
|
6290
|
+
return this.#serial(() => this.#clear());
|
|
6291
|
+
}
|
|
6292
|
+
/**
|
|
6293
|
+
* Clears only if the stored token is still the one that was rejected. A
|
|
6294
|
+
* request that left with an old token can come back after the user signed
|
|
6295
|
+
* in again; its INVALID_SESSION must not sign the new session out. The
|
|
6296
|
+
* read and the removal are one operation: with an async storage, a sign-in
|
|
6297
|
+
* that lands between them would otherwise be the token removed.
|
|
6298
|
+
*/
|
|
6299
|
+
clearIf(token) {
|
|
6300
|
+
return this.#serial(async () => {
|
|
6301
|
+
if (await this.#get() === token) await this.#clear();
|
|
6302
|
+
});
|
|
6303
|
+
}
|
|
6304
|
+
/**
|
|
6305
|
+
* Reads the value, hands it to `transform` and stores what that returns
|
|
6306
|
+
* (null clears it), as one operation — the per-flow verifiers
|
|
6307
|
+
* (`flows.ts`) change one entry of a JSON object this way.
|
|
6308
|
+
*/
|
|
6309
|
+
update(transform) {
|
|
6310
|
+
return this.#serial(async () => {
|
|
6311
|
+
const next = transform(await this.#get());
|
|
6312
|
+
if (next === null) await this.#clear();
|
|
6313
|
+
else await this.#set(next);
|
|
6314
|
+
});
|
|
6315
|
+
}
|
|
6316
|
+
/** Stores `next` only if `expected` is still stored, as one operation. */
|
|
6317
|
+
replaceIf(expected, next) {
|
|
6318
|
+
return this.#serial(async () => {
|
|
6319
|
+
if (await this.#get() === expected) await this.#set(next);
|
|
6320
|
+
});
|
|
6321
|
+
}
|
|
6322
|
+
/**
|
|
6323
|
+
* Runs `operation` once every earlier one has finished. The operations
|
|
6324
|
+
* below never reject, and none of them calls a public method (that would
|
|
6325
|
+
* wait on itself).
|
|
6326
|
+
*/
|
|
6327
|
+
#serial(operation) {
|
|
6328
|
+
const result = this.#queue.then(operation);
|
|
6329
|
+
this.#queue = result.catch(() => void 0);
|
|
6330
|
+
return result;
|
|
6331
|
+
}
|
|
6332
|
+
async #get() {
|
|
6333
|
+
if (this.#fallback) return this.#fallback.token;
|
|
6334
|
+
try {
|
|
6335
|
+
const token = await this.#storage.getItem(this.#key);
|
|
6336
|
+
return typeof token === "string" && token !== "" ? token : null;
|
|
6337
|
+
} catch {
|
|
6338
|
+
await this.#fallBack(null, true);
|
|
6339
|
+
return null;
|
|
6340
|
+
}
|
|
6341
|
+
}
|
|
6342
|
+
async #set(token) {
|
|
6343
|
+
if (this.#fallback) {
|
|
6344
|
+
this.#fallback.token = token;
|
|
6345
|
+
return;
|
|
6346
|
+
}
|
|
6347
|
+
try {
|
|
6348
|
+
await this.#storage.setItem(this.#key, token);
|
|
6349
|
+
} catch {
|
|
6350
|
+
await this.#fallBack(token, true);
|
|
6351
|
+
}
|
|
6352
|
+
}
|
|
6353
|
+
async #clear() {
|
|
6354
|
+
if (this.#fallback) {
|
|
6355
|
+
this.#fallback.token = null;
|
|
6356
|
+
return;
|
|
6357
|
+
}
|
|
6358
|
+
try {
|
|
6359
|
+
await this.#storage.removeItem(this.#key);
|
|
6360
|
+
} catch {
|
|
6361
|
+
await this.#fallBack(null, false);
|
|
6362
|
+
}
|
|
6363
|
+
}
|
|
6364
|
+
/**
|
|
6365
|
+
* Stops using the storage, keeping `token` in memory. The token the storage
|
|
6366
|
+
* still holds is removed once, if it lets us: left there it comes back on
|
|
6367
|
+
* the next load — after a sign-out that could not read it, or over a
|
|
6368
|
+
* sign-in whose write failed (the previous user's session).
|
|
6369
|
+
*/
|
|
6370
|
+
async #fallBack(token, removeStored) {
|
|
6371
|
+
this.#fallback = { token };
|
|
6372
|
+
if (!removeStored) return;
|
|
6373
|
+
try {
|
|
6374
|
+
await this.#storage.removeItem(this.#key);
|
|
6375
|
+
} catch {
|
|
6376
|
+
}
|
|
6377
|
+
}
|
|
6378
|
+
};
|
|
6379
|
+
function isSpace2(char) {
|
|
6380
|
+
return char.trim() === "";
|
|
6381
|
+
}
|
|
6382
|
+
function tokenize(input) {
|
|
6383
|
+
const tokens = [];
|
|
6384
|
+
let index = 0;
|
|
6385
|
+
const at = () => input[index] ?? "";
|
|
6386
|
+
for (; ; ) {
|
|
6387
|
+
while (index < input.length && isSpace2(at())) index++;
|
|
6388
|
+
let token;
|
|
6389
|
+
if (at() === '"') {
|
|
6390
|
+
index++;
|
|
6391
|
+
let name = "";
|
|
6392
|
+
for (; ; ) {
|
|
6393
|
+
if (index >= input.length)
|
|
6394
|
+
return `has an unclosed quote in column ${String(tokens.length + 1)}`;
|
|
6395
|
+
const char = at();
|
|
6396
|
+
index++;
|
|
6397
|
+
if (char !== '"') name += char;
|
|
6398
|
+
else if (at() === '"') {
|
|
6399
|
+
name += '"';
|
|
6400
|
+
index++;
|
|
6401
|
+
} else break;
|
|
6402
|
+
}
|
|
6403
|
+
while (index < input.length && isSpace2(at())) index++;
|
|
6404
|
+
if (index < input.length && at() !== ",")
|
|
6405
|
+
return `has text after the closing quote of column ${String(tokens.length + 1)}`;
|
|
6406
|
+
token = { name, quoted: true };
|
|
6407
|
+
} else {
|
|
6408
|
+
let name = "";
|
|
6409
|
+
while (index < input.length && at() !== ",") {
|
|
6410
|
+
if (at() === '"')
|
|
6411
|
+
return `has a quote inside column ${String(tokens.length + 1)}; quote the whole name`;
|
|
6412
|
+
name += at();
|
|
6413
|
+
index++;
|
|
6414
|
+
}
|
|
6415
|
+
token = { name: name.trim(), quoted: false };
|
|
6416
|
+
}
|
|
6417
|
+
tokens.push(token);
|
|
6418
|
+
if (index >= input.length) return tokens;
|
|
6419
|
+
index++;
|
|
6420
|
+
}
|
|
6421
|
+
}
|
|
6422
|
+
function parseColumns(input) {
|
|
6423
|
+
if (input === void 0) return { ok: true, columns: "*" };
|
|
6424
|
+
if (typeof input !== "string")
|
|
6425
|
+
return { ok: false, reason: "must be a string of column names" };
|
|
6426
|
+
const tokens = tokenize(input);
|
|
6427
|
+
if (typeof tokens === "string") return { ok: false, reason: tokens };
|
|
6428
|
+
const star = tokens.findIndex((token) => !token.quoted && token.name === "*");
|
|
6429
|
+
if (star >= 0) {
|
|
6430
|
+
if (tokens.length === 1) return { ok: true, columns: "*" };
|
|
6431
|
+
return { ok: false, reason: '"*" cannot be combined with column names' };
|
|
6432
|
+
}
|
|
6433
|
+
const empty = tokens.findIndex((token) => token.name === "");
|
|
6434
|
+
if (empty >= 0)
|
|
6435
|
+
return {
|
|
6436
|
+
ok: false,
|
|
6437
|
+
reason: tokens.length === 1 ? "must name at least one column" : `has an empty column name at position ${String(empty + 1)}`
|
|
6438
|
+
};
|
|
6439
|
+
return { ok: true, columns: tokens.map((token) => token.name) };
|
|
6440
|
+
}
|
|
6441
|
+
var MAX_FILTER_DEPTH2 = 4;
|
|
6442
|
+
var OPERATORS2 = {
|
|
6443
|
+
eq: "eq",
|
|
6444
|
+
neq: "neq",
|
|
6445
|
+
gt: "gt",
|
|
6446
|
+
gte: "gte",
|
|
6447
|
+
lt: "lt",
|
|
6448
|
+
lte: "lte",
|
|
6449
|
+
in: "in",
|
|
6450
|
+
is: "is",
|
|
6451
|
+
like: "like",
|
|
6452
|
+
ilike: "ilike",
|
|
6453
|
+
contains: "contains",
|
|
6454
|
+
containedBy: "containedBy",
|
|
6455
|
+
overlaps: "overlaps",
|
|
6456
|
+
cs: "contains",
|
|
6457
|
+
cd: "containedBy",
|
|
6458
|
+
ov: "overlaps"
|
|
6459
|
+
};
|
|
6460
|
+
function operatorOf2(name) {
|
|
6461
|
+
return typeof name === "string" && Object.hasOwn(OPERATORS2, name) ? OPERATORS2[name] : void 0;
|
|
6462
|
+
}
|
|
6463
|
+
var Refusal2 = class extends Error {
|
|
6464
|
+
};
|
|
6465
|
+
function refuse3(reason) {
|
|
6466
|
+
throw new Refusal2(reason);
|
|
6467
|
+
}
|
|
6468
|
+
var LIST_HINT2 = {
|
|
6469
|
+
in: ["(", ")", "needs a list in parentheses, like in.(a,b),"],
|
|
6470
|
+
array: ["{", "}", "needs a list in braces, like cs.{a,b},"]
|
|
6471
|
+
};
|
|
6472
|
+
function isSpace22(char) {
|
|
6473
|
+
return char !== "" && char.trim() === "";
|
|
6474
|
+
}
|
|
6475
|
+
function looseWord2(text) {
|
|
6476
|
+
return text.replace(/\s+/g, "").toLowerCase().replace(/\(\)$/, "");
|
|
6477
|
+
}
|
|
6478
|
+
var Parser2 = class {
|
|
6479
|
+
constructor(text, tokens = {}) {
|
|
6480
|
+
this.text = text;
|
|
6481
|
+
this.tokens = tokens;
|
|
6482
|
+
}
|
|
6483
|
+
index = 0;
|
|
6484
|
+
/**
|
|
6485
|
+
* A token at the next value, followed only by spaces and then one of
|
|
6486
|
+
* `stops` or the end — `auth.uid()x` is text, not the token.
|
|
6487
|
+
*/
|
|
6488
|
+
token(stops) {
|
|
6489
|
+
this.skipSpaces();
|
|
6490
|
+
const at = this.index;
|
|
6491
|
+
for (const [word, value] of Object.entries(this.tokens)) {
|
|
6492
|
+
if (!this.text.startsWith(word, at)) continue;
|
|
6493
|
+
let end = at + word.length;
|
|
6494
|
+
while (isSpace22(this.text[end] ?? "")) end++;
|
|
6495
|
+
const next = this.text[end] ?? "";
|
|
6496
|
+
if (next !== "" && !stops.includes(next)) continue;
|
|
6497
|
+
this.index = end;
|
|
6498
|
+
return { value, word, at };
|
|
6499
|
+
}
|
|
6500
|
+
return void 0;
|
|
6501
|
+
}
|
|
6502
|
+
at() {
|
|
6503
|
+
return this.text[this.index] ?? "";
|
|
6504
|
+
}
|
|
6505
|
+
/**
|
|
6506
|
+
* An unquoted value that is one of the tokens misspelled — `auth.uid`,
|
|
6507
|
+
* `AUTH.UID` — is refused: read as text it would compare with the words
|
|
6508
|
+
* themselves (사용자 리뷰 반영). Quoted, it is the text.
|
|
6509
|
+
*/
|
|
6510
|
+
notNearToken(value, at) {
|
|
6511
|
+
const loose = looseWord2(value);
|
|
6512
|
+
for (const word of Object.keys(this.tokens))
|
|
6513
|
+
if (loose !== "" && loose === looseWord2(word))
|
|
6514
|
+
refuse3(
|
|
6515
|
+
`has ${JSON.stringify(value)} ${this.where(at)}: did you mean ${word}? Quote it to mean the text`
|
|
6516
|
+
);
|
|
6517
|
+
}
|
|
6518
|
+
/** `scalar`, with an unquoted value held to notNearToken. */
|
|
6519
|
+
bareOrQuoted(stops) {
|
|
6520
|
+
this.skipSpaces();
|
|
6521
|
+
const at = this.index;
|
|
6522
|
+
const quoted = this.at() === '"';
|
|
6523
|
+
const text = this.scalar(stops);
|
|
6524
|
+
if (!quoted) this.notNearToken(text, at);
|
|
6525
|
+
return { text, quoted };
|
|
6526
|
+
}
|
|
6527
|
+
where(index = this.index) {
|
|
6528
|
+
return `at character ${String(index + 1)}`;
|
|
6529
|
+
}
|
|
6530
|
+
skipSpaces() {
|
|
6531
|
+
while (isSpace22(this.at())) this.index++;
|
|
6532
|
+
}
|
|
6533
|
+
/** `item ("," item)*`, up to a `)` or the end, inside `level` groups. */
|
|
6534
|
+
list(level) {
|
|
6535
|
+
const nodes = [];
|
|
6536
|
+
for (; ; ) {
|
|
6537
|
+
nodes.push(this.item(level));
|
|
6538
|
+
this.skipSpaces();
|
|
6539
|
+
const char = this.at();
|
|
6540
|
+
if (char === ",") this.index++;
|
|
6541
|
+
else if (char === ")" || char === "") return nodes;
|
|
6542
|
+
else refuse3(`has unexpected text ${this.where()}`);
|
|
6543
|
+
}
|
|
6544
|
+
}
|
|
6545
|
+
item(level) {
|
|
6546
|
+
this.skipSpaces();
|
|
6547
|
+
const start = this.index;
|
|
6548
|
+
const char = this.at();
|
|
6549
|
+
if (char === "" || char === "," || char === ")")
|
|
6550
|
+
refuse3(`has an empty item ${this.where()}`);
|
|
6551
|
+
const group = /^(not\.)?(and|or)\(/.exec(this.text.slice(start));
|
|
6552
|
+
if (!group) return this.leaf(level);
|
|
6553
|
+
const negated = group[1] !== void 0;
|
|
6554
|
+
const inner = level + (negated ? 2 : 1);
|
|
6555
|
+
if (inner > MAX_FILTER_DEPTH2)
|
|
6556
|
+
refuse3(`nests deeper than ${String(MAX_FILTER_DEPTH2)} groups`);
|
|
6557
|
+
const open = start + group[0].length - 1;
|
|
6558
|
+
this.index = open + 1;
|
|
6559
|
+
const children = this.list(inner);
|
|
6560
|
+
if (this.at() !== ")") refuse3(`has an unclosed "(" ${this.where(open)}`);
|
|
6561
|
+
this.index++;
|
|
6562
|
+
const node = group[2] === "or" ? { or: children } : { and: children };
|
|
6563
|
+
return negated ? { not: node } : node;
|
|
6564
|
+
}
|
|
6565
|
+
/** `column.[not.]operator.value`. */
|
|
6566
|
+
leaf(level) {
|
|
6567
|
+
const start = this.index;
|
|
6568
|
+
const column = this.at() === '"' ? this.quoted() : this.word().trim();
|
|
6569
|
+
if (column === "" || this.at() !== ".")
|
|
6570
|
+
refuse3(`needs column.operator.value ${this.where(start)}`);
|
|
6571
|
+
this.index++;
|
|
6572
|
+
let nameAt = this.index;
|
|
6573
|
+
let name = this.word();
|
|
6574
|
+
let negated = false;
|
|
6575
|
+
if (name === "not" && this.at() === ".") {
|
|
6576
|
+
if (level + 1 > MAX_FILTER_DEPTH2)
|
|
6577
|
+
refuse3(`nests deeper than ${String(MAX_FILTER_DEPTH2)} groups`);
|
|
6578
|
+
negated = true;
|
|
6579
|
+
this.index++;
|
|
6580
|
+
nameAt = this.index;
|
|
6581
|
+
name = this.word();
|
|
6582
|
+
}
|
|
6583
|
+
const operator = operatorOf2(name);
|
|
6584
|
+
if (this.at() !== ".") {
|
|
6585
|
+
if (name !== "" && operator === void 0)
|
|
6586
|
+
refuse3(`has an unknown operator "${name}" ${this.where(nameAt)}`);
|
|
6587
|
+
refuse3(`needs column.operator.value ${this.where(start)}`);
|
|
6588
|
+
}
|
|
6589
|
+
if (operator === void 0)
|
|
6590
|
+
refuse3(`has an unknown operator "${name}" ${this.where(nameAt)}`);
|
|
6591
|
+
this.index++;
|
|
6592
|
+
const leaf = { operator, column, value: this.value(operator) };
|
|
6593
|
+
return negated ? { not: leaf } : leaf;
|
|
6594
|
+
}
|
|
6595
|
+
/** Up to the next `.`, `,`, `(`, `)` or the end. */
|
|
6596
|
+
word() {
|
|
6597
|
+
const start = this.index;
|
|
6598
|
+
while (this.index < this.text.length && !".,()".includes(this.at()))
|
|
6599
|
+
this.index++;
|
|
6600
|
+
return this.text.slice(start, this.index);
|
|
6601
|
+
}
|
|
6602
|
+
/** `"…"`, with `\` making the next character literal. */
|
|
6603
|
+
quoted() {
|
|
6604
|
+
const start = this.index;
|
|
6605
|
+
this.index++;
|
|
6606
|
+
let text = "";
|
|
6607
|
+
for (; ; ) {
|
|
6608
|
+
if (this.index >= this.text.length)
|
|
6609
|
+
refuse3(`has an unclosed quote ${this.where(start)}`);
|
|
6610
|
+
const char = this.at();
|
|
6611
|
+
this.index++;
|
|
6612
|
+
if (char === '"') return text;
|
|
6613
|
+
if (char === "\\" && this.index < this.text.length) {
|
|
6614
|
+
text += this.at();
|
|
6615
|
+
this.index++;
|
|
6616
|
+
} else text += char;
|
|
6617
|
+
}
|
|
6618
|
+
}
|
|
6619
|
+
/** A quoted value, or everything up to `stops`, trimmed. */
|
|
6620
|
+
scalar(stops) {
|
|
6621
|
+
this.skipSpaces();
|
|
6622
|
+
if (this.at() === '"') {
|
|
6623
|
+
const text = this.quoted();
|
|
6624
|
+
this.skipSpaces();
|
|
6625
|
+
if (this.at() !== "" && !stops.includes(this.at()))
|
|
6626
|
+
refuse3(`has text after a closing quote ${this.where()}`);
|
|
6627
|
+
return text;
|
|
6628
|
+
}
|
|
6629
|
+
const start = this.index;
|
|
6630
|
+
while (this.index < this.text.length && !stops.includes(this.at()))
|
|
6631
|
+
this.index++;
|
|
6632
|
+
return this.text.slice(start, this.index).trim();
|
|
6633
|
+
}
|
|
6634
|
+
/**
|
|
6635
|
+
* `(a,b)` for `in`: text values, except that an unquoted `true` or `false`
|
|
6636
|
+
* is a boolean (사용자 리뷰 반영, as in `eq`). `{a,b}` for the array
|
|
6637
|
+
* operators: text, except that an unquoted exact decimal is a number
|
|
6638
|
+
* (적대적 리뷰 반영, narrowed by 사용자 리뷰 반영) — json_each's values
|
|
6639
|
+
* have no type affinity to turn the text "1" into 1, so as text `cs.{1}`
|
|
6640
|
+
* matched no numeric array and `not.ov.{1}` matched every row. Quoted,
|
|
6641
|
+
* `"1"` and `"true"` stay text.
|
|
6642
|
+
*/
|
|
6643
|
+
listValue(kind) {
|
|
6644
|
+
const [open, close, hint] = LIST_HINT2[kind];
|
|
6645
|
+
this.skipSpaces();
|
|
6646
|
+
const openAt = this.index;
|
|
6647
|
+
if (this.at() !== open) refuse3(`${hint} ${this.where()}`);
|
|
6648
|
+
this.index++;
|
|
6649
|
+
this.skipSpaces();
|
|
6650
|
+
const values = [];
|
|
6651
|
+
if (this.at() === close) {
|
|
6652
|
+
this.index++;
|
|
6653
|
+
return values;
|
|
6654
|
+
}
|
|
6655
|
+
for (; ; ) {
|
|
6656
|
+
this.skipSpaces();
|
|
6657
|
+
const itemAt = this.index;
|
|
6658
|
+
const token = this.token(`,${close}`);
|
|
6659
|
+
if (token) values.push(token.value);
|
|
6660
|
+
else {
|
|
6661
|
+
const { text: value, quoted } = this.bareOrQuoted(`,${close}`);
|
|
6662
|
+
if (!quoted && value === "") {
|
|
6663
|
+
if (this.index >= this.text.length)
|
|
6664
|
+
refuse3(`has an unclosed "${open}" ${this.where(openAt)}`);
|
|
6665
|
+
refuse3(`has an empty item ${this.where(itemAt)}`);
|
|
6666
|
+
}
|
|
6667
|
+
values.push(
|
|
6668
|
+
quoted ? value : kind === "array" ? exactNumber2(value) : truth2(value)
|
|
6669
|
+
);
|
|
6670
|
+
}
|
|
6671
|
+
if (this.index >= this.text.length)
|
|
6672
|
+
refuse3(`has an unclosed "${open}" ${this.where(openAt)}`);
|
|
6673
|
+
const char = this.at();
|
|
6674
|
+
this.index++;
|
|
6675
|
+
if (char === close) return values;
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
/** The value after `operator.`. */
|
|
6679
|
+
value(operator) {
|
|
6680
|
+
const start = this.index;
|
|
6681
|
+
switch (operator) {
|
|
6682
|
+
case "in":
|
|
6683
|
+
return this.listValue("in");
|
|
6684
|
+
case "contains":
|
|
6685
|
+
case "containedBy":
|
|
6686
|
+
case "overlaps":
|
|
6687
|
+
return this.listValue("array");
|
|
6688
|
+
case "is":
|
|
6689
|
+
return keyword2(this.scalar(",)"), start);
|
|
6690
|
+
case "like":
|
|
6691
|
+
case "ilike": {
|
|
6692
|
+
const token = this.token(",)");
|
|
6693
|
+
if (token)
|
|
6694
|
+
refuse3(
|
|
6695
|
+
`cannot use ${token.word} with ${operator} ${this.where(token.at)}`
|
|
6696
|
+
);
|
|
6697
|
+
return likePattern2(this.bareOrQuoted(",)").text);
|
|
6698
|
+
}
|
|
6699
|
+
case "eq":
|
|
6700
|
+
case "neq": {
|
|
6701
|
+
const token = this.token(",)");
|
|
6702
|
+
if (token) return token.value;
|
|
6703
|
+
const { text, quoted } = this.bareOrQuoted(",)");
|
|
6704
|
+
return quoted ? text : truth2(text);
|
|
6705
|
+
}
|
|
6706
|
+
default: {
|
|
6707
|
+
const token = this.token(",)");
|
|
6708
|
+
return token ? token.value : this.bareOrQuoted(",)").text;
|
|
6709
|
+
}
|
|
6710
|
+
}
|
|
6711
|
+
}
|
|
6712
|
+
};
|
|
6713
|
+
function likePattern2(pattern) {
|
|
6714
|
+
return pattern.replaceAll("*", "%");
|
|
6715
|
+
}
|
|
6716
|
+
function truth2(text) {
|
|
6717
|
+
const word = text.toLowerCase();
|
|
6718
|
+
if (word === "true") return true;
|
|
6719
|
+
if (word === "false") return false;
|
|
6720
|
+
return text;
|
|
6721
|
+
}
|
|
6722
|
+
var DECIMAL3 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
6723
|
+
function exactNumber2(text) {
|
|
6724
|
+
if (!DECIMAL3.test(text)) return text;
|
|
6725
|
+
const number = Number(text);
|
|
6726
|
+
if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
|
|
6727
|
+
const trimmed = text.replace(/\.?0+$/, "");
|
|
6728
|
+
return String(number) === trimmed ? number : text;
|
|
6729
|
+
}
|
|
6730
|
+
function keyword2(text, at) {
|
|
6731
|
+
const word = text.toLowerCase();
|
|
6732
|
+
if (word === "null") return null;
|
|
6733
|
+
if (word === "true") return true;
|
|
6734
|
+
if (word === "false") return false;
|
|
6735
|
+
return refuse3(
|
|
6736
|
+
`needs null, true or false after is at character ${String(at + 1)}`
|
|
6737
|
+
);
|
|
6738
|
+
}
|
|
6739
|
+
function attempt2(parse2) {
|
|
6740
|
+
try {
|
|
6741
|
+
return { ok: true, node: parse2() };
|
|
6742
|
+
} catch (error) {
|
|
6743
|
+
if (error instanceof Refusal2) return { ok: false, reason: error.message };
|
|
6744
|
+
throw error;
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
function parseLogic2(text, kind, options = {}) {
|
|
6748
|
+
if (typeof text !== "string")
|
|
6749
|
+
return { ok: false, reason: "must be a filter string or a function" };
|
|
6750
|
+
if (text.trim() === "") return { ok: false, reason: "must not be empty" };
|
|
6751
|
+
return attempt2(() => {
|
|
6752
|
+
if (options.group === false) {
|
|
6753
|
+
const alone = items2(text, options.tokens, 0);
|
|
6754
|
+
if (alone.length === 1 && alone[0]) return alone[0];
|
|
6755
|
+
}
|
|
6756
|
+
const nodes = items2(text, options.tokens, 1);
|
|
6757
|
+
return kind === "or" ? { or: nodes } : { and: nodes };
|
|
6758
|
+
});
|
|
6759
|
+
}
|
|
6760
|
+
function items2(text, tokens, level) {
|
|
6761
|
+
const parser = new Parser2(text, tokens);
|
|
6762
|
+
const nodes = parser.list(level);
|
|
6763
|
+
if (parser.at() === ")") refuse3(`has an unmatched ")" ${parser.where()}`);
|
|
6764
|
+
return nodes;
|
|
6765
|
+
}
|
|
6766
|
+
function conditionOf(column, name, value) {
|
|
6767
|
+
const operator = operatorOf2(name);
|
|
6768
|
+
if (operator === void 0)
|
|
6769
|
+
return { ok: false, reason: `has an unknown operator "${String(name)}"` };
|
|
6770
|
+
if (typeof value !== "string")
|
|
6771
|
+
return {
|
|
6772
|
+
ok: true,
|
|
6773
|
+
node: {
|
|
6774
|
+
operator,
|
|
6775
|
+
column,
|
|
6776
|
+
value: Array.isArray(value) ? [...value] : value
|
|
6777
|
+
}
|
|
6778
|
+
};
|
|
6779
|
+
return attempt2(() => {
|
|
6780
|
+
const parser = new Parser2(value);
|
|
6781
|
+
let parsed = value;
|
|
6782
|
+
if (["in", "contains", "containedBy", "overlaps", "is"].includes(operator)) {
|
|
6783
|
+
parsed = parser.value(operator);
|
|
6784
|
+
parser.skipSpaces();
|
|
6785
|
+
if (parser.at() !== "") refuse3(`has unexpected text ${parser.where()}`);
|
|
6786
|
+
} else if (operator === "like" || operator === "ilike")
|
|
6787
|
+
parsed = likePattern2(value);
|
|
6788
|
+
else if (operator === "eq" || operator === "neq") parsed = truth2(value);
|
|
6789
|
+
return { operator, column, value: parsed };
|
|
6790
|
+
});
|
|
6791
|
+
}
|
|
6792
|
+
function patternOf(pattern) {
|
|
6793
|
+
return typeof pattern === "string" ? likePattern2(pattern) : pattern;
|
|
6794
|
+
}
|
|
6795
|
+
var MAX_LIMIT = 1e3;
|
|
6796
|
+
var CAP = `one select reads at most ${String(MAX_LIMIT)} rows, by limit() or range()`;
|
|
6797
|
+
function initialState(table2) {
|
|
6798
|
+
return { table: table2, problem: null, filters: [], columns: "*", order: [] };
|
|
6799
|
+
}
|
|
6800
|
+
function copyRow(row) {
|
|
6801
|
+
return { ...row };
|
|
6802
|
+
}
|
|
6803
|
+
var Builder = class {
|
|
6804
|
+
run;
|
|
6805
|
+
state;
|
|
6806
|
+
constructor(run, state) {
|
|
6807
|
+
this.run = run;
|
|
6808
|
+
this.state = state;
|
|
6809
|
+
}
|
|
6810
|
+
/** A new builder of the same kind with `patch` applied. */
|
|
6811
|
+
next(patch) {
|
|
6812
|
+
const Next = this.constructor;
|
|
6813
|
+
return new Next(this.run, { ...this.state, ...patch });
|
|
6814
|
+
}
|
|
6815
|
+
fail(path, reason) {
|
|
6816
|
+
return this.failWith(invalidQuery(path, reason));
|
|
6817
|
+
}
|
|
6818
|
+
/** Keeps the first problem: it is the one the user has to fix first. */
|
|
6819
|
+
failWith(problem) {
|
|
6820
|
+
if (this.state.problem !== null) return this;
|
|
6821
|
+
return this.next({ problem });
|
|
6822
|
+
}
|
|
6823
|
+
/**
|
|
6824
|
+
* Runs the query. Every `await` sends a new request with the same AST.
|
|
6825
|
+
* Never rejects: failures resolve as `{ data: null, error }`.
|
|
6826
|
+
*/
|
|
6827
|
+
// oxlint-disable-next-line unicorn/no-thenable -- awaitable builders are the API (spec §15)
|
|
6828
|
+
then(onfulfilled, onrejected) {
|
|
6829
|
+
return this.execute().then(onfulfilled, onrejected);
|
|
6830
|
+
}
|
|
6831
|
+
async execute() {
|
|
6832
|
+
if (this.state.problem !== null)
|
|
6833
|
+
return { data: null, error: this.state.problem };
|
|
6834
|
+
return await this.run(this.build());
|
|
6835
|
+
}
|
|
6836
|
+
/** A mutation's `.select()`: RETURNING these columns. */
|
|
6837
|
+
withReturning(columns) {
|
|
6838
|
+
const parsed = parseColumns(columns);
|
|
6839
|
+
if (!parsed.ok) return this.fail("returning", parsed.reason);
|
|
6840
|
+
return this.next({ returning: parsed.columns });
|
|
6841
|
+
}
|
|
6842
|
+
};
|
|
6843
|
+
function copyList(values) {
|
|
6844
|
+
return Array.isArray(values) ? [...values] : values;
|
|
6845
|
+
}
|
|
6846
|
+
var FilterBuilder = class _FilterBuilder extends Builder {
|
|
6847
|
+
/** One more entry in the top-level list, which is an AND. */
|
|
6848
|
+
add(node) {
|
|
6849
|
+
return this.next({ filters: [...this.state.filters, node] });
|
|
6850
|
+
}
|
|
6851
|
+
condition(operator, column, value) {
|
|
6852
|
+
return this.add({ operator, column, value });
|
|
6853
|
+
}
|
|
6854
|
+
eq(column, value) {
|
|
6855
|
+
return this.condition("eq", column, value);
|
|
6856
|
+
}
|
|
6857
|
+
neq(column, value) {
|
|
6858
|
+
return this.condition("neq", column, value);
|
|
6859
|
+
}
|
|
6860
|
+
gt(column, value) {
|
|
6861
|
+
return this.condition("gt", column, value);
|
|
6862
|
+
}
|
|
6863
|
+
gte(column, value) {
|
|
6864
|
+
return this.condition("gte", column, value);
|
|
6865
|
+
}
|
|
6866
|
+
lt(column, value) {
|
|
6867
|
+
return this.condition("lt", column, value);
|
|
6868
|
+
}
|
|
6869
|
+
lte(column, value) {
|
|
6870
|
+
return this.condition("lte", column, value);
|
|
6871
|
+
}
|
|
6872
|
+
/** An empty list matches nothing. */
|
|
6873
|
+
in(column, values) {
|
|
6874
|
+
return this.condition("in", column, copyList(values));
|
|
6875
|
+
}
|
|
6876
|
+
/** `IS NULL`, `IS TRUE` or `IS FALSE` — any non-zero number is true, NULL neither. */
|
|
6877
|
+
is(column, value) {
|
|
6878
|
+
return this.condition("is", column, value);
|
|
6879
|
+
}
|
|
6880
|
+
/**
|
|
6881
|
+
* Case-sensitive, `%` and `_` as in SQL; `*` is `%` too, as in PostgREST,
|
|
6882
|
+
* so a literal `*` cannot be matched.
|
|
6883
|
+
*/
|
|
6884
|
+
like(column, pattern) {
|
|
6885
|
+
return this.condition("like", column, patternOf(pattern));
|
|
6886
|
+
}
|
|
6887
|
+
/** Case-insensitive for ASCII letters; `%`, `_` and `*` as in `like`. */
|
|
6888
|
+
ilike(column, pattern) {
|
|
6889
|
+
return this.condition("ilike", column, patternOf(pattern));
|
|
6890
|
+
}
|
|
6891
|
+
/**
|
|
6892
|
+
* The column — TEXT holding a JSON array — has every one of the values.
|
|
6893
|
+
* A row that is not a JSON array is no match; NULL is neither.
|
|
6894
|
+
*/
|
|
6895
|
+
contains(column, values) {
|
|
6896
|
+
return this.condition("contains", column, copyList(values));
|
|
6897
|
+
}
|
|
6898
|
+
/** Every element of the column's JSON array is one of the values (`[]` is). */
|
|
6899
|
+
containedBy(column, values) {
|
|
6900
|
+
return this.condition("containedBy", column, copyList(values));
|
|
6901
|
+
}
|
|
6902
|
+
/** The column's JSON array has at least one of the values. */
|
|
6903
|
+
overlaps(column, values) {
|
|
6904
|
+
return this.condition("overlaps", column, copyList(values));
|
|
6905
|
+
}
|
|
6906
|
+
/** An `eq` for each key, in key order. */
|
|
6907
|
+
match(query) {
|
|
6908
|
+
const conditions = Object.entries(query).map(
|
|
6909
|
+
([column, value]) => ({
|
|
6910
|
+
operator: "eq",
|
|
6911
|
+
column,
|
|
6912
|
+
value
|
|
6913
|
+
})
|
|
6914
|
+
);
|
|
6915
|
+
return this.next({ filters: [...this.state.filters, ...conditions] });
|
|
6916
|
+
}
|
|
6917
|
+
/**
|
|
6918
|
+
* Any operator by name, Supabase's escape hatch: `filter("id", "in",
|
|
6919
|
+
* "(1,2)")`, `filter("tags", "cs", "{a}")`, `filter("n", "not.gte", 10)`.
|
|
6920
|
+
* A string value is PostgREST syntax where the operator needs one, and text
|
|
6921
|
+
* otherwise.
|
|
6922
|
+
*/
|
|
6923
|
+
filter(column, operator, value) {
|
|
6924
|
+
const name = operator;
|
|
6925
|
+
const negated = typeof name === "string" && name.startsWith("not.");
|
|
6926
|
+
const parsed = conditionOf(
|
|
6927
|
+
column,
|
|
6928
|
+
negated ? name.slice(4) : name,
|
|
6929
|
+
value
|
|
6930
|
+
);
|
|
6931
|
+
if (!parsed.ok) return this.fail("filter", parsed.reason);
|
|
6932
|
+
return this.add(negated ? { not: parsed.node } : parsed.node);
|
|
6933
|
+
}
|
|
6934
|
+
not(target, operator, value) {
|
|
6935
|
+
if (typeof target === "function") {
|
|
6936
|
+
const nodes = this.group("not", target);
|
|
6937
|
+
if (!Array.isArray(nodes)) return this.failWith(nodes);
|
|
6938
|
+
const [only] = nodes;
|
|
6939
|
+
return this.add({
|
|
6940
|
+
not: nodes.length === 1 && only ? only : { and: nodes }
|
|
6941
|
+
});
|
|
6942
|
+
}
|
|
6943
|
+
const parsed = conditionOf(target, operator, value);
|
|
6944
|
+
if (!parsed.ok) return this.fail("not", parsed.reason);
|
|
6945
|
+
return this.add({ not: parsed.node });
|
|
6946
|
+
}
|
|
6947
|
+
/**
|
|
6948
|
+
* Rows matching at least one of the conditions: a callback,
|
|
6949
|
+
* `or((q) => q.eq("status", "draft").lt("price", 10))`, or Supabase's
|
|
6950
|
+
* string, `or("status.eq.draft,price.lt.10")`, whose values are text.
|
|
6951
|
+
*/
|
|
6952
|
+
or(filters) {
|
|
6953
|
+
return this.logic("or", filters);
|
|
6954
|
+
}
|
|
6955
|
+
/** Rows matching all of the conditions, as a group: for use inside `or`. */
|
|
6956
|
+
and(filters) {
|
|
6957
|
+
return this.logic("and", filters);
|
|
6958
|
+
}
|
|
6959
|
+
logic(kind, filters) {
|
|
6960
|
+
if (typeof filters === "function") {
|
|
6961
|
+
const nodes = this.group(kind, filters);
|
|
6962
|
+
if (!Array.isArray(nodes)) return this.failWith(nodes);
|
|
6963
|
+
return this.add(kind === "or" ? { or: nodes } : { and: nodes });
|
|
6964
|
+
}
|
|
6965
|
+
const parsed = parseLogic2(filters, kind);
|
|
6966
|
+
if (!parsed.ok) return this.fail(kind, parsed.reason);
|
|
6967
|
+
return this.add(parsed.node);
|
|
6968
|
+
}
|
|
6969
|
+
/**
|
|
6970
|
+
* The conditions `build` adds, or why there are none to use. `build` gets
|
|
6971
|
+
* a builder of this kind with no filters; FilterGroup shows only its filter
|
|
6972
|
+
* methods, and only its conditions and first problem are read back.
|
|
6973
|
+
*/
|
|
6974
|
+
group(path, build) {
|
|
6975
|
+
const built = build(this.next({ filters: [], problem: null }));
|
|
6976
|
+
if (!(built instanceof _FilterBuilder))
|
|
6977
|
+
return invalidQuery(path, "must return the filter builder it was given");
|
|
6978
|
+
const { filters, problem } = built.state;
|
|
6979
|
+
if (problem !== null) return problem;
|
|
6980
|
+
if (filters.length === 0) return invalidQuery(path, "must not be empty");
|
|
6981
|
+
return [...filters];
|
|
6982
|
+
}
|
|
6983
|
+
filtersPart() {
|
|
6984
|
+
return this.state.filters.length === 0 ? {} : { filters: [...this.state.filters] };
|
|
6985
|
+
}
|
|
6986
|
+
};
|
|
6987
|
+
var SelectBuilder = class extends FilterBuilder {
|
|
6988
|
+
/** Call again to sort by more columns, in call order. */
|
|
6989
|
+
order(column, options = {}) {
|
|
6990
|
+
const term = { column, ascending: options.ascending ?? true };
|
|
6991
|
+
if (options.nullsFirst !== void 0) term.nullsFirst = options.nullsFirst;
|
|
6992
|
+
return this.next({ order: [...this.state.order, term] });
|
|
6993
|
+
}
|
|
6994
|
+
/** At most `count` rows (1–1,000; a select without one reads up to 1,000). */
|
|
6995
|
+
limit(count2) {
|
|
6996
|
+
if (!Number.isSafeInteger(count2) || count2 < 1 || count2 > MAX_LIMIT)
|
|
6997
|
+
return this.fail(
|
|
6998
|
+
"limit",
|
|
6999
|
+
`must be an integer from 1 to ${String(MAX_LIMIT)}; ${CAP}`
|
|
7000
|
+
);
|
|
7001
|
+
return this.next({ limit: count2 });
|
|
7002
|
+
}
|
|
7003
|
+
offset(count2) {
|
|
7004
|
+
return this.next({ offset: count2 });
|
|
7005
|
+
}
|
|
7006
|
+
/**
|
|
7007
|
+
* Rows `from` to `to`, both inclusive and counted from 0 — at most 1,000
|
|
7008
|
+
* of them. Sent as `offset` and `limit`, so it is checked here: past the
|
|
7009
|
+
* cap the worker would name `limit`, which the app never called.
|
|
7010
|
+
*/
|
|
7011
|
+
range(from, to) {
|
|
7012
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 0 || to < from)
|
|
7013
|
+
return this.fail("range", "must be two integers with 0 <= from <= to");
|
|
7014
|
+
const rows = to - from + 1;
|
|
7015
|
+
if (rows > MAX_LIMIT)
|
|
7016
|
+
return this.fail(
|
|
7017
|
+
"range",
|
|
7018
|
+
`spans ${String(rows)} rows (to - from + 1); ${CAP}`
|
|
7019
|
+
);
|
|
7020
|
+
return this.next({ offset: from, limit: rows });
|
|
7021
|
+
}
|
|
7022
|
+
/** Exactly one row, or NOT_SINGLE_ROW. */
|
|
7023
|
+
single() {
|
|
7024
|
+
return this.next({ single: "one" });
|
|
7025
|
+
}
|
|
7026
|
+
/** One row or null; more than one is NOT_SINGLE_ROW. */
|
|
7027
|
+
maybeSingle() {
|
|
7028
|
+
return this.next({ single: "maybe" });
|
|
7029
|
+
}
|
|
7030
|
+
build() {
|
|
7031
|
+
const { table: table2, columns, order, limit, offset, single } = this.state;
|
|
7032
|
+
return {
|
|
7033
|
+
version: 1,
|
|
7034
|
+
action: "select",
|
|
7035
|
+
table: table2,
|
|
7036
|
+
columns: columns === "*" ? "*" : [...columns],
|
|
7037
|
+
...this.filtersPart(),
|
|
7038
|
+
...order.length === 0 ? {} : { order: order.map((term) => ({ ...term })) },
|
|
7039
|
+
...limit === void 0 ? {} : { limit },
|
|
7040
|
+
...offset === void 0 ? {} : { offset },
|
|
7041
|
+
...single === void 0 ? {} : { single }
|
|
7042
|
+
};
|
|
7043
|
+
}
|
|
7044
|
+
};
|
|
7045
|
+
var InsertBuilder = class extends Builder {
|
|
7046
|
+
/** Return the inserted rows (all columns, or the ones named). */
|
|
7047
|
+
select(columns) {
|
|
7048
|
+
return this.withReturning(columns);
|
|
7049
|
+
}
|
|
7050
|
+
build() {
|
|
7051
|
+
const { table: table2, rows = [], returning } = this.state;
|
|
7052
|
+
return {
|
|
7053
|
+
version: 1,
|
|
7054
|
+
action: "insert",
|
|
7055
|
+
table: table2,
|
|
7056
|
+
rows: rows.map(copyRow),
|
|
7057
|
+
...returning === void 0 ? {} : { returning }
|
|
7058
|
+
};
|
|
7059
|
+
}
|
|
7060
|
+
};
|
|
7061
|
+
var MutationBuilder = class extends FilterBuilder {
|
|
7062
|
+
/** Return the changed rows (all columns, or the ones named). */
|
|
7063
|
+
select(columns) {
|
|
7064
|
+
return this.withReturning(columns);
|
|
7065
|
+
}
|
|
7066
|
+
/** Apply to every row with no filter at all. Service key only. */
|
|
7067
|
+
all() {
|
|
7068
|
+
return this.next({ allowUnfiltered: true });
|
|
7069
|
+
}
|
|
7070
|
+
build() {
|
|
7071
|
+
const {
|
|
7072
|
+
table: table2,
|
|
7073
|
+
action,
|
|
7074
|
+
values = {},
|
|
7075
|
+
returning,
|
|
7076
|
+
allowUnfiltered
|
|
7077
|
+
} = this.state;
|
|
7078
|
+
const tail = {
|
|
7079
|
+
...this.filtersPart(),
|
|
7080
|
+
...returning === void 0 ? {} : { returning },
|
|
7081
|
+
...allowUnfiltered === void 0 ? {} : { allowUnfiltered }
|
|
7082
|
+
};
|
|
7083
|
+
return action === "delete" ? { version: 1, action: "delete", table: table2, ...tail } : {
|
|
7084
|
+
version: 1,
|
|
7085
|
+
action: "update",
|
|
7086
|
+
table: table2,
|
|
7087
|
+
values: copyRow(values),
|
|
7088
|
+
...tail
|
|
7089
|
+
};
|
|
7090
|
+
}
|
|
7091
|
+
};
|
|
7092
|
+
var TableBuilder = class {
|
|
7093
|
+
#run;
|
|
7094
|
+
#table;
|
|
7095
|
+
constructor(run, table2) {
|
|
7096
|
+
this.#run = run;
|
|
7097
|
+
this.#table = table2;
|
|
7098
|
+
}
|
|
7099
|
+
/** `"*"` (the default) or `"id, title"`. */
|
|
7100
|
+
select(columns) {
|
|
7101
|
+
const state = initialState(this.#table);
|
|
7102
|
+
const parsed = parseColumns(columns);
|
|
7103
|
+
return new SelectBuilder(
|
|
7104
|
+
this.#run,
|
|
7105
|
+
parsed.ok ? { ...state, columns: parsed.columns } : { ...state, problem: invalidQuery("columns", parsed.reason) }
|
|
7106
|
+
);
|
|
7107
|
+
}
|
|
7108
|
+
/** One row or several; one request, one transaction. */
|
|
7109
|
+
insert(rows) {
|
|
7110
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
7111
|
+
return new InsertBuilder(this.#run, {
|
|
7112
|
+
...initialState(this.#table),
|
|
7113
|
+
rows: list.map(copyRow)
|
|
7114
|
+
});
|
|
7115
|
+
}
|
|
7116
|
+
update(values) {
|
|
7117
|
+
return new MutationBuilder(this.#run, {
|
|
7118
|
+
...initialState(this.#table),
|
|
7119
|
+
action: "update",
|
|
7120
|
+
values: copyRow(values)
|
|
7121
|
+
});
|
|
7122
|
+
}
|
|
7123
|
+
delete() {
|
|
7124
|
+
return new MutationBuilder(this.#run, {
|
|
7125
|
+
...initialState(this.#table),
|
|
7126
|
+
action: "delete"
|
|
7127
|
+
});
|
|
7128
|
+
}
|
|
7129
|
+
};
|
|
7130
|
+
var MAX_SINGLE_UPLOAD_BYTES2 = 52428800;
|
|
7131
|
+
var STORAGE_MESSAGES = {
|
|
7132
|
+
bucket: "The bucket name is not valid.",
|
|
7133
|
+
tooLarge: "Uploads over 50 MiB are not supported yet.",
|
|
7134
|
+
request: "The storage request is not valid.",
|
|
7135
|
+
body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
|
|
7136
|
+
};
|
|
7137
|
+
var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
|
|
7138
|
+
"authenticated",
|
|
7139
|
+
"copy",
|
|
7140
|
+
"info",
|
|
7141
|
+
"list",
|
|
7142
|
+
"move",
|
|
7143
|
+
"public",
|
|
7144
|
+
"render",
|
|
7145
|
+
"sign",
|
|
7146
|
+
"upload"
|
|
7147
|
+
]);
|
|
7148
|
+
var DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
|
|
7149
|
+
var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
7150
|
+
var OCTET_STREAM = "application/octet-stream";
|
|
7151
|
+
var UTF8_TEXT = "text/plain;charset=utf-8";
|
|
7152
|
+
function invalidPath(reason) {
|
|
7153
|
+
return {
|
|
7154
|
+
code: "INVALID_PATH",
|
|
7155
|
+
message: `The object path is not valid: it ${reason}.`
|
|
7156
|
+
};
|
|
7157
|
+
}
|
|
7158
|
+
function objectTooLarge() {
|
|
7159
|
+
return { code: "OBJECT_TOO_LARGE", message: STORAGE_MESSAGES.tooLarge };
|
|
7160
|
+
}
|
|
7161
|
+
function encodePath(path) {
|
|
7162
|
+
if (typeof path !== "string" || path === "")
|
|
7163
|
+
return { ok: false, error: invalidPath("is empty") };
|
|
7164
|
+
if (LONE_SURROGATE.test(path))
|
|
7165
|
+
return { ok: false, error: invalidPath("is not valid UTF-8") };
|
|
7166
|
+
if (path.startsWith("/") || path.endsWith("/"))
|
|
7167
|
+
return { ok: false, error: invalidPath("starts or ends with /") };
|
|
7168
|
+
if (path.includes("//"))
|
|
7169
|
+
return { ok: false, error: invalidPath("has an empty segment (//)") };
|
|
7170
|
+
const segments2 = path.split("/");
|
|
7171
|
+
if (segments2.some((segment) => segment === "." || segment === ".."))
|
|
7172
|
+
return { ok: false, error: invalidPath("has a . or .. segment") };
|
|
7173
|
+
return { ok: true, encoded: segments2.map(encodeURIComponent).join("/") };
|
|
7174
|
+
}
|
|
7175
|
+
function downloadQuery(download2) {
|
|
7176
|
+
if (download2 === true) return { ok: true, encoded: "?download" };
|
|
7177
|
+
if (typeof download2 !== "string") return { ok: true, encoded: "" };
|
|
7178
|
+
if (LONE_SURROGATE.test(download2))
|
|
7179
|
+
return {
|
|
7180
|
+
ok: false,
|
|
7181
|
+
error: {
|
|
7182
|
+
code: "INVALID_REQUEST",
|
|
7183
|
+
message: STORAGE_MESSAGES.request,
|
|
7184
|
+
details: { path: "download", reason: DOWNLOAD_NAME_REASON }
|
|
7185
|
+
}
|
|
7186
|
+
};
|
|
7187
|
+
return { ok: true, encoded: `?download=${encodeURIComponent(download2)}` };
|
|
7188
|
+
}
|
|
7189
|
+
function encodeBucket(bucket) {
|
|
7190
|
+
if (typeof bucket !== "string" || bucket === "" || bucket === "." || bucket === ".." || LONE_SURROGATE.test(bucket) || RESERVED_BUCKETS.has(bucket))
|
|
7191
|
+
return {
|
|
7192
|
+
ok: false,
|
|
7193
|
+
error: { code: "INVALID_PATH", message: STORAGE_MESSAGES.bucket }
|
|
7194
|
+
};
|
|
7195
|
+
return { ok: true, encoded: encodeURIComponent(bucket) };
|
|
7196
|
+
}
|
|
7197
|
+
var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
|
|
7198
|
+
function isDetached(buffer) {
|
|
7199
|
+
if (!isArrayBuffer(buffer)) return false;
|
|
7200
|
+
const flag = buffer.detached;
|
|
7201
|
+
if (typeof flag === "boolean") return flag;
|
|
7202
|
+
if (buffer.byteLength > 0) return false;
|
|
7203
|
+
try {
|
|
7204
|
+
buffer.slice(0);
|
|
7205
|
+
return false;
|
|
7206
|
+
} catch {
|
|
7207
|
+
return true;
|
|
7208
|
+
}
|
|
7209
|
+
}
|
|
7210
|
+
var CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
|
|
7211
|
+
var MAX_TYPE_IN_MESSAGE = 256;
|
|
7212
|
+
function unsendable(value) {
|
|
7213
|
+
return /[\0\r\n]|[^\0-\xff]/.test(
|
|
7214
|
+
value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "")
|
|
7215
|
+
);
|
|
7216
|
+
}
|
|
7217
|
+
function headerProblem(contentType, cacheControl) {
|
|
7218
|
+
if (typeof contentType === "string" && unsendable(contentType)) {
|
|
7219
|
+
let shown = contentType;
|
|
7220
|
+
if (shown.length > MAX_TYPE_IN_MESSAGE) {
|
|
7221
|
+
shown = shown.slice(0, MAX_TYPE_IN_MESSAGE);
|
|
7222
|
+
if (/[\ud800-\udbff]$/.test(shown)) shown = shown.slice(0, -1);
|
|
7223
|
+
}
|
|
7224
|
+
return {
|
|
7225
|
+
code: "INVALID_CONTENT_TYPE",
|
|
7226
|
+
message: `Content-Type ${shown} is not a media type like image/png.`
|
|
7227
|
+
};
|
|
7228
|
+
}
|
|
7229
|
+
if (unsendable(cacheControl))
|
|
7230
|
+
return {
|
|
7231
|
+
code: "INVALID_REQUEST",
|
|
7232
|
+
message: STORAGE_MESSAGES.request,
|
|
7233
|
+
details: { path: "cache-control", reason: CACHE_CONTROL_REASON }
|
|
7234
|
+
};
|
|
7235
|
+
return null;
|
|
7236
|
+
}
|
|
7237
|
+
function payloadOf(body, contentType) {
|
|
7238
|
+
if (typeof body === "string") {
|
|
7239
|
+
const bytes = new TextEncoder().encode(body);
|
|
7240
|
+
return {
|
|
7241
|
+
ok: true,
|
|
7242
|
+
body: bytes,
|
|
7243
|
+
size: bytes.byteLength,
|
|
7244
|
+
type: contentType ?? UTF8_TEXT
|
|
7245
|
+
};
|
|
7246
|
+
}
|
|
7247
|
+
if (typeof Blob === "function" && body instanceof Blob)
|
|
7248
|
+
return {
|
|
7249
|
+
ok: true,
|
|
7250
|
+
body,
|
|
7251
|
+
size: body.size,
|
|
7252
|
+
type: contentType ?? (body.type === "" ? OCTET_STREAM : body.type)
|
|
7253
|
+
};
|
|
7254
|
+
if (isArrayBuffer(body) && !isDetached(body))
|
|
7255
|
+
return {
|
|
7256
|
+
ok: true,
|
|
7257
|
+
body,
|
|
7258
|
+
size: body.byteLength,
|
|
7259
|
+
type: contentType ?? OCTET_STREAM
|
|
7260
|
+
};
|
|
7261
|
+
if (ArrayBuffer.isView(body) && !isDetached(body.buffer)) {
|
|
7262
|
+
const { buffer, byteOffset, byteLength } = body;
|
|
7263
|
+
const bytes = isArrayBuffer(buffer) ? new Uint8Array(buffer, byteOffset, byteLength) : Uint8Array.from(new Uint8Array(buffer, byteOffset, byteLength));
|
|
7264
|
+
return {
|
|
7265
|
+
ok: true,
|
|
7266
|
+
body: bytes,
|
|
7267
|
+
size: byteLength,
|
|
7268
|
+
type: contentType ?? OCTET_STREAM
|
|
7269
|
+
};
|
|
7270
|
+
}
|
|
7271
|
+
return {
|
|
7272
|
+
ok: false,
|
|
7273
|
+
error: { code: "INVALID_BODY", message: STORAGE_MESSAGES.body }
|
|
7274
|
+
};
|
|
7275
|
+
}
|
|
7276
|
+
var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
|
|
7277
|
+
function fail(error) {
|
|
7278
|
+
return { data: null, error };
|
|
7279
|
+
}
|
|
7280
|
+
var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
|
|
7281
|
+
var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
|
|
7282
|
+
var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
|
|
7283
|
+
var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
|
|
7284
|
+
function listOf(guard) {
|
|
7285
|
+
return (value) => Array.isArray(value) && value.every(guard);
|
|
7286
|
+
}
|
|
7287
|
+
function dataOf2(status, body, guard) {
|
|
7288
|
+
if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
|
|
7289
|
+
return { data: body.data, error: null };
|
|
7290
|
+
return fail(invalidResponse(status));
|
|
7291
|
+
}
|
|
7292
|
+
async function readJson(response, guard) {
|
|
7293
|
+
let text;
|
|
7294
|
+
try {
|
|
7295
|
+
text = await response.text();
|
|
7296
|
+
} catch (error) {
|
|
7297
|
+
return fail(networkError(error));
|
|
7298
|
+
}
|
|
7299
|
+
let body;
|
|
7300
|
+
try {
|
|
7301
|
+
body = JSON.parse(text);
|
|
7302
|
+
} catch {
|
|
7303
|
+
return fail(invalidResponse(response.status));
|
|
7304
|
+
}
|
|
7305
|
+
return dataOf2(response.status, body, guard);
|
|
7306
|
+
}
|
|
7307
|
+
var StorageFileApi = class {
|
|
7308
|
+
#http;
|
|
7309
|
+
#auth;
|
|
7310
|
+
#bucket;
|
|
7311
|
+
constructor(http, auth, bucket) {
|
|
7312
|
+
this.#http = http;
|
|
7313
|
+
this.#auth = auth;
|
|
7314
|
+
this.#bucket = bucket;
|
|
7315
|
+
}
|
|
7316
|
+
/** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
|
|
7317
|
+
#objectPath(route2, path) {
|
|
7318
|
+
const bucket = encodeBucket(this.#bucket);
|
|
7319
|
+
if (!bucket.ok) return bucket;
|
|
7320
|
+
const name = encodePath(path);
|
|
7321
|
+
if (!name.ok) return name;
|
|
7322
|
+
return {
|
|
7323
|
+
ok: true,
|
|
7324
|
+
path: `/storage/object/${route2}${bucket.encoded}/${name.encoded}`
|
|
7325
|
+
};
|
|
7326
|
+
}
|
|
7327
|
+
/** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
|
|
7328
|
+
#bucketPath(route2) {
|
|
7329
|
+
const bucket = encodeBucket(this.#bucket);
|
|
7330
|
+
if (!bucket.ok) return bucket;
|
|
7331
|
+
return { ok: true, path: `/storage/object/${route2}${bucket.encoded}` };
|
|
7332
|
+
}
|
|
7333
|
+
async #json(request, guard) {
|
|
7334
|
+
await this.#auth.ready();
|
|
7335
|
+
const result = await call(this.#http, { ...request, withToken: true });
|
|
7336
|
+
if (result.error !== null) return fail(result.error);
|
|
7337
|
+
return dataOf2(result.answer.status, result.answer.body, guard);
|
|
7338
|
+
}
|
|
7339
|
+
async #write(method, path, body, options, upsert) {
|
|
7340
|
+
const target = this.#objectPath("", path);
|
|
7341
|
+
if (!target.ok) return fail(target.error);
|
|
7342
|
+
const payload = payloadOf(body, options.contentType);
|
|
7343
|
+
if (!payload.ok) return fail(payload.error);
|
|
7344
|
+
const cache = `max-age=${options.cacheControl ?? "3600"}`;
|
|
7345
|
+
const unreadable = headerProblem(options.contentType, cache);
|
|
7346
|
+
if (unreadable !== null) return fail(unreadable);
|
|
7347
|
+
if (payload.size > MAX_SINGLE_UPLOAD_BYTES2) return fail(objectTooLarge());
|
|
7348
|
+
await this.#auth.ready();
|
|
7349
|
+
const headers = {
|
|
7350
|
+
"content-type": payload.type,
|
|
7351
|
+
"cache-control": cache
|
|
7352
|
+
};
|
|
7353
|
+
if (upsert) headers["x-upsert"] = "true";
|
|
7354
|
+
const sent = await send(this.#http, {
|
|
7355
|
+
method,
|
|
7356
|
+
path: target.path,
|
|
7357
|
+
headers,
|
|
7358
|
+
body: payload.body,
|
|
7359
|
+
withToken: true
|
|
7360
|
+
});
|
|
7361
|
+
if (sent.error !== null) return fail(sent.error);
|
|
7362
|
+
return readJson(sent.response, isUploadResult);
|
|
7363
|
+
}
|
|
7364
|
+
/**
|
|
7365
|
+
* A new object; with `upsert: true`, one of the same name is replaced.
|
|
7366
|
+
* `null` options, as a JS caller may pass, are no options.
|
|
7367
|
+
*/
|
|
7368
|
+
upload(path, body, options = {}) {
|
|
7369
|
+
const given = options ?? {};
|
|
7370
|
+
return this.#write("POST", path, body, given, given.upsert === true);
|
|
7371
|
+
}
|
|
7372
|
+
/** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
|
|
7373
|
+
update(path, body, options = {}) {
|
|
7374
|
+
return this.#write("PUT", path, body, options ?? {}, false);
|
|
7375
|
+
}
|
|
7376
|
+
/**
|
|
7377
|
+
* The object's bytes, as a Blob of the type the worker serves it under:
|
|
7378
|
+
* the stored type for the images, audio, video, PDF, plain text, CSV,
|
|
7379
|
+
* JSON and SVG it serves as themselves, `application/octet-stream` for
|
|
7380
|
+
* anything else (HTML, scripts, archives, unknown types — `info` and
|
|
7381
|
+
* `list` hold the stored type).
|
|
7382
|
+
*/
|
|
7383
|
+
async download(path) {
|
|
7384
|
+
const target = this.#objectPath("", path);
|
|
7385
|
+
if (!target.ok) return fail(target.error);
|
|
7386
|
+
await this.#auth.ready();
|
|
7387
|
+
const sent = await send(this.#http, {
|
|
7388
|
+
method: "GET",
|
|
7389
|
+
path: target.path,
|
|
7390
|
+
withToken: true
|
|
7391
|
+
});
|
|
7392
|
+
if (sent.error !== null) return fail(sent.error);
|
|
7393
|
+
try {
|
|
7394
|
+
return { data: await sent.response.blob(), error: null };
|
|
7395
|
+
} catch (error) {
|
|
7396
|
+
return fail(networkError(error));
|
|
7397
|
+
}
|
|
7398
|
+
}
|
|
7399
|
+
async info(path) {
|
|
7400
|
+
const target = this.#objectPath("info/", path);
|
|
7401
|
+
if (!target.ok) return fail(target.error);
|
|
7402
|
+
return this.#json({ method: "GET", path: target.path }, isFileObject);
|
|
7403
|
+
}
|
|
7404
|
+
/**
|
|
7405
|
+
* Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
|
|
7406
|
+
* every other error comes back as it is (plan C1 — asked with `info`,
|
|
7407
|
+
* not HEAD, whose answer has no body to tell a missing bucket from a
|
|
7408
|
+
* missing object).
|
|
7409
|
+
*/
|
|
7410
|
+
async exists(path) {
|
|
7411
|
+
const found = await this.info(path);
|
|
7412
|
+
if (found.error === null) return { data: true, error: null };
|
|
7413
|
+
if (found.error.code === "OBJECT_NOT_FOUND")
|
|
7414
|
+
return { data: false, error: null };
|
|
7415
|
+
return fail(found.error);
|
|
7416
|
+
}
|
|
7417
|
+
/**
|
|
7418
|
+
* What is directly below `prefix` (the whole bucket by default): folders
|
|
7419
|
+
* first, then files. The options go to the worker as they are given.
|
|
7420
|
+
*/
|
|
7421
|
+
async list(prefix, options = {}) {
|
|
7422
|
+
const target = this.#bucketPath("list/");
|
|
7423
|
+
if (!target.ok) return fail(target.error);
|
|
7424
|
+
return this.#json(
|
|
7425
|
+
{
|
|
7426
|
+
method: "POST",
|
|
7427
|
+
path: target.path,
|
|
7428
|
+
body: { ...prefix === void 0 ? {} : { prefix }, ...options }
|
|
7429
|
+
},
|
|
7430
|
+
listOf(isEntry2)
|
|
7431
|
+
);
|
|
7432
|
+
}
|
|
7433
|
+
/** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
|
|
7434
|
+
async remove(paths) {
|
|
7435
|
+
const target = this.#bucketPath("");
|
|
7436
|
+
if (!target.ok) return fail(target.error);
|
|
7437
|
+
return this.#json(
|
|
7438
|
+
{ method: "DELETE", path: target.path, body: { prefixes: paths } },
|
|
7439
|
+
listOf(isFileObject)
|
|
7440
|
+
);
|
|
7441
|
+
}
|
|
7442
|
+
/**
|
|
7443
|
+
* A link to the object for `expiresIn` seconds (1–604,800). It stops
|
|
7444
|
+
* working once the object is replaced or removed.
|
|
7445
|
+
*/
|
|
7446
|
+
async createSignedUrl(path, expiresIn, options = {}) {
|
|
7447
|
+
const target = this.#objectPath("sign/", path);
|
|
7448
|
+
if (!target.ok) return fail(target.error);
|
|
7449
|
+
const { download: download2 } = options ?? {};
|
|
7450
|
+
const signed = await this.#json(
|
|
7451
|
+
{
|
|
7452
|
+
method: "POST",
|
|
7453
|
+
path: target.path,
|
|
7454
|
+
body: {
|
|
7455
|
+
expiresIn,
|
|
7456
|
+
...download2 === void 0 || download2 === false ? {} : { download: download2 }
|
|
7457
|
+
}
|
|
7458
|
+
},
|
|
7459
|
+
isSigned
|
|
7460
|
+
);
|
|
7461
|
+
if (signed.error !== null) return signed;
|
|
7462
|
+
return {
|
|
7463
|
+
data: { signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}` },
|
|
7464
|
+
error: null
|
|
7465
|
+
};
|
|
7466
|
+
}
|
|
7467
|
+
/**
|
|
7468
|
+
* A public bucket's URL for the object. No request is made, so whether
|
|
7469
|
+
* the bucket is public is not checked (as in supabase-js); a path no URL
|
|
7470
|
+
* can carry is INVALID_PATH (plan C3), and a download name none can carry
|
|
7471
|
+
* is the worker's INVALID_REQUEST for it.
|
|
7472
|
+
*/
|
|
7473
|
+
getPublicUrl(path, options = {}) {
|
|
7474
|
+
const target = this.#objectPath("public/", path);
|
|
7475
|
+
if (!target.ok) return fail(target.error);
|
|
7476
|
+
const { download: download2 } = options ?? {};
|
|
7477
|
+
const query = downloadQuery(download2);
|
|
7478
|
+
if (!query.ok) return fail(query.error);
|
|
7479
|
+
return {
|
|
7480
|
+
data: {
|
|
7481
|
+
publicUrl: `${this.#http.baseUrl}/_naro/v1${target.path}${query.encoded}`
|
|
7482
|
+
},
|
|
7483
|
+
error: null
|
|
7484
|
+
};
|
|
7485
|
+
}
|
|
7486
|
+
};
|
|
7487
|
+
var StorageClient = class {
|
|
7488
|
+
#http;
|
|
7489
|
+
#auth;
|
|
7490
|
+
constructor(http, auth) {
|
|
7491
|
+
this.#http = http;
|
|
7492
|
+
this.#auth = auth;
|
|
7493
|
+
}
|
|
7494
|
+
/** The objects of one bucket. */
|
|
7495
|
+
from(bucket) {
|
|
7496
|
+
return new StorageFileApi(this.#http, this.#auth, bucket);
|
|
7497
|
+
}
|
|
7498
|
+
};
|
|
7499
|
+
var CONFIG_MESSAGES = {
|
|
7500
|
+
url: "createClient needs the project url, like https://my-app.naro.sh.",
|
|
7501
|
+
urlNotOrigin: "createClient needs the project url alone, like https://my-app.naro.sh: no path, no ? or #, no user:password@. Naro serves its API at /_naro/v1 on that origin.",
|
|
7502
|
+
key: "createClient needs an anonKey (or, on a server, a serviceKey).",
|
|
7503
|
+
bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
|
|
7504
|
+
serviceKeyOffServer: "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.",
|
|
7505
|
+
fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
|
|
7506
|
+
};
|
|
7507
|
+
var SERVICE_KEY_PREFIX = "naro_sk_";
|
|
7508
|
+
function baseUrlOf(url) {
|
|
7509
|
+
if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
|
|
7510
|
+
const trimmed = url.trim();
|
|
7511
|
+
let parsed;
|
|
7512
|
+
try {
|
|
7513
|
+
parsed = new URL(trimmed.replace(/\/+$/, ""));
|
|
7514
|
+
} catch {
|
|
7515
|
+
throw new Error(CONFIG_MESSAGES.url);
|
|
7516
|
+
}
|
|
7517
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
7518
|
+
throw new Error(CONFIG_MESSAGES.url);
|
|
7519
|
+
if (trimmed.includes("?") || trimmed.includes("#") || parsed.pathname !== "/" || parsed.username !== "" || parsed.password !== "")
|
|
7520
|
+
throw new Error(CONFIG_MESSAGES.urlNotOrigin);
|
|
7521
|
+
return parsed.origin;
|
|
7522
|
+
}
|
|
7523
|
+
function apiKeyOf(options) {
|
|
7524
|
+
const { anonKey, serviceKey } = options;
|
|
7525
|
+
const hasAnon = typeof anonKey === "string" && anonKey !== "";
|
|
7526
|
+
const hasService = typeof serviceKey === "string" && serviceKey !== "";
|
|
7527
|
+
if (hasAnon && hasService) throw new Error(CONFIG_MESSAGES.bothKeys);
|
|
7528
|
+
const key = hasService ? serviceKey : hasAnon ? anonKey : void 0;
|
|
7529
|
+
if (key === void 0) throw new Error(CONFIG_MESSAGES.key);
|
|
7530
|
+
const service = hasService || key.startsWith(SERVICE_KEY_PREFIX);
|
|
7531
|
+
if (service && !isKnownServer())
|
|
7532
|
+
throw new Error(CONFIG_MESSAGES.serviceKeyOffServer);
|
|
7533
|
+
return { key, service };
|
|
7534
|
+
}
|
|
7535
|
+
function queryRunner(http, auth) {
|
|
7536
|
+
return async (query) => {
|
|
7537
|
+
await auth.ready();
|
|
7538
|
+
const result = await call(http, {
|
|
7539
|
+
method: "POST",
|
|
7540
|
+
path: "/data/query",
|
|
7541
|
+
body: query,
|
|
7542
|
+
withToken: true
|
|
7543
|
+
});
|
|
7544
|
+
if (result.error !== null) return { data: null, error: result.error };
|
|
7545
|
+
const { body, status } = result.answer;
|
|
7546
|
+
if (!isRecord(body) || !Object.hasOwn(body, "data"))
|
|
7547
|
+
return { data: null, error: invalidResponse(status) };
|
|
7548
|
+
return { data: body.data, error: null };
|
|
7549
|
+
};
|
|
7550
|
+
}
|
|
7551
|
+
var NaroClient2 = class {
|
|
7552
|
+
/**
|
|
7553
|
+
* Sign-up, sign-in, sign-out and the current user (spec §13); with the
|
|
7554
|
+
* service key, `auth.admin` manages users.
|
|
7555
|
+
*/
|
|
7556
|
+
auth;
|
|
7557
|
+
/**
|
|
7558
|
+
* Files in the project's Naro Storage (first-release design 2026-10-06
|
|
7559
|
+
* §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
|
|
7560
|
+
* them, as supabase-js does.
|
|
7561
|
+
*/
|
|
7562
|
+
storage;
|
|
7563
|
+
#run;
|
|
7564
|
+
constructor(options) {
|
|
7565
|
+
const baseUrl = baseUrlOf(options.url);
|
|
7566
|
+
const { key: apiKey, service } = apiKeyOf(options);
|
|
7567
|
+
if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
|
|
7568
|
+
throw new Error(CONFIG_MESSAGES.fetch);
|
|
7569
|
+
const storage = options.auth?.storage ?? defaultStorage();
|
|
7570
|
+
const http = {
|
|
7571
|
+
baseUrl,
|
|
7572
|
+
apiKey,
|
|
7573
|
+
fetch: options.fetch,
|
|
7574
|
+
tokens: new TokenStore(storage, storageKey(baseUrl))
|
|
7575
|
+
};
|
|
7576
|
+
this.auth = new AuthClient(http, {
|
|
7577
|
+
verifiers: new FlowVerifiers(
|
|
7578
|
+
new TokenStore(storage, verifierKey(baseUrl))
|
|
7579
|
+
),
|
|
7580
|
+
recovery: new RecoveryToken(
|
|
7581
|
+
new TokenStore(storage, recoveryKey(baseUrl))
|
|
7582
|
+
),
|
|
7583
|
+
detectSessionInUrl: options.auth?.detectSessionInUrl ?? true,
|
|
7584
|
+
shared: { storage, key: storageKey(baseUrl) },
|
|
7585
|
+
serviceKey: service
|
|
7586
|
+
});
|
|
7587
|
+
this.#run = queryRunner(http, this.auth);
|
|
7588
|
+
this.storage = new StorageClient(http, this.auth);
|
|
7589
|
+
}
|
|
7590
|
+
/** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
|
|
7591
|
+
from(table2) {
|
|
7592
|
+
return new TableBuilder(this.#run, table2);
|
|
7593
|
+
}
|
|
7594
|
+
};
|
|
7595
|
+
function createClient(options) {
|
|
7596
|
+
return new NaroClient2(options);
|
|
7597
|
+
}
|
|
7598
|
+
|
|
7599
|
+
// src/commands/storage-objects.ts
|
|
7600
|
+
var NARO_SCHEME = "naro://";
|
|
7601
|
+
var CP_USAGE = "Usage: naro storage cp <from> <to> (one side naro://<bucket>/<path>, the other a local file)";
|
|
7602
|
+
var RM_USAGE = "Usage: naro storage rm naro://<bucket>/<path>\u2026 [--recursive] [--yes]";
|
|
7603
|
+
var LS_USAGE = "Usage: naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]";
|
|
7604
|
+
var BACKEND_OFF_MESSAGE = "The backend is turned off for this project. Turn it on with `naro backend enable` first.";
|
|
7605
|
+
var PAGE = 1e3;
|
|
7606
|
+
var MAX_OFFSET = 1e5;
|
|
7607
|
+
var TRUNCATED_WARNING = `\u26A0 A folder's listing stopped at ${(MAX_OFFSET + PAGE).toLocaleString("en-US")} entries, as far as the API's offset reaches; there may be more.`;
|
|
7608
|
+
var REMOVE_BATCH = 1e3;
|
|
7609
|
+
var CACHE_CONTROL_RULE = "--cache-control takes whole seconds from 0 to 31536000 (a year), like 3600.";
|
|
7610
|
+
function isMaxAge(text) {
|
|
7611
|
+
return parseCacheControl(`max-age=${text}`).ok;
|
|
7612
|
+
}
|
|
7613
|
+
var TYPES = {
|
|
7614
|
+
".png": "image/png",
|
|
7615
|
+
".jpg": "image/jpeg",
|
|
7616
|
+
".jpeg": "image/jpeg",
|
|
7617
|
+
".gif": "image/gif",
|
|
7618
|
+
".webp": "image/webp",
|
|
7619
|
+
".avif": "image/avif",
|
|
7620
|
+
".svg": "image/svg+xml",
|
|
7621
|
+
".ico": "image/x-icon",
|
|
7622
|
+
".bmp": "image/bmp",
|
|
7623
|
+
".pdf": "application/pdf",
|
|
7624
|
+
".json": "application/json",
|
|
7625
|
+
".txt": "text/plain",
|
|
7626
|
+
".csv": "text/csv",
|
|
7627
|
+
".md": "text/markdown",
|
|
7628
|
+
".html": "text/html",
|
|
7629
|
+
".htm": "text/html",
|
|
7630
|
+
".css": "text/css",
|
|
7631
|
+
".js": "text/javascript",
|
|
7632
|
+
".mjs": "text/javascript",
|
|
7633
|
+
".xml": "application/xml",
|
|
7634
|
+
".zip": "application/zip",
|
|
7635
|
+
".gz": "application/gzip",
|
|
7636
|
+
".mp3": "audio/mpeg",
|
|
7637
|
+
".wav": "audio/wav",
|
|
7638
|
+
".ogg": "audio/ogg",
|
|
7639
|
+
".m4a": "audio/mp4",
|
|
7640
|
+
".mp4": "video/mp4",
|
|
7641
|
+
".webm": "video/webm",
|
|
7642
|
+
".mov": "video/quicktime",
|
|
7643
|
+
".woff": "font/woff",
|
|
7644
|
+
".woff2": "font/woff2",
|
|
7645
|
+
".ttf": "font/ttf"
|
|
7646
|
+
};
|
|
7647
|
+
function parseStorageUrl(text) {
|
|
7648
|
+
if (!text.startsWith(NARO_SCHEME)) return null;
|
|
7649
|
+
const rest = text.slice(NARO_SCHEME.length);
|
|
7650
|
+
const slash = rest.indexOf("/");
|
|
7651
|
+
const bucket = slash < 0 ? rest : rest.slice(0, slash);
|
|
7652
|
+
if (bucket === "")
|
|
7653
|
+
throw new NaroError(
|
|
7654
|
+
"VALIDATION_FAILED",
|
|
7655
|
+
"A naro:// path needs a bucket: naro://<bucket>/<path>."
|
|
7656
|
+
);
|
|
7657
|
+
return { bucket, path: slash < 0 ? "" : rest.slice(slash + 1) };
|
|
7658
|
+
}
|
|
7659
|
+
function typeFor(file) {
|
|
7660
|
+
return TYPES[extname(file).toLowerCase()];
|
|
7661
|
+
}
|
|
7662
|
+
function unwrap(result) {
|
|
7663
|
+
if (result.error === null) return result.data;
|
|
7664
|
+
const { code, message, details } = result.error;
|
|
7665
|
+
let shown = typeof details === "object" && details !== null && !Array.isArray(details) ? details : void 0;
|
|
7666
|
+
if (shown && code === "NETWORK_ERROR") {
|
|
7667
|
+
const { cause: _cause, ...rest } = shown;
|
|
7668
|
+
shown = Object.keys(rest).length > 0 ? rest : void 0;
|
|
7669
|
+
}
|
|
7670
|
+
throw new NaroError(code, message, shown ? { details: shown } : {});
|
|
7671
|
+
}
|
|
7672
|
+
var ONE_KEY = /^[\x21-\x7e]+$/;
|
|
7673
|
+
async function objects(context) {
|
|
7674
|
+
const client = await context.client();
|
|
7675
|
+
const projectId = await context.resolveProjectId();
|
|
7676
|
+
const backend = await client.backend.info(projectId);
|
|
7677
|
+
if (backend.status === "none")
|
|
7678
|
+
throw new NaroError(
|
|
7679
|
+
"BACKEND_NOT_READY",
|
|
7680
|
+
"This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
|
|
7681
|
+
);
|
|
7682
|
+
if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
|
|
7683
|
+
let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
|
|
7684
|
+
if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
|
|
7685
|
+
throw new NaroError(
|
|
7686
|
+
"VALIDATION_FAILED",
|
|
7687
|
+
"NARO_SERVICE_KEY is not a single key: set it to the service key alone."
|
|
7688
|
+
);
|
|
7689
|
+
if (serviceKey === "") {
|
|
7690
|
+
serviceKey = await client.backend.revealServiceKey(projectId);
|
|
7691
|
+
context.output.warn(SERVICE_KEY_WARNING);
|
|
7692
|
+
}
|
|
7693
|
+
let naro;
|
|
7694
|
+
try {
|
|
7695
|
+
naro = createClient({
|
|
7696
|
+
url: backend.url,
|
|
7697
|
+
serviceKey,
|
|
7698
|
+
auth: { detectSessionInUrl: false }
|
|
7699
|
+
});
|
|
7700
|
+
} catch (error) {
|
|
7701
|
+
throw new NaroError(
|
|
7702
|
+
"BACKEND_NOT_READY",
|
|
7703
|
+
`The project's backend url cannot be used (${printable(backend.url)}). Run \`naro backend\` to see where the backend got to.`,
|
|
7704
|
+
{ cause: error }
|
|
7705
|
+
);
|
|
7706
|
+
}
|
|
7707
|
+
return { projectId, storage: naro.storage };
|
|
7708
|
+
}
|
|
7709
|
+
function isFolder(entry) {
|
|
7710
|
+
return entry.id === null;
|
|
7711
|
+
}
|
|
7712
|
+
function fileEntry(path, object) {
|
|
7713
|
+
const { size, mimetype } = object.metadata;
|
|
7714
|
+
return {
|
|
7715
|
+
path,
|
|
7716
|
+
kind: "file",
|
|
7717
|
+
size: typeof size === "number" ? size : null,
|
|
7718
|
+
contentType: typeof mimetype === "string" ? mimetype : null,
|
|
7719
|
+
updatedAt: object.updated_at
|
|
7720
|
+
};
|
|
7721
|
+
}
|
|
7722
|
+
async function listAll(storage, bucket, prefix, recursive) {
|
|
7723
|
+
const entries = [];
|
|
7724
|
+
let truncated = false;
|
|
7725
|
+
const folders = [prefix];
|
|
7726
|
+
for (let folder = folders.shift(); folder !== void 0; folder = folders.shift()) {
|
|
7727
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
7728
|
+
if (offset > MAX_OFFSET) {
|
|
7729
|
+
truncated = true;
|
|
7730
|
+
break;
|
|
7731
|
+
}
|
|
7732
|
+
const page = unwrap(
|
|
7733
|
+
await storage.from(bucket).list(folder, { limit: PAGE, offset })
|
|
7734
|
+
);
|
|
7735
|
+
for (const entry of page) {
|
|
7736
|
+
const path = folder === "" ? entry.name : `${folder}/${entry.name}`;
|
|
7737
|
+
if (isFolder(entry)) {
|
|
7738
|
+
if (recursive) folders.push(path);
|
|
7739
|
+
else
|
|
7740
|
+
entries.push({
|
|
7741
|
+
path: `${path}/`,
|
|
7742
|
+
kind: "folder",
|
|
7743
|
+
size: null,
|
|
7744
|
+
contentType: null,
|
|
7745
|
+
updatedAt: null
|
|
7746
|
+
});
|
|
7747
|
+
continue;
|
|
7748
|
+
}
|
|
7749
|
+
entries.push(fileEntry(path, entry));
|
|
7750
|
+
}
|
|
7751
|
+
if (page.length < PAGE) break;
|
|
7752
|
+
}
|
|
7753
|
+
}
|
|
7754
|
+
return { entries, truncated };
|
|
7755
|
+
}
|
|
7756
|
+
function entryLine(bucket, entry) {
|
|
7757
|
+
const where = `${NARO_SCHEME}${bucket}/${printable(entry.path)}`;
|
|
7758
|
+
if (entry.kind === "folder") return `${" ".repeat(38)}${where}`;
|
|
7759
|
+
return `${(entry.size === null ? "?" : binarySize(entry.size)).padStart(10)} ${printable(entry.updatedAt ?? "")} ${where}`;
|
|
7760
|
+
}
|
|
7761
|
+
async function storageLs(context) {
|
|
7762
|
+
const [target, ...extra] = context.positionals;
|
|
7763
|
+
if (extra.length > 0) throw new NaroError("VALIDATION_FAILED", LS_USAGE);
|
|
7764
|
+
if (target === void 0) {
|
|
7765
|
+
const client = await context.client();
|
|
7766
|
+
const projectId2 = await context.resolveProjectId();
|
|
7767
|
+
const buckets = await client.storage.listBuckets(projectId2);
|
|
7768
|
+
context.output.result(
|
|
7769
|
+
{ projectId: projectId2, buckets },
|
|
7770
|
+
buckets.length === 0 ? [NO_BUCKETS] : buckets.map((bucket) => `${NARO_SCHEME}${printable(bucket.name)}/`)
|
|
7771
|
+
);
|
|
7772
|
+
return;
|
|
7773
|
+
}
|
|
7774
|
+
const url = parseStorageUrl(target);
|
|
7775
|
+
if (url === null)
|
|
7776
|
+
throw new NaroError(
|
|
7777
|
+
"VALIDATION_FAILED",
|
|
7778
|
+
`${printable(target)} is not a naro:// path. ${LS_USAGE}`
|
|
7779
|
+
);
|
|
7780
|
+
const prefix = url.path.replace(/\/+$/, "");
|
|
7781
|
+
const { projectId, storage } = await objects(context);
|
|
7782
|
+
const { entries, truncated } = await listAll(
|
|
7783
|
+
storage,
|
|
7784
|
+
url.bucket,
|
|
7785
|
+
prefix,
|
|
7786
|
+
context.flags.recursive === true
|
|
7787
|
+
);
|
|
7788
|
+
if (truncated) context.output.warn(TRUNCATED_WARNING);
|
|
7789
|
+
if (entries.length === 0 && prefix !== "" && !url.path.endsWith("/")) {
|
|
7790
|
+
const found = await storage.from(url.bucket).info(prefix);
|
|
7791
|
+
if (found.error === null) entries.push(fileEntry(prefix, found.data));
|
|
7792
|
+
else if (found.error.code !== "OBJECT_NOT_FOUND") unwrap(found);
|
|
7793
|
+
}
|
|
7794
|
+
context.output.result(
|
|
7795
|
+
{ projectId, bucket: url.bucket, prefix, entries, truncated },
|
|
7796
|
+
entries.length === 0 ? [`Nothing under ${NARO_SCHEME}${url.bucket}/${printable(prefix)}.`] : entries.map((entry) => entryLine(url.bucket, entry))
|
|
7797
|
+
);
|
|
7798
|
+
}
|
|
7799
|
+
var INTERRUPTS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
7800
|
+
function removeOnInterrupt(partial) {
|
|
7801
|
+
const stop = () => {
|
|
7802
|
+
for (const signal of INTERRUPTS) process.off(signal, onSignal);
|
|
7803
|
+
};
|
|
7804
|
+
function onSignal(signal) {
|
|
7805
|
+
stop();
|
|
7806
|
+
try {
|
|
7807
|
+
rmSync2(partial, { force: true });
|
|
7808
|
+
} catch {
|
|
7809
|
+
}
|
|
7810
|
+
try {
|
|
7811
|
+
process.kill(process.pid, signal);
|
|
7812
|
+
} catch {
|
|
7813
|
+
process.exit(1);
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
for (const signal of INTERRUPTS) process.on(signal, onSignal);
|
|
7817
|
+
return stop;
|
|
7818
|
+
}
|
|
7819
|
+
async function writeAtomically(blob, destination) {
|
|
7820
|
+
const partial = join6(dirname3(destination), `.${randomUUID()}.naro-partial`);
|
|
7821
|
+
const stopListening = removeOnInterrupt(partial);
|
|
7822
|
+
try {
|
|
7823
|
+
await pipeline2(
|
|
7824
|
+
Readable.fromWeb(blob.stream()),
|
|
7825
|
+
createWriteStream2(partial, { flags: "wx" })
|
|
7826
|
+
);
|
|
7827
|
+
await rename(partial, destination);
|
|
7828
|
+
} catch (error) {
|
|
7829
|
+
await rm(partial, { force: true }).catch(() => void 0);
|
|
7830
|
+
throw error;
|
|
7831
|
+
} finally {
|
|
7832
|
+
stopListening();
|
|
7833
|
+
}
|
|
7834
|
+
}
|
|
7835
|
+
async function upload(context, local, target) {
|
|
7836
|
+
const file = resolve5(context.cwd, local);
|
|
7837
|
+
const info = await stat(file).catch(() => null);
|
|
7838
|
+
if (info === null)
|
|
7839
|
+
throw new NaroError(
|
|
7840
|
+
"VALIDATION_FAILED",
|
|
7841
|
+
`No such file: ${printable(local)}.`
|
|
7842
|
+
);
|
|
7843
|
+
if (info.isDirectory())
|
|
7844
|
+
throw new NaroError(
|
|
7845
|
+
"VALIDATION_FAILED",
|
|
7846
|
+
`${printable(local)} is a directory. naro storage cp copies one file at a time.`
|
|
7847
|
+
);
|
|
7848
|
+
if (!info.isFile())
|
|
7849
|
+
throw new NaroError(
|
|
7850
|
+
"VALIDATION_FAILED",
|
|
7851
|
+
`${printable(local)} is not a regular file.`
|
|
7852
|
+
);
|
|
7853
|
+
if (info.size > MAX_SINGLE_UPLOAD_BYTES)
|
|
7854
|
+
throw new NaroError(
|
|
7855
|
+
"OBJECT_TOO_LARGE",
|
|
7856
|
+
dataError("OBJECT_TOO_LARGE", { single: true }).message
|
|
7857
|
+
);
|
|
7858
|
+
const { cacheControl } = context.flags;
|
|
7859
|
+
if (cacheControl !== void 0 && !isMaxAge(cacheControl))
|
|
7860
|
+
throw new NaroError("VALIDATION_FAILED", CACHE_CONTROL_RULE);
|
|
7861
|
+
const path = target.path === "" || target.path.endsWith("/") ? `${target.path}${basename2(file)}` : target.path;
|
|
7862
|
+
const { projectId, storage } = await objects(context);
|
|
7863
|
+
const body = await openAsBlob(file);
|
|
7864
|
+
const contentType = context.flags.contentType ?? typeFor(file);
|
|
7865
|
+
const result = unwrap(
|
|
7866
|
+
await storage.from(target.bucket).upload(path, body, {
|
|
7867
|
+
...contentType === void 0 ? {} : { contentType },
|
|
7868
|
+
...cacheControl === void 0 ? {} : { cacheControl },
|
|
7869
|
+
upsert: context.flags.upsert === true
|
|
7870
|
+
})
|
|
7871
|
+
);
|
|
7872
|
+
const to = `${NARO_SCHEME}${target.bucket}/${path}`;
|
|
7873
|
+
context.output.result(
|
|
7874
|
+
{ projectId, from: local, to, bytes: body.size, id: result.id },
|
|
7875
|
+
[
|
|
7876
|
+
`\u2705 Uploaded ${printable(local)} \u2192 ${printable(to)} (${binarySize(body.size)})`
|
|
7877
|
+
]
|
|
7878
|
+
);
|
|
7879
|
+
}
|
|
7880
|
+
var WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
7881
|
+
var MAX_FILE_NAME_BYTES = 255;
|
|
7882
|
+
function savedAs(path) {
|
|
7883
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
7884
|
+
if (name === "." || name === ".." || name.includes("\\") || name.includes(":") || name.includes("\0") || WINDOWS_DEVICE.test(name) || Buffer.byteLength(name, "utf8") > MAX_FILE_NAME_BYTES)
|
|
7885
|
+
throw new NaroError(
|
|
7886
|
+
"VALIDATION_FAILED",
|
|
7887
|
+
`The object's name (${printable(name)}) cannot be used as a file name. Name the file: naro storage cp naro://<bucket>/<path> <file>.`
|
|
7888
|
+
);
|
|
7889
|
+
return name;
|
|
7890
|
+
}
|
|
7891
|
+
async function download(context, source, local) {
|
|
7892
|
+
if (source.path === "" || source.path.endsWith("/"))
|
|
7893
|
+
throw new NaroError(
|
|
7894
|
+
"VALIDATION_FAILED",
|
|
7895
|
+
"naro storage cp downloads one object: name it, naro://<bucket>/<path>."
|
|
7896
|
+
);
|
|
7897
|
+
const resolved = resolve5(context.cwd, local);
|
|
7898
|
+
const isDirectory = local.endsWith("/") || (await stat(resolved).catch(() => null))?.isDirectory() === true;
|
|
7899
|
+
const destination = isDirectory ? join6(resolved, savedAs(source.path)) : resolved;
|
|
7900
|
+
const folder = await stat(dirname3(destination)).catch(() => null);
|
|
7901
|
+
if (folder === null || !folder.isDirectory())
|
|
7902
|
+
throw new NaroError(
|
|
7903
|
+
"VALIDATION_FAILED",
|
|
7904
|
+
`Cannot save to ${printable(local)}: its folder does not exist.`
|
|
7905
|
+
);
|
|
7906
|
+
const { projectId, storage } = await objects(context);
|
|
7907
|
+
const blob = unwrap(await storage.from(source.bucket).download(source.path));
|
|
7908
|
+
await writeAtomically(blob, destination);
|
|
7909
|
+
const from = `${NARO_SCHEME}${source.bucket}/${source.path}`;
|
|
7910
|
+
context.output.result(
|
|
7911
|
+
{ projectId, from, to: destination, bytes: blob.size },
|
|
7912
|
+
[
|
|
7913
|
+
`\u2705 Downloaded ${printable(from)} \u2192 ${printable(destination)} (${binarySize(blob.size)})`
|
|
7914
|
+
]
|
|
7915
|
+
);
|
|
7916
|
+
}
|
|
7917
|
+
async function storageCp(context) {
|
|
7918
|
+
const [from, to, ...extra] = context.positionals;
|
|
7919
|
+
if (from === void 0 || to === void 0 || extra.length > 0)
|
|
7920
|
+
throw new NaroError("VALIDATION_FAILED", CP_USAGE);
|
|
7921
|
+
const source = parseStorageUrl(from);
|
|
7922
|
+
const target = parseStorageUrl(to);
|
|
7923
|
+
if (source !== null && target !== null)
|
|
7924
|
+
throw new NaroError(
|
|
7925
|
+
"VALIDATION_FAILED",
|
|
7926
|
+
"naro storage cp copies between this machine and Storage: one side is naro://<bucket>/<path>, the other a local file. Copying inside Storage is not supported yet."
|
|
7927
|
+
);
|
|
7928
|
+
if (target !== null) return upload(context, from, target);
|
|
7929
|
+
if (source !== null) return download(context, source, to);
|
|
7930
|
+
throw new NaroError("VALIDATION_FAILED", CP_USAGE);
|
|
7931
|
+
}
|
|
7932
|
+
async function namesUnder(storage, bucket, prefixes) {
|
|
7933
|
+
const names = /* @__PURE__ */ new Set();
|
|
7934
|
+
let truncated = false;
|
|
7935
|
+
for (const written of prefixes) {
|
|
7936
|
+
const prefix = written.replace(/\/+$/, "");
|
|
7937
|
+
if (prefix !== "" && !written.endsWith("/") && unwrap(await storage.from(bucket).exists(prefix)))
|
|
7938
|
+
names.add(prefix);
|
|
7939
|
+
const found = await listAll(storage, bucket, prefix, true);
|
|
7940
|
+
for (const entry of found.entries) names.add(entry.path);
|
|
7941
|
+
truncated ||= found.truncated;
|
|
7942
|
+
}
|
|
7943
|
+
return { names: [...names], truncated };
|
|
7944
|
+
}
|
|
7945
|
+
async function storageRm(context) {
|
|
7946
|
+
if (context.positionals.length === 0)
|
|
7947
|
+
throw new NaroError("VALIDATION_FAILED", RM_USAGE);
|
|
7948
|
+
const recursive = context.flags.recursive === true;
|
|
7949
|
+
const byBucket = /* @__PURE__ */ new Map();
|
|
7950
|
+
for (const arg of context.positionals) {
|
|
7951
|
+
const url = parseStorageUrl(arg);
|
|
7952
|
+
if (url === null)
|
|
7953
|
+
throw new NaroError(
|
|
7954
|
+
"VALIDATION_FAILED",
|
|
7955
|
+
`${printable(arg)} is not a naro:// path. ${RM_USAGE}`
|
|
7956
|
+
);
|
|
7957
|
+
if (!recursive && (url.path === "" || url.path.endsWith("/")))
|
|
7958
|
+
throw new NaroError(
|
|
7959
|
+
"VALIDATION_FAILED",
|
|
7960
|
+
`${printable(arg)} is a folder: pass --recursive to remove what is under it.`
|
|
7961
|
+
);
|
|
7962
|
+
byBucket.set(url.bucket, [...byBucket.get(url.bucket) ?? [], url.path]);
|
|
7963
|
+
}
|
|
7964
|
+
if (!recursive)
|
|
7965
|
+
for (const [bucket, paths] of byBucket)
|
|
7966
|
+
await confirmOrAbort(
|
|
7967
|
+
context,
|
|
7968
|
+
`Remove ${count(new Set(paths).size, "object")} from ${bucket}?`
|
|
7969
|
+
);
|
|
7970
|
+
else if (!context.flags.yes && cannotAsk(context))
|
|
7971
|
+
throw new NaroError(
|
|
7972
|
+
"CONFIRMATION_REQUIRED",
|
|
7973
|
+
`Remove everything under ${context.positionals.map((arg) => printable(arg)).join(", ")}? Re-run with --yes to confirm.`
|
|
7974
|
+
);
|
|
7975
|
+
const { projectId, storage } = await objects(context);
|
|
7976
|
+
const results = [];
|
|
7977
|
+
for (const [bucket, paths] of byBucket) {
|
|
7978
|
+
let names;
|
|
7979
|
+
if (recursive) {
|
|
7980
|
+
const found = await namesUnder(storage, bucket, paths);
|
|
7981
|
+
names = found.names;
|
|
7982
|
+
if (found.truncated) context.output.warn(TRUNCATED_WARNING);
|
|
7983
|
+
} else names = [...new Set(paths)];
|
|
7984
|
+
if (names.length === 0) {
|
|
7985
|
+
context.output.log(`Nothing to remove in ${bucket}.`);
|
|
7986
|
+
results.push({ bucket, requested: 0, removed: [] });
|
|
7987
|
+
continue;
|
|
7988
|
+
}
|
|
7989
|
+
if (recursive)
|
|
7990
|
+
await confirmOrAbort(
|
|
7991
|
+
context,
|
|
7992
|
+
`Remove ${count(names.length, "object")} from ${bucket}?`
|
|
7993
|
+
);
|
|
7994
|
+
const removed = [];
|
|
7995
|
+
try {
|
|
7996
|
+
for (let at = 0; at < names.length; at += REMOVE_BATCH) {
|
|
7997
|
+
const page = unwrap(
|
|
7998
|
+
await storage.from(bucket).remove(names.slice(at, at + REMOVE_BATCH))
|
|
7999
|
+
);
|
|
8000
|
+
removed.push(...page.map((object) => object.name));
|
|
8001
|
+
}
|
|
8002
|
+
} catch (error) {
|
|
8003
|
+
if (removed.length > 0)
|
|
8004
|
+
context.output.warn(
|
|
8005
|
+
`\u26A0 Removed ${removed.length.toLocaleString("en-US")} of ${count(names.length, "object")} from ${bucket} before the error.`
|
|
8006
|
+
);
|
|
8007
|
+
throw error;
|
|
8008
|
+
}
|
|
8009
|
+
if (removed.length < names.length)
|
|
8010
|
+
context.output.warn(
|
|
8011
|
+
`\u26A0 ${String(names.length - removed.length)} of ${String(names.length)} were not removed: they were not there, or another request was changing them.`
|
|
8012
|
+
);
|
|
8013
|
+
context.output.log(
|
|
8014
|
+
removed.length === 0 ? `Nothing was removed from ${bucket}.` : `\u2705 Removed ${count(removed.length, "object")} from ${bucket}.`
|
|
8015
|
+
);
|
|
8016
|
+
results.push({ bucket, requested: names.length, removed });
|
|
8017
|
+
}
|
|
8018
|
+
context.output.result({ projectId, removed: results });
|
|
8019
|
+
}
|
|
8020
|
+
var ENV_KEY_REFUSED = "\u26A0 The service key came from NARO_SERVICE_KEY, which is used in every project: it may be another project's. Unset it to use this project's key.";
|
|
8021
|
+
async function explainingKey(context, run) {
|
|
8022
|
+
try {
|
|
8023
|
+
await run(context);
|
|
8024
|
+
} catch (error) {
|
|
8025
|
+
if (isNaroError(error) && error.code === "INVALID_API_KEY" && (process.env.NARO_SERVICE_KEY?.trim() ?? "") !== "")
|
|
8026
|
+
context.output.warn(ENV_KEY_REFUSED);
|
|
8027
|
+
throw error;
|
|
8028
|
+
}
|
|
8029
|
+
}
|
|
8030
|
+
function runStorageLs(context) {
|
|
8031
|
+
return explainingKey(context, storageLs);
|
|
8032
|
+
}
|
|
8033
|
+
function runStorageCp(context) {
|
|
8034
|
+
return explainingKey(context, storageCp);
|
|
8035
|
+
}
|
|
8036
|
+
function runStorageRm(context) {
|
|
8037
|
+
return explainingKey(context, storageRm);
|
|
8038
|
+
}
|
|
8039
|
+
|
|
8040
|
+
// src/router.ts
|
|
8041
|
+
var HELP = `naro \u2014 deploy Next.js apps to Naro (https://naro.sh)
|
|
8042
|
+
|
|
8043
|
+
Usage:
|
|
8044
|
+
naro login | logout | whoami
|
|
8045
|
+
naro token Print an access token for CI (NARO_TOKEN)
|
|
8046
|
+
|
|
8047
|
+
naro link | init Link this directory to a Naro project
|
|
8048
|
+
naro unlink Remove the link (.naro/)
|
|
8049
|
+
naro projects [--team <slug>] List projects
|
|
8050
|
+
naro project inspect Show the linked project
|
|
8051
|
+
|
|
8052
|
+
naro deploy [--prod] Build locally and deploy (preview by default)
|
|
8053
|
+
[--root <dir>] [--name <name>] --root points at the app in a monorepo
|
|
8054
|
+
naro deployments List deployments of the linked project
|
|
8055
|
+
naro inspect <deployment-id> Show one deployment + build logs
|
|
8056
|
+
naro cancel <deployment-id> Stop a build that is still running
|
|
8057
|
+
|
|
8058
|
+
naro git connect [owner/repo] Deploy on every push (defaults to your origin remote)
|
|
8059
|
+
naro git disconnect Stop deploying on push
|
|
8060
|
+
|
|
8061
|
+
naro logs [deployment-id] Runtime logs (or build logs of a deployment)
|
|
8062
|
+
[--type build|runtime] [--env production|preview] [--level error] [--follow]
|
|
8063
|
+
|
|
8064
|
+
naro insights [--metric lcp] [--range 7d] [--env preview]
|
|
8065
|
+
Speed Insights: Web Vitals p75 by page, device, the elements
|
|
8066
|
+
behind slow visits and deployment (production by default)
|
|
8067
|
+
naro insights --route '/posts/[id]' The paths opened under one route
|
|
8068
|
+
naro insights enable | disable Turn measuring on or off (production pages get a
|
|
8069
|
+
same-origin script, preview pages too; past measurements are kept)
|
|
8070
|
+
|
|
8071
|
+
naro protection Preview protection: on or off, and whether a bypass secret is set
|
|
8072
|
+
naro protection enable | disable Protect the preview URL (members only) or open it to anyone with the link (disable asks first; --yes in CI)
|
|
8073
|
+
naro protection bypass [remove] Create (shown once) or remove the secret automation sends as x-naro-protection-bypass (replacing or removing asks first; --yes in CI)
|
|
8074
|
+
|
|
8075
|
+
naro env list List env vars (secrets masked)
|
|
8076
|
+
naro env add KEY [environment] Add/update (value from prompt or stdin)
|
|
8077
|
+
naro env remove KEY [environment] Remove
|
|
8078
|
+
naro env pull [file] Write env vars to .env.local
|
|
8079
|
+
|
|
8080
|
+
naro domains list | add <host> [--no-www] | remove <host>
|
|
8081
|
+
add example.com also connects www.example.com and redirects the apex to
|
|
8082
|
+
it, because most registrars cannot put a CNAME on an apex; --no-www
|
|
8083
|
+
registers the apex alone
|
|
8084
|
+
naro domains verify [host] [--wait] Re-check DNS and print what is missing
|
|
8085
|
+
exits non-zero while a domain is not live yet
|
|
8086
|
+
|
|
8087
|
+
naro db enable Create the project's database (Cloudflare D1)
|
|
8088
|
+
naro db info Database status, size, table count and limits
|
|
8089
|
+
and today's writes against the daily write limit (UTC)
|
|
8090
|
+
naro db query <sql> | --file <f> Run SQL against it (reads stdin when piped)
|
|
8091
|
+
naro db push [--dry-run] Apply pending migrations from ./migrations (asks first; --yes to skip)
|
|
8092
|
+
--dry-run checks their policy statements on this machine and
|
|
8093
|
+
exits 1 on one the server would refuse
|
|
8094
|
+
naro db dump [--output <f>] Download a SQL dump of the database (stdout by default)
|
|
8095
|
+
[--schema-only|--data-only] [--table a,b] Cloudflare holds other queries until it finishes
|
|
8096
|
+
naro db restore --bookmark <id> Roll the database back (Time Travel, last 30 days; asks first)
|
|
8097
|
+
| --timestamp <time> [--dry-run] prints the command that undoes it
|
|
8098
|
+
naro db pull [--dir <d>] Write the remote schema as migrations/0001_remote_schema.sql
|
|
8099
|
+
and record it as applied - a baseline (asks first)
|
|
8100
|
+
naro db reset [--dir <d>] Drop everything and clear the policies and the ledger, then re-apply
|
|
8101
|
+
[--dry-run] [--yes] ./migrations, which create the policies again. Refuses files the server
|
|
8102
|
+
would refuse; lists first the policies the files do not make
|
|
8103
|
+
(asks first; db restore --bookmark undoes it)
|
|
8104
|
+
naro db lint [--strict] Check the schema for what D1 actually trips on, and the RLS policies
|
|
8105
|
+
(rls_no_policy, rls_policy_invalid, rls_policy_text_default,
|
|
8106
|
+
rls_policy_unindexed; exits 1 on an error, --strict on a warning too)
|
|
8107
|
+
naro db table create <name> --column <spec> [--column <spec>\u2026]
|
|
8108
|
+
[--owner <column>] [--if-not-exists] Create a table without SQL. <spec> is name:type[:modifier\u2026]:
|
|
8109
|
+
types integer, real, text, blob, numeric, boolean, datetime, json;
|
|
8110
|
+
modifiers pk, autoincrement, notnull, unique, default=<v>,
|
|
8111
|
+
references=<table>.<column>, ondelete=cascade|set-null|\u2026
|
|
8112
|
+
--owner adds that column (TEXT NOT NULL) and an owner policy
|
|
8113
|
+
naro db table add-column <table> --column <spec>
|
|
8114
|
+
naro db table rename-column <table> <column> <new-name>
|
|
8115
|
+
naro db table rename <table> <new-name>
|
|
8116
|
+
naro db table drop-column <table> <column> Asks first (--yes to skip)
|
|
8117
|
+
naro db table drop <table> Asks first (--yes to skip)
|
|
8118
|
+
naro db index create <table> <col[,col\u2026]> [--unique] [--name <name>]
|
|
8119
|
+
naro db index drop <name>
|
|
8120
|
+
Each runs as one migration in d1_migrations and is written to ./migrations
|
|
8121
|
+
(--no-write skips the file; --dry-run prints the SQL and changes nothing)
|
|
8122
|
+
naro migration new <name> Create migrations/NNNN_<name>.sql (stdin becomes the body when piped)
|
|
8123
|
+
naro migration list Local vs applied migrations
|
|
8124
|
+
naro migration up [--dry-run] Same as db push
|
|
8125
|
+
naro migration repair <name> Fix the ledger alone: mark a migration applied or
|
|
8126
|
+
--status applied|reverted [--yes] remove its row. Its SQL is never run (asks first)
|
|
8127
|
+
naro gen types [--lang typescript] [--output <file>]
|
|
8128
|
+
TypeScript types for the schema, for createClient<Database>()
|
|
8129
|
+
from naro-js (stdout by default)
|
|
8130
|
+
|
|
8131
|
+
naro backend [status] On or off, status, URL and anon key (the service key stays hidden)
|
|
8132
|
+
naro backend enable Turn the backend on: D1, sign-in and the Data API (safe to re-run;
|
|
8133
|
+
resumes a failed run; brings a turned-off one back with the same keys;
|
|
8134
|
+
provision is the same command)
|
|
8135
|
+
naro backend disable Turn it off: in up to about 90 seconds the app's /_naro/* answers 404;
|
|
8136
|
+
data, users and keys are kept (asks first; --yes to skip; re-run
|
|
8137
|
+
finishes a turn-off that stopped half-way)
|
|
8138
|
+
naro backend keys [--reveal] API keys; --reveal prints the service key (a secret; needs database write)
|
|
8139
|
+
naro backend keys rotate --anon|--service
|
|
8140
|
+
Replace a key; the old one stops working at once (asks first)
|
|
8141
|
+
naro backend auth Sign-in settings: email, Google, GitHub, redirects, callback URLs,
|
|
8142
|
+
sign-ups and the minimum password length
|
|
8143
|
+
naro backend auth signups <on|off>
|
|
8144
|
+
off: nobody signs up on their own, by email or Google/GitHub;
|
|
8145
|
+
the accounts you create (naro backend users create) sign in
|
|
8146
|
+
naro backend auth password --min-length <n>
|
|
4911
8147
|
The fewest characters a new password takes (6-128; 6 by default)
|
|
4912
8148
|
naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin
|
|
4913
8149
|
Confirmation and reset emails from your Resend account
|
|
@@ -4949,6 +8185,27 @@ Usage:
|
|
|
4949
8185
|
naro_insert, naro_update, naro_delete (shorthand for CREATE POLICY)
|
|
4950
8186
|
naro policy remove <table> Drop every policy on it: back to deny (asks first)
|
|
4951
8187
|
|
|
8188
|
+
naro storage [status] Naro Storage: on or off, limits, what is stored, the buckets
|
|
8189
|
+
naro storage enable Turn Storage on: the project's R2 bucket, bound to its backend
|
|
8190
|
+
(safe to re-run; applies a plan change's limits)
|
|
8191
|
+
naro storage bucket list The buckets: public or private, objects, bytes, limits
|
|
8192
|
+
naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]
|
|
8193
|
+
<size>: bytes, 5MB (1000s) or 5MiB (1024s); turns Storage on first if needed
|
|
8194
|
+
naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]
|
|
8195
|
+
--public asks first: anyone with an object's URL can then download it
|
|
8196
|
+
naro storage bucket empty <name> Delete every object in it and keep the bucket (asks first)
|
|
8197
|
+
naro storage bucket remove <name> [--empty]
|
|
8198
|
+
Remove an empty bucket; --empty deletes its objects first (asks first)
|
|
8199
|
+
naro storage ls [naro://<bucket>[/<prefix>]] [--recursive]
|
|
8200
|
+
The buckets, or the folders and objects under a prefix
|
|
8201
|
+
naro storage cp <from> <to> [--upsert] [--content-type <t>] [--cache-control <seconds>]
|
|
8202
|
+
One file between this machine and naro://<bucket>/<path>, 50 MiB at most,
|
|
8203
|
+
with the project's service key (NARO_SERVICE_KEY, or fetched with a warning).
|
|
8204
|
+
A download replaces a local file of the same name
|
|
8205
|
+
naro storage rm naro://<bucket>/<path>\u2026 [--recursive]
|
|
8206
|
+
Remove objects; --recursive removes what is under a folder (asks first):
|
|
8207
|
+
<path>/ is the folder alone, <path> also an object of that name
|
|
8208
|
+
|
|
4952
8209
|
naro promote <deployment-id> Publish a build to production
|
|
4953
8210
|
naro rollback [deployment-id] Roll production back to the previous build
|
|
4954
8211
|
|
|
@@ -4963,6 +8220,10 @@ Global options:
|
|
|
4963
8220
|
--cwd <dir> Run as if started in <dir>
|
|
4964
8221
|
--prod Deploy/target production
|
|
4965
8222
|
--wait Poll until the domain goes live (naro domains verify)
|
|
8223
|
+
--metric <m> lcp, inp, cls, fcp or ttfb; lcp by default (naro insights)
|
|
8224
|
+
--range <r> 24h, 7d or 30d; 7d by default (naro insights)
|
|
8225
|
+
--route <pattern> A route from the pages table, e.g. '/posts/[id]': the paths under it (naro insights)
|
|
8226
|
+
--env <e> production or preview (naro logs, naro insights: production by default; naro env pull: preview by default)
|
|
4966
8227
|
--no-www Register the apex alone (naro domains add)
|
|
4967
8228
|
--file <path> SQL file to run (naro db query, naro policy create)
|
|
4968
8229
|
--dry-run Show what would change without changing it (naro db push, db restore, db reset, db table, db index)
|
|
@@ -4989,6 +8250,14 @@ Global options:
|
|
|
4989
8250
|
--password-stdin Read the new user's password from stdin (a pipe, or a hidden prompt)
|
|
4990
8251
|
--password <p> The password in argv: works, but lands in shell history (naro backend users create)
|
|
4991
8252
|
--min-length <n> Fewest characters of a new password, 6-128 (naro backend auth password)
|
|
8253
|
+
--public | --private Who may download a bucket's objects by URL (naro storage bucket create, update)
|
|
8254
|
+
--file-size-limit <size> A bucket's largest object: bytes, 5MB or 5MiB; none clears it
|
|
8255
|
+
--allowed-mime-types <t,\u2026> The types a bucket takes, like image/png,image/*; any clears it
|
|
8256
|
+
--empty Delete the bucket's objects first (naro storage bucket remove)
|
|
8257
|
+
--recursive, -r Everything under a folder (naro storage ls, naro storage rm)
|
|
8258
|
+
--upsert Replace an object that is there (naro storage cp)
|
|
8259
|
+
--content-type <t> The object's type; by default from the file's extension (naro storage cp)
|
|
8260
|
+
--cache-control <seconds> How long a browser may keep it; 3600 by default (naro storage cp)
|
|
4992
8261
|
--select <rule> Rule for reads; --insert, --update, --delete likewise (naro policy set)
|
|
4993
8262
|
--owner <column> Column that holds the signed-in user's id, for the owner rule (naro policy set, db table create)
|
|
4994
8263
|
--select-when <filter> Condition for reads, e.g. 'user_id.eq.auth.uid()'; --insert-when (what a new
|
|
@@ -5082,6 +8351,56 @@ function routeBackendUsers(words) {
|
|
|
5082
8351
|
return null;
|
|
5083
8352
|
}
|
|
5084
8353
|
}
|
|
8354
|
+
function routeStorage(words) {
|
|
8355
|
+
const [sub, action] = words;
|
|
8356
|
+
switch (sub) {
|
|
8357
|
+
case void 0:
|
|
8358
|
+
case "status":
|
|
8359
|
+
return words.length <= 1 ? { handler: runStorageStatus, positionals: [] } : null;
|
|
8360
|
+
case "enable":
|
|
8361
|
+
return words.length === 1 ? { handler: runStorageEnable, positionals: [] } : null;
|
|
8362
|
+
case "bucket":
|
|
8363
|
+
case "buckets":
|
|
8364
|
+
switch (action) {
|
|
8365
|
+
case void 0:
|
|
8366
|
+
case "list":
|
|
8367
|
+
case "ls":
|
|
8368
|
+
return words.length <= 2 ? { handler: runStorageBucketList, positionals: [] } : null;
|
|
8369
|
+
case "create":
|
|
8370
|
+
return {
|
|
8371
|
+
handler: runStorageBucketCreate,
|
|
8372
|
+
positionals: words.slice(2)
|
|
8373
|
+
};
|
|
8374
|
+
case "update":
|
|
8375
|
+
return {
|
|
8376
|
+
handler: runStorageBucketUpdate,
|
|
8377
|
+
positionals: words.slice(2)
|
|
8378
|
+
};
|
|
8379
|
+
case "empty":
|
|
8380
|
+
return {
|
|
8381
|
+
handler: runStorageBucketEmpty,
|
|
8382
|
+
positionals: words.slice(2)
|
|
8383
|
+
};
|
|
8384
|
+
case "remove":
|
|
8385
|
+
case "rm":
|
|
8386
|
+
case "delete":
|
|
8387
|
+
return {
|
|
8388
|
+
handler: runStorageBucketRemove,
|
|
8389
|
+
positionals: words.slice(2)
|
|
8390
|
+
};
|
|
8391
|
+
default:
|
|
8392
|
+
return null;
|
|
8393
|
+
}
|
|
8394
|
+
case "ls":
|
|
8395
|
+
return { handler: runStorageLs, positionals: words.slice(1) };
|
|
8396
|
+
case "cp":
|
|
8397
|
+
return { handler: runStorageCp, positionals: words.slice(1) };
|
|
8398
|
+
case "rm":
|
|
8399
|
+
return { handler: runStorageRm, positionals: words.slice(1) };
|
|
8400
|
+
default:
|
|
8401
|
+
return null;
|
|
8402
|
+
}
|
|
8403
|
+
}
|
|
5085
8404
|
function route(parsed) {
|
|
5086
8405
|
const { command, positionals } = parsed;
|
|
5087
8406
|
const sub = positionals[0];
|
|
@@ -5119,6 +8438,39 @@ function route(parsed) {
|
|
|
5119
8438
|
return { handler: runCancel, positionals };
|
|
5120
8439
|
case "logs":
|
|
5121
8440
|
return { handler: runLogs, positionals };
|
|
8441
|
+
// Spec 2026-10-08 §5.
|
|
8442
|
+
case "insights": {
|
|
8443
|
+
if (positionals.length > 1) return null;
|
|
8444
|
+
switch (sub) {
|
|
8445
|
+
case void 0:
|
|
8446
|
+
return { handler: runInsights, positionals: [] };
|
|
8447
|
+
case "enable":
|
|
8448
|
+
return { handler: runInsightsEnable, positionals: [] };
|
|
8449
|
+
case "disable":
|
|
8450
|
+
return { handler: runInsightsDisable, positionals: [] };
|
|
8451
|
+
default:
|
|
8452
|
+
return null;
|
|
8453
|
+
}
|
|
8454
|
+
}
|
|
8455
|
+
// Spec 2026-10-08-preview-protection §9.
|
|
8456
|
+
case "protection": {
|
|
8457
|
+
const [, second, third] = positionals;
|
|
8458
|
+
if (third !== void 0) return null;
|
|
8459
|
+
switch (sub) {
|
|
8460
|
+
case void 0:
|
|
8461
|
+
return { handler: runProtection, positionals: [] };
|
|
8462
|
+
case "enable":
|
|
8463
|
+
return second === void 0 ? { handler: runProtectionEnable, positionals: [] } : null;
|
|
8464
|
+
case "disable":
|
|
8465
|
+
return second === void 0 ? { handler: runProtectionDisable, positionals: [] } : null;
|
|
8466
|
+
case "bypass":
|
|
8467
|
+
if (second === void 0)
|
|
8468
|
+
return { handler: runProtectionBypass, positionals: [] };
|
|
8469
|
+
return second === "remove" ? { handler: runProtectionBypassRemove, positionals: [] } : null;
|
|
8470
|
+
default:
|
|
8471
|
+
return null;
|
|
8472
|
+
}
|
|
8473
|
+
}
|
|
5122
8474
|
case "env":
|
|
5123
8475
|
switch (sub) {
|
|
5124
8476
|
case void 0:
|
|
@@ -5252,6 +8604,8 @@ function route(parsed) {
|
|
|
5252
8604
|
default:
|
|
5253
8605
|
return null;
|
|
5254
8606
|
}
|
|
8607
|
+
case "storage":
|
|
8608
|
+
return routeStorage(positionals);
|
|
5255
8609
|
case "policy":
|
|
5256
8610
|
switch (sub) {
|
|
5257
8611
|
case void 0:
|
|
@@ -5299,7 +8653,7 @@ function version() {
|
|
|
5299
8653
|
try {
|
|
5300
8654
|
const pkg = JSON.parse(
|
|
5301
8655
|
readFileSync5(
|
|
5302
|
-
|
|
8656
|
+
join7(dirname4(fileURLToPath(import.meta.url)), "..", "package.json"),
|
|
5303
8657
|
"utf8"
|
|
5304
8658
|
)
|
|
5305
8659
|
);
|