naro-cli 0.11.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 +271 -13
- package/dist/{chunk-L7JOGWP3.js → chunk-O4NJRDJE.js} +5167 -363
- package/dist/deploy.js +1 -1
- package/dist/index.js +3827 -210
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
import {
|
|
3
3
|
BACKEND_STEPS,
|
|
4
4
|
BOOKMARK_FORMAT_MESSAGE,
|
|
5
|
+
INDEX_SQL,
|
|
6
|
+
MAX_SINGLE_UPLOAD_BYTES,
|
|
7
|
+
NAMED_POLICY_SQL,
|
|
8
|
+
NAMED_POLICY_TABLE,
|
|
5
9
|
NaroClient,
|
|
6
10
|
NaroError,
|
|
7
11
|
POLICY_ACTIONS,
|
|
8
12
|
POLICY_RULES,
|
|
13
|
+
RESET_BACKEND_OUTDATED,
|
|
9
14
|
RESTORE_OUTCOME_UNKNOWN_CODES,
|
|
15
|
+
SCHEMA_SQL,
|
|
16
|
+
SPEED_INSIGHTS_METRICS,
|
|
17
|
+
SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
|
|
18
|
+
STORAGE_OBJECTS_TABLE,
|
|
10
19
|
buildResetPlan,
|
|
11
20
|
cellText,
|
|
12
21
|
clearSession,
|
|
@@ -15,11 +24,13 @@ import {
|
|
|
15
24
|
controlPlaneUrl,
|
|
16
25
|
countLintedTables,
|
|
17
26
|
createAuthClient,
|
|
27
|
+
dataError,
|
|
18
28
|
deployDirectory,
|
|
19
29
|
detectGitInfo,
|
|
20
30
|
ensureGitignoreHasNaro,
|
|
21
31
|
formatRedirectEntry,
|
|
22
32
|
generateTypes,
|
|
33
|
+
indexesFromRows,
|
|
23
34
|
isBookmark,
|
|
24
35
|
isExpired,
|
|
25
36
|
isNaroError,
|
|
@@ -27,18 +38,22 @@ import {
|
|
|
27
38
|
isTerminalStatus,
|
|
28
39
|
lintSchema,
|
|
29
40
|
listMigrationFiles,
|
|
41
|
+
loadPolicy,
|
|
30
42
|
loadStoredSession,
|
|
31
43
|
login,
|
|
32
44
|
migrationNameMismatch,
|
|
33
45
|
migrationsPendingError,
|
|
34
46
|
nextMigrationFileName,
|
|
35
47
|
openBrowser,
|
|
48
|
+
parseCacheControl,
|
|
36
49
|
parseGitHubRemote,
|
|
37
50
|
parsePolicyNode,
|
|
38
51
|
parsePolicyStatement,
|
|
39
52
|
parseRedirectEntry,
|
|
40
53
|
pendingMigrationNames,
|
|
54
|
+
planPolicyMigration,
|
|
41
55
|
policiesForTypes,
|
|
56
|
+
policiesLostByReset,
|
|
42
57
|
projectPath,
|
|
43
58
|
promptForMissingEnv,
|
|
44
59
|
quoteIdent,
|
|
@@ -47,18 +62,26 @@ import {
|
|
|
47
62
|
refreshStoredSession,
|
|
48
63
|
removeProjectLink,
|
|
49
64
|
renderRows,
|
|
65
|
+
resetFileRefusedMessage,
|
|
66
|
+
resetPoliciesLostMessage,
|
|
67
|
+
resetPoliciesWithTablesMessage,
|
|
50
68
|
resolveAuth,
|
|
51
69
|
resolveMigrationsDir,
|
|
52
70
|
saveSession,
|
|
53
71
|
schemaMigrationTitle,
|
|
54
72
|
serializeDotenv,
|
|
55
73
|
slugify,
|
|
74
|
+
snapshotFromRows,
|
|
56
75
|
splitPolicyStatements,
|
|
76
|
+
splitStatements,
|
|
77
|
+
storedPolicySql,
|
|
57
78
|
summarizeStatement,
|
|
58
79
|
systemTablePredicate,
|
|
80
|
+
tableNames,
|
|
59
81
|
toNaroError,
|
|
82
|
+
unreadablePolicyComment,
|
|
60
83
|
writeProjectLink
|
|
61
|
-
} from "./chunk-
|
|
84
|
+
} from "./chunk-O4NJRDJE.js";
|
|
62
85
|
import "./chunk-K3NQKI34.js";
|
|
63
86
|
|
|
64
87
|
// src/args.ts
|
|
@@ -91,7 +114,13 @@ var BOOLEAN_FLAGS = {
|
|
|
91
114
|
"--allow-unindexed": "allowUnindexed",
|
|
92
115
|
"--api-key-stdin": "apiKeyStdin",
|
|
93
116
|
"--client-secret-stdin": "clientSecretStdin",
|
|
94
|
-
"--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"
|
|
95
124
|
};
|
|
96
125
|
var REPEATED_FLAGS = {
|
|
97
126
|
"--column": "columns"
|
|
@@ -107,6 +136,9 @@ var STRING_FLAGS = {
|
|
|
107
136
|
"--type": "type",
|
|
108
137
|
"--env": "env",
|
|
109
138
|
"--level": "level",
|
|
139
|
+
"--metric": "metric",
|
|
140
|
+
"--range": "range",
|
|
141
|
+
"--route": "route",
|
|
110
142
|
"--file": "file",
|
|
111
143
|
"--output": "output",
|
|
112
144
|
"--lang": "lang",
|
|
@@ -132,7 +164,11 @@ var STRING_FLAGS = {
|
|
|
132
164
|
"--username": "username",
|
|
133
165
|
"--email": "email",
|
|
134
166
|
"--password": "password",
|
|
135
|
-
"--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"
|
|
136
172
|
};
|
|
137
173
|
var SECRET_FLAGS = {
|
|
138
174
|
"--api-key": "--api-key-stdin",
|
|
@@ -318,7 +354,7 @@ var TtyPrompter = class {
|
|
|
318
354
|
});
|
|
319
355
|
try {
|
|
320
356
|
return await new Promise(
|
|
321
|
-
(
|
|
357
|
+
(resolve6) => rl.question(question, resolve6)
|
|
322
358
|
);
|
|
323
359
|
} finally {
|
|
324
360
|
rl.close();
|
|
@@ -339,7 +375,7 @@ var TtyPrompter = class {
|
|
|
339
375
|
});
|
|
340
376
|
try {
|
|
341
377
|
const answer = new Promise(
|
|
342
|
-
(
|
|
378
|
+
(resolve6) => rl.question(question, resolve6)
|
|
343
379
|
);
|
|
344
380
|
muted = true;
|
|
345
381
|
const value = await answer;
|
|
@@ -421,10 +457,10 @@ function createContext(flags, positionals = []) {
|
|
|
421
457
|
positionals,
|
|
422
458
|
output,
|
|
423
459
|
prompter,
|
|
424
|
-
positionalOrThrow: (
|
|
425
|
-
if (positionals.length <
|
|
460
|
+
positionalOrThrow: (count2, usage) => {
|
|
461
|
+
if (positionals.length < count2)
|
|
426
462
|
throw new NaroError("VALIDATION_FAILED", usage);
|
|
427
|
-
return positionals.slice(0,
|
|
463
|
+
return positionals.slice(0, count2);
|
|
428
464
|
},
|
|
429
465
|
auth,
|
|
430
466
|
client: async () => {
|
|
@@ -450,8 +486,8 @@ function createContext(flags, positionals = []) {
|
|
|
450
486
|
}
|
|
451
487
|
|
|
452
488
|
// src/router.ts
|
|
453
|
-
import { readFileSync as
|
|
454
|
-
import { dirname as
|
|
489
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
490
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
455
491
|
import { fileURLToPath } from "url";
|
|
456
492
|
|
|
457
493
|
// src/commands/auth.ts
|
|
@@ -536,9 +572,12 @@ async function runToken(context) {
|
|
|
536
572
|
}
|
|
537
573
|
|
|
538
574
|
// src/commands/confirm.ts
|
|
575
|
+
function cannotAsk(context) {
|
|
576
|
+
return context.flags.json || !isInteractive();
|
|
577
|
+
}
|
|
539
578
|
async function confirmOrAbort(context, question) {
|
|
540
579
|
if (context.flags.yes) return;
|
|
541
|
-
if (context
|
|
580
|
+
if (cannotAsk(context)) {
|
|
542
581
|
throw new NaroError(
|
|
543
582
|
"CONFIRMATION_REQUIRED",
|
|
544
583
|
`${question} Re-run with --yes to confirm.`
|
|
@@ -574,18 +613,29 @@ async function appliedMigrations(client, projectId) {
|
|
|
574
613
|
throw error;
|
|
575
614
|
}
|
|
576
615
|
}
|
|
616
|
+
var UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
577
617
|
function printable(text) {
|
|
578
|
-
|
|
579
|
-
for (const char of text) {
|
|
580
|
-
const code = char.charCodeAt(0);
|
|
581
|
-
const control = code < 32 || code >= 127 && code <= 159;
|
|
582
|
-
out += control ? "?" : char;
|
|
583
|
-
}
|
|
584
|
-
return out;
|
|
618
|
+
return text.replace(UNPRINTABLE, "?");
|
|
585
619
|
}
|
|
586
620
|
function isPrintable(text) {
|
|
587
621
|
return printable(text) === text;
|
|
588
622
|
}
|
|
623
|
+
var UNSHOWN_WARNING_LINE = "(a line of this warning has characters the terminal cannot show; run with --json to see it exactly)";
|
|
624
|
+
function warningLines(message) {
|
|
625
|
+
return message.split("\n").map(
|
|
626
|
+
(line, index) => index === 0 ? `\u26A0 ${printable(line)}` : ` ${isPrintable(line) ? line : UNSHOWN_WARNING_LINE}`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
function policyProblems(dir, names) {
|
|
630
|
+
return names.flatMap((name) => {
|
|
631
|
+
const split = splitStatements(readFileSync(join(dir, name), "utf8"));
|
|
632
|
+
if (!split.ok) return [];
|
|
633
|
+
const planned = planPolicyMigration(
|
|
634
|
+
split.statements.map((statement) => statement.text)
|
|
635
|
+
);
|
|
636
|
+
return planned.ok ? [] : [{ migration: name, message: planned.message }];
|
|
637
|
+
});
|
|
638
|
+
}
|
|
589
639
|
function warningLine(warning) {
|
|
590
640
|
if (warning === "out-of-order")
|
|
591
641
|
return " \u26A0 older than an applied migration \u2014 db push will still apply it";
|
|
@@ -636,23 +686,42 @@ async function runMigrationList(context) {
|
|
|
636
686
|
}
|
|
637
687
|
async function applyMigrationFiles(context, client, projectId, dir, names) {
|
|
638
688
|
const applied = [];
|
|
689
|
+
const warnings = [];
|
|
639
690
|
for (const name of names) {
|
|
640
691
|
const sql = readFileSync(join(dir, name), "utf8");
|
|
692
|
+
let answer;
|
|
641
693
|
try {
|
|
642
|
-
await client.db.applyMigration(projectId, { name, sql });
|
|
694
|
+
answer = await client.db.applyMigration(projectId, { name, sql });
|
|
643
695
|
} catch (error) {
|
|
644
696
|
if (!isNaroError(error)) throw error;
|
|
697
|
+
for (const message of serverWarnings(error.details))
|
|
698
|
+
for (const line of warningLines(message)) context.output.warn(line);
|
|
645
699
|
const shown = context.output.json ? name : printable(name);
|
|
646
700
|
throw new NaroError(error.code, `${shown}: ${error.message}`, {
|
|
647
701
|
...error.status === void 0 ? {} : { status: error.status },
|
|
648
|
-
details: {
|
|
702
|
+
details: {
|
|
703
|
+
...error.details,
|
|
704
|
+
migration: name,
|
|
705
|
+
applied,
|
|
706
|
+
appliedWarnings: warnings
|
|
707
|
+
},
|
|
649
708
|
cause: error
|
|
650
709
|
});
|
|
651
710
|
}
|
|
652
711
|
context.output.log(`\u2713 ${printable(name)}`);
|
|
653
712
|
applied.push(name);
|
|
713
|
+
for (const message of answer.warnings) {
|
|
714
|
+
warnings.push({ migration: name, message });
|
|
715
|
+
for (const line of warningLines(message)) context.output.warn(line);
|
|
716
|
+
}
|
|
654
717
|
}
|
|
655
|
-
return applied;
|
|
718
|
+
return { applied, warnings };
|
|
719
|
+
}
|
|
720
|
+
function serverWarnings(details) {
|
|
721
|
+
const warnings = details?.warnings;
|
|
722
|
+
return Array.isArray(warnings) ? warnings.filter(
|
|
723
|
+
(warning) => typeof warning === "string"
|
|
724
|
+
) : [];
|
|
656
725
|
}
|
|
657
726
|
var MIGRATION_NAME_RE = /^[A-Za-z0-9_.-]{1,200}\.sql$/;
|
|
658
727
|
var LEDGER_DDL = "CREATE TABLE IF NOT EXISTS d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)";
|
|
@@ -724,10 +793,15 @@ async function runMigrationUp(context) {
|
|
|
724
793
|
return;
|
|
725
794
|
}
|
|
726
795
|
if (context.flags.dryRun) {
|
|
727
|
-
|
|
796
|
+
const problems = policyProblems(dir, pending);
|
|
797
|
+
context.output.result({ projectId, dryRun: true, pending, problems }, [
|
|
728
798
|
`Pending migrations (${String(pending.length)}):`,
|
|
729
|
-
...pending.map((name) => ` ${printable(name)}`)
|
|
799
|
+
...pending.map((name) => ` ${printable(name)}`),
|
|
800
|
+
...problems.map(
|
|
801
|
+
(problem) => `${printable(problem.migration)}: ${printable(problem.message)}`
|
|
802
|
+
)
|
|
730
803
|
]);
|
|
804
|
+
if (problems.length > 0) process.exitCode = 1;
|
|
731
805
|
return;
|
|
732
806
|
}
|
|
733
807
|
context.output.log(`Pending migrations (${String(pending.length)}):`);
|
|
@@ -736,14 +810,14 @@ async function runMigrationUp(context) {
|
|
|
736
810
|
context,
|
|
737
811
|
`Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
|
|
738
812
|
);
|
|
739
|
-
const applied = await applyMigrationFiles(
|
|
813
|
+
const { applied, warnings } = await applyMigrationFiles(
|
|
740
814
|
context,
|
|
741
815
|
client,
|
|
742
816
|
projectId,
|
|
743
817
|
dir,
|
|
744
818
|
pending
|
|
745
819
|
);
|
|
746
|
-
context.output.result({ projectId, applied, pending: [] }, [
|
|
820
|
+
context.output.result({ projectId, applied, pending: [], warnings }, [
|
|
747
821
|
`\u2705 Applied ${String(applied.length)} migration(s).`
|
|
748
822
|
]);
|
|
749
823
|
}
|
|
@@ -1055,9 +1129,9 @@ function adviceLines2(auth) {
|
|
|
1055
1129
|
"Redirect URIs to register with each provider \u2014 the one for the origin your app passes to createClient({ url }):"
|
|
1056
1130
|
);
|
|
1057
1131
|
for (const provider of ["google", "github"]) {
|
|
1058
|
-
const
|
|
1132
|
+
const label3 = ` ${LABELS[provider]}: `;
|
|
1059
1133
|
callbacksOf(auth, provider).forEach(
|
|
1060
|
-
(url, index) => lines.push(`${index === 0 ?
|
|
1134
|
+
(url, index) => lines.push(`${index === 0 ? label3 : " ".repeat(label3.length)}${url}`)
|
|
1061
1135
|
);
|
|
1062
1136
|
}
|
|
1063
1137
|
return lines;
|
|
@@ -1084,11 +1158,11 @@ async function readSecret(context, input, given, flag, what, usage) {
|
|
|
1084
1158
|
throw new NaroError("VALIDATION_FAILED", `No ${what} came in on stdin.`);
|
|
1085
1159
|
return secret;
|
|
1086
1160
|
}
|
|
1087
|
-
async function change(context,
|
|
1161
|
+
async function change(context, send2, done, after) {
|
|
1088
1162
|
const projectId = await context.resolveProjectId();
|
|
1089
1163
|
let answer;
|
|
1090
1164
|
try {
|
|
1091
|
-
answer = await
|
|
1165
|
+
answer = await send2(projectId);
|
|
1092
1166
|
} catch (error) {
|
|
1093
1167
|
if (isNaroError(error) && error.code === "TIMEOUT")
|
|
1094
1168
|
throw new NaroError(
|
|
@@ -1353,9 +1427,9 @@ var processStdin3 = {
|
|
|
1353
1427
|
stdinIsTty: () => process.stdin.isTTY === true,
|
|
1354
1428
|
readStdin
|
|
1355
1429
|
};
|
|
1356
|
-
async function usersCall(
|
|
1430
|
+
async function usersCall(call2) {
|
|
1357
1431
|
try {
|
|
1358
|
-
return await
|
|
1432
|
+
return await call2();
|
|
1359
1433
|
} catch (error) {
|
|
1360
1434
|
if (isNaroError(error) && error.code === "BACKEND_OUTDATED")
|
|
1361
1435
|
throw new NaroError("BACKEND_OUTDATED", OUTDATED_MESSAGE, {
|
|
@@ -1658,6 +1732,8 @@ async function runDbQuery(context, deps = processStdin4) {
|
|
|
1658
1732
|
});
|
|
1659
1733
|
if (result.truncated) lines.push(ROW_CAP_NOTICE);
|
|
1660
1734
|
context.output.result({ projectId, result }, lines);
|
|
1735
|
+
for (const warning of result.warnings ?? [])
|
|
1736
|
+
for (const line of warningLines(warning)) context.output.warn(line);
|
|
1661
1737
|
}
|
|
1662
1738
|
|
|
1663
1739
|
// src/commands/db-backup.ts
|
|
@@ -1910,7 +1986,7 @@ async function runDbRestore(context) {
|
|
|
1910
1986
|
target = resolved.bookmark;
|
|
1911
1987
|
at = resolved.timestamp ?? given.timestamp;
|
|
1912
1988
|
}
|
|
1913
|
-
const
|
|
1989
|
+
const label3 = at === null ? `bookmark ${target}` : `${at} (bookmark ${target})`;
|
|
1914
1990
|
if (context.flags.dryRun) {
|
|
1915
1991
|
context.output.result(
|
|
1916
1992
|
{
|
|
@@ -1918,13 +1994,13 @@ async function runDbRestore(context) {
|
|
|
1918
1994
|
bookmark: target,
|
|
1919
1995
|
...at === null ? {} : { timestamp: at }
|
|
1920
1996
|
},
|
|
1921
|
-
[`Would restore to ${
|
|
1997
|
+
[`Would restore to ${label3}. Nothing was changed.`]
|
|
1922
1998
|
);
|
|
1923
1999
|
return;
|
|
1924
2000
|
}
|
|
1925
2001
|
await confirmOrAbort(
|
|
1926
2002
|
context,
|
|
1927
|
-
`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.`
|
|
1928
2004
|
);
|
|
1929
2005
|
const before = await bookmarkNow(context, client, projectId);
|
|
1930
2006
|
let restored;
|
|
@@ -1998,6 +2074,75 @@ var DIAGNOSTICS_SQL = [
|
|
|
1998
2074
|
"SELECT * FROM pragma_foreign_key_check;"
|
|
1999
2075
|
].join("\n");
|
|
2000
2076
|
var TRUNCATED_NOTICE = "\u26A0 The diagnostics were cut at 10,000 rows \u2014 there may be more broken rows than these.";
|
|
2077
|
+
var POLICIES_TRUNCATED_NOTICE = "\u26A0 The policies were cut at 10,000 rows, so they were not checked.";
|
|
2078
|
+
var POLICY_TABLE_PROBE = `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '${NAMED_POLICY_TABLE}'`;
|
|
2079
|
+
var POLICY_READ_SQL = [NAMED_POLICY_SQL.list, INDEX_SQL, ...SCHEMA_SQL].map((sql) => `${sql};`).join("\n");
|
|
2080
|
+
function objectsOf(statement) {
|
|
2081
|
+
return statement.rows.map(
|
|
2082
|
+
(row) => Object.fromEntries(
|
|
2083
|
+
statement.columns.map((column, index) => [column, row[index]])
|
|
2084
|
+
)
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
async function readPolicies(client, projectId, warn) {
|
|
2088
|
+
let backend;
|
|
2089
|
+
try {
|
|
2090
|
+
backend = await client.backend.info(projectId);
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
if (error instanceof NaroError && error.code === "BACKEND_DISABLED")
|
|
2093
|
+
return void 0;
|
|
2094
|
+
throw error;
|
|
2095
|
+
}
|
|
2096
|
+
if (backend.status === "none") return void 0;
|
|
2097
|
+
const probe = await client.db.query(projectId, {
|
|
2098
|
+
sql: POLICY_TABLE_PROBE,
|
|
2099
|
+
readOnly: true
|
|
2100
|
+
});
|
|
2101
|
+
if ((probe.statements[0]?.rows.length ?? 0) === 0) return void 0;
|
|
2102
|
+
const answer = await client.db.query(projectId, { sql: POLICY_READ_SQL });
|
|
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)
|
|
2106
|
+
throw new NaroError(
|
|
2107
|
+
"DATABASE_QUERY_FAILED",
|
|
2108
|
+
`The policies could not be read: the database answered with ${String(answer.statements.length)} result sets instead of ${String(expected)}.`
|
|
2109
|
+
);
|
|
2110
|
+
if (answer.truncated) {
|
|
2111
|
+
warn(POLICIES_TRUNCATED_NOTICE);
|
|
2112
|
+
return void 0;
|
|
2113
|
+
}
|
|
2114
|
+
const policies = [];
|
|
2115
|
+
const unreadable = [];
|
|
2116
|
+
for (const row of objectsOf(stored)) {
|
|
2117
|
+
const policy = loadPolicy(row);
|
|
2118
|
+
if (policy === void 0) continue;
|
|
2119
|
+
if (policy.invalid !== null)
|
|
2120
|
+
unreadable.push({
|
|
2121
|
+
table: policy.table,
|
|
2122
|
+
name: policy.name,
|
|
2123
|
+
reason: policy.invalid
|
|
2124
|
+
});
|
|
2125
|
+
else
|
|
2126
|
+
policies.push({
|
|
2127
|
+
table: policy.table,
|
|
2128
|
+
name: policy.name,
|
|
2129
|
+
permissive: policy.permissive,
|
|
2130
|
+
command: policy.command,
|
|
2131
|
+
roles: policy.roles,
|
|
2132
|
+
using: policy.using,
|
|
2133
|
+
check: policy.check,
|
|
2134
|
+
owner: policy.owner
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
const snapshot = snapshotFromRows(schema.flatMap(objectsOf));
|
|
2138
|
+
return {
|
|
2139
|
+
tables: tableNames(snapshot),
|
|
2140
|
+
policies,
|
|
2141
|
+
unreadable,
|
|
2142
|
+
snapshot,
|
|
2143
|
+
indexes: indexesFromRows(objectsOf(indexes))
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2001
2146
|
function firstCell(row) {
|
|
2002
2147
|
const value = row[0];
|
|
2003
2148
|
return value === null || value === void 0 ? "" : String(value);
|
|
@@ -2025,17 +2170,17 @@ async function runDbLint(context) {
|
|
|
2025
2170
|
const projectId = await context.resolveProjectId();
|
|
2026
2171
|
const tables = await client.db.tables(projectId);
|
|
2027
2172
|
const answer = await client.db.query(projectId, { sql: DIAGNOSTICS_SQL });
|
|
2028
|
-
const [triggers,
|
|
2029
|
-
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)
|
|
2030
2175
|
throw new NaroError(
|
|
2031
2176
|
"DATABASE_QUERY_FAILED",
|
|
2032
2177
|
`The diagnostics could not be read: the database answered with ${String(answer.statements.length)} result sets instead of 4.`
|
|
2033
2178
|
);
|
|
2034
2179
|
const virtualTables = [];
|
|
2035
2180
|
const shadow = /* @__PURE__ */ new Set();
|
|
2036
|
-
for (const row of
|
|
2037
|
-
const name = cellText(
|
|
2038
|
-
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);
|
|
2039
2184
|
else virtualTables.push(name);
|
|
2040
2185
|
}
|
|
2041
2186
|
const foreignKeyCheck = broken.rows.map((row) => ({
|
|
@@ -2043,12 +2188,18 @@ async function runDbLint(context) {
|
|
|
2043
2188
|
rowid: rowidOf(cellText(broken, row, "rowid")),
|
|
2044
2189
|
parent: cellText(broken, row, "parent")
|
|
2045
2190
|
}));
|
|
2191
|
+
const policies = await readPolicies(
|
|
2192
|
+
client,
|
|
2193
|
+
projectId,
|
|
2194
|
+
(message) => context.output.warn(message)
|
|
2195
|
+
);
|
|
2046
2196
|
const input = {
|
|
2047
|
-
tables: tables.filter((
|
|
2197
|
+
tables: tables.filter((table2) => !shadow.has(table2.name)),
|
|
2048
2198
|
foreignKeyCheck,
|
|
2049
2199
|
quickCheck: quick.rows.map(firstCell),
|
|
2050
2200
|
triggers: triggers.rows.map((row) => cellText(triggers, row, "name")),
|
|
2051
|
-
virtualTables
|
|
2201
|
+
virtualTables,
|
|
2202
|
+
...policies === void 0 ? {} : { policies }
|
|
2052
2203
|
};
|
|
2053
2204
|
const issues = lintSchema(input);
|
|
2054
2205
|
const counted = countLintedTables(input);
|
|
@@ -2064,24 +2215,26 @@ async function runDbLint(context) {
|
|
|
2064
2215
|
}
|
|
2065
2216
|
|
|
2066
2217
|
// src/commands/db-reset.ts
|
|
2218
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
2219
|
+
import { join as join3 } from "path";
|
|
2067
2220
|
var MAX_STATEMENTS = 50;
|
|
2068
2221
|
var SCHEMA_FILTER = `${systemTablePredicate("name")} AND ${systemTablePredicate("tbl_name")}`;
|
|
2069
|
-
var
|
|
2222
|
+
var SCHEMA_SQL2 = [
|
|
2070
2223
|
`SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view', 'trigger') AND ${SCHEMA_FILTER} ORDER BY name;`,
|
|
2071
2224
|
"SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'virtual';",
|
|
2072
|
-
|
|
2225
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sqlite_sequence', 'd1_migrations', '${NAMED_POLICY_TABLE}', '${STORAGE_OBJECTS_TABLE}');`
|
|
2073
2226
|
].join("\n");
|
|
2074
2227
|
function literal(value) {
|
|
2075
2228
|
return `'${value.replace(/'/g, "''")}'`;
|
|
2076
2229
|
}
|
|
2077
|
-
function chunk(
|
|
2230
|
+
function chunk(items3, size) {
|
|
2078
2231
|
const pieces = [];
|
|
2079
|
-
for (let index = 0; index <
|
|
2080
|
-
pieces.push(
|
|
2232
|
+
for (let index = 0; index < items3.length; index += size)
|
|
2233
|
+
pieces.push(items3.slice(index, index + size));
|
|
2081
2234
|
return pieces;
|
|
2082
2235
|
}
|
|
2083
2236
|
async function readSchema(client, projectId) {
|
|
2084
|
-
const schema = await client.db.query(projectId, { sql:
|
|
2237
|
+
const schema = await client.db.query(projectId, { sql: SCHEMA_SQL2 });
|
|
2085
2238
|
const [listed, virtual, internal] = schema.statements;
|
|
2086
2239
|
if (listed === void 0 || virtual === void 0 || internal === void 0)
|
|
2087
2240
|
throw new NaroError(
|
|
@@ -2094,7 +2247,7 @@ async function readSchema(client, projectId) {
|
|
|
2094
2247
|
const virtualNames = new Set(
|
|
2095
2248
|
virtual.rows.map((row) => cellText(virtual, row, "name").toLowerCase())
|
|
2096
2249
|
);
|
|
2097
|
-
const
|
|
2250
|
+
const objects2 = listed.rows.map((row) => {
|
|
2098
2251
|
const name = cellText(listed, row, "name");
|
|
2099
2252
|
const type = cellText(listed, row, "type");
|
|
2100
2253
|
if (type === "view" || type === "trigger") return { type, name };
|
|
@@ -2103,7 +2256,7 @@ async function readSchema(client, projectId) {
|
|
|
2103
2256
|
name
|
|
2104
2257
|
};
|
|
2105
2258
|
});
|
|
2106
|
-
const lookups =
|
|
2259
|
+
const lookups = objects2.filter((object) => object.type === "table" || object.type === "virtual").map(
|
|
2107
2260
|
(object) => `SELECT ${literal(object.name)} AS child, "table" AS parent FROM pragma_foreign_key_list(${literal(object.name)});`
|
|
2108
2261
|
);
|
|
2109
2262
|
const foreignKeys = [];
|
|
@@ -2122,12 +2275,63 @@ async function readSchema(client, projectId) {
|
|
|
2122
2275
|
});
|
|
2123
2276
|
}
|
|
2124
2277
|
return {
|
|
2125
|
-
objects,
|
|
2278
|
+
objects: objects2,
|
|
2126
2279
|
foreignKeys,
|
|
2127
2280
|
internal: {
|
|
2128
2281
|
sqliteSequence: internalNames.has("sqlite_sequence"),
|
|
2129
2282
|
ledger: internalNames.has("d1_migrations")
|
|
2130
|
-
}
|
|
2283
|
+
},
|
|
2284
|
+
policyTable: internalNames.has(NAMED_POLICY_TABLE),
|
|
2285
|
+
storageTable: internalNames.has(STORAGE_OBJECTS_TABLE)
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
async function hasBackend(client, projectId) {
|
|
2289
|
+
try {
|
|
2290
|
+
return (await client.backend.info(projectId)).status !== "none";
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
if (isNaroError(error) && error.code === "BACKEND_DISABLED") return false;
|
|
2293
|
+
throw error;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
async function policyPlan(client, projectId, schema, dir, files) {
|
|
2297
|
+
if (!schema.policyTable) {
|
|
2298
|
+
if (await hasBackend(client, projectId))
|
|
2299
|
+
throw new NaroError("BACKEND_OUTDATED", RESET_BACKEND_OUTDATED);
|
|
2300
|
+
return { clearPolicies: false, lost: [], withTables: [] };
|
|
2301
|
+
}
|
|
2302
|
+
const answer = await client.db.query(projectId, {
|
|
2303
|
+
sql: NAMED_POLICY_SQL.list
|
|
2304
|
+
});
|
|
2305
|
+
const stored = answer.statements[0];
|
|
2306
|
+
if (stored === void 0)
|
|
2307
|
+
throw new NaroError(
|
|
2308
|
+
"DATABASE_QUERY_FAILED",
|
|
2309
|
+
"The policies could not be read: the database answered with no result set."
|
|
2310
|
+
);
|
|
2311
|
+
if (answer.truncated)
|
|
2312
|
+
throw new NaroError(
|
|
2313
|
+
"DATABASE_QUERY_FAILED",
|
|
2314
|
+
"The policies are too many to read in one pass: the list was cut at 10,000 rows, so the reset cannot tell which ones your migration files do not make."
|
|
2315
|
+
);
|
|
2316
|
+
const current = stored.rows.map((row) => ({
|
|
2317
|
+
table: cellText(stored, row, "table_name"),
|
|
2318
|
+
name: cellText(stored, row, "name"),
|
|
2319
|
+
...storedPolicySql(
|
|
2320
|
+
Object.fromEntries(
|
|
2321
|
+
stored.columns.map((column, index) => [column, row[index]])
|
|
2322
|
+
)
|
|
2323
|
+
)
|
|
2324
|
+
}));
|
|
2325
|
+
return {
|
|
2326
|
+
clearPolicies: true,
|
|
2327
|
+
...policiesLostByReset({
|
|
2328
|
+
current,
|
|
2329
|
+
tables: schema.tables,
|
|
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
|
|
2334
|
+
})
|
|
2131
2335
|
};
|
|
2132
2336
|
}
|
|
2133
2337
|
function resetStopped(context, error, bookmark, lead, message) {
|
|
@@ -2149,16 +2353,75 @@ async function runDbReset(context) {
|
|
|
2149
2353
|
const dir = migrationsDir(context);
|
|
2150
2354
|
const shownDir = relativeToCwd(context, dir);
|
|
2151
2355
|
const files = listMigrationFiles(dir);
|
|
2356
|
+
const problems = policyProblems(dir, files);
|
|
2357
|
+
const problemLines = problems.map(
|
|
2358
|
+
(problem) => `${context.output.json ? problem.migration : printable(problem.migration)}: ${context.output.json ? problem.message : printable(problem.message)}`
|
|
2359
|
+
);
|
|
2360
|
+
if (problems.length > 0 && !context.flags.dryRun)
|
|
2361
|
+
throw new NaroError(
|
|
2362
|
+
"VALIDATION_FAILED",
|
|
2363
|
+
`The control plane would refuse these migration files, so the reset would stop at the first of them with the schema already dropped. Nothing was changed:
|
|
2364
|
+
${problemLines.join("\n")}`,
|
|
2365
|
+
{ details: { problems } }
|
|
2366
|
+
);
|
|
2152
2367
|
const client = await context.client();
|
|
2153
2368
|
const projectId = await context.resolveProjectId();
|
|
2154
2369
|
const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
|
|
2155
|
-
const
|
|
2370
|
+
const { policyTable: policyTable2, storageTable, ...input } = await readSchema(
|
|
2371
|
+
client,
|
|
2372
|
+
projectId
|
|
2373
|
+
);
|
|
2374
|
+
const plan = buildResetPlan(input, { clearPolicies: policyTable2 });
|
|
2156
2375
|
const { tables, views, triggers } = plan.dropped;
|
|
2157
2376
|
const counts = `${String(tables.length)} table(s), ${String(views.length)} view(s) and ${String(triggers.length)} trigger(s)`;
|
|
2158
2377
|
const dropped = tables.length + views.length + triggers.length;
|
|
2378
|
+
const policies = await policyPlan(
|
|
2379
|
+
client,
|
|
2380
|
+
projectId,
|
|
2381
|
+
{ policyTable: policyTable2, storageTable, tables },
|
|
2382
|
+
dir,
|
|
2383
|
+
files
|
|
2384
|
+
);
|
|
2385
|
+
const policiesNotInFiles = policies.lost.map((policy) => ({
|
|
2386
|
+
name: `${policy.table}.${policy.name}`,
|
|
2387
|
+
sql: policy.sql
|
|
2388
|
+
}));
|
|
2389
|
+
const policiesOfTablesNotInFiles = policies.withTables.map(
|
|
2390
|
+
(policy) => `${policy.table}.${policy.name}`
|
|
2391
|
+
);
|
|
2392
|
+
const refused = policies.refused && {
|
|
2393
|
+
migration: files[policies.refused.file] ?? "",
|
|
2394
|
+
message: policies.refused.reason
|
|
2395
|
+
};
|
|
2396
|
+
if (refused && !problems.some((one) => one.migration === refused.migration))
|
|
2397
|
+
context.output.warn(
|
|
2398
|
+
`\u26A0 ${printable(resetFileRefusedMessage(refused.migration, refused.message))}`
|
|
2399
|
+
);
|
|
2400
|
+
if (policies.lost.length > 0) {
|
|
2401
|
+
context.output.warn(
|
|
2402
|
+
`\u26A0 ${printable(resetPoliciesLostMessage(policies.lost))}`
|
|
2403
|
+
);
|
|
2404
|
+
for (const policy of policies.lost)
|
|
2405
|
+
for (const line of policy.statements.every(isPrintable) ? policy.statements : [UNSHOWN_WARNING_LINE])
|
|
2406
|
+
context.output.warn(` ${line}`);
|
|
2407
|
+
}
|
|
2408
|
+
if (policies.withTables.length > 0)
|
|
2409
|
+
context.output.warn(
|
|
2410
|
+
`\u26A0 ${printable(resetPoliciesWithTablesMessage(policies.withTables))}`
|
|
2411
|
+
);
|
|
2159
2412
|
if (context.flags.dryRun) {
|
|
2160
2413
|
context.output.result(
|
|
2161
|
-
{
|
|
2414
|
+
{
|
|
2415
|
+
dryRun: true,
|
|
2416
|
+
tables,
|
|
2417
|
+
views,
|
|
2418
|
+
triggers,
|
|
2419
|
+
migrations: files,
|
|
2420
|
+
policiesNotInFiles,
|
|
2421
|
+
policiesOfTablesNotInFiles,
|
|
2422
|
+
problems,
|
|
2423
|
+
...refused ? { wouldBeRefused: refused } : {}
|
|
2424
|
+
},
|
|
2162
2425
|
[
|
|
2163
2426
|
`Would drop ${counts}:`,
|
|
2164
2427
|
...views.map((name) => ` ${"view".padEnd(9)}${printable(name)}`),
|
|
@@ -2166,9 +2429,11 @@ async function runDbReset(context) {
|
|
|
2166
2429
|
...tables.map((name) => ` ${"table".padEnd(9)}${printable(name)}`),
|
|
2167
2430
|
`Would re-apply ${String(files.length)} migration file(s) from ${shownDir}:`,
|
|
2168
2431
|
...files.map((name) => ` ${printable(name)}`),
|
|
2169
|
-
"Nothing was changed."
|
|
2432
|
+
"Nothing was changed.",
|
|
2433
|
+
...problemLines
|
|
2170
2434
|
]
|
|
2171
2435
|
);
|
|
2436
|
+
if (problems.length > 0 || refused) process.exitCode = 1;
|
|
2172
2437
|
return;
|
|
2173
2438
|
}
|
|
2174
2439
|
if (bookmark !== null)
|
|
@@ -2200,15 +2465,16 @@ async function runDbReset(context) {
|
|
|
2200
2465
|
`No migration files in ${shownDir} \u2014 the database is now empty.`
|
|
2201
2466
|
);
|
|
2202
2467
|
let reapplied = [];
|
|
2468
|
+
let warnings = [];
|
|
2203
2469
|
try {
|
|
2204
2470
|
if (files.length > 0)
|
|
2205
|
-
reapplied = await applyMigrationFiles(
|
|
2471
|
+
({ applied: reapplied, warnings } = await applyMigrationFiles(
|
|
2206
2472
|
context,
|
|
2207
2473
|
client,
|
|
2208
2474
|
projectId,
|
|
2209
2475
|
dir,
|
|
2210
2476
|
files
|
|
2211
|
-
);
|
|
2477
|
+
));
|
|
2212
2478
|
} catch (error) {
|
|
2213
2479
|
throw resetStopped(
|
|
2214
2480
|
context,
|
|
@@ -2218,18 +2484,28 @@ async function runDbReset(context) {
|
|
|
2218
2484
|
(underlying) => underlying
|
|
2219
2485
|
);
|
|
2220
2486
|
}
|
|
2221
|
-
context.output.result(
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2487
|
+
context.output.result(
|
|
2488
|
+
{
|
|
2489
|
+
dropped,
|
|
2490
|
+
reapplied: reapplied.length,
|
|
2491
|
+
bookmark,
|
|
2492
|
+
policiesNotInFiles,
|
|
2493
|
+
policiesOfTablesNotInFiles,
|
|
2494
|
+
warnings
|
|
2495
|
+
},
|
|
2496
|
+
[
|
|
2497
|
+
`\u2713 Reset the project database: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
|
|
2498
|
+
...bookmark === null ? [] : [` To undo: naro db restore --bookmark ${bookmark}`]
|
|
2499
|
+
]
|
|
2500
|
+
);
|
|
2225
2501
|
}
|
|
2226
2502
|
|
|
2227
2503
|
// src/commands/db-schema.ts
|
|
2228
2504
|
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
2229
|
-
import { join as
|
|
2505
|
+
import { join as join4 } from "path";
|
|
2230
2506
|
|
|
2231
2507
|
// src/commands/policy.ts
|
|
2232
|
-
import { readFileSync as
|
|
2508
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
2233
2509
|
import { resolve as resolve3 } from "path";
|
|
2234
2510
|
|
|
2235
2511
|
// ../../packages/naro-sdk/src/query/filters.ts
|
|
@@ -2535,9 +2811,9 @@ function keyword(text, at) {
|
|
|
2535
2811
|
`needs null, true or false after is at character ${String(at + 1)}`
|
|
2536
2812
|
);
|
|
2537
2813
|
}
|
|
2538
|
-
function attempt(
|
|
2814
|
+
function attempt(parse2) {
|
|
2539
2815
|
try {
|
|
2540
|
-
return { ok: true, node:
|
|
2816
|
+
return { ok: true, node: parse2() };
|
|
2541
2817
|
} catch (error) {
|
|
2542
2818
|
if (error instanceof Refusal) return { ok: false, reason: error.message };
|
|
2543
2819
|
throw error;
|
|
@@ -2644,10 +2920,10 @@ function conditionsFrom(flags) {
|
|
|
2644
2920
|
);
|
|
2645
2921
|
return conditions;
|
|
2646
2922
|
}
|
|
2647
|
-
function patchFromFile(context, path,
|
|
2923
|
+
function patchFromFile(context, path, table2) {
|
|
2648
2924
|
let text;
|
|
2649
2925
|
try {
|
|
2650
|
-
text =
|
|
2926
|
+
text = readFileSync4(resolve3(context.cwd, path), "utf8");
|
|
2651
2927
|
} catch {
|
|
2652
2928
|
refuse2(`Could not read ${path}.`);
|
|
2653
2929
|
}
|
|
@@ -2666,9 +2942,9 @@ function patchFromFile(context, path, table) {
|
|
|
2666
2942
|
if (Object.hasOwn(body, "table")) {
|
|
2667
2943
|
if (typeof named !== "string")
|
|
2668
2944
|
refuse2(`${path}: table must be a string, the table's name.`);
|
|
2669
|
-
if (
|
|
2945
|
+
if (table2 !== void 0 && asciiLower(named) !== asciiLower(table2))
|
|
2670
2946
|
refuse2(
|
|
2671
|
-
`${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)}.`
|
|
2672
2948
|
);
|
|
2673
2949
|
}
|
|
2674
2950
|
const allowed = /* @__PURE__ */ new Set([...POLICY_ACTIONS, "ownerColumn"]);
|
|
@@ -2681,7 +2957,7 @@ function patchFromFile(context, path, table) {
|
|
|
2681
2957
|
function asciiLower(text) {
|
|
2682
2958
|
return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
|
2683
2959
|
}
|
|
2684
|
-
function policyPatch(flags, context,
|
|
2960
|
+
function policyPatch(flags, context, table2) {
|
|
2685
2961
|
if (flags.fromFile !== void 0) {
|
|
2686
2962
|
if (OTHER_FLAGS.some(
|
|
2687
2963
|
(key) => flags[key] !== void 0 && flags[key] !== false
|
|
@@ -2690,7 +2966,7 @@ function policyPatch(flags, context, table) {
|
|
|
2690
2966
|
"--from-file takes the whole policy: leave out the other policy options."
|
|
2691
2967
|
);
|
|
2692
2968
|
if (!context) refuse2(SET_USAGE);
|
|
2693
|
-
return patchFromFile(context, flags.fromFile,
|
|
2969
|
+
return patchFromFile(context, flags.fromFile, table2);
|
|
2694
2970
|
}
|
|
2695
2971
|
const patch = {};
|
|
2696
2972
|
for (const action of POLICY_ACTIONS) {
|
|
@@ -2763,9 +3039,9 @@ function leafText(leaf, negated) {
|
|
|
2763
3039
|
if (leaf.operator === "in" || short !== void 0) {
|
|
2764
3040
|
if (!Array.isArray(leaf.value)) return void 0;
|
|
2765
3041
|
const slot = short === void 0 ? "in" : "array";
|
|
2766
|
-
const
|
|
2767
|
-
if (
|
|
2768
|
-
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(",");
|
|
2769
3045
|
return slot === "in" ? `${prefix}in.(${list})` : `${prefix}${String(short)}.{${list}}`;
|
|
2770
3046
|
}
|
|
2771
3047
|
if (Array.isArray(leaf.value)) return void 0;
|
|
@@ -2829,7 +3105,7 @@ function policyTable(policies) {
|
|
|
2829
3105
|
const widths = header.map(
|
|
2830
3106
|
(title, index) => Math.max(title.length, ...rows.map((row) => row[index]?.length ?? 0))
|
|
2831
3107
|
);
|
|
2832
|
-
const
|
|
3108
|
+
const table2 = [header, ...rows].map(
|
|
2833
3109
|
(cells) => cells.map(
|
|
2834
3110
|
(cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)
|
|
2835
3111
|
).join(" ").trimEnd()
|
|
@@ -2846,10 +3122,10 @@ function policyTable(policies) {
|
|
|
2846
3122
|
}
|
|
2847
3123
|
)
|
|
2848
3124
|
);
|
|
2849
|
-
if (lines.length === 0) return
|
|
3125
|
+
if (lines.length === 0) return table2;
|
|
2850
3126
|
const width = Math.max(...lines.map((line) => line.label.length));
|
|
2851
3127
|
return [
|
|
2852
|
-
...
|
|
3128
|
+
...table2,
|
|
2853
3129
|
"",
|
|
2854
3130
|
...lines.map((line) => `${line.label.padEnd(width)} ${line.text}`)
|
|
2855
3131
|
];
|
|
@@ -2858,43 +3134,47 @@ var UNSHOWN = "has characters that cannot be shown here";
|
|
|
2858
3134
|
function statementLine(policy) {
|
|
2859
3135
|
if (policy.invalid !== null)
|
|
2860
3136
|
return printable(
|
|
2861
|
-
|
|
3137
|
+
unreadablePolicyComment({
|
|
3138
|
+
name: policy.name,
|
|
3139
|
+
table: policy.table,
|
|
3140
|
+
invalid: policy.invalid
|
|
3141
|
+
})
|
|
2862
3142
|
);
|
|
2863
3143
|
return shownStatement(policy, policy.table, "naro policy list --json");
|
|
2864
3144
|
}
|
|
2865
|
-
function policyNamed(name,
|
|
2866
|
-
return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(
|
|
3145
|
+
function policyNamed(name, table2) {
|
|
3146
|
+
return `Policy ${commentText(quotePolicyName(name))} on ${commentText(quoteIdent(table2))}`;
|
|
2867
3147
|
}
|
|
2868
|
-
function shownStatement(statement,
|
|
3148
|
+
function shownStatement(statement, table2, exact) {
|
|
2869
3149
|
if (isPrintable(statement.sql)) return statement.sql;
|
|
2870
3150
|
return printable(
|
|
2871
|
-
`-- ${policyNamed(statement.name,
|
|
3151
|
+
`-- ${policyNamed(statement.name, table2)} ${UNSHOWN}, so it is not printed as CREATE POLICY: ${exact} has its exact text.`
|
|
2872
3152
|
);
|
|
2873
3153
|
}
|
|
2874
3154
|
function policyStatements(policies) {
|
|
2875
3155
|
const lines = [];
|
|
2876
|
-
let
|
|
3156
|
+
let table2;
|
|
2877
3157
|
for (const policy of policies) {
|
|
2878
|
-
if (
|
|
2879
|
-
|
|
3158
|
+
if (table2 !== void 0 && policy.table !== table2) lines.push("");
|
|
3159
|
+
table2 = policy.table;
|
|
2880
3160
|
lines.push(statementLine(policy));
|
|
2881
3161
|
}
|
|
2882
3162
|
return lines;
|
|
2883
3163
|
}
|
|
2884
3164
|
async function runPolicyList(context) {
|
|
2885
|
-
const
|
|
3165
|
+
const table2 = context.flags.table;
|
|
2886
3166
|
const client = await context.client();
|
|
2887
3167
|
const projectId = await context.resolveProjectId();
|
|
2888
3168
|
const policies = await client.backend.rlsPolicies(
|
|
2889
3169
|
projectId,
|
|
2890
|
-
|
|
3170
|
+
table2 === void 0 ? {} : { table: table2 }
|
|
2891
3171
|
);
|
|
2892
3172
|
let lines;
|
|
2893
3173
|
if (policies.length > 0)
|
|
2894
3174
|
lines = [...policyStatements(policies), "", DEFAULT_DENY];
|
|
2895
|
-
else if (
|
|
3175
|
+
else if (table2 !== void 0)
|
|
2896
3176
|
lines = [
|
|
2897
|
-
`No policies on ${printable(
|
|
3177
|
+
`No policies on ${printable(table2)}: anon and signed-in requests to it are denied.`
|
|
2898
3178
|
];
|
|
2899
3179
|
else
|
|
2900
3180
|
lines = [
|
|
@@ -2911,7 +3191,7 @@ function statementText(context) {
|
|
|
2911
3191
|
let text = positional;
|
|
2912
3192
|
if (file !== void 0) {
|
|
2913
3193
|
try {
|
|
2914
|
-
text =
|
|
3194
|
+
text = readFileSync4(resolve3(context.cwd, file), "utf8");
|
|
2915
3195
|
} catch {
|
|
2916
3196
|
refuse2(`Could not read ${file}.`);
|
|
2917
3197
|
}
|
|
@@ -2990,17 +3270,17 @@ async function runPolicyCreate(context) {
|
|
|
2990
3270
|
}
|
|
2991
3271
|
async function runPolicyDrop(context) {
|
|
2992
3272
|
const [name] = context.positionalOrThrow(1, DROP_USAGE);
|
|
2993
|
-
const
|
|
2994
|
-
if (
|
|
3273
|
+
const table2 = context.flags.table;
|
|
3274
|
+
if (table2 === void 0 || table2 === "") refuse2(DROP_USAGE);
|
|
2995
3275
|
await confirmOrAbort(
|
|
2996
3276
|
context,
|
|
2997
|
-
`Drop policy ${printable(name)} on ${printable(
|
|
3277
|
+
`Drop policy ${printable(name)} on ${printable(table2)}? It stops applying within about 10 seconds.`
|
|
2998
3278
|
);
|
|
2999
3279
|
const client = await context.client();
|
|
3000
3280
|
const projectId = await context.resolveProjectId();
|
|
3001
3281
|
const dropped = await client.backend.dropRlsPolicy(
|
|
3002
3282
|
projectId,
|
|
3003
|
-
|
|
3283
|
+
table2,
|
|
3004
3284
|
name
|
|
3005
3285
|
);
|
|
3006
3286
|
context.output.result({ projectId, dropped }, [
|
|
@@ -3008,13 +3288,13 @@ async function runPolicyDrop(context) {
|
|
|
3008
3288
|
]);
|
|
3009
3289
|
}
|
|
3010
3290
|
async function runPolicySet(context) {
|
|
3011
|
-
const [
|
|
3012
|
-
const patch = policyPatch(context.flags, context,
|
|
3291
|
+
const [table2] = context.positionalOrThrow(1, SET_USAGE);
|
|
3292
|
+
const patch = policyPatch(context.flags, context, table2);
|
|
3013
3293
|
const client = await context.client();
|
|
3014
3294
|
const projectId = await context.resolveProjectId();
|
|
3015
3295
|
const policy = await client.backend.setPolicy(
|
|
3016
3296
|
projectId,
|
|
3017
|
-
|
|
3297
|
+
table2,
|
|
3018
3298
|
patch
|
|
3019
3299
|
);
|
|
3020
3300
|
let named;
|
|
@@ -3035,14 +3315,14 @@ async function runPolicySet(context) {
|
|
|
3035
3315
|
);
|
|
3036
3316
|
}
|
|
3037
3317
|
async function runPolicyRemove(context) {
|
|
3038
|
-
const [
|
|
3318
|
+
const [table2] = context.positionalOrThrow(1, REMOVE_USAGE);
|
|
3039
3319
|
await confirmOrAbort(
|
|
3040
3320
|
context,
|
|
3041
|
-
`Remove every policy on ${printable(
|
|
3321
|
+
`Remove every policy on ${printable(table2)}? Anon and signed-in requests to it are denied from then on.`
|
|
3042
3322
|
);
|
|
3043
3323
|
const client = await context.client();
|
|
3044
3324
|
const projectId = await context.resolveProjectId();
|
|
3045
|
-
const policy = await client.backend.removePolicy(projectId,
|
|
3325
|
+
const policy = await client.backend.removePolicy(projectId, table2);
|
|
3046
3326
|
const names = policy.policies ?? [];
|
|
3047
3327
|
context.output.result({ projectId, removed: true, policy }, [
|
|
3048
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.`
|
|
@@ -3129,14 +3409,14 @@ function parseColumnSpec(spec) {
|
|
|
3129
3409
|
column.default = defaultValue(value);
|
|
3130
3410
|
else if (value !== void 0 && key === "references") {
|
|
3131
3411
|
const dot = value.lastIndexOf(".");
|
|
3132
|
-
const
|
|
3412
|
+
const table2 = dot < 0 ? "" : value.slice(0, dot);
|
|
3133
3413
|
const target = dot < 0 ? "" : value.slice(dot + 1);
|
|
3134
|
-
if (!
|
|
3414
|
+
if (!table2 || !target)
|
|
3135
3415
|
throw new NaroError(
|
|
3136
3416
|
"VALIDATION_FAILED",
|
|
3137
3417
|
`${shown}: references needs <table>.<column>.`
|
|
3138
3418
|
);
|
|
3139
|
-
column.references = { table, column: target };
|
|
3419
|
+
column.references = { table: table2, column: target };
|
|
3140
3420
|
} else if (value !== void 0 && key === "ondelete") {
|
|
3141
3421
|
onDelete = ON_DELETE[value.toLowerCase()];
|
|
3142
3422
|
if (!onDelete)
|
|
@@ -3273,13 +3553,13 @@ async function runSchemaChange(context, operations, question) {
|
|
|
3273
3553
|
let file = null;
|
|
3274
3554
|
let fileWarning;
|
|
3275
3555
|
const relDir = relativeToCwd(context, dir);
|
|
3276
|
-
const rel =
|
|
3556
|
+
const rel = join4(relDir === "." ? "" : relDir, name);
|
|
3277
3557
|
if (context.flags.noWrite) {
|
|
3278
3558
|
fileWarning = `Not written to ${relDir} (--no-write): naro db reset will not re-apply ${result.name}, and naro migration list shows it as remote-only.`;
|
|
3279
3559
|
} else {
|
|
3280
3560
|
try {
|
|
3281
3561
|
mkdirSync3(dir, { recursive: true });
|
|
3282
|
-
writeFileSync3(
|
|
3562
|
+
writeFileSync3(join4(dir, name), result.sql, { flag: "wx" });
|
|
3283
3563
|
file = rel;
|
|
3284
3564
|
} catch (error) {
|
|
3285
3565
|
const reason = (error instanceof Error ? error.message : String(error)).replace(/[.\s]+$/, "");
|
|
@@ -3315,16 +3595,16 @@ async function runDbIndexUsage() {
|
|
|
3315
3595
|
throw new NaroError("VALIDATION_FAILED", USAGE2.index);
|
|
3316
3596
|
}
|
|
3317
3597
|
async function runDbTableCreate(context) {
|
|
3318
|
-
const [
|
|
3598
|
+
const [table2] = context.positionals;
|
|
3319
3599
|
const specs = columnsFlag(context);
|
|
3320
|
-
if (!
|
|
3600
|
+
if (!table2 || specs.length === 0)
|
|
3321
3601
|
throw new NaroError("VALIDATION_FAILED", USAGE2.create);
|
|
3322
3602
|
const columns = specs.map(parseColumnSpec);
|
|
3323
3603
|
const owner = context.flags.ownerColumn;
|
|
3324
3604
|
await runSchemaChange(context, [
|
|
3325
3605
|
{
|
|
3326
3606
|
op: "createTable",
|
|
3327
|
-
table,
|
|
3607
|
+
table: table2,
|
|
3328
3608
|
columns,
|
|
3329
3609
|
...context.flags.ifNotExists ? { ifNotExists: true } : {},
|
|
3330
3610
|
...owner === void 0 ? {} : { owner: { column: owner } }
|
|
@@ -3332,56 +3612,56 @@ async function runDbTableCreate(context) {
|
|
|
3332
3612
|
]);
|
|
3333
3613
|
}
|
|
3334
3614
|
async function runDbTableAddColumn(context) {
|
|
3335
|
-
const [
|
|
3615
|
+
const [table2] = context.positionals;
|
|
3336
3616
|
const specs = columnsFlag(context);
|
|
3337
3617
|
const [spec] = specs;
|
|
3338
|
-
if (!
|
|
3618
|
+
if (!table2 || spec === void 0 || specs.length !== 1)
|
|
3339
3619
|
throw new NaroError("VALIDATION_FAILED", USAGE2.addColumn);
|
|
3340
3620
|
await runSchemaChange(context, [
|
|
3341
|
-
{ op: "addColumn", table, column: parseColumnSpec(spec) }
|
|
3621
|
+
{ op: "addColumn", table: table2, column: parseColumnSpec(spec) }
|
|
3342
3622
|
]);
|
|
3343
3623
|
}
|
|
3344
3624
|
async function runDbTableRenameColumn(context) {
|
|
3345
|
-
const [
|
|
3346
|
-
if (!
|
|
3625
|
+
const [table2, column, to] = context.positionals;
|
|
3626
|
+
if (!table2 || !column || !to)
|
|
3347
3627
|
throw new NaroError("VALIDATION_FAILED", USAGE2.renameColumn);
|
|
3348
|
-
await runSchemaChange(context, [{ op: "renameColumn", table, column, to }]);
|
|
3628
|
+
await runSchemaChange(context, [{ op: "renameColumn", table: table2, column, to }]);
|
|
3349
3629
|
}
|
|
3350
3630
|
async function runDbTableRename(context) {
|
|
3351
|
-
const [
|
|
3352
|
-
if (!
|
|
3353
|
-
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 }]);
|
|
3354
3634
|
}
|
|
3355
3635
|
async function runDbTableDropColumn(context) {
|
|
3356
|
-
const [
|
|
3357
|
-
if (!
|
|
3636
|
+
const [table2, column] = context.positionals;
|
|
3637
|
+
if (!table2 || !column)
|
|
3358
3638
|
throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
|
|
3359
|
-
const target = `${
|
|
3639
|
+
const target = `${table2}.${column}`;
|
|
3360
3640
|
await runSchemaChange(
|
|
3361
3641
|
context,
|
|
3362
|
-
[{ op: "dropColumn", table, column, confirm: target }],
|
|
3642
|
+
[{ op: "dropColumn", table: table2, column, confirm: target }],
|
|
3363
3643
|
`Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
|
|
3364
3644
|
);
|
|
3365
3645
|
}
|
|
3366
3646
|
async function runDbTableDrop(context) {
|
|
3367
|
-
const [
|
|
3368
|
-
if (!
|
|
3647
|
+
const [table2] = context.positionals;
|
|
3648
|
+
if (!table2) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
|
|
3369
3649
|
await runSchemaChange(
|
|
3370
3650
|
context,
|
|
3371
|
-
[{ op: "dropTable", table, confirm:
|
|
3372
|
-
`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).`
|
|
3373
3653
|
);
|
|
3374
3654
|
}
|
|
3375
3655
|
async function runDbIndexCreate(context) {
|
|
3376
|
-
const [
|
|
3656
|
+
const [table2, list] = context.positionals;
|
|
3377
3657
|
const columns = listFlag(list);
|
|
3378
|
-
if (!
|
|
3658
|
+
if (!table2 || columns.length === 0)
|
|
3379
3659
|
throw new NaroError("VALIDATION_FAILED", USAGE2.indexCreate);
|
|
3380
3660
|
const name = context.flags.name;
|
|
3381
3661
|
await runSchemaChange(context, [
|
|
3382
3662
|
{
|
|
3383
3663
|
op: "createIndex",
|
|
3384
|
-
table,
|
|
3664
|
+
table: table2,
|
|
3385
3665
|
columns,
|
|
3386
3666
|
...context.flags.unique ? { unique: true } : {},
|
|
3387
3667
|
...name === void 0 ? {} : { name }
|
|
@@ -3398,7 +3678,7 @@ async function runDbIndexDrop(context) {
|
|
|
3398
3678
|
var defaults2 = {
|
|
3399
3679
|
detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
|
|
3400
3680
|
openBrowser,
|
|
3401
|
-
sleep: (ms) => new Promise((
|
|
3681
|
+
sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
|
|
3402
3682
|
};
|
|
3403
3683
|
function connected(context, project) {
|
|
3404
3684
|
const branch = project.defaultBranch;
|
|
@@ -3468,7 +3748,7 @@ async function waitForInstall(context, projectId, fullName, deps) {
|
|
|
3468
3748
|
if (teamSlug) {
|
|
3469
3749
|
const attempts = deps.pollAttempts ?? 60;
|
|
3470
3750
|
const interval = deps.pollIntervalMs ?? 3e3;
|
|
3471
|
-
for (let
|
|
3751
|
+
for (let attempt3 = 0; attempt3 < attempts; attempt3 += 1) {
|
|
3472
3752
|
await deps.sleep(interval);
|
|
3473
3753
|
let reachable = false;
|
|
3474
3754
|
try {
|
|
@@ -3595,9 +3875,9 @@ async function resolveTeam(context, teams, deps = {}) {
|
|
|
3595
3875
|
);
|
|
3596
3876
|
const account = deps.accountLabel ? await deps.accountLabel() : "this account";
|
|
3597
3877
|
if (only) {
|
|
3598
|
-
const
|
|
3878
|
+
const label3 = only.isPersonal ? `${only.name} (personal)` : only.name;
|
|
3599
3879
|
context.output.log(`
|
|
3600
|
-
${account} \u2192 ${
|
|
3880
|
+
${account} \u2192 ${label3}`);
|
|
3601
3881
|
const ok = await context.prompter.confirm(
|
|
3602
3882
|
"Deploy to this account and team?"
|
|
3603
3883
|
);
|
|
@@ -3886,7 +4166,7 @@ async function runInspect(context) {
|
|
|
3886
4166
|
|
|
3887
4167
|
// src/commands/domains.ts
|
|
3888
4168
|
var defaults3 = {
|
|
3889
|
-
sleep: (ms) => new Promise((
|
|
4169
|
+
sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)),
|
|
3890
4170
|
now: () => Date.now()
|
|
3891
4171
|
};
|
|
3892
4172
|
var FIRST_DELAY_MS = 5e3;
|
|
@@ -4130,7 +4410,7 @@ async function runDomainsRemove(context) {
|
|
|
4130
4410
|
|
|
4131
4411
|
// src/commands/env.ts
|
|
4132
4412
|
import { existsSync, writeFileSync as writeFileSync4 } from "fs";
|
|
4133
|
-
import { join as
|
|
4413
|
+
import { join as join5 } from "path";
|
|
4134
4414
|
var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
|
|
4135
4415
|
var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4136
4416
|
function parseTarget(value) {
|
|
@@ -4233,7 +4513,7 @@ async function runEnvPull(context) {
|
|
|
4233
4513
|
const vars = Object.fromEntries(
|
|
4234
4514
|
selected.map((row) => [row.key, row.value ?? ""])
|
|
4235
4515
|
);
|
|
4236
|
-
const path =
|
|
4516
|
+
const path = join5(context.cwd, file);
|
|
4237
4517
|
if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
|
|
4238
4518
|
const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
|
|
4239
4519
|
if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
|
|
@@ -4267,7 +4547,7 @@ async function runGenTypes(context) {
|
|
|
4267
4547
|
read.policies === void 0 ? {} : { policies: read.policies }
|
|
4268
4548
|
);
|
|
4269
4549
|
const undescribed = tables.flatMap(
|
|
4270
|
-
(
|
|
4550
|
+
(table2) => table2.error === void 0 ? [] : [{ name: table2.name, error: table2.error }]
|
|
4271
4551
|
);
|
|
4272
4552
|
for (const { name, error } of undescribed)
|
|
4273
4553
|
context.output.warn(
|
|
@@ -4303,10 +4583,242 @@ async function runGenTypes(context) {
|
|
|
4303
4583
|
]);
|
|
4304
4584
|
}
|
|
4305
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
|
+
|
|
4306
4818
|
// src/commands/logs.ts
|
|
4307
4819
|
var FOLLOW_INTERVAL_MS = 3e3;
|
|
4308
4820
|
function sleep(ms) {
|
|
4309
|
-
return new Promise((
|
|
4821
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
4310
4822
|
}
|
|
4311
4823
|
function formatBuildLog(log) {
|
|
4312
4824
|
return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
|
|
@@ -4554,80 +5066,3067 @@ async function runRollback(context) {
|
|
|
4554
5066
|
await promoteAndWait(context, client, project, target, "rollback");
|
|
4555
5067
|
}
|
|
4556
5068
|
|
|
4557
|
-
// src/
|
|
4558
|
-
var
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
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
|
+
}
|
|
4585
5116
|
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
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
|
+
}
|
|
4592
5150
|
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
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
|
+
}
|
|
5342
|
+
|
|
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";
|
|
5350
|
+
|
|
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)
|
|
4631
8130
|
|
|
4632
8131
|
naro backend [status] On or off, status, URL and anon key (the service key stays hidden)
|
|
4633
8132
|
naro backend enable Turn the backend on: D1, sign-in and the Data API (safe to re-run;
|
|
@@ -4686,6 +8185,27 @@ Usage:
|
|
|
4686
8185
|
naro_insert, naro_update, naro_delete (shorthand for CREATE POLICY)
|
|
4687
8186
|
naro policy remove <table> Drop every policy on it: back to deny (asks first)
|
|
4688
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
|
+
|
|
4689
8209
|
naro promote <deployment-id> Publish a build to production
|
|
4690
8210
|
naro rollback [deployment-id] Roll production back to the previous build
|
|
4691
8211
|
|
|
@@ -4700,6 +8220,10 @@ Global options:
|
|
|
4700
8220
|
--cwd <dir> Run as if started in <dir>
|
|
4701
8221
|
--prod Deploy/target production
|
|
4702
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)
|
|
4703
8227
|
--no-www Register the apex alone (naro domains add)
|
|
4704
8228
|
--file <path> SQL file to run (naro db query, naro policy create)
|
|
4705
8229
|
--dry-run Show what would change without changing it (naro db push, db restore, db reset, db table, db index)
|
|
@@ -4726,6 +8250,14 @@ Global options:
|
|
|
4726
8250
|
--password-stdin Read the new user's password from stdin (a pipe, or a hidden prompt)
|
|
4727
8251
|
--password <p> The password in argv: works, but lands in shell history (naro backend users create)
|
|
4728
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)
|
|
4729
8261
|
--select <rule> Rule for reads; --insert, --update, --delete likewise (naro policy set)
|
|
4730
8262
|
--owner <column> Column that holds the signed-in user's id, for the owner rule (naro policy set, db table create)
|
|
4731
8263
|
--select-when <filter> Condition for reads, e.g. 'user_id.eq.auth.uid()'; --insert-when (what a new
|
|
@@ -4819,6 +8351,56 @@ function routeBackendUsers(words) {
|
|
|
4819
8351
|
return null;
|
|
4820
8352
|
}
|
|
4821
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
|
+
}
|
|
4822
8404
|
function route(parsed) {
|
|
4823
8405
|
const { command, positionals } = parsed;
|
|
4824
8406
|
const sub = positionals[0];
|
|
@@ -4856,6 +8438,39 @@ function route(parsed) {
|
|
|
4856
8438
|
return { handler: runCancel, positionals };
|
|
4857
8439
|
case "logs":
|
|
4858
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
|
+
}
|
|
4859
8474
|
case "env":
|
|
4860
8475
|
switch (sub) {
|
|
4861
8476
|
case void 0:
|
|
@@ -4989,6 +8604,8 @@ function route(parsed) {
|
|
|
4989
8604
|
default:
|
|
4990
8605
|
return null;
|
|
4991
8606
|
}
|
|
8607
|
+
case "storage":
|
|
8608
|
+
return routeStorage(positionals);
|
|
4992
8609
|
case "policy":
|
|
4993
8610
|
switch (sub) {
|
|
4994
8611
|
case void 0:
|
|
@@ -5035,8 +8652,8 @@ function route(parsed) {
|
|
|
5035
8652
|
function version() {
|
|
5036
8653
|
try {
|
|
5037
8654
|
const pkg = JSON.parse(
|
|
5038
|
-
|
|
5039
|
-
|
|
8655
|
+
readFileSync5(
|
|
8656
|
+
join7(dirname4(fileURLToPath(import.meta.url)), "..", "package.json"),
|
|
5040
8657
|
"utf8"
|
|
5041
8658
|
)
|
|
5042
8659
|
);
|