naro-cli 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -32,6 +32,8 @@ import {
32
32
  deployDirectory,
33
33
  detectGitInfo,
34
34
  ensureGitignoreHasNaro,
35
+ environmentGuidance,
36
+ environmentSlugProblem,
35
37
  formatRedirectEntry,
36
38
  generateTypes,
37
39
  indexesFromRows,
@@ -97,7 +99,7 @@ import {
97
99
  usageMonthOf,
98
100
  userForAccessToken,
99
101
  writeProjectLink
100
- } from "./chunk-SAA66XLD.js";
102
+ } from "./chunk-MBSO3K6L.js";
101
103
  import "./chunk-K3NQKI34.js";
102
104
 
103
105
  // src/args.ts
@@ -543,6 +545,37 @@ import { readFileSync as readFileSync6 } from "fs";
543
545
  import { dirname as dirname4, join as join6 } from "path";
544
546
  import { fileURLToPath } from "url";
545
547
 
548
+ // src/data-environment.ts
549
+ var DATA_ENV_MESSAGES = {
550
+ name: "--env must be production or an environment name.",
551
+ preview: "--env preview has no data of its own: preview deployments use production's. Drop --env, or name an environment.",
552
+ projectWide: (command) => `--env is not an option of ${command}: turning the backend on or off applies to every environment.`
553
+ };
554
+ function dataEnvironment(flags) {
555
+ const value = flags.env;
556
+ if (value === void 0 || value === "production") return null;
557
+ if (value === "preview")
558
+ throw new NaroError("VALIDATION_FAILED", DATA_ENV_MESSAGES.preview);
559
+ if (environmentSlugProblem(value) !== null)
560
+ throw new NaroError("VALIDATION_FAILED", DATA_ENV_MESSAGES.name);
561
+ return value;
562
+ }
563
+ async function dataClient(context) {
564
+ return (await context.client()).inEnvironment(dataEnvironment(context.flags));
565
+ }
566
+ function databaseName(environment) {
567
+ return environment === null ? "the project database" : `the ${environment} environment's database`;
568
+ }
569
+ function enableBackendCommand(environment) {
570
+ return environment === null ? "naro backend enable" : `naro environments provision ${environment}`;
571
+ }
572
+ function envFlag(environment) {
573
+ return environment === null ? "" : ` --env ${environment}`;
574
+ }
575
+ function forEnvironment(text, environment) {
576
+ return environmentGuidance(text, environment);
577
+ }
578
+
546
579
  // src/commands/migration.ts
547
580
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
548
581
  import { join, relative } from "path";
@@ -641,7 +674,7 @@ async function runMigrationNew(context, deps = processStdin) {
641
674
  ]);
642
675
  }
643
676
  async function runMigrationList(context) {
644
- const client = await context.client();
677
+ const client = await dataClient(context);
645
678
  const projectId = await context.resolveProjectId();
646
679
  const dir = migrationsDir(context);
647
680
  const local = listMigrationFiles(dir);
@@ -721,7 +754,7 @@ async function runMigrationRepair(context) {
721
754
  "VALIDATION_FAILED",
722
755
  "Pass --status applied or --status reverted."
723
756
  );
724
- const client = await context.client();
757
+ const client = await dataClient(context);
725
758
  const projectId = await context.resolveProjectId();
726
759
  const ledger = await appliedMigrations(client, projectId);
727
760
  const recorded = ledger.some(
@@ -735,7 +768,7 @@ async function runMigrationRepair(context) {
735
768
  }
736
769
  await confirmOrAbort(
737
770
  context,
738
- status === "applied" ? `Mark ${key} as applied in the project database's ledger? Its SQL is not run.` : `Remove ${key} from the project database's ledger? Its SQL is not rolled back.`
771
+ status === "applied" ? `Mark ${key} as applied in ${databaseName(dataEnvironment(context.flags))}'s ledger? Its SQL is not run.` : `Remove ${key} from ${databaseName(dataEnvironment(context.flags))}'s ledger? Its SQL is not rolled back.`
739
772
  );
740
773
  if (status === "applied" && ledger.length === 0)
741
774
  await client.db.query(projectId, { sql: LEDGER_DDL });
@@ -754,7 +787,7 @@ async function runMigrationRepair(context) {
754
787
  ]);
755
788
  }
756
789
  async function runMigrationUp(context) {
757
- const client = await context.client();
790
+ const client = await dataClient(context);
758
791
  const projectId = await context.resolveProjectId();
759
792
  const dir = migrationsDir(context);
760
793
  const local = listMigrationFiles(dir);
@@ -782,7 +815,7 @@ async function runMigrationUp(context) {
782
815
  for (const name of pending) context.output.log(` ${printable(name)}`);
783
816
  await confirmOrAbort(
784
817
  context,
785
- `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
818
+ `Apply ${String(pending.length)} migration(s) to ${databaseName(dataEnvironment(context.flags))}? Each file is applied all-or-nothing; a failure stops the run.`
786
819
  );
787
820
  const { applied, warnings } = await applyMigrationFiles(
788
821
  context,
@@ -1187,16 +1220,16 @@ var DISABLE_QUESTION = "Turn the backend off? In up to about 90 seconds your app
1187
1220
  var OFF_ADVICE = "Turned off: your app's /_naro/* requests get 404. The data, users and keys are kept \u2014 `naro backend enable` turns it back on with the same keys.";
1188
1221
  var TURNING_OFF_ADVICE = "Turning it off did not finish: your app's /_naro/* requests may still be answered. Run `naro backend disable` again to finish it \u2014 the control plane also retries every few minutes.";
1189
1222
  var TURNING_OFF_NOW_ADVICE = "Turning off: another run is removing the routes right now. Check again in a minute with `naro backend`.";
1190
- function leaseHeld(info, now) {
1223
+ function leaseHeld(info, now2) {
1191
1224
  if (info.leaseExpiresAt === null) return false;
1192
1225
  const until = Date.parse(info.leaseExpiresAt);
1193
- return !Number.isNaN(until) && until > now;
1226
+ return !Number.isNaN(until) && until > now2;
1194
1227
  }
1195
1228
  function sentence(text) {
1196
1229
  const line = printable(text).trim();
1197
1230
  return /[.!?…]$/.test(line) ? line : `${line}.`;
1198
1231
  }
1199
- function statusText(info, now) {
1232
+ function statusText(info, now2) {
1200
1233
  switch (info.status) {
1201
1234
  case "none":
1202
1235
  return "not provisioned";
@@ -1204,7 +1237,7 @@ function statusText(info, now) {
1204
1237
  return "ready";
1205
1238
  default: {
1206
1239
  const text = info.step ? `${info.status} (step: ${info.step})` : info.status;
1207
- return isStalledBackend(info, now) ? `${text}, stalled` : text;
1240
+ return isStalledBackend(info, now2) ? `${text}, stalled` : text;
1208
1241
  }
1209
1242
  }
1210
1243
  }
@@ -1214,10 +1247,10 @@ function switchText(info) {
1214
1247
  return info.disabledAt ? `turning off (since ${printable(info.disabledAt)})` : "turning off";
1215
1248
  return info.disabledAt ? `off since ${printable(info.disabledAt)}` : "off";
1216
1249
  }
1217
- function backendLines(info, now = Date.now()) {
1250
+ function backendLines(info, now2 = Date.now()) {
1218
1251
  const lines = [
1219
1252
  `Backend: ${switchText(info)}`,
1220
- `Status: ${statusText(info, now)}`,
1253
+ `Status: ${statusText(info, now2)}`,
1221
1254
  `URL: ${info.url}`
1222
1255
  ];
1223
1256
  if (info.anonKey) lines.push(`Anon key: ${info.anonKey}`);
@@ -1227,21 +1260,27 @@ function backendLines(info, now = Date.now()) {
1227
1260
  );
1228
1261
  return lines;
1229
1262
  }
1230
- function adviceLines(info, now) {
1263
+ var ENVIRONMENT_TURNING_OFF_ADVICE = "Turning it off did not finish: your app's /_naro/* requests may still be answered. The control plane finishes it within a few minutes: the backend's switch is production's, and every environment follows it.";
1264
+ function adviceLines(info, now2, environment = null) {
1231
1265
  if (!info.enabled && info.status !== "none") {
1232
1266
  if (!info.turningOff) return [OFF_ADVICE];
1233
- return [leaseHeld(info, now) ? TURNING_OFF_NOW_ADVICE : TURNING_OFF_ADVICE];
1267
+ if (leaseHeld(info, now2)) return [TURNING_OFF_NOW_ADVICE];
1268
+ return [
1269
+ environment === null ? TURNING_OFF_ADVICE : ENVIRONMENT_TURNING_OFF_ADVICE
1270
+ ];
1234
1271
  }
1235
1272
  switch (info.status) {
1236
1273
  case "none":
1237
- return ["Run `naro backend enable` to turn it on."];
1274
+ return [
1275
+ environment === null ? "Run `naro backend enable` to turn it on." : `This environment has no backend yet: \`naro environments provision ${environment}\` makes it while production's backend is on \u2014 turn that on with \`naro backend enable\`.`
1276
+ ];
1238
1277
  case "error":
1239
1278
  return [
1240
1279
  ...info.error ? [`Error: ${printable(info.error)}`] : [],
1241
1280
  "Run `naro backend enable` again \u2014 it picks up where it stopped."
1242
1281
  ];
1243
1282
  case "provisioning":
1244
- return isStalledBackend(info, now) ? [
1283
+ return isStalledBackend(info, now2) ? [
1245
1284
  "No run is working on it: the last one stopped part-way. Run `naro backend enable` to pick it up."
1246
1285
  ] : [
1247
1286
  "Provisioning is running. Check again in a minute with `naro backend`."
@@ -1255,17 +1294,21 @@ function stoppedAt(info) {
1255
1294
  return `stopped at ${step}${info.error ? `: ${sentence(info.error)}` : "."}`;
1256
1295
  }
1257
1296
  async function runBackendInfo(context) {
1258
- const client = await context.client();
1297
+ const client = await dataClient(context);
1259
1298
  const projectId = await context.resolveProjectId();
1260
1299
  const backend = await client.backend.info(projectId);
1261
- const now = Date.now();
1300
+ const now2 = Date.now();
1301
+ const environment = dataEnvironment(context.flags);
1262
1302
  context.output.result(
1263
- { projectId, backend, stalled: isStalledBackend(backend, now) },
1264
- [...backendLines(backend, now), ...adviceLines(backend, now)]
1303
+ { projectId, backend, stalled: isStalledBackend(backend, now2) },
1304
+ [
1305
+ ...backendLines(backend, now2),
1306
+ ...adviceLines(backend, now2, environment)
1307
+ ].map((line) => forEnvironment(line, environment))
1265
1308
  );
1266
1309
  }
1267
1310
  async function runBackendEnable(context) {
1268
- const client = await context.client();
1311
+ const client = await dataClient(context);
1269
1312
  const projectId = await context.resolveProjectId();
1270
1313
  let backend;
1271
1314
  try {
@@ -1309,7 +1352,7 @@ async function runBackendEnable(context) {
1309
1352
  }
1310
1353
  async function runBackendDisable(context) {
1311
1354
  await confirmOrAbort(context, DISABLE_QUESTION);
1312
- const client = await context.client();
1355
+ const client = await dataClient(context);
1313
1356
  const projectId = await context.resolveProjectId();
1314
1357
  const backend = await client.backend.disable(projectId);
1315
1358
  if (backend.status === "none") {
@@ -1326,7 +1369,8 @@ async function runBackendDisable(context) {
1326
1369
  ]);
1327
1370
  }
1328
1371
  async function runBackendKeys(context) {
1329
- const client = await context.client();
1372
+ const environment = dataEnvironment(context.flags);
1373
+ const client = await dataClient(context);
1330
1374
  const projectId = await context.resolveProjectId();
1331
1375
  const keys = await client.backend.keys(projectId);
1332
1376
  if (!context.flags.reveal) {
@@ -1339,7 +1383,10 @@ async function runBackendKeys(context) {
1339
1383
  [
1340
1384
  `Anon key: ${keys.anonKey}`,
1341
1385
  `Service key: ${keys.serviceKeyPrefix}\u2026 (hidden)`,
1342
- "`naro backend keys --reveal` prints the service key (needs database write access)."
1386
+ forEnvironment(
1387
+ "`naro backend keys --reveal` prints the service key (needs database write access).",
1388
+ environment
1389
+ )
1343
1390
  ]
1344
1391
  );
1345
1392
  return;
@@ -1359,19 +1406,19 @@ function rotateKind(context) {
1359
1406
  function keyLabel(kind) {
1360
1407
  return kind === "anon" ? "Anon key: " : "Service key: ";
1361
1408
  }
1362
- function rotationWarning(kind, backend) {
1409
+ function rotationWarning(kind, backend, environment = null) {
1363
1410
  if (backend.status === "error")
1364
- return `\u26A0 The ${kind} key is replaced, but provisioning ${stoppedAt(backend)} Run \`naro backend enable\` to finish.`;
1411
+ return `\u26A0 The ${kind} key is replaced, but provisioning ${stoppedAt(backend)} Run \`${enableBackendCommand(environment)}\` to finish.`;
1365
1412
  if (backend.status !== "ready")
1366
- return `\u26A0 The ${kind} key is replaced; another provisioning run is finishing the backend. Check it with \`naro backend\`.`;
1413
+ return `\u26A0 The ${kind} key is replaced; another provisioning run is finishing the backend. Check it with \`naro backend${envFlag(environment)}\`.`;
1367
1414
  return null;
1368
1415
  }
1369
- function checkKeysSentence(kind) {
1416
+ function checkKeysSentence(kind, environment = null) {
1370
1417
  const keys = kind === "service" ? "naro backend keys --reveal" : "naro backend keys";
1371
- return `Run \`${keys}\` to see which key is live, then \`naro backend enable\`.`;
1418
+ return `Run \`${keys}${envFlag(environment)}\` to see which key is live, then \`${enableBackendCommand(environment)}\`.`;
1372
1419
  }
1373
- function rotationHint(kind) {
1374
- return `The rotation may have gone through anyway. ${checkKeysSentence(kind)}`;
1420
+ function rotationHint(kind, environment = null) {
1421
+ return `The rotation may have gone through anyway. ${checkKeysSentence(kind, environment)}`;
1375
1422
  }
1376
1423
  function outcomeUnknown(error) {
1377
1424
  return error.code === "BACKEND_ROTATION_FAILED" || error.code === "INTERNAL_ERROR" || error.code === "NETWORK_ERROR" || error.status !== void 0 && error.status >= 500;
@@ -1382,18 +1429,19 @@ async function runBackendKeysRotate(context) {
1382
1429
  context,
1383
1430
  `Replace the ${kind} key? Every client still sending the old one gets 401 INVALID_API_KEY as soon as the new key is live.`
1384
1431
  );
1385
- const client = await context.client();
1432
+ const client = await dataClient(context);
1433
+ const environment = dataEnvironment(context.flags);
1386
1434
  const projectId = await context.resolveProjectId();
1387
1435
  let rotated;
1388
1436
  try {
1389
1437
  rotated = await client.backend.rotateKey(projectId, kind);
1390
1438
  } catch (error) {
1391
1439
  if (!isNaroError(error)) throw error;
1392
- const hint = rotationHint(kind);
1440
+ const hint = rotationHint(kind, environment);
1393
1441
  if (error.code === "TIMEOUT")
1394
1442
  throw new NaroError(
1395
1443
  "TIMEOUT",
1396
- `Rotating the key did not answer within 2 minutes, so whether it happened is not known. ${checkKeysSentence(kind)}`,
1444
+ `Rotating the key did not answer within 2 minutes, so whether it happened is not known. ${checkKeysSentence(kind, environment)}`,
1397
1445
  { details: { hint }, cause: error }
1398
1446
  );
1399
1447
  if (!outcomeUnknown(error)) throw error;
@@ -1405,7 +1453,7 @@ async function runBackendKeysRotate(context) {
1405
1453
  });
1406
1454
  }
1407
1455
  const { backend, key } = rotated;
1408
- const warning = rotationWarning(kind, backend);
1456
+ const warning = rotationWarning(kind, backend, environment);
1409
1457
  if (kind === "service") context.output.warn(SERVICE_KEY_WARNING);
1410
1458
  if (warning) context.output.warn(warning);
1411
1459
  context.output.result(
@@ -1442,14 +1490,21 @@ function sentence2(text) {
1442
1490
  const line = printable(text).trim();
1443
1491
  return /[.!?…]$/.test(line) ? line : `${line}.`;
1444
1492
  }
1445
- function stoppedMessage(backend) {
1493
+ function stoppedMessage(backend, environment = null) {
1494
+ const enable = enableBackendCommand(environment);
1446
1495
  const step = backend.step ?? "worker";
1447
1496
  const reason = backend.error?.trim() ? `: ${sentence2(backend.error)}` : ".";
1448
1497
  if (BACKEND_STEPS.indexOf(step) > BACKEND_STEPS.indexOf("worker"))
1449
- return `Saved and live on the Naro worker, but updating the backend stopped after the upload, at the ${step} step${reason} Run \`naro backend enable\` to finish.`;
1450
- return `Saved, but not live yet: updating the Naro worker stopped at the ${step} step${reason} The worker keeps its previous settings until \`naro backend enable\` puts the saved ones on it.`;
1498
+ return `Saved and live on the Naro worker, but updating the backend stopped after the upload, at the ${step} step${reason} Run \`${enable}\` to finish.`;
1499
+ return `Saved, but not live yet: updating the Naro worker stopped at the ${step} step${reason} The worker keeps its previous settings until \`${enable}\` puts the saved ones on it.`;
1451
1500
  }
1452
1501
  var NOT_APPLIED_WARNING = "\u26A0 These are the saved settings. The Naro worker's last update did not get past its upload, so it may still run older ones \u2014 run `naro backend enable` to put these on it.";
1502
+ function notAppliedWarning(environment) {
1503
+ return environment === null ? NOT_APPLIED_WARNING : NOT_APPLIED_WARNING.replace(
1504
+ "`naro backend enable`",
1505
+ `\`${enableBackendCommand(environment)}\``
1506
+ );
1507
+ }
1453
1508
  function authLines(auth) {
1454
1509
  const lines = [
1455
1510
  auth.email ? `Email: ${auth.email.provider} \u2014 ${printable(auth.email.from)} (API key ${printable(auth.email.apiKeyPrefix)}\u2026)` : "Email: not set up"
@@ -1495,14 +1550,18 @@ function adviceLines2(auth) {
1495
1550
  return lines;
1496
1551
  }
1497
1552
  async function runBackendAuthStatus(context) {
1498
- const client = await context.client();
1553
+ const client = await dataClient(context);
1499
1554
  const projectId = await context.resolveProjectId();
1500
1555
  const auth = await client.backend.auth(projectId);
1501
- if (auth.applied === false) context.output.warn(NOT_APPLIED_WARNING);
1502
- context.output.result({ projectId, auth }, [
1503
- ...authLines(auth),
1504
- ...adviceLines2(auth)
1505
- ]);
1556
+ const environment = dataEnvironment(context.flags);
1557
+ if (auth.applied === false)
1558
+ context.output.warn(notAppliedWarning(environment));
1559
+ context.output.result(
1560
+ { projectId, auth },
1561
+ [...authLines(auth), ...adviceLines2(auth)].map(
1562
+ (line) => forEnvironment(line, environment)
1563
+ )
1564
+ );
1506
1565
  }
1507
1566
  async function readSecret(context, input, given, flag, what, usage) {
1508
1567
  if (!given)
@@ -1518,6 +1577,7 @@ async function readSecret(context, input, given, flag, what, usage) {
1518
1577
  }
1519
1578
  async function change(context, send2, done, after) {
1520
1579
  const projectId = await context.resolveProjectId();
1580
+ const environment = dataEnvironment(context.flags);
1521
1581
  let answer;
1522
1582
  try {
1523
1583
  answer = await send2(projectId);
@@ -1525,13 +1585,13 @@ async function change(context, send2, done, after) {
1525
1585
  if (isNaroError(error) && error.code === "TIMEOUT")
1526
1586
  throw new NaroError(
1527
1587
  "TIMEOUT",
1528
- "The change did not answer within 2 minutes. `naro backend auth` shows what is saved, and `naro backend enable` finishes putting it on the Naro worker.",
1588
+ `The change did not answer within 2 minutes. \`naro backend auth${envFlag(environment)}\` shows what is saved, and \`${enableBackendCommand(environment)}\` finishes putting it on the Naro worker.`,
1529
1589
  { cause: error }
1530
1590
  );
1531
1591
  const saved = isNaroError(error) ? error.details?.auth : void 0;
1532
1592
  if (isNaroError(error) && error.code === "BACKEND_PROVISION_FAILED" && typeof saved === "object" && saved !== null) {
1533
1593
  for (const line of authLines(saved))
1534
- context.output.log(line);
1594
+ context.output.log(forEnvironment(line, environment));
1535
1595
  throw new NaroError(error.code, error.message, {
1536
1596
  details: { projectId, ...error.details }
1537
1597
  });
@@ -1540,20 +1600,29 @@ async function change(context, send2, done, after) {
1540
1600
  }
1541
1601
  const { auth, backend } = answer;
1542
1602
  if (backend.status === "error") {
1543
- for (const line of authLines(auth)) context.output.log(line);
1544
- throw new NaroError("BACKEND_PROVISION_FAILED", stoppedMessage(backend), {
1545
- details: { projectId, auth, backend }
1546
- });
1603
+ for (const line of authLines(auth))
1604
+ context.output.log(forEnvironment(line, environment));
1605
+ throw new NaroError(
1606
+ "BACKEND_PROVISION_FAILED",
1607
+ stoppedMessage(backend, environment),
1608
+ {
1609
+ details: { projectId, auth, backend }
1610
+ }
1611
+ );
1547
1612
  }
1548
1613
  if (backend.status !== "ready")
1549
1614
  context.output.warn(
1550
- "\u26A0 Saved; another provisioning run is finishing the backend. Check it with `naro backend`."
1615
+ forEnvironment(
1616
+ "\u26A0 Saved; another provisioning run is finishing the backend. Check it with `naro backend`.",
1617
+ environment
1618
+ )
1551
1619
  );
1552
- context.output.result({ projectId, auth, backend }, [
1553
- ...done,
1554
- ...after ? after(auth) : [],
1555
- ...authLines(auth)
1556
- ]);
1620
+ context.output.result(
1621
+ { projectId, auth, backend },
1622
+ [...done, ...after ? after(auth) : [], ...authLines(auth)].map(
1623
+ (line) => forEnvironment(line, environment)
1624
+ )
1625
+ );
1557
1626
  }
1558
1627
  async function runBackendAuthEmailSet(context, input = processStdin2) {
1559
1628
  const { provider, from } = context.flags;
@@ -1572,7 +1641,7 @@ async function runBackendAuthEmailSet(context, input = processStdin2) {
1572
1641
  "Resend API key",
1573
1642
  AUTH_USAGE.email
1574
1643
  );
1575
- const client = await context.client();
1644
+ const client = await dataClient(context);
1576
1645
  await change(
1577
1646
  context,
1578
1647
  (projectId) => client.backend.setAuth(projectId, {
@@ -1588,7 +1657,7 @@ async function runBackendAuthEmailRemove(context) {
1588
1657
  context,
1589
1658
  "Remove email? Confirmation and password reset emails stop at once."
1590
1659
  );
1591
- const client = await context.client();
1660
+ const client = await dataClient(context);
1592
1661
  await change(
1593
1662
  context,
1594
1663
  (projectId) => client.backend.removeAuth(projectId, "email"),
@@ -1614,7 +1683,7 @@ async function runBackendAuthOAuthSet(context, input = processStdin2) {
1614
1683
  `${LABELS[provider]} client secret`,
1615
1684
  AUTH_USAGE.oauth
1616
1685
  );
1617
- const client = await context.client();
1686
+ const client = await dataClient(context);
1618
1687
  const patch = {
1619
1688
  oauth: { [provider]: { clientId, clientSecret } }
1620
1689
  };
@@ -1632,7 +1701,7 @@ async function runBackendAuthOAuthRemove(context) {
1632
1701
  context,
1633
1702
  `Remove ${LABELS[provider]} sign-in? Users who signed in with ${LABELS[provider]} cannot sign in with it until it is set up again.`
1634
1703
  );
1635
- const client = await context.client();
1704
+ const client = await dataClient(context);
1636
1705
  await change(
1637
1706
  context,
1638
1707
  (projectId) => client.backend.removeAuth(projectId, provider),
@@ -1640,7 +1709,7 @@ async function runBackendAuthOAuthRemove(context) {
1640
1709
  );
1641
1710
  }
1642
1711
  async function runBackendAuthRedirectsList(context) {
1643
- const client = await context.client();
1712
+ const client = await dataClient(context);
1644
1713
  const projectId = await context.resolveProjectId();
1645
1714
  const auth = await client.backend.auth(projectId);
1646
1715
  const production = productionOrigins(auth);
@@ -1657,7 +1726,10 @@ async function runBackendAuthRedirectsList(context) {
1657
1726
  ),
1658
1727
  ...auth.redirectOrigins,
1659
1728
  ...auth.redirectOrigins.length === 0 ? [
1660
- "Only the project's own origin may be sent back to. Add another with `naro backend auth redirects add <url>` \u2014 an origin, or only the pages under a path like https://app.example.com/auth/."
1729
+ forEnvironment(
1730
+ "Only the project's own origin may be sent back to. Add another with `naro backend auth redirects add <url>` \u2014 an origin, or only the pages under a path like https://app.example.com/auth/.",
1731
+ dataEnvironment(context.flags)
1732
+ )
1661
1733
  ] : []
1662
1734
  ]
1663
1735
  );
@@ -1670,7 +1742,7 @@ function originOf(context) {
1670
1742
  }
1671
1743
  async function runBackendAuthRedirectsAdd(context) {
1672
1744
  const origin = originOf(context);
1673
- const client = await context.client();
1745
+ const client = await dataClient(context);
1674
1746
  await change(
1675
1747
  context,
1676
1748
  (projectId) => client.backend.setAuth(projectId, {
@@ -1684,7 +1756,7 @@ function allowing(list, origin) {
1684
1756
  }
1685
1757
  async function runBackendAuthRedirectsRemove(context) {
1686
1758
  const given = originOf(context);
1687
- const client = await context.client();
1759
+ const client = await dataClient(context);
1688
1760
  const projectId = await context.resolveProjectId();
1689
1761
  const parsed = parseRedirectEntry(given);
1690
1762
  if (parsed) {
@@ -1730,7 +1802,7 @@ async function runBackendAuthRequireVerification(context) {
1730
1802
  if (value !== "on" && value !== "off")
1731
1803
  throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.verification);
1732
1804
  const on = value === "on";
1733
- const client = await context.client();
1805
+ const client = await dataClient(context);
1734
1806
  await change(
1735
1807
  context,
1736
1808
  (projectId) => client.backend.setAuth(projectId, { requireEmailVerification: on }),
@@ -1744,7 +1816,7 @@ async function runBackendAuthSignups(context) {
1744
1816
  if (value !== "on" && value !== "off")
1745
1817
  throw new NaroError("VALIDATION_FAILED", AUTH_USAGE.signups);
1746
1818
  const open = value === "on";
1747
- const client = await context.client();
1819
+ const client = await dataClient(context);
1748
1820
  await change(
1749
1821
  context,
1750
1822
  (projectId) => client.backend.setAuth(projectId, { signupsEnabled: open }),
@@ -1763,7 +1835,7 @@ async function runBackendAuthPassword(context) {
1763
1835
  "VALIDATION_FAILED",
1764
1836
  `--min-length must be a whole number from ${String(MIN_LENGTH_FLOOR)} to ${String(MIN_LENGTH_CEILING)}. ${AUTH_USAGE.password}`
1765
1837
  );
1766
- const client = await context.client();
1838
+ const client = await dataClient(context);
1767
1839
  await change(
1768
1840
  context,
1769
1841
  (projectId) => client.backend.setAuth(projectId, { passwordMinLength: length }),
@@ -1835,13 +1907,16 @@ function userTable(users) {
1835
1907
  );
1836
1908
  }
1837
1909
  async function runBackendUsersList(context) {
1838
- const client = await context.client();
1910
+ const client = await dataClient(context);
1839
1911
  const projectId = await context.resolveProjectId();
1840
1912
  const { users, total } = await allUsers(client, projectId);
1841
1913
  context.output.result(
1842
1914
  { projectId, users, total },
1843
1915
  users.length === 0 ? [
1844
- "No users yet. Create one with `naro backend users create --username <name> --password-stdin`."
1916
+ forEnvironment(
1917
+ "No users yet. Create one with `naro backend users create --username <name> --password-stdin`.",
1918
+ dataEnvironment(context.flags)
1919
+ )
1845
1920
  ] : [
1846
1921
  `${String(total)} ${total === 1 ? "user" : "users"}, newest first:`,
1847
1922
  ...userTable(users)
@@ -1885,7 +1960,7 @@ async function runBackendUsersCreate(context, input = processStdin3) {
1885
1960
  if (username === void 0 && email === void 0 || context.positionals.length > 0)
1886
1961
  throw new NaroError("VALIDATION_FAILED", USERS_USAGE.create);
1887
1962
  const password = await passwordOf(context, input);
1888
- const client = await context.client();
1963
+ const client = await dataClient(context);
1889
1964
  const projectId = await context.resolveProjectId();
1890
1965
  const body = { password };
1891
1966
  if (username !== void 0) body.username = username;
@@ -1911,7 +1986,7 @@ async function runBackendUsersDelete(context) {
1911
1986
  const [given] = context.positionals;
1912
1987
  if (given === void 0 || context.positionals.length > 1)
1913
1988
  throw new NaroError("VALIDATION_FAILED", USERS_USAGE.delete);
1914
- const client = await context.client();
1989
+ const client = await dataClient(context);
1915
1990
  const projectId = await context.resolveProjectId();
1916
1991
  const { users } = await allUsers(client, projectId);
1917
1992
  const user = find(users, given);
@@ -2018,7 +2093,7 @@ function usageLines(info) {
2018
2093
  }
2019
2094
  return lines;
2020
2095
  }
2021
- function describeLines(info) {
2096
+ function describeLines(info, environment = null) {
2022
2097
  const lines = [
2023
2098
  `Provider: ${info.provider}`,
2024
2099
  `Status: ${info.status}`,
@@ -2039,17 +2114,22 @@ function describeLines(info) {
2039
2114
  );
2040
2115
  lines.push(...usageLines(info));
2041
2116
  if (info.provider === "none")
2042
- lines.push("Run `naro db enable` to create the database.");
2117
+ lines.push(
2118
+ environment === null ? "Run `naro db enable` to create the database." : `The ${environment} environment has no database yet. Run \`naro environments provision ${environment}\` to make it.`
2119
+ );
2043
2120
  return lines;
2044
2121
  }
2045
2122
  async function runDbInfo(context) {
2046
- const client = await context.client();
2123
+ const client = await dataClient(context);
2047
2124
  const projectId = await context.resolveProjectId();
2048
2125
  const database = await client.db.info(projectId);
2049
- context.output.result({ projectId, database }, describeLines(database));
2126
+ context.output.result(
2127
+ { projectId, database },
2128
+ describeLines(database, dataEnvironment(context.flags))
2129
+ );
2050
2130
  }
2051
2131
  async function runDbEnable(context) {
2052
- const client = await context.client();
2132
+ const client = await dataClient(context);
2053
2133
  const projectId = await context.resolveProjectId();
2054
2134
  const database = await client.db.enable(projectId);
2055
2135
  context.output.result({ projectId, database }, [
@@ -2080,7 +2160,7 @@ async function readSql(context, deps) {
2080
2160
  }
2081
2161
  async function runDbQuery(context, deps = processStdin4) {
2082
2162
  const sql = await readSql(context, deps);
2083
- const client = await context.client();
2163
+ const client = await dataClient(context);
2084
2164
  const projectId = await context.resolveProjectId();
2085
2165
  const result = await client.db.query(projectId, { sql });
2086
2166
  const lines = [];
@@ -2115,14 +2195,16 @@ var defaults = {
2115
2195
  writeFileSync2(path, data, { flag: "wx" });
2116
2196
  }
2117
2197
  };
2118
- var EXPORT_NOTICE = "Exporting the project database. Cloudflare holds other queries to this database until the export finishes.";
2198
+ function exportNotice(environment) {
2199
+ return `Exporting ${databaseName(environment)}. Cloudflare holds other queries to this database until the export finishes.`;
2200
+ }
2119
2201
  function reasonOf(error) {
2120
2202
  return error instanceof Error ? error.message : String(error);
2121
2203
  }
2122
2204
  function withoutUrl(text, url) {
2123
2205
  return text.split(url).join("the signed URL").replace(/\S*X-Amz-Signature=\S*/gi, "the signed URL");
2124
2206
  }
2125
- function downloadFailed(url, reason, original) {
2207
+ function downloadFailed(url, reason, original, again = "naro db dump") {
2126
2208
  const scrubbed = withoutUrl(reason, url);
2127
2209
  let cause;
2128
2210
  if (original !== void 0) {
@@ -2135,7 +2217,7 @@ function downloadFailed(url, reason, original) {
2135
2217
  }
2136
2218
  return new NaroError(
2137
2219
  "DATABASE_UNAVAILABLE",
2138
- `Could not download the dump (${scrubbed}). Run naro db dump again.`,
2220
+ `Could not download the dump (${scrubbed}). Run ${again} again.`,
2139
2221
  cause === void 0 ? {} : { cause }
2140
2222
  );
2141
2223
  }
@@ -2153,17 +2235,22 @@ var DownloadStreamError = class extends Error {
2153
2235
  this.name = "DownloadStreamError";
2154
2236
  }
2155
2237
  };
2156
- async function openDownload(deps, url) {
2238
+ async function openDownload(deps, url, again) {
2157
2239
  let response;
2158
2240
  try {
2159
2241
  response = await deps.fetch(url);
2160
2242
  } catch (error) {
2161
- throw downloadFailed(url, reasonOf(error), error);
2243
+ throw downloadFailed(url, reasonOf(error), error, again);
2162
2244
  }
2163
2245
  if (!response.ok || response.body === null) {
2164
2246
  await response.body?.cancel().catch(() => {
2165
2247
  });
2166
- throw downloadFailed(url, `HTTP ${String(response.status)}`);
2248
+ throw downloadFailed(
2249
+ url,
2250
+ `HTTP ${String(response.status)}`,
2251
+ void 0,
2252
+ again
2253
+ );
2167
2254
  }
2168
2255
  return response.body;
2169
2256
  }
@@ -2198,8 +2285,8 @@ async function streamInto(body, destination, end) {
2198
2285
  await pipeline(chunksOf(body), counter, destination, { end });
2199
2286
  return bytes;
2200
2287
  }
2201
- function streamFailed(error, url, shown) {
2202
- return error instanceof DownloadStreamError ? downloadFailed(url, reasonOf(error.original), error.original) : writeFailed(shown, error);
2288
+ function streamFailed(error, url, shown, again) {
2289
+ return error instanceof DownloadStreamError ? downloadFailed(url, reasonOf(error.original), error.original, again) : writeFailed(shown, error);
2203
2290
  }
2204
2291
  function refuseDirectory(path, shown) {
2205
2292
  let isDirectory = false;
@@ -2220,8 +2307,8 @@ function makeOutputDirectory(path, shown) {
2220
2307
  throw writeFailed(shown, error);
2221
2308
  }
2222
2309
  }
2223
- async function downloadToFile(deps, url, path, shown) {
2224
- const body = await openDownload(deps, url);
2310
+ async function downloadToFile(deps, url, path, shown, again) {
2311
+ const body = await openDownload(deps, url, again);
2225
2312
  const partial = join2(
2226
2313
  dirname(path),
2227
2314
  `.${basename(path)}.${String(process.pid)}.part`
@@ -2235,15 +2322,15 @@ async function downloadToFile(deps, url, path, shown) {
2235
2322
  rmSync(partial, { force: true });
2236
2323
  } catch {
2237
2324
  }
2238
- throw streamFailed(error, url, shown);
2325
+ throw streamFailed(error, url, shown, again);
2239
2326
  }
2240
2327
  }
2241
- async function downloadToStdout(deps, url) {
2242
- const body = await openDownload(deps, url);
2328
+ async function downloadToStdout(deps, url, again) {
2329
+ const body = await openDownload(deps, url, again);
2243
2330
  try {
2244
2331
  return await streamInto(body, deps.stdout, false);
2245
2332
  } catch (error) {
2246
- throw streamFailed(error, url, "stdout");
2333
+ throw streamFailed(error, url, "stdout", again);
2247
2334
  }
2248
2335
  }
2249
2336
  async function runDbDump(context, overrides = {}) {
@@ -2272,10 +2359,12 @@ async function runDbDump(context, overrides = {}) {
2272
2359
  };
2273
2360
  const output = flags.output === void 0 ? null : { path: resolve2(context.cwd, flags.output), shown: flags.output };
2274
2361
  if (output) refuseDirectory(output.path, output.shown);
2275
- const client = await context.client();
2362
+ const client = await dataClient(context);
2276
2363
  const projectId = await context.resolveProjectId();
2277
2364
  if (output) makeOutputDirectory(output.path, output.shown);
2278
- context.output.warn(EXPORT_NOTICE);
2365
+ const environment = dataEnvironment(context.flags);
2366
+ const again = `naro db dump${envFlag(environment)}`;
2367
+ context.output.warn(exportNotice(environment));
2279
2368
  const step = await client.db.export(projectId, options, {
2280
2369
  sleep: deps.sleep
2281
2370
  });
@@ -2283,16 +2372,16 @@ async function runDbDump(context, overrides = {}) {
2283
2372
  if (file === null)
2284
2373
  throw new NaroError(
2285
2374
  "DATABASE_UNAVAILABLE",
2286
- "Could not download the dump (Cloudflare finished the export without a file). Run naro db dump again."
2375
+ `Could not download the dump (Cloudflare finished the export without a file). Run ${again} again.`
2287
2376
  );
2288
- const bytes = output === null ? await downloadToStdout(deps, file.url) : await downloadToFile(deps, file.url, output.path, output.shown);
2377
+ const bytes = output === null ? await downloadToStdout(deps, file.url, again) : await downloadToFile(deps, file.url, output.path, output.shown, again);
2289
2378
  const bookmark = step.atBookmark;
2290
2379
  if (!context.output.json) {
2291
2380
  context.output.warn(
2292
2381
  `\u2713 Wrote ${flags.output ?? "stdout"} (${humanSize(bytes)}) at bookmark ${bookmark}.`
2293
2382
  );
2294
2383
  context.output.warn(
2295
- ` naro db restore --bookmark ${bookmark} returns the database to this point.`
2384
+ ` naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))} returns the database to this point.`
2296
2385
  );
2297
2386
  }
2298
2387
  context.output.result({ output: flags.output, bytes, atBookmark: bookmark });
@@ -2313,7 +2402,7 @@ function withWayBack(context, error, before) {
2313
2402
  return error;
2314
2403
  if (!context.output.json)
2315
2404
  context.output.warn(
2316
- `Before this attempt the database was at bookmark ${before}. If the restore ran anyway, naro db restore --bookmark ${before} undoes it.`
2405
+ `Before this attempt the database was at bookmark ${before}. If the restore ran anyway, naro db restore --bookmark ${before}${envFlag(dataEnvironment(context.flags))} undoes it.`
2317
2406
  );
2318
2407
  return new NaroError(error.code, error.message, {
2319
2408
  ...error.status === void 0 ? {} : { status: error.status },
@@ -2331,7 +2420,7 @@ async function runDbRestore(context) {
2331
2420
  );
2332
2421
  if ("bookmark" in given && !isBookmark(given.bookmark))
2333
2422
  throw new NaroError("VALIDATION_FAILED", BOOKMARK_FORMAT_MESSAGE);
2334
- const client = await context.client();
2423
+ const client = await dataClient(context);
2335
2424
  const projectId = await context.resolveProjectId();
2336
2425
  let target;
2337
2426
  let at = null;
@@ -2358,7 +2447,7 @@ async function runDbRestore(context) {
2358
2447
  }
2359
2448
  await confirmOrAbort(
2360
2449
  context,
2361
- `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.`
2450
+ `Restore ${databaseName(dataEnvironment(context.flags))} to ${label3}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
2362
2451
  );
2363
2452
  const before = await bookmarkNow(context, client, projectId);
2364
2453
  let restored;
@@ -2373,8 +2462,8 @@ async function runDbRestore(context) {
2373
2462
  previousBookmark: restored.previousBookmark
2374
2463
  },
2375
2464
  [
2376
- `\u2713 Restored the project database to bookmark ${restored.bookmark}.`,
2377
- ` To undo: naro db restore --bookmark ${restored.previousBookmark}`
2465
+ `\u2713 Restored ${databaseName(dataEnvironment(context.flags))} to bookmark ${restored.bookmark}.`,
2466
+ ` To undo: naro db restore --bookmark ${restored.previousBookmark}${envFlag(dataEnvironment(context.flags))}`
2378
2467
  ]
2379
2468
  );
2380
2469
  }
@@ -2390,7 +2479,7 @@ async function runDbPull(context, overrides = {}) {
2390
2479
  const name = nextMigrationFileName(existing, "remote_schema");
2391
2480
  const path = join2(dir, name);
2392
2481
  const shown = relativeToCwd(context, path);
2393
- const client = await context.client();
2482
+ const client = await dataClient(context);
2394
2483
  const projectId = await context.resolveProjectId();
2395
2484
  await confirmOrAbort(
2396
2485
  context,
@@ -2524,7 +2613,7 @@ function issueLines(issues, tables) {
2524
2613
  ];
2525
2614
  }
2526
2615
  async function runDbLint(context) {
2527
- const client = await context.client();
2616
+ const client = await dataClient(context);
2528
2617
  const projectId = await context.resolveProjectId();
2529
2618
  const tables = await client.db.tables(projectId);
2530
2619
  const answer = await client.db.query(projectId, { sql: DIAGNOSTICS_SQL });
@@ -2730,7 +2819,7 @@ function resetStopped(context, error, bookmark, lead, message) {
2730
2819
  const naroError = isNaroError(error) ? error : toNaroError(error);
2731
2820
  if (!context.output.json)
2732
2821
  context.output.warn(
2733
- bookmark === null ? lead : `${lead} The database was at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark} undoes the reset.`
2822
+ bookmark === null ? lead : `${lead} The database was at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))} undoes the reset.`
2734
2823
  );
2735
2824
  return new NaroError(naroError.code, message(naroError.message), {
2736
2825
  ...naroError.status === void 0 ? {} : { status: naroError.status },
@@ -2757,7 +2846,7 @@ ${problemLines.join("\n")}`,
2757
2846
  { details: { problems } }
2758
2847
  );
2759
2848
  const texts = files.map((name) => readFileSync3(join3(dir, name), "utf8"));
2760
- const client = await context.client();
2849
+ const client = await dataClient(context);
2761
2850
  const projectId = await context.resolveProjectId();
2762
2851
  const bookmark = context.flags.dryRun ? null : await bookmarkNow(context, client, projectId, "resetting");
2763
2852
  const { policyTable: policyTable2, storageTable, ...input } = await readSchema(
@@ -2831,11 +2920,11 @@ ${problemLines.join("\n")}`,
2831
2920
  }
2832
2921
  if (bookmark !== null)
2833
2922
  context.output.log(
2834
- `The database is at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark} undoes the reset.`
2923
+ `The database is at bookmark ${bookmark} \u2014 naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))} undoes the reset.`
2835
2924
  );
2836
2925
  await confirmOrAbort(
2837
2926
  context,
2838
- `Reset the project database? This drops ${counts}, clears the migration ledger, then re-applies ${String(files.length)} migration file(s) from ${shownDir}.`
2927
+ `Reset ${databaseName(dataEnvironment(context.flags))}? This drops ${counts}, clears the migration ledger, then re-applies ${String(files.length)} migration file(s) from ${shownDir}.`
2839
2928
  );
2840
2929
  let done = 0;
2841
2930
  const total = plan.batches.length;
@@ -2887,8 +2976,10 @@ ${problemLines.join("\n")}`,
2887
2976
  warnings
2888
2977
  },
2889
2978
  [
2890
- `\u2713 Reset the project database: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
2891
- ...bookmark === null ? [] : [` To undo: naro db restore --bookmark ${bookmark}`]
2979
+ `\u2713 Reset ${databaseName(dataEnvironment(context.flags))}: dropped ${String(dropped)} object(s), re-applied ${String(reapplied.length)} migration(s).`,
2980
+ ...bookmark === null ? [] : [
2981
+ ` To undo: naro db restore --bookmark ${bookmark}${envFlag(dataEnvironment(context.flags))}`
2982
+ ]
2892
2983
  ]
2893
2984
  );
2894
2985
  }
@@ -3186,10 +3277,10 @@ function truth(text) {
3186
3277
  var DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
3187
3278
  function exactNumber(text) {
3188
3279
  if (!DECIMAL.test(text)) return text;
3189
- const number = Number(text);
3190
- if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
3280
+ const number2 = Number(text);
3281
+ if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
3191
3282
  const trimmed = text.replace(/\.?0+$/, "");
3192
- return String(number) === trimmed ? number : text;
3283
+ return String(number2) === trimmed ? number2 : text;
3193
3284
  }
3194
3285
  function quoteFilterValue(value) {
3195
3286
  const text = String(value);
@@ -3524,6 +3615,12 @@ function policyTable(policies) {
3524
3615
  ];
3525
3616
  }
3526
3617
  var UNSHOWN = "has characters that cannot be shown here";
3618
+ function hintsFor(lines, environment) {
3619
+ if (environment === null) return lines;
3620
+ return lines.map(
3621
+ (line) => line.includes(UNSHOWN) || line.startsWith("Add one with ") ? forEnvironment(line, environment) : line
3622
+ );
3623
+ }
3527
3624
  function statementLine(policy) {
3528
3625
  if (policy.invalid !== null)
3529
3626
  return printable(
@@ -3556,7 +3653,7 @@ function policyStatements(policies) {
3556
3653
  }
3557
3654
  async function runPolicyList(context) {
3558
3655
  const table3 = context.flags.table;
3559
- const client = await context.client();
3656
+ const client = await dataClient(context);
3560
3657
  const projectId = await context.resolveProjectId();
3561
3658
  const policies = await client.backend.rlsPolicies(
3562
3659
  projectId,
@@ -3574,7 +3671,10 @@ async function runPolicyList(context) {
3574
3671
  "No policies yet: every table denies anon and signed-in requests.",
3575
3672
  'Add one with `naro policy create "CREATE POLICY \u2026"` or `naro policy set <table> --select public`.'
3576
3673
  ];
3577
- context.output.result({ projectId, policies }, lines);
3674
+ context.output.result(
3675
+ { projectId, policies },
3676
+ hintsFor(lines, dataEnvironment(context.flags))
3677
+ );
3578
3678
  }
3579
3679
  function statementText(context) {
3580
3680
  const [positional] = context.positionals;
@@ -3648,7 +3748,7 @@ async function runStatements(client, projectId, statements, allowUnindexed) {
3648
3748
  }
3649
3749
  async function runPolicyCreate(context) {
3650
3750
  const statements = policyStatementsOf(statementText(context));
3651
- const client = await context.client();
3751
+ const client = await dataClient(context);
3652
3752
  const projectId = await context.resolveProjectId();
3653
3753
  const applied = await runStatements(
3654
3754
  client,
@@ -3669,7 +3769,7 @@ async function runPolicyDrop(context) {
3669
3769
  context,
3670
3770
  `Drop policy ${printable(name)} on ${printable(table3)}? It stops applying within about 10 seconds.`
3671
3771
  );
3672
- const client = await context.client();
3772
+ const client = await dataClient(context);
3673
3773
  const projectId = await context.resolveProjectId();
3674
3774
  const dropped = await client.backend.dropRlsPolicy(
3675
3775
  projectId,
@@ -3683,7 +3783,7 @@ async function runPolicyDrop(context) {
3683
3783
  async function runPolicySet(context) {
3684
3784
  const [table3] = context.positionalOrThrow(1, SET_USAGE);
3685
3785
  const patch = policyPatch(context.flags, context, table3);
3686
- const client = await context.client();
3786
+ const client = await dataClient(context);
3687
3787
  const projectId = await context.resolveProjectId();
3688
3788
  const policy = await client.backend.setPolicy(
3689
3789
  projectId,
@@ -3700,11 +3800,14 @@ async function runPolicySet(context) {
3700
3800
  }
3701
3801
  context.output.result(
3702
3802
  { projectId, policy, ...named === void 0 ? {} : { policies: named } },
3703
- [
3704
- `\u2705 Saved the policy for ${printable(policy.table)}. ${TAKES_EFFECT}`,
3705
- ...policyTable([policy]),
3706
- ...named === void 0 || named.length === 0 ? [] : ["", ...policyStatements(named)]
3707
- ]
3803
+ hintsFor(
3804
+ [
3805
+ `\u2705 Saved the policy for ${printable(policy.table)}. ${TAKES_EFFECT}`,
3806
+ ...policyTable([policy]),
3807
+ ...named === void 0 || named.length === 0 ? [] : ["", ...policyStatements(named)]
3808
+ ],
3809
+ dataEnvironment(context.flags)
3810
+ )
3708
3811
  );
3709
3812
  }
3710
3813
  async function runPolicyRemove(context) {
@@ -3713,7 +3816,7 @@ async function runPolicyRemove(context) {
3713
3816
  context,
3714
3817
  `Remove every policy on ${printable(table3)}? Anon and signed-in requests to it are denied from then on.`
3715
3818
  );
3716
- const client = await context.client();
3819
+ const client = await dataClient(context);
3717
3820
  const projectId = await context.resolveProjectId();
3718
3821
  const policy = await client.backend.removePolicy(projectId, table3);
3719
3822
  const names = policy.policies ?? [];
@@ -3846,7 +3949,7 @@ function ruleText(value) {
3846
3949
  return "invalid" in value ? "invalid" : "condition";
3847
3950
  }
3848
3951
  var SUGAR_NAME = /^naro_(?:select|insert|update|delete)$/;
3849
- function removedLines(policy, dryRun) {
3952
+ function removedLines(policy, dryRun, environment) {
3850
3953
  const verb = dryRun ? "Would remove" : "\u2713 Removed";
3851
3954
  const names = policy.policies ?? [];
3852
3955
  let line;
@@ -3884,7 +3987,11 @@ function removedLines(policy, dryRun) {
3884
3987
  lines.push(
3885
3988
  ` As a command, for the owner ${one ? "rule" : "rules"} (${owners.map((owner) => printable(owner.name)).join(", ")}): CREATE POLICY has no owner column, so naro policy create cannot make ${one ? "it" : "them"} again.`,
3886
3989
  // The server's bytes, or not shown: a name may hold a line break.
3887
- typeof command !== "string" ? " naro policy set <table> --<action> owner --owner <column> sets one." : isPrintable(command) ? command : " The command has characters that cannot be shown here: --json has the exact text."
3990
+ // Under --env, the command acts on the environment (PR #343 review 2).
3991
+ typeof command !== "string" ? forEnvironment(
3992
+ " naro policy set <table> --<action> owner --owner <column> sets one.",
3993
+ environment
3994
+ ) : isPrintable(command) ? forEnvironment(command, environment) : " The command has characters that cannot be shown here: --json has the exact text."
3888
3995
  );
3889
3996
  }
3890
3997
  return lines;
@@ -3893,13 +4000,13 @@ async function runSchemaChange(context, operations, question) {
3893
4000
  const dryRun = context.flags.dryRun === true;
3894
4001
  if (question !== void 0 && !dryRun)
3895
4002
  await confirmOrAbort(context, question);
3896
- const sent = dryRun ? operations.map((operation) => {
4003
+ const sent2 = dryRun ? operations.map((operation) => {
3897
4004
  if (operation.op !== "dropTable" && operation.op !== "dropColumn")
3898
4005
  return operation;
3899
4006
  const { confirm: _confirm, ...rest } = operation;
3900
4007
  return rest;
3901
4008
  }) : operations;
3902
- const client = await context.client();
4009
+ const client = await dataClient(context);
3903
4010
  const projectId = await context.resolveProjectId();
3904
4011
  const dir = migrationsDir(context);
3905
4012
  const local = listMigrationFiles(dir);
@@ -3911,7 +4018,7 @@ async function runSchemaChange(context, operations, question) {
3911
4018
  [...local, ...ledger.map((row) => row.name)],
3912
4019
  schemaMigrationTitle(operations[0] ?? {})
3913
4020
  );
3914
- const result = await client.db.schema(projectId, sent, {
4021
+ const result = await client.db.schema(projectId, sent2, {
3915
4022
  ...dryRun ? { dryRun: true } : {},
3916
4023
  name
3917
4024
  });
@@ -3924,7 +4031,9 @@ async function runSchemaChange(context, operations, question) {
3924
4031
  ...sqlLines,
3925
4032
  `Dry run \u2014 nothing was applied. It would be recorded as ${printable(result.name)}.`,
3926
4033
  ...result.policies.map((policy) => policyLine(policy, true)),
3927
- ...removed.flatMap((policy) => removedLines(policy, true))
4034
+ ...removed.flatMap(
4035
+ (policy) => removedLines(policy, true, dataEnvironment(context.flags))
4036
+ )
3928
4037
  ]);
3929
4038
  return;
3930
4039
  }
@@ -3946,7 +4055,10 @@ async function runSchemaChange(context, operations, question) {
3946
4055
  }
3947
4056
  }
3948
4057
  for (const warning of allWarnings)
3949
- context.output.warn(`\u26A0 ${printable(warning)}`);
4058
+ context.output.warn(
4059
+ `\u26A0 ${printable(forEnvironment(warning, dataEnvironment(context.flags)))}`
4060
+ );
4061
+ fileWarning &&= forEnvironment(fileWarning, dataEnvironment(context.flags));
3950
4062
  if (fileWarning) context.output.warn(`\u26A0 ${printable(fileWarning)}`);
3951
4063
  context.output.result(
3952
4064
  {
@@ -3960,7 +4072,9 @@ async function runSchemaChange(context, operations, question) {
3960
4072
  ...sqlLines,
3961
4073
  file === null ? `\u2705 Applied ${printable(result.name)}.` : `\u2705 Applied ${printable(result.name)} and wrote ${printable(file)}.`,
3962
4074
  ...result.policies.map((policy) => policyLine(policy)),
3963
- ...removed.flatMap((policy) => removedLines(policy, false))
4075
+ ...removed.flatMap(
4076
+ (policy) => removedLines(policy, false, dataEnvironment(context.flags))
4077
+ )
3964
4078
  ]
3965
4079
  );
3966
4080
  }
@@ -4011,24 +4125,29 @@ async function runDbTableRename(context) {
4011
4125
  if (!table3 || !to) throw new NaroError("VALIDATION_FAILED", USAGE2.rename);
4012
4126
  await runSchemaChange(context, [{ op: "renameTable", table: table3, to }]);
4013
4127
  }
4128
+ function inDatabase(environment) {
4129
+ return environment === null ? "" : ` in ${databaseName(environment)}`;
4130
+ }
4014
4131
  async function runDbTableDropColumn(context) {
4015
4132
  const [table3, column] = context.positionals;
4016
4133
  if (!table3 || !column)
4017
4134
  throw new NaroError("VALIDATION_FAILED", USAGE2.dropColumn);
4018
4135
  const target = `${table3}.${column}`;
4136
+ const env = dataEnvironment(context.flags);
4019
4137
  await runSchemaChange(
4020
4138
  context,
4021
4139
  [{ op: "dropColumn", table: table3, column, confirm: target }],
4022
- `Drop column ${printable(target)} and its value in every row? naro db restore can bring it back (Time Travel, 30 days).`
4140
+ `Drop column ${printable(target)}${inDatabase(env)} and its value in every row? naro db restore${envFlag(env)} can bring it back (Time Travel, 30 days).`
4023
4141
  );
4024
4142
  }
4025
4143
  async function runDbTableDrop(context) {
4026
4144
  const [table3] = context.positionals;
4027
4145
  if (!table3) throw new NaroError("VALIDATION_FAILED", USAGE2.drop);
4146
+ const env = dataEnvironment(context.flags);
4028
4147
  await runSchemaChange(
4029
4148
  context,
4030
4149
  [{ op: "dropTable", table: table3, confirm: table3 }],
4031
- `Drop table ${printable(table3)} and every row in it? naro db restore can bring it back (Time Travel, 30 days).`
4150
+ `Drop table ${printable(table3)}${inDatabase(env)} and every row in it? naro db restore${envFlag(env)} can bring it back (Time Travel, 30 days).`
4032
4151
  );
4033
4152
  }
4034
4153
  async function runDbIndexCreate(context) {
@@ -5103,6 +5222,7 @@ ${serializeDotenv(vars)}`;
5103
5222
  var CREATE_USAGE2 = "Usage: naro environments create <slug> [--branch <branch>] [--copy-env preview|production|none] [--database schema|empty]";
5104
5223
  var SET_USAGE2 = "Usage: naro environments set <slug> [--branch <branch> | --no-branch] [--protection on|off]";
5105
5224
  var DELETE_USAGE = "Usage: naro environments delete <slug> [--yes]";
5225
+ var PROVISION_USAGE = "Usage: naro environments provision <slug>";
5106
5226
  var NO_BRANCH = "\u2014";
5107
5227
  function parseCopyEnv(value) {
5108
5228
  if (value === void 0) return void 0;
@@ -5137,14 +5257,26 @@ function table2(rows) {
5137
5257
  ).join(" ")
5138
5258
  );
5139
5259
  }
5260
+ function dataCell(environment) {
5261
+ const status = environment.data?.status ?? "none";
5262
+ return status === "none" ? NO_BRANCH : status;
5263
+ }
5140
5264
  function environmentRow(environment) {
5141
5265
  return [
5142
5266
  environment.slug,
5143
5267
  environment.url,
5144
5268
  environment.branch ?? NO_BRANCH,
5145
- environment.protectionEnabled ? "on" : "off"
5269
+ environment.protectionEnabled ? "on" : "off",
5270
+ dataCell(environment)
5146
5271
  ];
5147
5272
  }
5273
+ function dataErrorLines(environments) {
5274
+ return environments.flatMap(
5275
+ (environment) => environment.data?.status === "error" ? [
5276
+ `\u26A0 ${environment.slug}: ${environment.data.error ?? "its data could not be made."} Run \`naro environments provision ${environment.slug}\` to try again.`
5277
+ ] : []
5278
+ );
5279
+ }
5148
5280
  async function runEnvironmentsList(context) {
5149
5281
  const projectId = await context.resolveProjectId();
5150
5282
  const client = await context.client();
@@ -5152,9 +5284,10 @@ async function runEnvironmentsList(context) {
5152
5284
  const count2 = String(environments.length);
5153
5285
  context.output.result({ projectId, environments, limit }, [
5154
5286
  ...environments.length === 0 ? ["No environments. Create one with `naro environments create qa`."] : table2([
5155
- ["slug", "url", "branch", "protection"],
5287
+ ["slug", "url", "branch", "protection", "data"],
5156
5288
  ...environments.map(environmentRow)
5157
5289
  ]),
5290
+ ...dataErrorLines(environments),
5158
5291
  limit === null ? `${count2} environment${environments.length === 1 ? "" : "s"}` : `${count2} of ${String(limit)} environments`
5159
5292
  ]);
5160
5293
  }
@@ -5217,7 +5350,7 @@ async function runEnvironmentsDelete(context) {
5217
5350
  const projectId = await context.resolveProjectId();
5218
5351
  await confirmOrAbort(
5219
5352
  context,
5220
- `Delete environment ${slug}? Its worker and protection key are removed.`
5353
+ `Delete environment ${slug}? Its worker, protection key and data \u2014 database, backend and Storage files \u2014 are removed.`
5221
5354
  );
5222
5355
  const client = await context.client();
5223
5356
  const found = await client.environments.get(projectId, slug);
@@ -5226,6 +5359,16 @@ async function runEnvironmentsDelete(context) {
5226
5359
  `\u2705 Deleted environment ${slug}`
5227
5360
  ]);
5228
5361
  }
5362
+ async function runEnvironmentsProvision(context) {
5363
+ const [slug] = context.positionalOrThrow(1, PROVISION_USAGE);
5364
+ const projectId = await context.resolveProjectId();
5365
+ const client = await context.client();
5366
+ const found = await client.environments.get(projectId, slug);
5367
+ const environment = await client.environments.provision(projectId, found.id);
5368
+ context.output.result({ projectId, environment }, [
5369
+ (environment.data?.status ?? "none") === "none" ? `Environment ${slug} has no data to make: the project uses neither a Naro database nor a backend.` : `\u2705 Making environment ${slug}'s data \u2014 \`naro environments\` shows how it goes.`
5370
+ ]);
5371
+ }
5229
5372
 
5230
5373
  // src/commands/gen.ts
5231
5374
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
@@ -5235,7 +5378,7 @@ async function runGenTypes(context) {
5235
5378
  const { lang } = context.flags;
5236
5379
  if (lang !== void 0 && lang !== "typescript")
5237
5380
  throw new NaroError("VALIDATION_FAILED", LANG_MESSAGE);
5238
- const client = await context.client();
5381
+ const client = await dataClient(context);
5239
5382
  const projectId = await context.resolveProjectId();
5240
5383
  const tables = await client.db.tables(projectId);
5241
5384
  const read = await policiesForTypes(client, projectId);
@@ -5722,6 +5865,127 @@ async function runProtectionBypassRemove(context) {
5722
5865
  context.output.result({ projectId, ...state }, ["Bypass secret removed."]);
5723
5866
  }
5724
5867
 
5868
+ // src/commands/realtime.ts
5869
+ var ADD_USAGE = "Usage: naro realtime tables add <table>\u2026";
5870
+ var REMOVE_USAGE2 = "Usage: naro realtime tables remove <table>\u2026 [--yes]";
5871
+ var DATA_API_NOTE = "Changes made through the Data API are published; writes your app makes with env.DB are not.";
5872
+ var number = (value) => value.toLocaleString("en-US");
5873
+ function tableName(table3) {
5874
+ const name = printable(table3.name);
5875
+ return table3.exists ? name : `${name} (dropped)`;
5876
+ }
5877
+ function linesFor(lines, environment) {
5878
+ return lines.map((line) => forEnvironment(line, environment));
5879
+ }
5880
+ function realtimeLines(info) {
5881
+ if (!info.enabled)
5882
+ return [
5883
+ "Realtime: not enabled",
5884
+ "Run `naro realtime enable` to turn it on (the project's backend must be on first)."
5885
+ ];
5886
+ const { live: live2 } = info;
5887
+ return [
5888
+ "Realtime: on (public channels: off)",
5889
+ info.tables.length === 0 ? "Published tables: none \u2014 naro realtime tables add <table>" : `Published tables: ${info.tables.map(tableName).join(", ")}`,
5890
+ live2 === null ? "Connections now: not known \u2014 the backend is off or not ready, or the hub did not answer" : `Connections now: ${number(live2.connections)} \xB7 channels ${number(live2.channels)} \xB7 presences ${number(live2.presences)}`
5891
+ ];
5892
+ }
5893
+ async function enableRealtime(client, projectId, environment) {
5894
+ try {
5895
+ return await client.realtime.enable(projectId);
5896
+ } catch (error) {
5897
+ if (isNaroError(error) && error.code === "TIMEOUT")
5898
+ throw new NaroError(
5899
+ "TIMEOUT",
5900
+ `Turning Realtime on did not answer within 2 minutes. \`naro realtime${envFlag(environment)}\` shows where it got to, and \`naro realtime enable${envFlag(environment)}\` again picks up where it stopped.`,
5901
+ { cause: error }
5902
+ );
5903
+ throw error;
5904
+ }
5905
+ }
5906
+ function tableNames2(context, usage) {
5907
+ if (context.positionals.length === 0)
5908
+ throw new NaroError("VALIDATION_FAILED", usage);
5909
+ return context.positionals;
5910
+ }
5911
+ async function runRealtimeStatus(context) {
5912
+ const client = await dataClient(context);
5913
+ const projectId = await context.resolveProjectId();
5914
+ const realtime = await client.realtime.info(projectId);
5915
+ context.output.result(
5916
+ { projectId, realtime },
5917
+ linesFor(realtimeLines(realtime), dataEnvironment(context.flags))
5918
+ );
5919
+ }
5920
+ async function runRealtimeEnable(context) {
5921
+ const client = await dataClient(context);
5922
+ const projectId = await context.resolveProjectId();
5923
+ const environment = dataEnvironment(context.flags);
5924
+ const realtime = await enableRealtime(client, projectId, environment);
5925
+ context.output.result(
5926
+ { projectId, realtime },
5927
+ linesFor(
5928
+ [
5929
+ "\u2705 Realtime is on. Publish a table with naro realtime tables add <table>.",
5930
+ ...realtimeLines(realtime)
5931
+ ],
5932
+ environment
5933
+ )
5934
+ );
5935
+ }
5936
+ async function runRealtimeTablesList(context) {
5937
+ const client = await dataClient(context);
5938
+ const projectId = await context.resolveProjectId();
5939
+ const tables = await client.realtime.listTables(projectId);
5940
+ context.output.result(
5941
+ { projectId, tables },
5942
+ tables.length === 0 ? [
5943
+ forEnvironment(
5944
+ "No published tables. `naro realtime tables add <table>` publishes one.",
5945
+ dataEnvironment(context.flags)
5946
+ )
5947
+ ] : tables.map(
5948
+ (table3) => table3.exists ? ` ${printable(table3.name)}` : ` ${printable(table3.name)} (dropped: no such table now)`
5949
+ )
5950
+ );
5951
+ }
5952
+ async function runRealtimeTablesAdd(context) {
5953
+ const names = tableNames2(context, ADD_USAGE);
5954
+ const client = await dataClient(context);
5955
+ const projectId = await context.resolveProjectId();
5956
+ const tables = [];
5957
+ for (const name of names) {
5958
+ const published = await client.realtime.publishTable(projectId, name);
5959
+ tables.push(published);
5960
+ context.output.log(
5961
+ `\u2705 ${printable(published.table)} now publishes its changes (within about 10 seconds).`
5962
+ );
5963
+ for (const warning of published.warnings)
5964
+ if (warning !== DATA_API_NOTE)
5965
+ context.output.log(`\u26A0 ${printable(warning)}`);
5966
+ }
5967
+ context.output.result({ projectId, tables }, [`\u26A0 ${DATA_API_NOTE}`]);
5968
+ }
5969
+ async function runRealtimeTablesRemove(context) {
5970
+ const names = tableNames2(context, REMOVE_USAGE2);
5971
+ const shown = names.map(printable).join(", ");
5972
+ await confirmOrAbort(
5973
+ context,
5974
+ names.length === 1 ? `Stop publishing ${shown}? Subscribers stop receiving its changes within about 10 seconds.` : `Stop publishing ${shown}? Subscribers stop receiving their changes within about 10 seconds.`
5975
+ );
5976
+ const client = await dataClient(context);
5977
+ const projectId = await context.resolveProjectId();
5978
+ const tables = [];
5979
+ for (const name of names) {
5980
+ const unpublished = await client.realtime.unpublishTable(projectId, name);
5981
+ tables.push(unpublished);
5982
+ context.output.log(
5983
+ `\u2705 ${printable(unpublished.table)} no longer publishes its changes.`
5984
+ );
5985
+ }
5986
+ context.output.result({ projectId, tables });
5987
+ }
5988
+
5725
5989
  // src/commands/storage-units.ts
5726
5990
  var SIZE_MESSAGE = "--file-size-limit takes a whole number of bytes, or a size like 5MB (1000s) or 5MiB (1024s), or none.";
5727
5991
  var UNITS = {
@@ -5760,7 +6024,7 @@ function count(n, noun) {
5760
6024
  var CREATE_USAGE3 = "Usage: naro storage bucket create <name> [--public] [--file-size-limit <size>] [--allowed-mime-types <t,\u2026>]";
5761
6025
  var UPDATE_USAGE = "Usage: naro storage bucket update <name> [--public | --private] [--file-size-limit <size> | none] [--allowed-mime-types <t,\u2026> | any]";
5762
6026
  var EMPTY_USAGE = "Usage: naro storage bucket empty <name> [--yes]";
5763
- var REMOVE_USAGE2 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
6027
+ var REMOVE_USAGE3 = "Usage: naro storage bucket remove <name> [--empty] [--yes]";
5764
6028
  var ENABLE_FIRST = "Storage is not enabled yet; enabling it first.";
5765
6029
  var DISABLE_QUESTION3 = "Turn Storage off for this project? Its Storage calls and file URLs answer 404 until naro storage enable; buckets and files are kept.";
5766
6030
  var NO_BUCKETS = "No buckets yet. `naro storage bucket create <name>` makes one.";
@@ -5829,6 +6093,9 @@ function meterLines(info) {
5829
6093
  );
5830
6094
  return lines;
5831
6095
  }
6096
+ function linesFor2(lines, environment) {
6097
+ return lines.map((line) => forEnvironment(line, environment));
6098
+ }
5832
6099
  function storageLines(info) {
5833
6100
  if (info.disabledAt !== null)
5834
6101
  return [
@@ -5852,27 +6119,27 @@ function storageLines(info) {
5852
6119
  ...info.buckets.map(bucketLine)
5853
6120
  ];
5854
6121
  }
5855
- async function enableStorage(client, projectId) {
6122
+ async function enableStorage(client, projectId, environment = null) {
5856
6123
  try {
5857
6124
  return await client.storage.enable(projectId);
5858
6125
  } catch (error) {
5859
6126
  if (isNaroError(error) && error.code === "TIMEOUT")
5860
6127
  throw new NaroError(
5861
6128
  "TIMEOUT",
5862
- "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.",
6129
+ `Turning Storage on did not answer within 2 minutes. \`naro storage${envFlag(environment)}\` shows where it got to, and \`naro storage enable${envFlag(environment)}\` again picks up where it stopped.`,
5863
6130
  { cause: error }
5864
6131
  );
5865
6132
  throw error;
5866
6133
  }
5867
6134
  }
5868
- async function disableStorage(client, projectId) {
6135
+ async function disableStorage(client, projectId, environment = null) {
5869
6136
  try {
5870
6137
  return await client.storage.disable(projectId);
5871
6138
  } catch (error) {
5872
6139
  if (isNaroError(error) && error.code === "TIMEOUT")
5873
6140
  throw new NaroError(
5874
6141
  "TIMEOUT",
5875
- "Turning Storage off did not answer within 2 minutes. `naro storage` shows where it got to, and `naro storage disable` again picks up where it stopped.",
6142
+ `Turning Storage off did not answer within 2 minutes. \`naro storage${envFlag(environment)}\` shows where it got to, and \`naro storage disable${envFlag(environment)}\` again picks up where it stopped.`,
5876
6143
  { cause: error }
5877
6144
  );
5878
6145
  throw error;
@@ -5899,23 +6166,34 @@ function bucketFields(flags) {
5899
6166
  return fields;
5900
6167
  }
5901
6168
  async function runStorageStatus(context) {
5902
- const client = await context.client();
6169
+ const client = await dataClient(context);
5903
6170
  const projectId = await context.resolveProjectId();
5904
6171
  const storage = await client.storage.info(projectId);
5905
- context.output.result({ projectId, storage }, storageLines(storage));
6172
+ context.output.result(
6173
+ { projectId, storage },
6174
+ linesFor2(storageLines(storage), dataEnvironment(context.flags))
6175
+ );
5906
6176
  }
5907
6177
  async function runStorageEnable(context) {
5908
- const client = await context.client();
6178
+ const client = await dataClient(context);
5909
6179
  const projectId = await context.resolveProjectId();
5910
- const storage = await enableStorage(client, projectId);
6180
+ const environment = dataEnvironment(context.flags);
6181
+ const storage = await enableStorage(client, projectId, environment);
5911
6182
  context.output.result({ projectId, storage }, [
5912
6183
  `\u2705 Storage is on: R2 bucket ${printable(storage.bucket ?? "unknown")}.`,
5913
- ...storage.usage === null ? ["\u23F3 The backend is still finishing; check `naro backend` in a minute."] : [],
5914
- ...storageLines(storage)
6184
+ ...linesFor2(
6185
+ [
6186
+ ...storage.usage === null ? [
6187
+ "\u23F3 The backend is still finishing; check `naro backend` in a minute."
6188
+ ] : [],
6189
+ ...storageLines(storage)
6190
+ ],
6191
+ environment
6192
+ )
5915
6193
  ]);
5916
6194
  }
5917
6195
  async function runStorageDisable(context) {
5918
- const client = await context.client();
6196
+ const client = await dataClient(context);
5919
6197
  const projectId = await context.resolveProjectId();
5920
6198
  const current = await client.storage.info(projectId);
5921
6199
  if (current.bucket === null) {
@@ -5924,27 +6202,34 @@ async function runStorageDisable(context) {
5924
6202
  ]);
5925
6203
  return;
5926
6204
  }
6205
+ const environment = dataEnvironment(context.flags);
5927
6206
  if (current.disabledAt === null)
5928
- await confirmOrAbort(context, DISABLE_QUESTION3);
5929
- const storage = await disableStorage(client, projectId);
6207
+ await confirmOrAbort(
6208
+ context,
6209
+ forEnvironment(DISABLE_QUESTION3, environment)
6210
+ );
6211
+ const storage = await disableStorage(client, projectId, environment);
5930
6212
  context.output.result({ projectId, storage }, [
5931
- "\u2705 Storage is off. Buckets and files are kept; naro storage enable turns it on again.",
5932
- ...storageLines(storage)
6213
+ `\u2705 Storage is off. Buckets and files are kept; naro storage enable${envFlag(environment)} turns it on again.`,
6214
+ ...linesFor2(storageLines(storage), environment)
5933
6215
  ]);
5934
6216
  }
5935
6217
  async function runStorageBucketList(context) {
5936
- const client = await context.client();
6218
+ const client = await dataClient(context);
5937
6219
  const projectId = await context.resolveProjectId();
5938
6220
  const buckets = await client.storage.listBuckets(projectId);
5939
6221
  context.output.result(
5940
6222
  { projectId, buckets },
5941
- buckets.length === 0 ? [NO_BUCKETS] : buckets.map(bucketLine)
6223
+ linesFor2(
6224
+ buckets.length === 0 ? [NO_BUCKETS] : buckets.map(bucketLine),
6225
+ dataEnvironment(context.flags)
6226
+ )
5942
6227
  );
5943
6228
  }
5944
6229
  async function runStorageBucketCreate(context) {
5945
6230
  const name = bucketName(context, CREATE_USAGE3);
5946
6231
  const input = { name, ...bucketFields(context.flags) };
5947
- const client = await context.client();
6232
+ const client = await dataClient(context);
5948
6233
  const projectId = await context.resolveProjectId();
5949
6234
  let bucket;
5950
6235
  try {
@@ -5953,12 +6238,12 @@ async function runStorageBucketCreate(context) {
5953
6238
  if (!isNaroError(error) || error.code !== "STORAGE_NOT_ENABLED")
5954
6239
  throw error;
5955
6240
  context.output.log(ENABLE_FIRST);
5956
- await enableStorage(client, projectId);
6241
+ await enableStorage(client, projectId, dataEnvironment(context.flags));
5957
6242
  bucket = await client.storage.createBucket(projectId, input);
5958
6243
  }
5959
6244
  context.output.result({ projectId, bucket }, [
5960
6245
  `\u2705 Created bucket ${printable(bucket.name)}.`,
5961
- bucketLine(bucket)
6246
+ forEnvironment(bucketLine(bucket), dataEnvironment(context.flags))
5962
6247
  ]);
5963
6248
  }
5964
6249
  async function runStorageBucketUpdate(context) {
@@ -5969,13 +6254,13 @@ async function runStorageBucketUpdate(context) {
5969
6254
  context,
5970
6255
  `Make bucket ${name} public? Anyone with an object's URL can download it.`
5971
6256
  );
5972
- const client = await context.client();
6257
+ const client = await dataClient(context);
5973
6258
  const projectId = await context.resolveProjectId();
5974
6259
  const bucket = await client.storage.updateBucket(projectId, name, patch);
5975
6260
  const takesEffect = patch.public === false ? "It takes effect within about 10 seconds; copies Naro's edge already holds can be served for up to 5 minutes." : "It takes effect within about 10 seconds.";
5976
6261
  context.output.result({ projectId, bucket }, [
5977
6262
  `\u2705 Updated bucket ${printable(bucket.name)}. ${takesEffect}`,
5978
- bucketLine(bucket)
6263
+ forEnvironment(bucketLine(bucket), dataEnvironment(context.flags))
5979
6264
  ]);
5980
6265
  }
5981
6266
  function refuseSearch(context, why) {
@@ -6011,7 +6296,7 @@ async function runStorageBucketEmpty(context) {
6011
6296
  context,
6012
6297
  `bucket empty deletes every object in the bucket. ${EMPTY_USAGE}`
6013
6298
  );
6014
- const client = await context.client();
6299
+ const client = await dataClient(context);
6015
6300
  const projectId = await context.resolveProjectId();
6016
6301
  const bucket = await client.storage.getBucket(projectId, name);
6017
6302
  await confirmOrAbort(
@@ -6028,13 +6313,13 @@ async function runStorageBucketEmpty(context) {
6028
6313
  ]);
6029
6314
  }
6030
6315
  async function runStorageBucketRemove(context) {
6031
- const name = bucketName(context, REMOVE_USAGE2);
6316
+ const name = bucketName(context, REMOVE_USAGE3);
6032
6317
  const empty = context.flags.empty === true;
6033
6318
  refuseSearch(
6034
6319
  context,
6035
- empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${REMOVE_USAGE2}` : `bucket remove removes the whole bucket. ${REMOVE_USAGE2}`
6320
+ empty ? `bucket remove --empty deletes every object in the bucket before it removes the bucket. ${REMOVE_USAGE3}` : `bucket remove removes the whole bucket. ${REMOVE_USAGE3}`
6036
6321
  );
6037
- const client = await context.client();
6322
+ const client = await dataClient(context);
6038
6323
  const projectId = await context.resolveProjectId();
6039
6324
  let question = `Remove bucket ${name}?`;
6040
6325
  if (empty) {
@@ -6132,6 +6417,105 @@ function cryptoUnavailable() {
6132
6417
  message: "This runtime has no crypto.subtle, which signing in with a provider needs (https or localhost)."
6133
6418
  };
6134
6419
  }
6420
+ var SERVICE_KEY_OFF_SERVER_MESSAGE = "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.";
6421
+ function websocketUnavailable() {
6422
+ return {
6423
+ code: "WEBSOCKET_UNAVAILABLE",
6424
+ message: "This runtime has no WebSocket. Pass one to createClient({ realtime: { WebSocket } })."
6425
+ };
6426
+ }
6427
+ function realtimeTimedOut(ms) {
6428
+ const seconds = Math.max(1, Math.round(ms / 1e3));
6429
+ return {
6430
+ code: "TIMED_OUT",
6431
+ message: `The Naro realtime server did not answer within ${String(seconds)} seconds.`
6432
+ };
6433
+ }
6434
+ function realtimeClosed(code) {
6435
+ return {
6436
+ code: "NETWORK_ERROR",
6437
+ message: `The realtime connection to the Naro backend closed (${String(code)}). It reconnects on its own.`,
6438
+ details: { code }
6439
+ };
6440
+ }
6441
+ function realtimeDisconnected() {
6442
+ return {
6443
+ code: "ABORTED",
6444
+ message: "The realtime connection was closed by the app (it removed its last channel) before the Naro realtime server answered."
6445
+ };
6446
+ }
6447
+ function realtimeStopped(code, reason) {
6448
+ return {
6449
+ code: reason === "" ? "NETWORK_ERROR" : reason,
6450
+ message: `The realtime connection to the Naro backend closed (${String(code)}) and does not reconnect.`,
6451
+ details: { code }
6452
+ };
6453
+ }
6454
+ function socketRefused(error) {
6455
+ return {
6456
+ code: "NETWORK_ERROR",
6457
+ message: "The realtime connection to the Naro backend could not be opened.",
6458
+ details: { cause: error instanceof Error ? error.name : typeof error }
6459
+ };
6460
+ }
6461
+ function invalidChannel(reason) {
6462
+ return {
6463
+ code: "INVALID_CHANNEL",
6464
+ message: `The channel name is not valid: it ${reason}.`
6465
+ };
6466
+ }
6467
+ function invalidChannelConfig(path, reason) {
6468
+ return {
6469
+ code: "INVALID_CHANNEL_CONFIG",
6470
+ message: "The channel settings are not valid.",
6471
+ details: { path, reason }
6472
+ };
6473
+ }
6474
+ function serviceKeyOffServer() {
6475
+ return {
6476
+ code: "SERVICE_KEY_OFF_SERVER",
6477
+ message: SERVICE_KEY_OFF_SERVER_MESSAGE
6478
+ };
6479
+ }
6480
+ function notJoined(channel) {
6481
+ return {
6482
+ code: "NOT_JOINED",
6483
+ message: `Join ${channel} before sending to it.`
6484
+ };
6485
+ }
6486
+ function invalidBroadcast() {
6487
+ return {
6488
+ code: "INVALID_MESSAGE",
6489
+ message: "The message is not a valid Naro realtime frame.",
6490
+ details: { reason: "needs type broadcast" }
6491
+ };
6492
+ }
6493
+ function invalidPresence() {
6494
+ return {
6495
+ code: "INVALID_MESSAGE",
6496
+ message: "The message is not a valid Naro realtime frame.",
6497
+ details: { reason: "needs a presence payload object" }
6498
+ };
6499
+ }
6500
+ function frameTooLarge(limit) {
6501
+ return {
6502
+ code: "INVALID_MESSAGE",
6503
+ message: "The message is not a valid Naro realtime frame.",
6504
+ details: { reason: `is larger than ${String(limit)} bytes` }
6505
+ };
6506
+ }
6507
+ function clientFlooding(perSecond) {
6508
+ return {
6509
+ code: "CLIENT_FLOODING",
6510
+ message: `This connection is sending messages faster than a connection may (${String(perSecond)} a second), so this one was not sent. Send less often.`
6511
+ };
6512
+ }
6513
+ function channelClosed() {
6514
+ return {
6515
+ code: "CHANNEL_CLOSED",
6516
+ message: "The Naro realtime server closed this channel."
6517
+ };
6518
+ }
6135
6519
  function describe(cause) {
6136
6520
  if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
6137
6521
  return String(cause);
@@ -6154,9 +6538,119 @@ function errorFromBody(status, body) {
6154
6538
  if (status >= 200 && status < 300) return null;
6155
6539
  return invalidResponse(status);
6156
6540
  }
6157
- var AUTH_PREFIX = "/auth/";
6541
+ var SESSION_PREFIXES = ["/auth/", "/realtime/"];
6158
6542
  function storeFor(config, path) {
6159
- return path.startsWith(AUTH_PREFIX) ? config.tokens : config.accessJwt ?? null;
6543
+ return SESSION_PREFIXES.some((prefix) => path.startsWith(prefix)) ? config.tokens : config.accessJwt ?? null;
6544
+ }
6545
+ var ACCESS_JWT_MARGIN_MS = 6e4;
6546
+ function timesOf(jwt) {
6547
+ const payload = jwt.split(".")[1];
6548
+ if (payload === void 0 || payload === "") return null;
6549
+ try {
6550
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
6551
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
6552
+ const claims = JSON.parse(atob(padded));
6553
+ if (!isRecord(claims)) return null;
6554
+ const { exp, iat } = claims;
6555
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
6556
+ return {
6557
+ exp: exp * 1e3,
6558
+ iat: typeof iat === "number" && Number.isFinite(iat) ? iat * 1e3 : null
6559
+ };
6560
+ } catch {
6561
+ return null;
6562
+ }
6563
+ }
6564
+ function expiryOf(jwt) {
6565
+ return timesOf(jwt)?.exp ?? null;
6566
+ }
6567
+ var skews = /* @__PURE__ */ new WeakMap();
6568
+ function localExpiry(config, jwt) {
6569
+ const exp = expiryOf(jwt);
6570
+ return exp === null ? null : exp - (skews.get(config.tokens) ?? 0);
6571
+ }
6572
+ var SESSION_GONE_CODES = /* @__PURE__ */ new Set([
6573
+ "UNAUTHORIZED",
6574
+ "INVALID_SESSION",
6575
+ "FAILED_TO_GET_SESSION"
6576
+ ]);
6577
+ var renewing = /* @__PURE__ */ new WeakMap();
6578
+ function renewAccessJwt(config, jwtStore, session) {
6579
+ const pending = renewing.get(config.tokens);
6580
+ if (pending?.session === session) return pending.done;
6581
+ const done = (async () => {
6582
+ const result = await callWith(
6583
+ config,
6584
+ { method: "GET", path: "/auth/token", withToken: true },
6585
+ void 0,
6586
+ session
6587
+ );
6588
+ if (result.error !== null) {
6589
+ if (result.answer?.status !== 401 || !SESSION_GONE_CODES.has(result.error.code))
6590
+ return result.error;
6591
+ await config.tokens.clearIf(session);
6592
+ await config.tokens.whileIs(null, () => jwtStore.clear());
6593
+ return invalidSession();
6594
+ }
6595
+ const { body, status } = result.answer;
6596
+ const jwt = isRecord(body) ? body.token : void 0;
6597
+ if (typeof jwt !== "string" || jwt === "") return invalidResponse(status);
6598
+ const arrived = Date.now();
6599
+ await config.tokens.whileIs(session, async () => {
6600
+ const iat = timesOf(jwt)?.iat;
6601
+ if (iat !== void 0 && iat !== null)
6602
+ skews.set(config.tokens, iat - arrived);
6603
+ await jwtStore.set(jwt);
6604
+ });
6605
+ return null;
6606
+ })();
6607
+ const entry = { session, done };
6608
+ renewing.set(config.tokens, entry);
6609
+ void done.finally(() => {
6610
+ if (renewing.get(config.tokens) === entry) renewing.delete(config.tokens);
6611
+ });
6612
+ return done;
6613
+ }
6614
+ async function accessJwtFor(config, jwtStore) {
6615
+ for (let round = 0; round < 2; round += 1) {
6616
+ const session = await config.tokens.get();
6617
+ const jwt = await jwtStore.get();
6618
+ if (session === null || config.service) return { token: jwt, session };
6619
+ const expiry = jwt === null ? null : localExpiry(config, jwt);
6620
+ if (jwt !== null && (expiry === null || expiry - ACCESS_JWT_MARGIN_MS > Date.now()))
6621
+ return { token: jwt, session };
6622
+ const error = await renewAccessJwt(config, jwtStore, session);
6623
+ const stored = await config.tokens.get();
6624
+ if (stored !== session) {
6625
+ if (stored === null) return error ? { error } : { token: null, session };
6626
+ continue;
6627
+ }
6628
+ if (error === null) {
6629
+ const renewed = await jwtStore.get();
6630
+ return renewed === null ? { error: invalidSession() } : { token: renewed, session };
6631
+ }
6632
+ if (jwt !== null && expiry !== null && expiry > Date.now())
6633
+ return { token: jwt, session };
6634
+ return { error };
6635
+ }
6636
+ return { error: invalidSession() };
6637
+ }
6638
+ async function bearerFor(config, path, withToken) {
6639
+ if (!withToken) return { token: null, session: null };
6640
+ const store = storeFor(config, path);
6641
+ if (store === null) return { token: null, session: null };
6642
+ if (store === config.tokens) {
6643
+ const token = await store.get();
6644
+ return { token, session: token };
6645
+ }
6646
+ return accessJwtFor(config, store);
6647
+ }
6648
+ async function retryBearer(config, path, error, first) {
6649
+ if (error?.code !== "INVALID_SESSION" || first.token === null || first.session === null || config.accessJwt === void 0 || storeFor(config, path) !== config.accessJwt || await config.tokens.get() !== first.session)
6650
+ return null;
6651
+ const renewed = await bearerFor(config, path, true);
6652
+ if ("error" in renewed) return renewed;
6653
+ return renewed.token === null || renewed.token === first.token || renewed.session !== first.session ? null : renewed;
6160
6654
  }
6161
6655
  async function receive(config, options, init) {
6162
6656
  const controller = options.timeoutMs !== void 0 && typeof AbortController === "function" ? new AbortController() : null;
@@ -6201,8 +6695,16 @@ async function call(config, options) {
6201
6695
  return { error: invalidQuery("", "is not JSON"), answer: null };
6202
6696
  }
6203
6697
  }
6698
+ const bearer = await bearerFor(config, options.path, options.withToken);
6699
+ if ("error" in bearer) return { error: bearer.error, answer: null };
6700
+ const result = await callWith(config, options, payload, bearer.token);
6701
+ const again = await retryBearer(config, options.path, result.error, bearer);
6702
+ if (again === null) return result;
6703
+ if ("error" in again) return { error: again.error, answer: null };
6704
+ return callWith(config, options, payload, again.token);
6705
+ }
6706
+ async function callWith(config, options, payload, token) {
6204
6707
  const store = storeFor(config, options.path);
6205
- const token = options.withToken && store ? await store.get() : null;
6206
6708
  const headers = {
6207
6709
  ...options.headers,
6208
6710
  apikey: config.apiKey
@@ -6246,8 +6748,48 @@ async function settled(store, error, headers, token) {
6246
6748
  return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
6247
6749
  }
6248
6750
  async function send(config, options) {
6751
+ const bearer = await untilAborted(
6752
+ bearerFor(config, options.path, options.withToken),
6753
+ options.signal
6754
+ );
6755
+ if (bearer === ABORTED) return { error: uploadAborted(), status: null };
6756
+ if ("error" in bearer) return { error: bearer.error, status: null };
6757
+ const result = await sendWith(config, options, bearer.token);
6758
+ if (result.error?.code !== "INVALID_SESSION" || isStream(options.body))
6759
+ return result;
6760
+ const again = await untilAborted(
6761
+ retryBearer(config, options.path, result.error, bearer),
6762
+ options.signal
6763
+ );
6764
+ if (again === ABORTED) return { error: uploadAborted(), status: null };
6765
+ if (again === null) return result;
6766
+ if ("error" in again) return { error: again.error, status: null };
6767
+ return sendWith(config, options, again.token);
6768
+ }
6769
+ var ABORTED = Symbol("aborted");
6770
+ function untilAborted(promise, signal) {
6771
+ if (!signal) return promise;
6772
+ if (signal.aborted) return Promise.resolve(ABORTED);
6773
+ return new Promise((resolve7, reject) => {
6774
+ const stop = () => resolve7(ABORTED);
6775
+ signal.addEventListener("abort", stop, { once: true });
6776
+ promise.then(
6777
+ (value) => {
6778
+ signal.removeEventListener("abort", stop);
6779
+ resolve7(value);
6780
+ },
6781
+ (error) => {
6782
+ signal.removeEventListener("abort", stop);
6783
+ reject(error);
6784
+ }
6785
+ );
6786
+ });
6787
+ }
6788
+ function isStream(body) {
6789
+ return typeof ReadableStream === "function" && body instanceof ReadableStream;
6790
+ }
6791
+ async function sendWith(config, options, token) {
6249
6792
  const store = storeFor(config, options.path);
6250
- const token = options.withToken && store ? await store.get() : null;
6251
6793
  const headers = {
6252
6794
  ...options.headers,
6253
6795
  apikey: config.apiKey
@@ -6308,7 +6850,9 @@ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
6308
6850
  // Storage (original spec §13): one object written twice within a second
6309
6851
  // (`x-retry-after: 1`), and the daily operation limit (3-1d).
6310
6852
  "OBJECT_RATE_LIMITED",
6311
- "STORAGE_OPS_QUOTA_EXCEEDED"
6853
+ "STORAGE_OPS_QUOTA_EXCEEDED",
6854
+ // Realtime's REST broadcast over the project's message budget (x-retry-after: 1).
6855
+ "TOO_MANY_MESSAGES"
6312
6856
  ]);
6313
6857
  function withRetryAfter(error, headers) {
6314
6858
  const header = headers.get("x-retry-after")?.trim() ?? "";
@@ -6887,7 +7431,7 @@ var MAX_FLOWS = 10;
6887
7431
  function isEntry(value) {
6888
7432
  return typeof value === "object" && value !== null && typeof value.verifier === "string" && typeof value.expiresAt === "number";
6889
7433
  }
6890
- function live(text, now) {
7434
+ function live(text, now2) {
6891
7435
  if (text === null) return {};
6892
7436
  let parsed;
6893
7437
  try {
@@ -6898,7 +7442,7 @@ function live(text, now) {
6898
7442
  if (typeof parsed !== "object" || parsed === null) return {};
6899
7443
  const entries = {};
6900
7444
  for (const [flow, entry] of Object.entries(parsed))
6901
- if (isEntry(entry) && entry.expiresAt > now) entries[flow] = entry;
7445
+ if (isEntry(entry) && entry.expiresAt > now2) entries[flow] = entry;
6902
7446
  return entries;
6903
7447
  }
6904
7448
  function pick(entries, oldest) {
@@ -6914,16 +7458,16 @@ function serialize(entries) {
6914
7458
  var FlowVerifiers = class {
6915
7459
  #store;
6916
7460
  #now;
6917
- constructor(store, now = Date.now) {
7461
+ constructor(store, now2 = Date.now) {
6918
7462
  this.#store = store;
6919
- this.#now = now;
7463
+ this.#now = now2;
6920
7464
  }
6921
7465
  /** Keeps `verifier` for the flow `flow`, for ten minutes. */
6922
7466
  async add(flow, verifier) {
6923
- const now = this.#now();
7467
+ const now2 = this.#now();
6924
7468
  await this.#store.update((text) => {
6925
- const entries = live(text, now);
6926
- entries[flow] = { verifier, expiresAt: now + FLOW_TTL_MS };
7469
+ const entries = live(text, now2);
7470
+ entries[flow] = { verifier, expiresAt: now2 + FLOW_TTL_MS };
6927
7471
  while (Object.keys(entries).length > MAX_FLOWS) {
6928
7472
  const oldest = pick(entries, true);
6929
7473
  if (oldest === void 0) break;
@@ -6938,10 +7482,10 @@ var FlowVerifiers = class {
6938
7482
  * expired.
6939
7483
  */
6940
7484
  async take(flow) {
6941
- const now = this.#now();
7485
+ const now2 = this.#now();
6942
7486
  let taken = null;
6943
7487
  await this.#store.update((text) => {
6944
- const entries = live(text, now);
7488
+ const entries = live(text, now2);
6945
7489
  const id = flow ?? pick(entries, false);
6946
7490
  if (id !== void 0 && Object.hasOwn(entries, id)) {
6947
7491
  taken = entries[id]?.verifier ?? null;
@@ -6966,9 +7510,9 @@ function parse(text) {
6966
7510
  var RecoveryToken = class {
6967
7511
  #store;
6968
7512
  #now;
6969
- constructor(store, now = () => Date.now()) {
7513
+ constructor(store, now2 = () => Date.now()) {
6970
7514
  this.#store = store;
6971
- this.#now = now;
7515
+ this.#now = now2;
6972
7516
  }
6973
7517
  /** Keeps the token a recovery link brought, for 15 minutes. */
6974
7518
  async keep(token) {
@@ -6977,11 +7521,11 @@ var RecoveryToken = class {
6977
7521
  }
6978
7522
  /** The token, while it lasts; an expired or unreadable one is cleared. */
6979
7523
  async current() {
6980
- const now = this.#now();
7524
+ const now2 = this.#now();
6981
7525
  let token = null;
6982
7526
  await this.#store.update((text) => {
6983
7527
  const kept = parse(text);
6984
- if (kept === null || kept.expiresAt <= now) return null;
7528
+ if (kept === null || kept.expiresAt <= now2) return null;
6985
7529
  token = kept.token;
6986
7530
  return text;
6987
7531
  });
@@ -7041,6 +7585,8 @@ var TokenStore = class {
7041
7585
  #fallback = null;
7042
7586
  /** Settles when the last queued operation has finished. */
7043
7587
  #queue = Promise.resolve();
7588
+ /** Called after each write; see `subscribe`. */
7589
+ #listeners = /* @__PURE__ */ new Set();
7044
7590
  constructor(storage, key) {
7045
7591
  this.#storage = storage;
7046
7592
  this.#key = key;
@@ -7049,21 +7595,31 @@ var TokenStore = class {
7049
7595
  return this.#serial(() => this.#get());
7050
7596
  }
7051
7597
  set(token) {
7052
- return this.#serial(() => this.#set(token));
7598
+ return this.#serial(async () => {
7599
+ await this.#set(token);
7600
+ this.#changed();
7601
+ });
7053
7602
  }
7054
7603
  clear() {
7055
- return this.#serial(() => this.#clear());
7604
+ return this.#serial(async () => {
7605
+ await this.#clear();
7606
+ this.#changed();
7607
+ });
7056
7608
  }
7057
7609
  /**
7058
- * Clears only if the stored token is still the one that was rejected. A
7059
- * request that left with an old token can come back after the user signed
7060
- * in again; its INVALID_SESSION must not sign the new session out. The
7061
- * read and the removal are one operation: with an async storage, a sign-in
7062
- * that lands between them would otherwise be the token removed.
7610
+ * Clears only if the stored token is still the one that was rejected, and
7611
+ * says whether it did. A request that left with an old token can come back
7612
+ * after the user signed in again; its INVALID_SESSION must not sign the
7613
+ * new session out. The read and the removal are one operation: with an
7614
+ * async storage, a sign-in that lands between them would otherwise be the
7615
+ * token removed.
7063
7616
  */
7064
7617
  clearIf(token) {
7065
7618
  return this.#serial(async () => {
7066
- if (await this.#get() === token) await this.#clear();
7619
+ if (await this.#get() !== token) return false;
7620
+ await this.#clear();
7621
+ this.#changed();
7622
+ return true;
7067
7623
  });
7068
7624
  }
7069
7625
  /**
@@ -7076,17 +7632,59 @@ var TokenStore = class {
7076
7632
  const next = transform(await this.#get());
7077
7633
  if (next === null) await this.#clear();
7078
7634
  else await this.#set(next);
7635
+ this.#changed();
7079
7636
  });
7080
7637
  }
7081
7638
  /** Stores `next` only if `expected` is still stored, as one operation. */
7082
7639
  replaceIf(expected, next) {
7083
7640
  return this.#serial(async () => {
7084
- if (await this.#get() === expected) await this.#set(next);
7641
+ if (await this.#get() !== expected) return;
7642
+ await this.#set(next);
7643
+ this.#changed();
7085
7644
  });
7086
7645
  }
7087
7646
  /**
7088
- * Runs `operation` once every earlier one has finished. The operations
7089
- * below never reject, and none of them calls a public method (that would
7647
+ * Runs `operation` only while `expected` is the stored token (null: none
7648
+ * is stored), inside this
7649
+ * store's queue, and says whether it ran: no write to this store lands
7650
+ * between the check and the end of the operation. A refreshed access JWT
7651
+ * is kept this way (`http.ts`), so one minted for a session that a
7652
+ * sign-in replaced meanwhile never lands on top of the new session's.
7653
+ * `operation` must not call this store's public methods (it would wait
7654
+ * on itself); another store's are fine.
7655
+ */
7656
+ whileIs(expected, operation) {
7657
+ return this.#serial(async () => {
7658
+ if (await this.#get() !== expected) return false;
7659
+ await operation();
7660
+ return true;
7661
+ });
7662
+ }
7663
+ /**
7664
+ * Calls `listener` after every write of the token — a sign-in, a
7665
+ * sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
7666
+ * write's turn, so a `get()` it starts reads the value just written — or
7667
+ * that of a write already queued behind it, which calls the listener
7668
+ * again. The Realtime connection (Realtime 3-2d) uses it to tell the hub.
7669
+ * What a listener throws is its own: the write has happened and stands.
7670
+ */
7671
+ subscribe(listener) {
7672
+ this.#listeners.add(listener);
7673
+ return () => {
7674
+ this.#listeners.delete(listener);
7675
+ };
7676
+ }
7677
+ #changed() {
7678
+ for (const listener of Array.from(this.#listeners)) {
7679
+ try {
7680
+ listener();
7681
+ } catch {
7682
+ }
7683
+ }
7684
+ }
7685
+ /**
7686
+ * Runs `operation` once every earlier one has finished. The operations
7687
+ * below never reject, and none of them awaits a public method (that would
7090
7688
  * wait on itself).
7091
7689
  */
7092
7690
  #serial(operation) {
@@ -7487,10 +8085,10 @@ function truth2(text) {
7487
8085
  var DECIMAL3 = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
7488
8086
  function exactNumber2(text) {
7489
8087
  if (!DECIMAL3.test(text)) return text;
7490
- const number = Number(text);
7491
- if (!text.includes(".")) return Number.isSafeInteger(number) ? number : text;
8088
+ const number2 = Number(text);
8089
+ if (!text.includes(".")) return Number.isSafeInteger(number2) ? number2 : text;
7492
8090
  const trimmed = text.replace(/\.?0+$/, "");
7493
- return String(number) === trimmed ? number : text;
8091
+ return String(number2) === trimmed ? number2 : text;
7494
8092
  }
7495
8093
  function keyword2(text, at) {
7496
8094
  const word = text.toLowerCase();
@@ -7892,6 +8490,2059 @@ var TableBuilder = class {
7892
8490
  });
7893
8491
  }
7894
8492
  };
8493
+ var BACKOFF_STEPS_MS = [1e3, 2e3, 5e3, 1e4];
8494
+ var RATE_LIMITED_MIN_MS = 5e3;
8495
+ var IP_LIMIT_WAIT_MS = 6e4;
8496
+ var RESTART_JITTER_MS = 1e4;
8497
+ var UNOPENED_SLOWDOWN_AFTER = 6;
8498
+ var UNOPENED_DELAY_MS = 6e4;
8499
+ var STOP_CODES = /* @__PURE__ */ new Set([
8500
+ 1e3,
8501
+ 4e3,
8502
+ 4001,
8503
+ 4003,
8504
+ 4004,
8505
+ 4410
8506
+ ]);
8507
+ function closeAction(code, reason) {
8508
+ if (STOP_CODES.has(code)) return { kind: "stop" };
8509
+ if (code === 4401) return { kind: "session" };
8510
+ if (code === 4503) return { kind: "restart" };
8511
+ if (code === 4029)
8512
+ return reason === "IP_RATE_LIMITED" ? { kind: "wait", ms: IP_LIMIT_WAIT_MS } : { kind: "retry", minMs: RATE_LIMITED_MIN_MS };
8513
+ return { kind: "retry", minMs: 0 };
8514
+ }
8515
+ function backoffMs(attempt22, random) {
8516
+ const step = BACKOFF_STEPS_MS[Math.min(attempt22, BACKOFF_STEPS_MS.length - 1)] ?? 1e4;
8517
+ return Math.round(step * (0.5 + random()));
8518
+ }
8519
+ function delayAfter(action, state, random) {
8520
+ if (action.kind === "restart")
8521
+ return Math.floor(random() * RESTART_JITTER_MS);
8522
+ if (action.kind === "wait")
8523
+ return action.ms + Math.floor(random() * RESTART_JITTER_MS);
8524
+ const base = state.unopened >= UNOPENED_SLOWDOWN_AFTER ? Math.round(UNOPENED_DELAY_MS * (0.5 + random())) : backoffMs(state.attempt, random);
8525
+ return Math.max(action.kind === "retry" ? action.minMs : 0, base);
8526
+ }
8527
+ function entryOf(meta) {
8528
+ if (!isRecord(meta) || typeof meta.phx_ref !== "string") return null;
8529
+ const { phx_ref: ref, naro_user_id: user, ...state } = meta;
8530
+ return {
8531
+ ...state,
8532
+ presence_ref: ref,
8533
+ naro_user_id: typeof user === "string" ? user : null
8534
+ };
8535
+ }
8536
+ function entriesOf(value) {
8537
+ const read = /* @__PURE__ */ new Map();
8538
+ if (!isRecord(value)) return read;
8539
+ for (const [key, item] of Object.entries(value)) {
8540
+ if (!isRecord(item) || !Array.isArray(item.metas)) continue;
8541
+ const entries = item.metas.map(entryOf).filter((entry) => entry !== null);
8542
+ if (entries.length > 0) read.set(key, entries);
8543
+ }
8544
+ return read;
8545
+ }
8546
+ function without(entries, gone) {
8547
+ return entries.filter(
8548
+ (entry) => !gone.some((left) => left.presence_ref === entry.presence_ref)
8549
+ );
8550
+ }
8551
+ function copies(entries) {
8552
+ return entries.map((entry) => ({ ...entry }));
8553
+ }
8554
+ var Presence = class {
8555
+ #state = /* @__PURE__ */ new Map();
8556
+ /** A presence_state came for the current join: diffs apply only after it. */
8557
+ #synced = false;
8558
+ state() {
8559
+ return Object.fromEntries(
8560
+ [...this.#state].map(([key, entries]) => [key, copies(entries)])
8561
+ );
8562
+ }
8563
+ /** A new join, or the connection dropped: what is here stays until its presence_state. */
8564
+ unsync() {
8565
+ this.#synced = false;
8566
+ }
8567
+ clear() {
8568
+ this.#state = /* @__PURE__ */ new Map();
8569
+ this.#synced = false;
8570
+ }
8571
+ /**
8572
+ * No presence_state will say what is here any more (a join that may not
8573
+ * read presence, one refused, the server's close, a stop — plan 정정
8574
+ * (k)): each key leaves, then sync — said only when someone was here. A
8575
+ * diff waits for the next presence_state.
8576
+ */
8577
+ leaveAll(emit) {
8578
+ if (this.#state.size > 0) this.applyState({}, emit);
8579
+ this.#synced = false;
8580
+ }
8581
+ applyState(payload, emit) {
8582
+ const before = this.#state;
8583
+ const next = entriesOf(payload);
8584
+ this.#state = next;
8585
+ this.#synced = true;
8586
+ for (const [key, entries] of next) {
8587
+ const held = before.get(key) ?? [];
8588
+ const added = without(entries, held);
8589
+ if (added.length > 0) emit.join(key, copies(added), copies(held));
8590
+ }
8591
+ for (const [key, entries] of before) {
8592
+ const left = without(entries, next.get(key) ?? []);
8593
+ if (left.length > 0)
8594
+ emit.leave(key, copies(left), copies(next.get(key) ?? []));
8595
+ }
8596
+ emit.sync();
8597
+ }
8598
+ applyDiff(payload, emit) {
8599
+ if (!this.#synced || !isRecord(payload)) return;
8600
+ for (const [key, added] of entriesOf(payload.joins)) {
8601
+ const held = this.#state.get(key) ?? [];
8602
+ this.#state.set(key, [...without(held, added), ...added]);
8603
+ emit.join(key, copies(added), copies(held));
8604
+ }
8605
+ for (const [key, left] of entriesOf(payload.leaves)) {
8606
+ const current = without(this.#state.get(key) ?? [], left);
8607
+ if (current.length > 0) this.#state.set(key, current);
8608
+ else this.#state.delete(key);
8609
+ emit.leave(key, copies(left), copies(current));
8610
+ }
8611
+ emit.sync();
8612
+ }
8613
+ };
8614
+ var REALTIME_PROTOCOL_VERSION = "1.0.0";
8615
+ var PHOENIX_TOPIC = "phoenix";
8616
+ var REALTIME_TOPIC_PREFIX = "realtime:";
8617
+ var HEARTBEAT_REQUEST = '{"topic":"phoenix","event":"heartbeat","payload":{},"ref":"hb"}';
8618
+ var HEARTBEAT_REF = "hb";
8619
+ var MAX_CHANNEL_NAME_LENGTH = 128;
8620
+ var CONNECTION_FRAMES_PER_SECOND = 10;
8621
+ var CONNECTION_FRAME_BURST = 120;
8622
+ var WARNED_CODES = /* @__PURE__ */ new Set([
8623
+ "TOO_MANY_JOINS",
8624
+ "TOO_MANY_MESSAGES",
8625
+ "TOO_MANY_PRESENCE_MESSAGES"
8626
+ ]);
8627
+ var FLOOD_WINDOW_MS = 6e4;
8628
+ var MAX_CHANGE_CHANNELS_PER_TABLE = 4;
8629
+ var CHANGE_CHANNELS_REASON = `would put more than ${String(MAX_CHANGE_CHANNELS_PER_TABLE)} channels of this connection on one table's changes (* counts for every table): add the subscription to a channel it already has`;
8630
+ function readServerFrame(data) {
8631
+ if (typeof data !== "string") return null;
8632
+ let value;
8633
+ try {
8634
+ value = JSON.parse(data);
8635
+ } catch {
8636
+ return null;
8637
+ }
8638
+ if (!isRecord(value)) return null;
8639
+ const { topic, event, payload, ref } = value;
8640
+ const joinRef = value.join_ref;
8641
+ if (typeof topic !== "string" || typeof event !== "string") return null;
8642
+ if (!isRecord(payload)) return null;
8643
+ return {
8644
+ topic,
8645
+ event,
8646
+ payload,
8647
+ ref: typeof ref === "string" ? ref : null,
8648
+ joinRef: typeof joinRef === "string" ? joinRef : null
8649
+ };
8650
+ }
8651
+ function frameLimitOf(reason) {
8652
+ if (typeof reason !== "string") return null;
8653
+ const match = /^is larger than (\d+) bytes$/.exec(reason);
8654
+ return match === null ? null : Number(match[1]);
8655
+ }
8656
+ function utf8Length(text) {
8657
+ let bytes = 0;
8658
+ for (let index = 0; index < text.length; index++) {
8659
+ const unit = text.charCodeAt(index);
8660
+ if (unit < 128) bytes += 1;
8661
+ else if (unit < 2048) bytes += 2;
8662
+ else if (unit >= 55296 && unit <= 56319 && index + 1 < text.length) {
8663
+ const next = text.charCodeAt(index + 1);
8664
+ if (next >= 56320 && next <= 57343) {
8665
+ bytes += 4;
8666
+ index++;
8667
+ } else bytes += 3;
8668
+ } else bytes += 3;
8669
+ }
8670
+ return bytes;
8671
+ }
8672
+ function clientFrame(topic, event, payload, ref, joinRef) {
8673
+ return JSON.stringify(
8674
+ joinRef === void 0 ? { topic, event, payload, ref } : { topic, event, payload, ref, join_ref: joinRef }
8675
+ );
8676
+ }
8677
+ function atMostCodePoints(text, max) {
8678
+ if (text.length <= max) return true;
8679
+ return text.length <= max * 2 && [...text].length <= max;
8680
+ }
8681
+ function hasControlCharacter(text) {
8682
+ for (let i = 0; i < text.length; i += 1) {
8683
+ const code = text.charCodeAt(i);
8684
+ if (code <= 31 || code >= 127 && code <= 159) return true;
8685
+ }
8686
+ return false;
8687
+ }
8688
+ function hasBidiControl(text) {
8689
+ for (let i = 0; i < text.length; i += 1) {
8690
+ const code = text.charCodeAt(i);
8691
+ if (code === 1564 || code === 8206 || code === 8207 || code >= 8234 && code <= 8238 || code >= 8294 && code <= 8297)
8692
+ return true;
8693
+ }
8694
+ return false;
8695
+ }
8696
+ function channelNameProblem(name) {
8697
+ if (name === "") return "is empty";
8698
+ if (!atMostCodePoints(name, MAX_CHANNEL_NAME_LENGTH))
8699
+ return `is longer than ${String(MAX_CHANNEL_NAME_LENGTH)} characters`;
8700
+ if (hasControlCharacter(name)) return "has a control character";
8701
+ if (hasBidiControl(name)) return "has a bidirectional control character";
8702
+ return void 0;
8703
+ }
8704
+ function socketUrl(baseUrl, apiKey) {
8705
+ const url = new URL("/_naro/v1/realtime/websocket", baseUrl);
8706
+ url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
8707
+ return `${url.href}?apikey=${encodeURIComponent(apiKey)}&vsn=${REALTIME_PROTOCOL_VERSION}`;
8708
+ }
8709
+ var HEARTBEAT_MS = 25e3;
8710
+ var TIMEOUT_MS = 1e4;
8711
+ var PACED_BURST = CONNECTION_FRAME_BURST - 20;
8712
+ var SDK_WARNING_ROOM = 6;
8713
+ var ignore = () => void 0;
8714
+ function now() {
8715
+ return typeof performance === "undefined" ? Date.now() : performance.now();
8716
+ }
8717
+ function detach(ws) {
8718
+ ws.onopen = null;
8719
+ ws.onmessage = null;
8720
+ ws.onclose = null;
8721
+ ws.onerror = ignore;
8722
+ }
8723
+ function closeQuietly(ws, code) {
8724
+ try {
8725
+ ws.close(code);
8726
+ } catch {
8727
+ }
8728
+ }
8729
+ function safely(callback) {
8730
+ try {
8731
+ callback();
8732
+ } catch (error) {
8733
+ queueMicrotask(() => {
8734
+ throw error;
8735
+ });
8736
+ }
8737
+ }
8738
+ function replyOf(payload) {
8739
+ if (payload.status === "ok") {
8740
+ const { response } = payload;
8741
+ return {
8742
+ ok: true,
8743
+ response: typeof response === "object" && response !== null && !Array.isArray(response) ? response : {}
8744
+ };
8745
+ }
8746
+ return {
8747
+ ok: false,
8748
+ error: asError(payload.response) ?? invalidResponse(101),
8749
+ lost: false
8750
+ };
8751
+ }
8752
+ var RealtimeSocket = class {
8753
+ #config;
8754
+ #heartbeatMs;
8755
+ #timeoutMs;
8756
+ #ws = null;
8757
+ #state = "idle";
8758
+ #generation = 0;
8759
+ /** Drops since the hub last answered: the next wait's step (결정 7). */
8760
+ #attempt = 0;
8761
+ /** Tries in a row that never opened (결정 9). */
8762
+ #unopened = 0;
8763
+ /** When the wait the last close or stop set ends: no connect() opens before it. */
8764
+ #resumeAt = 0;
8765
+ /**
8766
+ * The last close was 4008 (CLIENT_FLOODING), and no heartbeat was
8767
+ * answered since: a channel's word that the hub answered does not start
8768
+ * the backoff over — a connection that floods again must not come back
8769
+ * every second (PR #328 review).
8770
+ */
8771
+ #flooded = false;
8772
+ /**
8773
+ * The frames waiting their turn on this connection, in the order they
8774
+ * go: the first `#ahead` before the app's (`#enqueue`).
8775
+ */
8776
+ #queue = [];
8777
+ /** How many at the front of `#queue` go before the app's frames: the SDK's own and the identity's, and the app's an identity frame must not pass. */
8778
+ #ahead = 0;
8779
+ /** The app's frames in `#queue`: not in flight yet, so no room is kept for them (`#hasRoom`). */
8780
+ #appQueued = 0;
8781
+ /** The budget this connection has left (`PACED_BURST` at the open), as of `#tokensAt`. */
8782
+ #tokens = 0;
8783
+ #tokensAt = 0;
8784
+ #paceTimer;
8785
+ /** When the hub refused a frame on this connection with a warning (`WARNED_CODES`), oldest first. */
8786
+ #warnings = [];
8787
+ /** The SDK's own frames waiting for room, by who sends them, in the order they asked (`whenRoom()`). */
8788
+ #roomWaiters = /* @__PURE__ */ new Map();
8789
+ /** Due when the oldest warning leaves the window, while a frame waits for room. */
8790
+ #roomTimer;
8791
+ #draining = false;
8792
+ #ref = 0;
8793
+ #retryTimer;
8794
+ #openTimer;
8795
+ #heartbeat;
8796
+ #heartbeatPending = false;
8797
+ /** The connection-level `system` frame since the open: why it is about to close. */
8798
+ #said = null;
8799
+ /**
8800
+ * The size limit the hub named when it refused a frame on this
8801
+ * connection (`#refusedOverLimit`): a frame over it is answered so without
8802
+ * being sent. Forgotten with the connection — the next hub's plan may
8803
+ * differ.
8804
+ */
8805
+ #frameLimit = null;
8806
+ #replies = /* @__PURE__ */ new Map();
8807
+ constructor(config) {
8808
+ this.#config = config;
8809
+ this.#heartbeatMs = config.options.heartbeatIntervalMs ?? HEARTBEAT_MS;
8810
+ this.#timeoutMs = config.options.timeoutMs ?? TIMEOUT_MS;
8811
+ }
8812
+ get generation() {
8813
+ return this.#generation;
8814
+ }
8815
+ get timeoutMs() {
8816
+ return this.#timeoutMs;
8817
+ }
8818
+ isOpen() {
8819
+ return this.#state === "open";
8820
+ }
8821
+ nextRef() {
8822
+ this.#ref += 1;
8823
+ return String(this.#ref);
8824
+ }
8825
+ /**
8826
+ * Opens unless a connection is open, opening or due — once the wait the
8827
+ * last close or stop set is over. After the app's own close the counts
8828
+ * start over; after a stop they go on, as after a drop.
8829
+ */
8830
+ connect() {
8831
+ if (this.#state !== "idle" && this.#state !== "stopped") return;
8832
+ if (this.#state === "idle") {
8833
+ this.#attempt = 0;
8834
+ this.#unopened = 0;
8835
+ }
8836
+ const wait = this.#resumeAt - now();
8837
+ if (wait <= 0) return this.#open();
8838
+ this.#state = "waiting";
8839
+ this.#retryTimer = setTimeout(() => {
8840
+ this.#retryTimer = void 0;
8841
+ if (this.#state === "waiting") this.#open();
8842
+ }, wait);
8843
+ }
8844
+ /** The last channel went: close for good, with no event. */
8845
+ disconnect() {
8846
+ const ws = this.#ws;
8847
+ this.#ws = null;
8848
+ this.#clearTimers();
8849
+ this.#state = "idle";
8850
+ this.#said = null;
8851
+ this.#failReplies(realtimeDisconnected());
8852
+ if (ws !== null) {
8853
+ detach(ws);
8854
+ closeQuietly(ws, 1e3);
8855
+ }
8856
+ }
8857
+ /**
8858
+ * Lets go of the open connection and comes back as after a drop (1006),
8859
+ * the channels told: for a connection that holds an identity the app no
8860
+ * longer means and cannot change (identity.ts). Nothing unless open.
8861
+ */
8862
+ reconnect() {
8863
+ const ws = this.#ws;
8864
+ if (ws === null || this.#state !== "open") return;
8865
+ detach(ws);
8866
+ closeQuietly(ws, 1e3);
8867
+ this.#closed(ws, 1006, "", null);
8868
+ }
8869
+ /**
8870
+ * The hub answered this connection: the next drop waits from the first
8871
+ * step — unless the last close was 4008 and no heartbeat was answered
8872
+ * since (`#flooded`).
8873
+ */
8874
+ served() {
8875
+ if (!this.#flooded) this.#attempt = 0;
8876
+ }
8877
+ /**
8878
+ * A frame, in its `turn` (the app's unless said), and the `phx_reply`
8879
+ * with its `ref` within `timeoutMs` of when it went — or, for one over the
8880
+ * size limit the hub named on this connection, that refusal at once and
8881
+ * nothing sent; for an app's frame but a leave that would not go within
8882
+ * `timeoutMs`, CLIENT_FLOODING at once and nothing sent.
8883
+ */
8884
+ request(topic, event, payload, options = {}) {
8885
+ const ref = options.ref ?? this.nextRef();
8886
+ const turn = options.turn ?? "app";
8887
+ return new Promise((resolve7) => {
8888
+ const lost = {
8889
+ ok: false,
8890
+ error: realtimeClosed(1006),
8891
+ lost: true
8892
+ };
8893
+ if (!this.isOpen()) return resolve7(lost);
8894
+ let text;
8895
+ try {
8896
+ text = clientFrame(topic, event, payload, ref, options.joinRef);
8897
+ } catch {
8898
+ return resolve7(lost);
8899
+ }
8900
+ const bytes = utf8Length(text);
8901
+ const limit = this.#frameLimit;
8902
+ if (limit !== null && bytes > limit)
8903
+ return resolve7({ ok: false, error: frameTooLarge(limit), lost: false });
8904
+ if (turn === "app" && event !== "phx_leave" && !this.#goesInTime())
8905
+ return resolve7({
8906
+ ok: false,
8907
+ error: clientFlooding(CONNECTION_FRAMES_PER_SECOND),
8908
+ lost: false
8909
+ });
8910
+ this.#replies.set(ref, { resolve: resolve7, timer: void 0, bytes, turn });
8911
+ this.#enqueue({ text, bytes, ref, turn, presence: event === "presence" });
8912
+ });
8913
+ }
8914
+ /**
8915
+ * Whether an app's frame behind every frame waiting would go within
8916
+ * `timeoutMs`: past the budget left, the hub takes
8917
+ * `CONNECTION_FRAMES_PER_SECOND` a second. The SDK's own frames that come
8918
+ * later go before it, a few at most (`whenRoom()`).
8919
+ */
8920
+ #goesInTime() {
8921
+ this.#refill();
8922
+ const over = this.#queue.length + 1 - this.#tokens;
8923
+ return over <= CONNECTION_FRAMES_PER_SECOND * this.#timeoutMs / 1e3;
8924
+ }
8925
+ /**
8926
+ * Runs `go` once a frame the SDK sends of its own accord may go: at once
8927
+ * when nothing waits before it and there is room — the warnings of the
8928
+ * last minute and the requests in flight fewer than `SDK_WARNING_ROOM`
8929
+ * (the app's frames still waiting their turn are not in flight yet, and
8930
+ * go after the SDK's), or the warnings fewer and none of the SDK's own
8931
+ * unanswered — or else when there is, in the order asked. The
8932
+ * frame itself is sent with `turn: "sdk"`. `key` names the sender:
8933
+ * asked again before it ran, it keeps its place and runs the newer `go`,
8934
+ * which reads its own state again then. Nothing while the connection is
8935
+ * not open, and forgotten with it: the sender tries again when it is live.
8936
+ */
8937
+ whenRoom(key, go) {
8938
+ if (this.#state !== "open") return;
8939
+ if (this.#roomWaiters.size === 0 && this.#hasRoom()) return go();
8940
+ this.#roomWaiters.set(key, go);
8941
+ this.#armRoom();
8942
+ }
8943
+ /**
8944
+ * Room for one more frame of the SDK's own (`whenRoom()`): the requests
8945
+ * that count are those sent and not answered, and the SDK's own still to
8946
+ * go — not the app's waiting their turn, which the SDK's go before (PR
8947
+ * #328 re-review). And while none of the SDK's own is unanswered, one
8948
+ * goes whatever the app has in flight, if the warnings leave room (PR #328
8949
+ * re-review 2): an app sending 10 a second over a link of 0.7 s or more
8950
+ * always has six in flight, and a second channel's join would wait,
8951
+ * untold, for as long as it sends. A refusal of the app's is a warning
8952
+ * when it comes, so the SDK adds one at most.
8953
+ */
8954
+ #hasRoom() {
8955
+ if (this.#state !== "open") return false;
8956
+ const since = now() - FLOOD_WINDOW_MS;
8957
+ this.#warnings = this.#warnings.filter((at) => at > since);
8958
+ const warnings = this.#warnings.length;
8959
+ if (warnings >= SDK_WARNING_ROOM) return false;
8960
+ const inFlight2 = this.#replies.size - this.#appQueued;
8961
+ return warnings + inFlight2 < SDK_WARNING_ROOM || !this.#sdkUnanswered();
8962
+ }
8963
+ /** Whether a request of the SDK's own or the identity's waits for its answer, sent or still to go. */
8964
+ #sdkUnanswered() {
8965
+ for (const waiter of this.#replies.values())
8966
+ if (waiter.turn !== "app") return true;
8967
+ return false;
8968
+ }
8969
+ /** The frames waiting for room go while there is: after an answer, a time-out, and when a warning leaves the window. */
8970
+ #drainRoom() {
8971
+ if (this.#draining) return;
8972
+ this.#draining = true;
8973
+ try {
8974
+ for (const [key, go] of this.#roomWaiters) {
8975
+ if (!this.#hasRoom()) break;
8976
+ this.#roomWaiters.delete(key);
8977
+ safely(go);
8978
+ }
8979
+ } finally {
8980
+ this.#draining = false;
8981
+ }
8982
+ this.#armRoom();
8983
+ }
8984
+ /** While a frame waits for room the warnings hold, look again when the oldest leaves the window. */
8985
+ #armRoom() {
8986
+ clearTimeout(this.#roomTimer);
8987
+ this.#roomTimer = void 0;
8988
+ const oldest = this.#warnings[0];
8989
+ if (this.#roomWaiters.size === 0 || oldest === void 0) return;
8990
+ this.#roomTimer = setTimeout(
8991
+ () => {
8992
+ this.#roomTimer = void 0;
8993
+ this.#drainRoom();
8994
+ },
8995
+ Math.max(1, oldest + FLOOD_WINDOW_MS - now() + 1)
8996
+ );
8997
+ }
8998
+ /**
8999
+ * Puts `frame` in its turn and sends what may go. The app's go last; the
9000
+ * SDK's own and the identity's after the others before the app's. An
9001
+ * identity frame goes after the app's presence frames still to go, with
9002
+ * whatever waits before them: the hub reads a presence as the user it
9003
+ * holds when the frame comes, and would hold the last user's state for the
9004
+ * new one.
9005
+ */
9006
+ #enqueue(frame) {
9007
+ if (frame.turn === "app") {
9008
+ this.#queue.push(frame);
9009
+ this.#appQueued += 1;
9010
+ } else {
9011
+ if (frame.turn === "identity") this.#ahead = this.#pastPresence();
9012
+ this.#queue.splice(this.#ahead, 0, frame);
9013
+ this.#ahead += 1;
9014
+ }
9015
+ this.#flush();
9016
+ }
9017
+ /** Just past the app's last presence frame still to go — `#ahead` when none is. */
9018
+ #pastPresence() {
9019
+ for (let index = this.#queue.length - 1; index >= this.#ahead; index -= 1)
9020
+ if (this.#queue[index]?.presence === true) return index + 1;
9021
+ return this.#ahead;
9022
+ }
9023
+ /** The next frame to go, out of `#queue`. */
9024
+ #next() {
9025
+ const frame = this.#queue.shift();
9026
+ if (frame === void 0) return void 0;
9027
+ if (this.#ahead > 0) this.#ahead -= 1;
9028
+ if (frame.turn === "app") this.#appQueued -= 1;
9029
+ return frame;
9030
+ }
9031
+ /** `ref`'s frame out of `#queue`, if it is still there: answered before it went. */
9032
+ #unqueue(ref) {
9033
+ const index = this.#queue.findIndex((frame2) => frame2.ref === ref);
9034
+ if (index === -1) return;
9035
+ const [frame] = this.#queue.splice(index, 1);
9036
+ if (index < this.#ahead) this.#ahead -= 1;
9037
+ if (frame?.turn === "app") this.#appQueued -= 1;
9038
+ }
9039
+ /**
9040
+ * Sends the frames waiting their turn while the budget has room, in their
9041
+ * turn, and comes back when the next one is due. One whose frame the
9042
+ * socket cannot send is lost now — its close event follows.
9043
+ */
9044
+ #flush() {
9045
+ const ws = this.#ws;
9046
+ if (ws === null || this.#state !== "open") return;
9047
+ this.#refill();
9048
+ while (this.#queue.length > 0 && this.#tokens >= 1) {
9049
+ const frame = this.#next();
9050
+ const waiter = this.#replies.get(frame.ref);
9051
+ if (waiter === void 0) continue;
9052
+ const limit = this.#frameLimit;
9053
+ if (limit !== null && frame.bytes > limit) {
9054
+ this.#answer(frame.ref, {
9055
+ ok: false,
9056
+ error: frameTooLarge(limit),
9057
+ lost: false
9058
+ });
9059
+ continue;
9060
+ }
9061
+ try {
9062
+ ws.send(frame.text);
9063
+ } catch {
9064
+ this.#answer(frame.ref, {
9065
+ ok: false,
9066
+ error: realtimeClosed(1006),
9067
+ lost: true
9068
+ });
9069
+ continue;
9070
+ }
9071
+ this.#tokens -= 1;
9072
+ waiter.timer = this.#timeOut(frame.ref, waiter);
9073
+ }
9074
+ if (this.#queue.length === 0 || this.#paceTimer !== void 0) return;
9075
+ const due = Math.ceil(
9076
+ (1 - this.#tokens) * 1e3 / CONNECTION_FRAMES_PER_SECOND
9077
+ );
9078
+ this.#paceTimer = setTimeout(
9079
+ () => {
9080
+ this.#paceTimer = void 0;
9081
+ this.#flush();
9082
+ },
9083
+ Math.max(1, due)
9084
+ );
9085
+ }
9086
+ /** The budget refilled for the time since it was last looked at, as the hub's bucket refills. */
9087
+ #refill() {
9088
+ const at = now();
9089
+ if (at <= this.#tokensAt) return;
9090
+ this.#tokens = Math.min(
9091
+ PACED_BURST,
9092
+ this.#tokens + (at - this.#tokensAt) / 1e3 * CONNECTION_FRAMES_PER_SECOND
9093
+ );
9094
+ this.#tokensAt = at;
9095
+ }
9096
+ /**
9097
+ * `ref`'s request hears `reply`, now: one fewer in flight — or, answered
9098
+ * before its frame went (over a size limit the hub named), one fewer to
9099
+ * go, and nothing sent.
9100
+ */
9101
+ #answer(ref, reply) {
9102
+ const waiter = this.#replies.get(ref);
9103
+ if (waiter === void 0) return;
9104
+ this.#replies.delete(ref);
9105
+ clearTimeout(waiter.timer);
9106
+ if (waiter.timer === void 0) this.#unqueue(ref);
9107
+ waiter.resolve(reply);
9108
+ this.#drainRoom();
9109
+ }
9110
+ /** TIMED_OUT for `ref`'s request `timeoutMs` after its frame went. */
9111
+ #timeOut(ref, waiter) {
9112
+ return setTimeout(() => {
9113
+ if (this.#replies.get(ref) !== waiter) return;
9114
+ this.#answer(ref, {
9115
+ ok: false,
9116
+ error: realtimeTimedOut(this.#timeoutMs),
9117
+ lost: false
9118
+ });
9119
+ }, this.#timeoutMs);
9120
+ }
9121
+ #open() {
9122
+ const Impl = this.#config.options.WebSocket ?? globalThis.WebSocket;
9123
+ if (typeof Impl !== "function")
9124
+ return this.#stop(websocketUnavailable(), true);
9125
+ if (this.#config.service && !isKnownServer())
9126
+ return this.#stop(serviceKeyOffServer(), true);
9127
+ let ws;
9128
+ try {
9129
+ ws = new Impl(
9130
+ socketUrl(this.#config.baseUrl, this.#config.apiKey)
9131
+ );
9132
+ } catch (error) {
9133
+ return this.#stop(socketRefused(error), true);
9134
+ }
9135
+ this.#generation += 1;
9136
+ this.#ws = ws;
9137
+ this.#state = "connecting";
9138
+ this.#said = null;
9139
+ this.#frameLimit = null;
9140
+ ws.onopen = () => this.#opened(ws);
9141
+ ws.onmessage = (event) => this.#message(ws, event.data);
9142
+ ws.onclose = (event) => this.#closed(ws, event.code, event.reason ?? "", null);
9143
+ ws.onerror = ignore;
9144
+ this.#openTimer = setTimeout(
9145
+ () => this.#closed(ws, 1006, "", this.#timeoutMs),
9146
+ this.#timeoutMs
9147
+ );
9148
+ }
9149
+ #opened(ws) {
9150
+ if (this.#ws !== ws || this.#state !== "connecting") return;
9151
+ clearTimeout(this.#openTimer);
9152
+ this.#openTimer = void 0;
9153
+ this.#state = "open";
9154
+ this.#unopened = 0;
9155
+ this.#heartbeatPending = false;
9156
+ this.#queue = [];
9157
+ this.#ahead = 0;
9158
+ this.#appQueued = 0;
9159
+ this.#tokens = PACED_BURST;
9160
+ this.#tokensAt = now();
9161
+ this.#warnings = [];
9162
+ this.#heartbeat = setInterval(() => this.#beat(ws), this.#heartbeatMs);
9163
+ safely(() => this.#config.events.opened());
9164
+ }
9165
+ #beat(ws) {
9166
+ if (this.#ws !== ws) return;
9167
+ if (this.#heartbeatPending)
9168
+ return this.#closed(ws, 1e3, "", this.#heartbeatMs);
9169
+ this.#heartbeatPending = true;
9170
+ try {
9171
+ ws.send(HEARTBEAT_REQUEST);
9172
+ } catch {
9173
+ }
9174
+ }
9175
+ #message(ws, data) {
9176
+ if (this.#ws !== ws) return;
9177
+ const frame = readServerFrame(data);
9178
+ if (frame === null) return;
9179
+ if (frame.event === "phx_reply" && frame.ref !== null) {
9180
+ if (frame.ref === HEARTBEAT_REF) {
9181
+ this.#heartbeatPending = false;
9182
+ this.#flooded = false;
9183
+ this.#attempt = 0;
9184
+ return;
9185
+ }
9186
+ const reply = replyOf(frame.payload);
9187
+ if (!reply.ok && WARNED_CODES.has(reply.error.code))
9188
+ this.#warnings.push(now());
9189
+ if (this.#replies.has(frame.ref)) {
9190
+ this.#answer(frame.ref, reply);
9191
+ return;
9192
+ }
9193
+ }
9194
+ if (frame.topic === PHOENIX_TOPIC) {
9195
+ if (frame.event === "system") this.#said = asError(frame.payload);
9196
+ else if (frame.event === "phx_reply" && frame.ref === null)
9197
+ this.#refusedOverLimit(frame.payload);
9198
+ return;
9199
+ }
9200
+ safely(() => this.#config.events.frame(frame));
9201
+ }
9202
+ /**
9203
+ * The hub refused a frame over its size limit before reading it (final
9204
+ * review A M3, plan 정정 (m)): it names no `ref`, so no request waits for
9205
+ * it by name — it waited `timeoutMs` and answered TIMED_OUT. But the hub
9206
+ * refuses every frame over the limit, and only those, before anything
9207
+ * else: each request waiting whose frame is over it is one refused so, and
9208
+ * hears it now. The limit stays for the connection.
9209
+ */
9210
+ #refusedOverLimit(payload) {
9211
+ if (payload.status !== "error") return;
9212
+ const error = asError(payload.response);
9213
+ if (error?.code !== "INVALID_MESSAGE" || !isRecord(error.details)) return;
9214
+ const limit = frameLimitOf(error.details.reason);
9215
+ if (limit === null) return;
9216
+ this.#frameLimit = limit;
9217
+ for (const [ref, waiter] of this.#replies)
9218
+ if (waiter.bytes > limit)
9219
+ this.#answer(ref, {
9220
+ ok: false,
9221
+ error: frameTooLarge(limit),
9222
+ lost: false
9223
+ });
9224
+ }
9225
+ /**
9226
+ * `ws` ended: its close event (`waitedMs` null), or a heartbeat or an open
9227
+ * that had no answer in `waitedMs` — then it is let go at once, its
9228
+ * handlers first, since a dead TCP connection can take minutes to report
9229
+ * its close (결정 11).
9230
+ */
9231
+ #closed(ws, code, reason, waitedMs) {
9232
+ if (this.#ws !== ws) return;
9233
+ const wasOpen = this.#state === "open";
9234
+ const timedOut = waitedMs !== null;
9235
+ detach(ws);
9236
+ this.#ws = null;
9237
+ this.#clearTimers();
9238
+ if (timedOut) closeQuietly(ws, 1e3);
9239
+ if (!wasOpen) this.#unopened += 1;
9240
+ const said = this.#said;
9241
+ this.#said = null;
9242
+ if (!timedOut && code === 4008) this.#flooded = true;
9243
+ const action = timedOut ? { kind: "retry", minMs: 0 } : closeAction(code, said?.code ?? reason);
9244
+ if (action.kind === "stop")
9245
+ return this.#stop(said ?? realtimeStopped(code, reason));
9246
+ const error = timedOut ? realtimeTimedOut(waitedMs) : said ?? realtimeClosed(code);
9247
+ this.#failReplies(error);
9248
+ this.#state = "waiting";
9249
+ const delay = delayAfter(
9250
+ action,
9251
+ { attempt: this.#attempt, unopened: this.#unopened },
9252
+ Math.random
9253
+ );
9254
+ if (action.kind !== "restart" && action.kind !== "wait") this.#attempt += 1;
9255
+ this.#wait(action.kind === "session", delay);
9256
+ safely(
9257
+ () => this.#config.events.dropped(
9258
+ error,
9259
+ timedOut ? "TIMED_OUT" : "CHANNEL_ERROR"
9260
+ )
9261
+ );
9262
+ }
9263
+ /**
9264
+ * The next try, `delay` from now — after a 4401, from when its
9265
+ * conditional delete is done, or from `timeoutMs` on if it is not: an
9266
+ * app's `auth.storage` that never answers must not keep the connection
9267
+ * away for good.
9268
+ */
9269
+ #wait(session, delay) {
9270
+ const generation = this.#generation;
9271
+ this.#resumeAt = now() + delay;
9272
+ let due = false;
9273
+ const schedule = () => {
9274
+ if (due || this.#state !== "waiting" || this.#generation !== generation)
9275
+ return;
9276
+ due = true;
9277
+ this.#resumeAt = now() + delay;
9278
+ clearTimeout(this.#retryTimer);
9279
+ this.#retryTimer = setTimeout(() => {
9280
+ this.#retryTimer = void 0;
9281
+ if (this.#state === "waiting") this.#open();
9282
+ }, delay);
9283
+ };
9284
+ if (!session) return schedule();
9285
+ this.#retryTimer = setTimeout(schedule, this.#timeoutMs);
9286
+ safely(() => {
9287
+ void this.#config.events.sessionRefused().then(schedule, schedule);
9288
+ });
9289
+ }
9290
+ /**
9291
+ * Stopped until a connect(), which waits as after a drop: the stop counts
9292
+ * as one (`unopened` when no socket was made — the runtime cannot open).
9293
+ */
9294
+ #stop(error, unopened = false) {
9295
+ this.#clearTimers();
9296
+ this.#state = "stopped";
9297
+ this.#failReplies(error);
9298
+ if (unopened) this.#unopened += 1;
9299
+ this.#resumeAt = now() + delayAfter(
9300
+ { kind: "retry", minMs: 0 },
9301
+ { attempt: this.#attempt, unopened: this.#unopened },
9302
+ Math.random
9303
+ );
9304
+ this.#attempt += 1;
9305
+ safely(() => this.#config.events.stopped(error));
9306
+ }
9307
+ /** Every request still waiting hears `error`, as lost with the connection. */
9308
+ #failReplies(error) {
9309
+ const waiting = [...this.#replies.values()];
9310
+ this.#replies.clear();
9311
+ for (const waiter of waiting) {
9312
+ clearTimeout(waiter.timer);
9313
+ waiter.resolve({ ok: false, error, lost: true });
9314
+ }
9315
+ }
9316
+ /** Every timer of the connection, and the frames still to go with it. */
9317
+ #clearTimers() {
9318
+ clearTimeout(this.#retryTimer);
9319
+ clearTimeout(this.#openTimer);
9320
+ clearInterval(this.#heartbeat);
9321
+ clearTimeout(this.#paceTimer);
9322
+ clearTimeout(this.#roomTimer);
9323
+ this.#retryTimer = void 0;
9324
+ this.#openTimer = void 0;
9325
+ this.#heartbeat = void 0;
9326
+ this.#paceTimer = void 0;
9327
+ this.#roomTimer = void 0;
9328
+ this.#queue = [];
9329
+ this.#ahead = 0;
9330
+ this.#appQueued = 0;
9331
+ this.#roomWaiters.clear();
9332
+ }
9333
+ };
9334
+ function sent(reply) {
9335
+ return reply.ok ? { data: null, error: null, lost: false } : { data: null, error: reply.error, lost: reply.lost };
9336
+ }
9337
+ function unsent(error) {
9338
+ return { data: null, error, lost: false };
9339
+ }
9340
+ function jsonObject(value) {
9341
+ try {
9342
+ const copy = JSON.parse(JSON.stringify(value));
9343
+ return isRecord(copy) ? copy : null;
9344
+ } catch {
9345
+ return null;
9346
+ }
9347
+ }
9348
+ function presenceReadDenied(response) {
9349
+ const { access } = response;
9350
+ return isRecord(access) && isRecord(access.presence) && access.presence.read === false;
9351
+ }
9352
+ function tablesOf(changes) {
9353
+ return changes.map((binding) => binding.filter.table ?? "*");
9354
+ }
9355
+ var JOIN_RETRY_CODES = /* @__PURE__ */ new Set([
9356
+ "TOO_MANY_JOINS",
9357
+ "INTERNAL_ERROR",
9358
+ "TIMED_OUT"
9359
+ ]);
9360
+ var RETRACK_RETRY_CODES = /* @__PURE__ */ new Set([
9361
+ "TOO_MANY_PRESENCE_MESSAGES",
9362
+ "TOO_MANY_MESSAGES",
9363
+ "TOO_MANY_JOINS",
9364
+ "INTERNAL_ERROR",
9365
+ "TIMED_OUT"
9366
+ ]);
9367
+ var RETRACK_WARNED_RESENDS = 3;
9368
+ var LATE_LISTENER = "naro-js: postgres_changes and presence listeners added after subscribe() count from the channel's next join; call unsubscribe() and subscribe() again to add them now.";
9369
+ function isDevelopment() {
9370
+ const env = globalThis.process?.env;
9371
+ return env?.NODE_ENV !== "production";
9372
+ }
9373
+ function warnInDevelopment(message) {
9374
+ if (isDevelopment()) console.warn(message);
9375
+ }
9376
+ function isJson(value) {
9377
+ try {
9378
+ JSON.stringify(value);
9379
+ return true;
9380
+ } catch {
9381
+ return false;
9382
+ }
9383
+ }
9384
+ function changeSpec(filter) {
9385
+ const {
9386
+ event,
9387
+ schema,
9388
+ table: table3,
9389
+ filter: text,
9390
+ ...rest
9391
+ } = filter;
9392
+ return {
9393
+ ...rest,
9394
+ event,
9395
+ ...schema === void 0 ? {} : { schema },
9396
+ ...table3 === void 0 ? {} : { table: table3 },
9397
+ ...text === void 0 || text === "" ? {} : { filter: text }
9398
+ };
9399
+ }
9400
+ function changePayload(data) {
9401
+ return {
9402
+ schema: "public",
9403
+ table: typeof data.table === "string" ? data.table : "",
9404
+ commit_timestamp: typeof data.commit_timestamp === "string" ? data.commit_timestamp : "",
9405
+ eventType: data.type,
9406
+ new: isRecord(data.record) ? data.record : {},
9407
+ old: isRecord(data.old_record) ? data.old_record : {},
9408
+ errors: Array.isArray(data.errors) ? data.errors.filter((item) => typeof item === "string") : null
9409
+ };
9410
+ }
9411
+ function systemMessage(payload) {
9412
+ const { extension, code, message, channel } = payload;
9413
+ return {
9414
+ status: payload.status === "ok" ? "ok" : "error",
9415
+ extension: typeof extension === "string" ? extension : "system",
9416
+ code: typeof code === "string" ? code : "",
9417
+ message: typeof message === "string" ? message : "",
9418
+ channel: typeof channel === "string" ? channel : null
9419
+ };
9420
+ }
9421
+ var RealtimeChannel = class {
9422
+ name;
9423
+ topic;
9424
+ isPrivate;
9425
+ options;
9426
+ #context;
9427
+ #bindings = [];
9428
+ #presence = new Presence();
9429
+ #state = "closed";
9430
+ /** subscribe() was called and unsubscribe() was not: join it on every connection. */
9431
+ #wanted = false;
9432
+ /** The join sent and not left or replaced since — set when it is sent, before its answer. */
9433
+ #joinRef = null;
9434
+ /** The tables that join's row subscriptions are on (`*` for every table): its leave holds them. */
9435
+ #joinTables = [];
9436
+ /**
9437
+ * The join was refused a cap a leave owed on the connection still holds
9438
+ * a place under (`ChannelContext.owesLeaveOn`): it waits for that leave
9439
+ * rather than knock again (PR #328 review). Null otherwise.
9440
+ */
9441
+ #capWait = null;
9442
+ #onStatus = null;
9443
+ /** The app already heard of this trouble; reset at SUBSCRIBED. */
9444
+ #told = false;
9445
+ #attempt = 0;
9446
+ #retry;
9447
+ /** The `system` error sent before a `phx_close` (protocol.ts `closeChannel`). */
9448
+ #closeError = null;
9449
+ /** The last state tracked, and the token the hub had then (결정 17). */
9450
+ #tracked = null;
9451
+ /** Bumped when what is tracked is let go of: a track answered after that does not bring it back. */
9452
+ #letGo = 0;
9453
+ /** Bumped by the app's track() and untrack(): one in the SUBSCRIBED callback says what the join holds. */
9454
+ #presenceCalls = 0;
9455
+ /** A track sent again and refused for now goes again when this is due (`#retrack`). */
9456
+ #retrackTimer;
9457
+ /**
9458
+ * The app's presence calls and broadcasts waiting for the connection to
9459
+ * be live, in the order it made them (`#inTurn`): each sends, or answers
9460
+ * that its join went, when called.
9461
+ */
9462
+ #waiting = [];
9463
+ /** Inside subscribe(): a stop is told a microtask later (`#toldStop`). */
9464
+ #subscribing = false;
9465
+ /** Who waits for room on the connection (`whenRoom()`): this channel's join, and its track sent again. */
9466
+ #joinTurn = {};
9467
+ #retrackTurn = {};
9468
+ /**
9469
+ * Frames of the join in flight that came before its answer was handled
9470
+ * (final review A M1, plan 정정 (m)): Node's `ws` and undici hand over
9471
+ * the frames of one read in one callback, while the answer's handler runs
9472
+ * a microtask later — a change right behind the `ok` had no subscription
9473
+ * id yet. They are heard once the `ok` is (after SUBSCRIBED, as in a
9474
+ * browser, which runs a task per message), and dropped with a refusal.
9475
+ */
9476
+ #early = [];
9477
+ /** @internal `naro.channel()` makes channels. */
9478
+ constructor(name, options, context) {
9479
+ this.name = name;
9480
+ this.topic = `${REALTIME_TOPIC_PREFIX}${name}`;
9481
+ this.isPrivate = options.config?.private === true;
9482
+ this.options = options;
9483
+ this.#context = context;
9484
+ }
9485
+ get state() {
9486
+ return this.#state;
9487
+ }
9488
+ /** @internal The join sent and not left or replaced since (client.ts routes by it). */
9489
+ get joinRef() {
9490
+ return this.#joinRef;
9491
+ }
9492
+ on(type, filter, callback) {
9493
+ const listener = callback;
9494
+ const spec = isRecord(filter) ? filter : {};
9495
+ if ((type === "postgres_changes" || type === "presence") && this.#wanted)
9496
+ warnInDevelopment(LATE_LISTENER);
9497
+ if (type === "postgres_changes")
9498
+ this.#bindings.push({
9499
+ type,
9500
+ filter: spec,
9501
+ callback: listener,
9502
+ id: null
9503
+ });
9504
+ else if (type === "broadcast" || type === "presence")
9505
+ this.#bindings.push({
9506
+ type,
9507
+ event: String(spec.event),
9508
+ callback: listener
9509
+ });
9510
+ else if (type === "system")
9511
+ this.#bindings.push({ type, callback: listener });
9512
+ return this;
9513
+ }
9514
+ subscribe(callback) {
9515
+ if (callback !== void 0) this.#onStatus = callback;
9516
+ if (this.#wanted) return this;
9517
+ const problem = this.#nameError();
9518
+ if (problem !== null) {
9519
+ this.#state = "errored";
9520
+ queueMicrotask(() => this.#status("CHANNEL_ERROR", problem));
9521
+ return this;
9522
+ }
9523
+ this.#wanted = true;
9524
+ this.#state = "joining";
9525
+ this.#attempt = 0;
9526
+ this.#told = false;
9527
+ this.#capWait = null;
9528
+ this.#clearRetry();
9529
+ this.#subscribing = true;
9530
+ try {
9531
+ this.#context.connect();
9532
+ this.#joinIfDue();
9533
+ } finally {
9534
+ this.#subscribing = false;
9535
+ }
9536
+ return this;
9537
+ }
9538
+ async unsubscribe() {
9539
+ const ref = this.#joinRef;
9540
+ const held = this.#state === "joined" || this.#state === "joining";
9541
+ this.#wanted = false;
9542
+ this.#clearRetry();
9543
+ this.#letGoOfTrack();
9544
+ this.#joinRef = null;
9545
+ this.#state = "leaving";
9546
+ this.#releaseWaiting();
9547
+ let error = null;
9548
+ if (held && ref !== null) {
9549
+ const reply = await this.#context.leave(this, ref, this.#joinTables);
9550
+ if (!reply.ok && !reply.lost && reply.error.code !== "NOT_JOINED")
9551
+ error = reply.error;
9552
+ }
9553
+ if (this.#state === "leaving") {
9554
+ this.#state = "closed";
9555
+ this.#presence.clear();
9556
+ this.#status("CLOSED");
9557
+ }
9558
+ return { data: null, error };
9559
+ }
9560
+ /**
9561
+ * Broadcasts `message` on the join, or over HTTP before it: the server's
9562
+ * answer, `lost` when the connection went before it (RealtimeSendResult).
9563
+ */
9564
+ async send(message) {
9565
+ if (!isRecord(message) || message.type !== "broadcast")
9566
+ return unsent(invalidBroadcast());
9567
+ const problem = this.#nameError();
9568
+ if (problem !== null) return unsent(problem);
9569
+ const { event, payload } = message;
9570
+ if (!isJson({ event, payload }))
9571
+ return unsent(invalidQuery("", "is not JSON"));
9572
+ const joinRef = this.#joinRef;
9573
+ if (this.#state === "joined" && joinRef !== null) {
9574
+ const reply = await this.#inTurn(
9575
+ joinRef,
9576
+ !this.#context.live(),
9577
+ () => this.#context.socket.request(
9578
+ this.topic,
9579
+ "broadcast",
9580
+ { type: "broadcast", event, payload },
9581
+ { joinRef }
9582
+ )
9583
+ );
9584
+ if (reply !== null) return sent(reply);
9585
+ }
9586
+ await this.#context.auth.ready();
9587
+ const result = await call(this.#context.http, {
9588
+ method: "POST",
9589
+ path: "/realtime/broadcast",
9590
+ body: {
9591
+ messages: [
9592
+ { topic: this.topic, event, payload, private: this.isPrivate }
9593
+ ]
9594
+ },
9595
+ withToken: true
9596
+ });
9597
+ return unsent(result.error);
9598
+ }
9599
+ /**
9600
+ * Tracks `state` as this connection's presence here (original spec
9601
+ * §11.1): the hub's answer. Only on a join — before it, NOT_JOINED, and
9602
+ * nothing is kept for later (결정 18). It goes as its JSON reads back, and
9603
+ * that copy, not the app's object, is what a later join sends again.
9604
+ * While who the connection is is not settled (a new token in flight, a
9605
+ * retry), it waits until it is (정정 (k)): the hub may yet refuse the new
9606
+ * token for now and keep the last user, whose presence it would be.
9607
+ */
9608
+ async track(state) {
9609
+ const problem = this.#nameError();
9610
+ if (problem !== null) return unsent(problem);
9611
+ const joinRef = this.#joinRef;
9612
+ if (this.#state !== "joined" || joinRef === null)
9613
+ return unsent(notJoined(this.name));
9614
+ const payload = jsonObject(state);
9615
+ if (payload === null) return unsent(invalidPresence());
9616
+ this.#presenceCalls += 1;
9617
+ this.#clearRetrack();
9618
+ const letGo = this.#letGo;
9619
+ const reply = await this.#inTurn(
9620
+ joinRef,
9621
+ !this.#context.live(),
9622
+ () => this.#sendTrack(payload, joinRef, letGo, true)
9623
+ );
9624
+ if (reply === null) {
9625
+ if (letGo === this.#letGo) this.#letGoOfTrack();
9626
+ return unsent(notJoined(this.name));
9627
+ }
9628
+ return sent(reply);
9629
+ }
9630
+ /**
9631
+ * Takes this connection's presence off the channel: the hub's answer, or
9632
+ * none when not joined. After the app's tracks that wait, if any.
9633
+ */
9634
+ async untrack() {
9635
+ this.#presenceCalls += 1;
9636
+ this.#letGoOfTrack();
9637
+ const joinRef = this.#joinRef;
9638
+ if (this.#state !== "joined" || joinRef === null) return unsent(null);
9639
+ const reply = await this.#inTurn(
9640
+ joinRef,
9641
+ false,
9642
+ () => this.#context.socket.request(
9643
+ this.topic,
9644
+ "presence",
9645
+ { type: "presence", event: "untrack" },
9646
+ { joinRef }
9647
+ )
9648
+ );
9649
+ return reply === null ? unsent(null) : sent(reply);
9650
+ }
9651
+ /** The presences of the channel, by key (결정 16): a copy. */
9652
+ presenceState() {
9653
+ return this.#presence.state();
9654
+ }
9655
+ /**
9656
+ * @internal
9657
+ * The connection is live, or a leave owed is done (client.ts): join if
9658
+ * the app wants it and the channel holds no join and waits for no retry.
9659
+ * Called whenever either happens, for every channel, so a channel joined
9660
+ * or joining stays as it is.
9661
+ */
9662
+ rejoin() {
9663
+ if (this.#context.live()) this.#releaseWaiting();
9664
+ this.#joinIfDue();
9665
+ }
9666
+ /**
9667
+ * @internal
9668
+ * A leave of this name and privacy owed on the connection was refused, or
9669
+ * not answered in time, and goes again (client.ts): a join waiting on it
9670
+ * tells the app once a wait, as a refused join tried again does (결정 14,
9671
+ * 정정 (j)), and waits on.
9672
+ */
9673
+ leaveRefused(error) {
9674
+ if (this.#wanted && this.#joinRef === null)
9675
+ this.#tell(
9676
+ error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
9677
+ error
9678
+ );
9679
+ }
9680
+ /** @internal The connection dropped and comes back: tell the app once, rejoin when live. */
9681
+ dropped(error, status) {
9682
+ if (!this.#wanted) return;
9683
+ this.#clearRetry();
9684
+ this.#capWait = null;
9685
+ this.#joinRef = null;
9686
+ this.#state = "joining";
9687
+ this.#releaseWaiting();
9688
+ this.#presence.unsync();
9689
+ this.#tell(status, error);
9690
+ }
9691
+ /** @internal The connection stopped for good: the app must subscribe() again. */
9692
+ stopped(error) {
9693
+ if (this.#wanted) this.#stop(error);
9694
+ }
9695
+ /** @internal A frame of this channel's join (client.ts routes by join_ref). */
9696
+ receive(frame) {
9697
+ if (this.#state === "joining") {
9698
+ this.#early.push(frame);
9699
+ return;
9700
+ }
9701
+ switch (frame.event) {
9702
+ case "postgres_changes":
9703
+ return this.#change(frame.payload);
9704
+ case "broadcast":
9705
+ return this.#broadcast(frame.payload);
9706
+ case "presence_state":
9707
+ return this.#presence.applyState(frame.payload, this.#presenceEmit);
9708
+ case "presence_diff":
9709
+ return this.#presence.applyDiff(frame.payload, this.#presenceEmit);
9710
+ case "system":
9711
+ return this.#system(frame.payload);
9712
+ case "phx_close":
9713
+ return this.#serverClosed();
9714
+ default:
9715
+ return;
9716
+ }
9717
+ }
9718
+ /**
9719
+ * INVALID_CHANNEL for a name the hub would refuse, before anything is
9720
+ * sent — and before any error of ours quotes the name.
9721
+ */
9722
+ #nameError() {
9723
+ const problem = channelNameProblem(this.name);
9724
+ return problem === void 0 ? null : invalidChannel(problem);
9725
+ }
9726
+ #config(changes) {
9727
+ const config = this.options.config ?? {};
9728
+ const presence = config.presence ?? {};
9729
+ const key = presence.key ?? "";
9730
+ const listens = this.#bindings.some(
9731
+ (binding) => binding.type === "presence"
9732
+ );
9733
+ return {
9734
+ ...config,
9735
+ // ack is always on the wire, so send() always ends in the hub's answer (결정 6).
9736
+ broadcast: {
9737
+ ...config.broadcast,
9738
+ self: config.broadcast?.self ?? false,
9739
+ ack: true
9740
+ },
9741
+ presence: {
9742
+ ...presence,
9743
+ key,
9744
+ enabled: listens || presence.enabled === true || key !== ""
9745
+ },
9746
+ postgres_changes: changes.map((binding) => changeSpec(binding.filter)),
9747
+ private: this.isPrivate
9748
+ };
9749
+ }
9750
+ /**
9751
+ * Joins when due — once there is room on the connection for a frame the
9752
+ * SDK sends of its own accord (`whenRoom()`, PR #328 review), and still
9753
+ * due then.
9754
+ */
9755
+ #joinIfDue() {
9756
+ if (!this.#dueToJoin()) return;
9757
+ this.#context.socket.whenRoom(this.#joinTurn, () => {
9758
+ if (this.#dueToJoin()) this.#join();
9759
+ });
9760
+ }
9761
+ #dueToJoin() {
9762
+ return this.#wanted && this.#joinRef === null && this.#retry === void 0 && this.#context.live() && !this.#context.owesLeave(this) && !this.#heldByCap();
9763
+ }
9764
+ /** A cap refused the last join, and a leave owed still holds the place it needs. */
9765
+ #heldByCap() {
9766
+ const wait = this.#capWait;
9767
+ if (wait === null) return false;
9768
+ if (this.#context.owesLeaveOn(wait.table)) return true;
9769
+ this.#capWait = null;
9770
+ return false;
9771
+ }
9772
+ /**
9773
+ * The cap `error` names, when a leave owed on the connection holds a place
9774
+ * under it (PR #328 review): the hub still counts that join — 100
9775
+ * channels a connection (TOO_MANY_CHANNELS), or 4 on one table's changes
9776
+ * (INVALID_CHANNEL_CONFIG with the hub's `changeChannels` reason at
9777
+ * `config.postgres_changes[<i>].table`, the table of `changes[i]`) — and
9778
+ * lets go of it once the leave goes through. Null for any other refusal.
9779
+ */
9780
+ #capHeld(error, changes) {
9781
+ if (error.code === "TOO_MANY_CHANNELS")
9782
+ return this.#context.owesLeaveOn(null) ? { table: null } : null;
9783
+ if (error.code !== "INVALID_CHANNEL_CONFIG" || !isRecord(error.details))
9784
+ return null;
9785
+ const { path, reason } = error.details;
9786
+ if (reason !== CHANGE_CHANNELS_REASON || typeof path !== "string")
9787
+ return null;
9788
+ const index = /^config\.postgres_changes\[(\d+)\]\.table$/.exec(path)?.[1];
9789
+ const binding = index === void 0 ? void 0 : changes[Number(index)];
9790
+ if (binding === void 0) return null;
9791
+ const table3 = binding.filter.table ?? "*";
9792
+ return this.#context.owesLeaveOn(table3) ? { table: table3 } : null;
9793
+ }
9794
+ #join() {
9795
+ const changes = this.#bindings.filter(
9796
+ (b) => b.type === "postgres_changes"
9797
+ );
9798
+ const config = this.#config(changes);
9799
+ if (!isJson(config))
9800
+ return this.#stop(invalidChannelConfig("config", "is not JSON"));
9801
+ const socket = this.#context.socket;
9802
+ const ref = socket.nextRef();
9803
+ this.#joinRef = ref;
9804
+ this.#joinTables = tablesOf(changes);
9805
+ this.#capWait = null;
9806
+ this.#state = "joining";
9807
+ this.#closeError = null;
9808
+ this.#early = [];
9809
+ this.#presence.unsync();
9810
+ for (const binding of changes) binding.id = null;
9811
+ void socket.request(
9812
+ this.topic,
9813
+ "phx_join",
9814
+ { config },
9815
+ { joinRef: ref, ref, turn: "sdk" }
9816
+ ).then((reply) => this.#joined(ref, changes, reply));
9817
+ }
9818
+ #joined(ref, changes, reply) {
9819
+ if (this.#joinRef !== ref) return;
9820
+ const early = this.#early;
9821
+ this.#early = [];
9822
+ if (reply.ok) {
9823
+ const ids = Array.isArray(reply.response.postgres_changes) ? reply.response.postgres_changes : [];
9824
+ changes.forEach((binding, index) => {
9825
+ const entry = ids[index];
9826
+ binding.id = isRecord(entry) && typeof entry.id === "number" ? entry.id : null;
9827
+ });
9828
+ this.#state = "joined";
9829
+ this.#attempt = 0;
9830
+ this.#told = false;
9831
+ this.#context.socket.served();
9832
+ const calls = this.#presenceCalls;
9833
+ this.#status("SUBSCRIBED");
9834
+ if (this.#presenceCalls === calls) this.#retrack(ref);
9835
+ if (this.#joinRef === ref && presenceReadDenied(reply.response))
9836
+ this.#presence.leaveAll(this.#presenceEmit);
9837
+ for (const frame of early) {
9838
+ if (this.#joinRef !== ref || this.#state !== "joined") break;
9839
+ this.receive(frame);
9840
+ }
9841
+ return;
9842
+ }
9843
+ if (reply.lost) {
9844
+ this.#joinRef = null;
9845
+ return;
9846
+ }
9847
+ const { error } = reply;
9848
+ this.#joinRef = null;
9849
+ const capHeld = this.#capHeld(error, changes);
9850
+ if (capHeld !== null) {
9851
+ this.#capWait = capHeld;
9852
+ this.#state = "joining";
9853
+ this.#tell("CHANNEL_ERROR", error);
9854
+ return;
9855
+ }
9856
+ const meetsOld = error.code === "ALREADY_JOINED" && this.#context.owesLeave(this);
9857
+ if (!JOIN_RETRY_CODES.has(error.code) && !meetsOld) {
9858
+ this.#wanted = false;
9859
+ this.#state = "errored";
9860
+ this.#presence.leaveAll(this.#presenceEmit);
9861
+ this.#status("CHANNEL_ERROR", error);
9862
+ return;
9863
+ }
9864
+ if (error.code === "TIMED_OUT")
9865
+ void this.#context.leave(this, ref, tablesOf(changes));
9866
+ this.#tell(
9867
+ error.code === "TIMED_OUT" ? "TIMED_OUT" : "CHANNEL_ERROR",
9868
+ error
9869
+ );
9870
+ this.#state = "joining";
9871
+ const delay = backoffMs(this.#attempt, Math.random);
9872
+ this.#attempt += 1;
9873
+ this.#retry = setTimeout(() => {
9874
+ this.#retry = void 0;
9875
+ this.#joinIfDue();
9876
+ }, delay);
9877
+ }
9878
+ #change(payload) {
9879
+ const ids = Array.isArray(payload.ids) ? payload.ids : [];
9880
+ if (!isRecord(payload.data)) return;
9881
+ const change2 = changePayload(payload.data);
9882
+ for (const binding of this.#bindings)
9883
+ if (binding.type === "postgres_changes" && binding.id !== null && ids.includes(binding.id))
9884
+ safely(() => binding.callback(change2));
9885
+ }
9886
+ #broadcast(payload) {
9887
+ const { event } = payload;
9888
+ if (typeof event !== "string") return;
9889
+ const message = {
9890
+ type: "broadcast",
9891
+ event,
9892
+ payload: isRecord(payload.payload) ? payload.payload : {}
9893
+ };
9894
+ for (const binding of this.#bindings)
9895
+ if (binding.type === "broadcast" && binding.event === event)
9896
+ safely(() => binding.callback(message));
9897
+ }
9898
+ #system(payload) {
9899
+ const message = systemMessage(payload);
9900
+ if (message.status === "error" && message.extension === "system")
9901
+ this.#closeError = asError(payload);
9902
+ for (const binding of this.#bindings)
9903
+ if (binding.type === "system") safely(() => binding.callback(message));
9904
+ }
9905
+ /** The hub closed this join (lost access, its state full): stop until subscribe(). */
9906
+ #serverClosed() {
9907
+ this.#clearRetry();
9908
+ this.#joinRef = null;
9909
+ this.#wanted = false;
9910
+ this.#state = "errored";
9911
+ this.#letGoOfTrack();
9912
+ this.#releaseWaiting();
9913
+ this.#presence.leaveAll(this.#presenceEmit);
9914
+ this.#status("CHANNEL_ERROR", this.#closeError ?? channelClosed());
9915
+ }
9916
+ /** The listeners' payloads, as supabase-js's: each says which event it is (정정 (k)). */
9917
+ #presenceEmit = {
9918
+ join: (key, newPresences, currentPresences) => {
9919
+ const payload = {
9920
+ event: "join",
9921
+ key,
9922
+ newPresences,
9923
+ currentPresences
9924
+ };
9925
+ this.#presenceCall("join", payload);
9926
+ },
9927
+ leave: (key, leftPresences, currentPresences) => {
9928
+ const payload = {
9929
+ event: "leave",
9930
+ key,
9931
+ leftPresences,
9932
+ currentPresences
9933
+ };
9934
+ this.#presenceCall("leave", payload);
9935
+ },
9936
+ sync: () => this.#presenceCall("sync", void 0)
9937
+ };
9938
+ #presenceCall(event, value) {
9939
+ for (const binding of this.#bindings)
9940
+ if (binding.type === "presence" && binding.event === event)
9941
+ safely(() => binding.callback(value));
9942
+ }
9943
+ /**
9944
+ * One track frame on the join `joinRef`. Its `ok` makes `state` the one
9945
+ * tracked, with the token the hub judged it for — the one it holds once
9946
+ * the identity frames sent before it are handled (`heldToken()`, 정정 (j))
9947
+ * — unless what was tracked was let go of since `letGo` (the app's call,
9948
+ * or now): the hub had that untrack or leave after it. The app's own call
9949
+ * (`app`) is kept as well when no answer came — the connection went with
9950
+ * it, or none in time: the hub may hold it, and it is the state the app
9951
+ * last asked for, so the next join sends it rather than the one before
9952
+ * (PR #328 review). The app's call waits its turn behind the app's frames,
9953
+ * and may be refused for it (CLIENT_FLOODING, nothing kept); one sent
9954
+ * again goes in the SDK's (PR #328 re-review).
9955
+ */
9956
+ #sendTrack(state, joinRef, letGo = this.#letGo, app = false) {
9957
+ const token = this.#context.identity.heldToken();
9958
+ return this.#context.socket.request(
9959
+ this.topic,
9960
+ "presence",
9961
+ { type: "presence", event: "track", payload: state },
9962
+ { joinRef, turn: app ? "app" : "sdk" }
9963
+ ).then((reply) => {
9964
+ const kept = reply.ok || app && (reply.lost || reply.error.code === "TIMED_OUT");
9965
+ if (kept && letGo === this.#letGo) this.#tracked = { state, token };
9966
+ return reply;
9967
+ });
9968
+ }
9969
+ /**
9970
+ * After a join comes back, the last state goes again (original spec §11.1)
9971
+ * — only as the same session: the hub drops a presence when its user
9972
+ * changes (3-2a P10), and a new token here may be someone else (결정 17).
9973
+ * Who the hub reads it as is the token it will hold when the frame comes
9974
+ * (`heldToken()`): a sign-in whose token went after the join would make
9975
+ * the last user's state the new one's. And while who the connection is
9976
+ * is not settled (a store read, a retry), it is let go of (정정 (j)).
9977
+ * A refusal for now (`RETRACK_RETRY_CODES`) sends it again on this join
9978
+ * after the channel backoff, checked as above each time, until the app
9979
+ * tracks or untracks, the channel leaves, or the join goes — after one
9980
+ * the hub warned the connection for, three times at most, and then it
9981
+ * waits for the next join (`WARNED_CODES`); any other refusal lets go of
9982
+ * it; a connection lost before the answer keeps it for the next join.
9983
+ * Each send, the first too, waits its turn for room in the connection's
9984
+ * warnings (`whenRoom()`, PR #328 review).
9985
+ */
9986
+ #retrack(joinRef) {
9987
+ const tracked = this.#tracked;
9988
+ if (tracked === null) return;
9989
+ const calls = this.#presenceCalls;
9990
+ const current = () => this.#tracked === tracked && this.#joinRef === joinRef && this.#presenceCalls === calls;
9991
+ const go = (attempt22, warned) => {
9992
+ if (!current()) return;
9993
+ this.#context.socket.whenRoom(this.#retrackTurn, () => {
9994
+ send2(attempt22, warned);
9995
+ });
9996
+ };
9997
+ const send2 = (attempt22, warned) => {
9998
+ if (!current()) return;
9999
+ if (!this.#context.live() || tracked.token !== this.#context.identity.heldToken())
10000
+ return this.#letGoOfTrack();
10001
+ void this.#sendTrack(tracked.state, joinRef).then((reply) => {
10002
+ if (reply.ok || reply.lost || this.#tracked !== tracked) return;
10003
+ const { code } = reply.error;
10004
+ if (!RETRACK_RETRY_CODES.has(code)) {
10005
+ this.#tracked = null;
10006
+ return;
10007
+ }
10008
+ if (!current()) return;
10009
+ const warning = WARNED_CODES.has(code) ? 1 : 0;
10010
+ if (warned + warning > RETRACK_WARNED_RESENDS) return;
10011
+ const delay = backoffMs(attempt22, Math.random);
10012
+ this.#retrackTimer = setTimeout(() => {
10013
+ this.#retrackTimer = void 0;
10014
+ go(attempt22 + 1, warned + warning);
10015
+ }, delay);
10016
+ });
10017
+ };
10018
+ go(0, 0);
10019
+ }
10020
+ /**
10021
+ * `send` now — or, when `wait` (a track or a broadcast while the
10022
+ * connection is not live) or an earlier call of the app's waits, at
10023
+ * `#releaseWaiting()`, in the order the app made them: the hub must have
10024
+ * them in that order. Null when the join went before its turn: nothing
10025
+ * was sent.
10026
+ */
10027
+ #inTurn(joinRef, wait, send2) {
10028
+ if (!wait && this.#waiting.length === 0) return send2();
10029
+ return new Promise((resolve7) => {
10030
+ this.#waiting.push(
10031
+ () => resolve7(this.#joinRef === joinRef ? send2() : null)
10032
+ );
10033
+ });
10034
+ }
10035
+ /**
10036
+ * The calls that waited go now, in order and all in this turn — a call
10037
+ * made after them must not go first. Called when the connection is live
10038
+ * again, and when the join goes (they answer that it went).
10039
+ */
10040
+ #releaseWaiting() {
10041
+ const waiting = this.#waiting;
10042
+ this.#waiting = [];
10043
+ for (const go of waiting) go();
10044
+ }
10045
+ /** Nothing is tracked from now on, whatever answer comes for a track sent before. */
10046
+ #letGoOfTrack() {
10047
+ this.#tracked = null;
10048
+ this.#letGo += 1;
10049
+ this.#clearRetrack();
10050
+ }
10051
+ #clearRetrack() {
10052
+ clearTimeout(this.#retrackTimer);
10053
+ this.#retrackTimer = void 0;
10054
+ }
10055
+ /**
10056
+ * Stops until subscribe(): no join, no retry, and the app hears `error`.
10057
+ * No presence_state comes until then, so what the app saw goes, as for a
10058
+ * join refused or the server's close (정정 (k)); what was tracked stays,
10059
+ * for a join of the same session after subscribe().
10060
+ */
10061
+ #stop(error) {
10062
+ this.#clearRetry();
10063
+ this.#joinRef = null;
10064
+ this.#wanted = false;
10065
+ this.#state = "errored";
10066
+ this.#releaseWaiting();
10067
+ this.#presence.leaveAll(this.#presenceEmit);
10068
+ this.#toldStop(error);
10069
+ }
10070
+ /**
10071
+ * CHANNEL_ERROR for a stop. Inside subscribe() it comes a microtask later,
10072
+ * as for an invalid name: in `const ch = naro.channel(…).subscribe(cb)`,
10073
+ * `cb` may read `ch`, which is not set until subscribe() returns.
10074
+ */
10075
+ #toldStop(error) {
10076
+ if (this.#subscribing)
10077
+ queueMicrotask(() => this.#status("CHANNEL_ERROR", error));
10078
+ else this.#status("CHANNEL_ERROR", error);
10079
+ }
10080
+ #tell(status, error) {
10081
+ if (this.#told) return;
10082
+ this.#told = true;
10083
+ this.#status(status, error);
10084
+ }
10085
+ #status(status, error) {
10086
+ const callback = this.#onStatus;
10087
+ if (callback !== null) safely(() => callback(status, error));
10088
+ }
10089
+ /** No join retry waits — nor a track sent again on a join that is going. */
10090
+ #clearRetry() {
10091
+ clearTimeout(this.#retry);
10092
+ this.#retry = void 0;
10093
+ this.#clearRetrack();
10094
+ }
10095
+ };
10096
+ var RETRY_CODES = /* @__PURE__ */ new Set([
10097
+ "TOO_MANY_JOINS",
10098
+ "TIMED_OUT",
10099
+ "INTERNAL_ERROR"
10100
+ ]);
10101
+ function rejudgeWaitMs(privateChannels) {
10102
+ return Math.max(1, Math.ceil(privateChannels / 2)) * 1e3;
10103
+ }
10104
+ var Identity = class {
10105
+ #config;
10106
+ /** The token the hub answered `ok` for on this connection; null is anon. */
10107
+ #confirmed = null;
10108
+ /**
10109
+ * The last frame sent, until its own reply; null when none is in flight.
10110
+ * Matched by the object, not the token: an older frame of the same token
10111
+ * answered meanwhile is not this one.
10112
+ */
10113
+ #inFlight = null;
10114
+ /**
10115
+ * The frame the hub answered INVALID_SESSION: the 4401 that follows is
10116
+ * about its token. An object, since that token is null when the frame was
10117
+ * a sign-out — a refusal still, and not "nothing refused".
10118
+ */
10119
+ #refused = null;
10120
+ /**
10121
+ * A frame got no answer in time (TIMED_OUT), and no `ok` came since: the
10122
+ * hub may have taken it late, so what it holds is not known, and a read
10123
+ * with no frame in flight tells the stored token even when it is
10124
+ * `heldToken()` (정정 (k)). An `ok` says what it holds — replies come in
10125
+ * the order the frames went, so it is about a later frame.
10126
+ */
10127
+ #unsure = false;
10128
+ /** Bumped when a connection ends: a reply about an earlier one changes nothing. */
10129
+ #epoch = 0;
10130
+ #attempt = 0;
10131
+ #retry;
10132
+ /** The socket's generation opened() was called for; null once it ended. */
10133
+ #connection = null;
10134
+ /** changed() calls of this connection still reading the store. */
10135
+ #reading = 0;
10136
+ /** onLive was called, and the connection has been live since. */
10137
+ #announced = false;
10138
+ constructor(config) {
10139
+ this.#config = config;
10140
+ }
10141
+ /** The token the hub took on this connection; null for anon or before its answer. */
10142
+ confirmed() {
10143
+ return this.#confirmed;
10144
+ }
10145
+ /**
10146
+ * The token the hub will hold once what was sent is handled — the last
10147
+ * frame in flight, or else the one it took: the hub handles the
10148
+ * connection's frames in order, so a frame sent now is judged for it.
10149
+ * A frame in flight may yet be refused; the hub then keeps `confirmed()`.
10150
+ */
10151
+ heldToken() {
10152
+ return this.#inFlight === null ? this.#confirmed : this.#inFlight.token;
10153
+ }
10154
+ /**
10155
+ * Nothing about who the connection is is pending — no read, no frame in
10156
+ * flight, no retry, no refusal: a join sent now is judged for the identity
10157
+ * the hub settled on, the stored one unless it refused that for good on
10158
+ * this connection (결정 24).
10159
+ */
10160
+ isLive() {
10161
+ const socket = this.#config.socket();
10162
+ return this.#connection === socket.generation && socket.isOpen() && this.#reading === 0 && this.#inFlight === null && this.#retry === void 0 && this.#refused === null;
10163
+ }
10164
+ /**
10165
+ * A new connection: anon until told. Resolves once the stored token is
10166
+ * sent, or found to need no frame; `onLive` says when the hub took it.
10167
+ */
10168
+ async opened() {
10169
+ this.closed();
10170
+ this.#connection = this.#config.socket().generation;
10171
+ await this.changed();
10172
+ }
10173
+ /** The stored token may have changed: tell the open connection what is new. */
10174
+ async changed() {
10175
+ if (this.#config.service) return this.#settle();
10176
+ const epoch = this.#epoch;
10177
+ this.#reading += 1;
10178
+ this.#announced = false;
10179
+ try {
10180
+ await this.#config.ready();
10181
+ } catch {
10182
+ }
10183
+ const token = await this.#config.tokens.get();
10184
+ if (epoch === this.#epoch) this.#reading -= 1;
10185
+ if (this.#config.socket().isOpen()) {
10186
+ const unknown = this.#unsure && this.#inFlight === null;
10187
+ if (unknown || token !== this.heldToken()) this.#send(token);
10188
+ }
10189
+ this.#settle();
10190
+ }
10191
+ /**
10192
+ * A 4401: forget the stored session only if the hub answered
10193
+ * INVALID_SESSION to it by name, and it is still stored — a sign-in since
10194
+ * then keeps its new token (결정 3). A 4401 with no such answer clears
10195
+ * nothing (plan 2026-10-28 정정): the hub keeps the expiry it read when it
10196
+ * took the token (known-issues #183) while the backend's sign-in slides
10197
+ * the session's under the same token, so a tab open for days is closed
10198
+ * with a session that is still good. The next connection tells the token
10199
+ * again, and hears INVALID_SESSION — and clears it — if it really is
10200
+ * dead. The access JWT goes with the session it was minted for, so only
10201
+ * when that session went.
10202
+ */
10203
+ async refused() {
10204
+ const epoch = this.#epoch;
10205
+ const early = this.#refused;
10206
+ await Promise.resolve();
10207
+ const refusal = this.#epoch === epoch ? this.#refused : early;
10208
+ this.closed();
10209
+ if (refusal === null || refusal.token === null) return;
10210
+ if (await this.#config.tokens.clearIf(refusal.token))
10211
+ await this.#config.accessJwt?.clear();
10212
+ }
10213
+ /** The connection is gone: what it was told is forgotten. */
10214
+ closed() {
10215
+ clearTimeout(this.#retry);
10216
+ this.#retry = void 0;
10217
+ this.#epoch += 1;
10218
+ this.#confirmed = null;
10219
+ this.#inFlight = null;
10220
+ this.#refused = null;
10221
+ this.#unsure = false;
10222
+ this.#attempt = 0;
10223
+ this.#connection = null;
10224
+ this.#reading = 0;
10225
+ this.#announced = false;
10226
+ }
10227
+ /** Live, and not said yet since it last was not: say it. */
10228
+ #settle() {
10229
+ if (this.#announced || !this.isLive()) return;
10230
+ this.#announced = true;
10231
+ safely(() => this.#config.onLive());
10232
+ }
10233
+ #send(token) {
10234
+ clearTimeout(this.#retry);
10235
+ this.#retry = void 0;
10236
+ const sent2 = { token };
10237
+ this.#inFlight = sent2;
10238
+ const epoch = this.#epoch;
10239
+ void this.#config.socket().request(
10240
+ PHOENIX_TOPIC,
10241
+ "access_token",
10242
+ { access_token: token },
10243
+ { turn: "identity" }
10244
+ ).then((reply) => {
10245
+ if (epoch === this.#epoch) this.#heard(sent2, reply);
10246
+ });
10247
+ }
10248
+ #heard(sent2, reply) {
10249
+ if (this.#inFlight === sent2) this.#inFlight = null;
10250
+ if (reply.ok) {
10251
+ this.#confirmed = sent2.token;
10252
+ this.#unsure = false;
10253
+ this.#attempt = 0;
10254
+ return this.#settle();
10255
+ }
10256
+ if (reply.lost) return;
10257
+ if (reply.error.code === "TIMED_OUT") this.#unsure = true;
10258
+ if (reply.error.code === "INVALID_SESSION") {
10259
+ this.#refused = sent2;
10260
+ return;
10261
+ }
10262
+ if (this.#inFlight !== null) return;
10263
+ if (!RETRY_CODES.has(reply.error.code)) {
10264
+ if (this.#confirmed !== null && sent2.token !== this.#confirmed) {
10265
+ this.closed();
10266
+ this.#config.socket().reconnect();
10267
+ return;
10268
+ }
10269
+ return this.#settle();
10270
+ }
10271
+ this.#later(
10272
+ reply.error.code === "TOO_MANY_JOINS" ? rejudgeWaitMs(this.#config.privateChannels()) : 0
10273
+ );
10274
+ }
10275
+ /**
10276
+ * Reads the store again after the backoff (at least `minMs`), once there
10277
+ * is room on the connection for a frame the SDK sends of its own accord
10278
+ * (`whenRoom()`, PR #328 review). `#retry` stays set until then: the
10279
+ * connection is not live while it waits.
10280
+ */
10281
+ #later(minMs) {
10282
+ clearTimeout(this.#retry);
10283
+ const delay = Math.max(minMs, backoffMs(this.#attempt, Math.random));
10284
+ this.#attempt += 1;
10285
+ const epoch = this.#epoch;
10286
+ this.#retry = setTimeout(() => {
10287
+ this.#config.socket().whenRoom(this, () => {
10288
+ if (epoch !== this.#epoch) return;
10289
+ this.#retry = void 0;
10290
+ void this.changed();
10291
+ });
10292
+ }, delay);
10293
+ }
10294
+ };
10295
+ var LEAVE_RETRY_CODES = /* @__PURE__ */ new Set([
10296
+ "TOO_MANY_JOINS",
10297
+ "INTERNAL_ERROR",
10298
+ "CONNECTION_STATE_TOO_LARGE",
10299
+ "TIMED_OUT"
10300
+ ]);
10301
+ function sameSettings(a, b) {
10302
+ if (a === b) return true;
10303
+ try {
10304
+ return JSON.stringify(a) === JSON.stringify(b);
10305
+ } catch {
10306
+ return false;
10307
+ }
10308
+ }
10309
+ function sameJoin(a, b) {
10310
+ return a.name === b.name && a.isPrivate === b.isPrivate;
10311
+ }
10312
+ function foldTable(table3) {
10313
+ return table3.replaceAll(/[A-Z]/g, (letter) => letter.toLowerCase());
10314
+ }
10315
+ var RealtimeClient = class {
10316
+ #http;
10317
+ #socket;
10318
+ #identity;
10319
+ #context;
10320
+ #channels = [];
10321
+ /** The leaves owed on the open connection (정정 (i)); forgotten with it. */
10322
+ #owed = /* @__PURE__ */ new Set();
10323
+ #onStorage = null;
10324
+ constructor(input) {
10325
+ const { http, auth, service, options } = input;
10326
+ this.#http = http;
10327
+ this.#socket = new RealtimeSocket({
10328
+ baseUrl: http.baseUrl,
10329
+ apiKey: http.apiKey,
10330
+ service,
10331
+ options,
10332
+ events: {
10333
+ // Not awaited, and not live yet: onLive says when the hub took it.
10334
+ opened: () => {
10335
+ this.#watchStorage();
10336
+ void this.#identity.opened();
10337
+ },
10338
+ frame: (frame) => this.#route(frame),
10339
+ // Only the leaves and the channels: the identity is closed by the
10340
+ // next opened() (the 4401's refused() reads it after this turn —
10341
+ // 정정 (f)).
10342
+ dropped: (error, status) => {
10343
+ this.#forgetLeaves();
10344
+ this.#each((channel) => channel.dropped(error, status));
10345
+ },
10346
+ stopped: (error) => this.#stopped(error),
10347
+ sessionRefused: () => this.#identity.refused()
10348
+ }
10349
+ });
10350
+ this.#identity = new Identity({
10351
+ tokens: http.tokens,
10352
+ accessJwt: http.accessJwt,
10353
+ service,
10354
+ socket: () => this.#socket,
10355
+ privateChannels: () => this.#channels.filter(
10356
+ (channel) => channel.isPrivate && channel.state === "joined"
10357
+ ).length,
10358
+ onLive: () => this.#each((channel) => channel.rejoin()),
10359
+ // A sign-in the page URL carries goes before any join (final review A I1).
10360
+ ready: () => auth.ready()
10361
+ });
10362
+ this.#context = {
10363
+ socket: this.#socket,
10364
+ identity: this.#identity,
10365
+ http,
10366
+ auth,
10367
+ connect: () => this.#socket.connect(),
10368
+ live: () => this.#identity.isLive(),
10369
+ leave: (channel, joinRef, tables) => this.#leave(channel, joinRef, tables),
10370
+ owesLeave: (channel) => this.#owesLeave(channel),
10371
+ owesLeaveOn: (table3) => this.#owesLeaveOn(table3)
10372
+ };
10373
+ http.tokens.subscribe(() => void this.#identity.changed());
10374
+ }
10375
+ channel(name, options = {}) {
10376
+ const settings = isRecord(options) ? options : {};
10377
+ const isPrivate = settings.config?.private === true;
10378
+ const found = this.#channels.find(
10379
+ (c) => c.name === name && c.isPrivate === isPrivate
10380
+ );
10381
+ if (found !== void 0) {
10382
+ if (!sameSettings(found.options, settings))
10383
+ warnInDevelopment(
10384
+ `naro-js: channel(${JSON.stringify(name)}) already exists with other settings; it keeps the first ones until removeChannel().`
10385
+ );
10386
+ return found;
10387
+ }
10388
+ const channel = new RealtimeChannel(
10389
+ name,
10390
+ settings,
10391
+ this.#context
10392
+ );
10393
+ this.#channels.push(channel);
10394
+ return channel;
10395
+ }
10396
+ getChannels() {
10397
+ return [...this.#channels];
10398
+ }
10399
+ async removeChannel(channel) {
10400
+ this.#channels = this.#channels.filter((c) => c !== channel);
10401
+ return this.#closeIfLast(await channel.unsubscribe());
10402
+ }
10403
+ async removeAllChannels() {
10404
+ const channels = this.#channels;
10405
+ this.#channels = [];
10406
+ const results = await Promise.all(
10407
+ channels.map((channel) => channel.unsubscribe())
10408
+ );
10409
+ return this.#closeIfLast({
10410
+ data: null,
10411
+ error: results.find((result) => result.error !== null)?.error ?? null
10412
+ });
10413
+ }
10414
+ /**
10415
+ * No channel left (the app may have added one meanwhile): the connection
10416
+ * closes. That is the strongest leave — the hub forgets every join with
10417
+ * its connection — so a leave's refusal no longer stands (정정 (j)).
10418
+ */
10419
+ #closeIfLast(result) {
10420
+ if (this.#channels.length > 0) return result;
10421
+ this.#disconnect();
10422
+ return { data: null, error: null };
10423
+ }
10424
+ /**
10425
+ * Each channel in turn, over a copy (a callback may add or remove one);
10426
+ * what one throws does not keep the next from hearing (결정 22).
10427
+ */
10428
+ #each(action) {
10429
+ for (const channel of Array.from(this.#channels))
10430
+ safely(() => action(channel));
10431
+ }
10432
+ #route(frame) {
10433
+ if (frame.topic === PHOENIX_TOPIC || frame.joinRef === null) return;
10434
+ const channel = this.#channels.find(
10435
+ (c) => c.joinRef === frame.joinRef && c.topic === frame.topic
10436
+ );
10437
+ channel?.receive(frame);
10438
+ }
10439
+ #stopped(error) {
10440
+ this.#identity.closed();
10441
+ this.#forgetLeaves();
10442
+ this.#unwatchStorage();
10443
+ this.#each((channel) => channel.stopped(error));
10444
+ }
10445
+ #disconnect() {
10446
+ this.#socket.disconnect();
10447
+ this.#identity.closed();
10448
+ this.#forgetLeaves();
10449
+ this.#unwatchStorage();
10450
+ }
10451
+ #leave(channel, joinRef, tables) {
10452
+ const owed = {
10453
+ channel,
10454
+ joinRef,
10455
+ tables,
10456
+ attempt: 0,
10457
+ timer: void 0
10458
+ };
10459
+ this.#owed.add(owed);
10460
+ return this.#sendLeave(owed, "app");
10461
+ }
10462
+ /**
10463
+ * One `phx_leave` of `owed`; its answer decides whether it is done,
10464
+ * whoever else awaits it. The first goes in the app's turn, after the
10465
+ * frames the app sent on the join before it (and is never refused for
10466
+ * it); one tried again, in the SDK's (PR #328 re-review).
10467
+ */
10468
+ #sendLeave(owed, turn) {
10469
+ const { channel, joinRef } = owed;
10470
+ const reply = this.#socket.request(
10471
+ channel.topic,
10472
+ "phx_leave",
10473
+ {},
10474
+ { joinRef, turn }
10475
+ );
10476
+ void reply.then((answer) => this.#leaveAnswered(owed, answer));
10477
+ return reply;
10478
+ }
10479
+ #leaveAnswered(owed, reply) {
10480
+ if (!this.#owed.has(owed)) return;
10481
+ if (!reply.ok && !reply.lost && LEAVE_RETRY_CODES.has(reply.error.code)) {
10482
+ const delay = backoffMs(owed.attempt, Math.random);
10483
+ owed.attempt += 1;
10484
+ owed.timer = setTimeout(() => {
10485
+ owed.timer = void 0;
10486
+ this.#socket.whenRoom(owed, () => {
10487
+ if (this.#owed.has(owed)) void this.#sendLeave(owed, "sdk");
10488
+ });
10489
+ }, delay);
10490
+ const { error } = reply;
10491
+ this.#each((channel) => {
10492
+ if (sameJoin(channel, owed.channel)) channel.leaveRefused(error);
10493
+ });
10494
+ return;
10495
+ }
10496
+ this.#owed.delete(owed);
10497
+ this.#each((channel) => channel.rejoin());
10498
+ }
10499
+ #owesLeave(channel) {
10500
+ for (const owed of this.#owed)
10501
+ if (sameJoin(owed.channel, channel)) return true;
10502
+ return false;
10503
+ }
10504
+ /** `ChannelContext.owesLeaveOn`: the hub's caps count the joins whose leaves are owed. */
10505
+ #owesLeaveOn(table3) {
10506
+ for (const owed of this.#owed) {
10507
+ if (table3 === null) return true;
10508
+ const key = foldTable(table3);
10509
+ if (owed.tables.some(
10510
+ (held) => held === "*" || key === "*" || foldTable(held) === key
10511
+ ))
10512
+ return true;
10513
+ }
10514
+ return false;
10515
+ }
10516
+ #forgetLeaves() {
10517
+ for (const owed of this.#owed) clearTimeout(owed.timer);
10518
+ this.#owed.clear();
10519
+ }
10520
+ /**
10521
+ * Another tab signing in or out writes the same localStorage key: read the
10522
+ * store again while the connection lives (결정 5) — from its first open to
10523
+ * a stop or the last channel's removal, drops included. Reading again
10524
+ * sends nothing unless the stored token is not what the hub has, so a
10525
+ * storage of the app's own (`auth.storage`), whose writes this event
10526
+ * never reports, costs a read at most.
10527
+ */
10528
+ #watchStorage() {
10529
+ const target = globalThis;
10530
+ if (this.#onStorage !== null || !isBrowser() || typeof target.addEventListener !== "function")
10531
+ return;
10532
+ const key = storageKey(this.#http.baseUrl);
10533
+ this.#onStorage = (event) => {
10534
+ if (event.key === key || event.key === null)
10535
+ void this.#identity.changed();
10536
+ };
10537
+ target.addEventListener("storage", this.#onStorage);
10538
+ }
10539
+ #unwatchStorage() {
10540
+ const target = globalThis;
10541
+ if (this.#onStorage === null) return;
10542
+ target.removeEventListener?.("storage", this.#onStorage);
10543
+ this.#onStorage = null;
10544
+ }
10545
+ };
7895
10546
  function fail(error) {
7896
10547
  return { data: null, error };
7897
10548
  }
@@ -8001,7 +10652,7 @@ async function uploadInParts(a) {
8001
10652
  failure.error ??= uploadAborted();
8002
10653
  };
8003
10654
  const tryPart = async (n, bytes) => {
8004
- const sent = await send(a.http, {
10655
+ const sent2 = await send(a.http, {
8005
10656
  method: "PUT",
8006
10657
  path: `${base}/${String(n)}`,
8007
10658
  headers: { "content-type": "application/octet-stream", ...signed },
@@ -8009,10 +10660,10 @@ async function uploadInParts(a) {
8009
10660
  withToken,
8010
10661
  signal
8011
10662
  });
8012
- if (sent.error !== null) return sent;
8013
- const read = await readJson(sent.response, partAnswer(n));
10663
+ if (sent2.error !== null) return sent2;
10664
+ const read = await readJson(sent2.response, partAnswer(n));
8014
10665
  if (read.error !== null)
8015
- return { error: read.error, status: sent.response.status };
10666
+ return { error: read.error, status: sent2.response.status };
8016
10667
  return { etag: read.data.etag };
8017
10668
  };
8018
10669
  const sendPart = async (n) => {
@@ -8299,7 +10950,7 @@ var StorageFileApi = class {
8299
10950
  "cache-control": cache
8300
10951
  };
8301
10952
  if (upsert) headers["x-upsert"] = "true";
8302
- const sent = await send(this.#http, {
10953
+ const sent2 = await send(this.#http, {
8303
10954
  method,
8304
10955
  path: target.path,
8305
10956
  headers,
@@ -8308,8 +10959,8 @@ var StorageFileApi = class {
8308
10959
  // The fetch stops with the body: the worker undoes its claim at once.
8309
10960
  signal
8310
10961
  });
8311
- if (sent.error !== null) return fail(sent.error);
8312
- return readJson(sent.response, isUploadResult);
10962
+ if (sent2.error !== null) return fail(sent2.error);
10963
+ return readJson(sent2.response, isUploadResult);
8313
10964
  }
8314
10965
  /**
8315
10966
  * A new object; with `upsert: true`, one of the same name is replaced.
@@ -8334,14 +10985,14 @@ var StorageFileApi = class {
8334
10985
  const target = this.#objectPath("", path);
8335
10986
  if (!target.ok) return fail(target.error);
8336
10987
  await this.#auth.ready();
8337
- const sent = await send(this.#http, {
10988
+ const sent2 = await send(this.#http, {
8338
10989
  method: "GET",
8339
10990
  path: target.path,
8340
10991
  withToken: true
8341
10992
  });
8342
- if (sent.error !== null) return fail(sent.error);
10993
+ if (sent2.error !== null) return fail(sent2.error);
8343
10994
  try {
8344
- return { data: await sent.response.blob(), error: null };
10995
+ return { data: await sent2.response.blob(), error: null };
8345
10996
  } catch (error) {
8346
10997
  return fail(networkError(error));
8347
10998
  }
@@ -8606,7 +11257,7 @@ var StorageFileApi = class {
8606
11257
  * is the permission. A failure's cause never carries the token.
8607
11258
  */
8608
11259
  async #putSigned(path, token, payload, cache, signal) {
8609
- const sent = await send(this.#http, {
11260
+ const sent2 = await send(this.#http, {
8610
11261
  method: "PUT",
8611
11262
  path: `${path}?token=${encodeURIComponent(token)}`,
8612
11263
  headers: { "content-type": payload.type, "cache-control": cache },
@@ -8614,8 +11265,8 @@ var StorageFileApi = class {
8614
11265
  withToken: false,
8615
11266
  signal
8616
11267
  });
8617
- if (sent.error !== null) return fail(withoutToken(sent.error, token));
8618
- const read = await readJson(sent.response, isUploadResult);
11268
+ if (sent2.error !== null) return fail(withoutToken(sent2.error, token));
11269
+ const read = await readJson(sent2.response, isUploadResult);
8619
11270
  return read.error === null ? read : fail(withoutToken(read.error, token));
8620
11271
  }
8621
11272
  /**
@@ -8655,10 +11306,36 @@ var CONFIG_MESSAGES = {
8655
11306
  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.",
8656
11307
  key: "createClient needs an anonKey (or, on a server, a serviceKey).",
8657
11308
  bothKeys: "Pass createClient an anonKey or a serviceKey, not both.",
8658
- 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.",
8659
- fetch: "This runtime has no fetch. Pass one to createClient({ fetch })."
11309
+ serviceKeyOffServer: SERVICE_KEY_OFF_SERVER_MESSAGE,
11310
+ fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).",
11311
+ realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647."
8660
11312
  };
8661
11313
  var SERVICE_KEY_PREFIX = "naro_sk_";
11314
+ var MAX_TIMER_MS = 2147483647;
11315
+ function isMilliseconds(value) {
11316
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
11317
+ }
11318
+ function realtimeOptionsOf(value) {
11319
+ if (value === void 0) return {};
11320
+ if (!isRecord(value)) throw new Error(CONFIG_MESSAGES.realtime);
11321
+ const { WebSocket, heartbeatIntervalMs, timeoutMs } = value;
11322
+ const options = {};
11323
+ if (WebSocket !== void 0) {
11324
+ if (typeof WebSocket !== "function")
11325
+ throw new Error(CONFIG_MESSAGES.realtime);
11326
+ options.WebSocket = WebSocket;
11327
+ }
11328
+ if (heartbeatIntervalMs !== void 0) {
11329
+ if (!isMilliseconds(heartbeatIntervalMs))
11330
+ throw new Error(CONFIG_MESSAGES.realtime);
11331
+ options.heartbeatIntervalMs = heartbeatIntervalMs;
11332
+ }
11333
+ if (timeoutMs !== void 0) {
11334
+ if (!isMilliseconds(timeoutMs)) throw new Error(CONFIG_MESSAGES.realtime);
11335
+ options.timeoutMs = timeoutMs;
11336
+ }
11337
+ return options;
11338
+ }
8662
11339
  function baseUrlOf(url) {
8663
11340
  if (typeof url !== "string") throw new Error(CONFIG_MESSAGES.url);
8664
11341
  const trimmed = url.trim();
@@ -8715,18 +11392,27 @@ var NaroClient2 = class {
8715
11392
  */
8716
11393
  storage;
8717
11394
  #run;
11395
+ #newRealtime;
11396
+ /**
11397
+ * Made by the first channel(), not by the constructor: it listens to every
11398
+ * write of the session token (to tell an open connection who signed in),
11399
+ * which an app that never uses Realtime should not pay for.
11400
+ */
11401
+ #realtime;
8718
11402
  constructor(options) {
8719
11403
  const baseUrl = baseUrlOf(options.url);
8720
11404
  const { key: apiKey, service } = apiKeyOf(options);
8721
11405
  if (options.fetch === void 0 && typeof globalThis.fetch !== "function")
8722
11406
  throw new Error(CONFIG_MESSAGES.fetch);
11407
+ const realtime = realtimeOptionsOf(options.realtime);
8723
11408
  const storage = options.auth?.storage ?? defaultStorage();
8724
11409
  const http = {
8725
11410
  baseUrl,
8726
11411
  apiKey,
8727
11412
  fetch: options.fetch,
8728
11413
  tokens: new TokenStore(storage, storageKey(baseUrl)),
8729
- accessJwt: new TokenStore(storage, accessJwtKey(baseUrl))
11414
+ accessJwt: new TokenStore(storage, accessJwtKey(baseUrl)),
11415
+ service
8730
11416
  };
8731
11417
  this.auth = new AuthClient(http, {
8732
11418
  verifiers: new FlowVerifiers(
@@ -8741,11 +11427,46 @@ var NaroClient2 = class {
8741
11427
  });
8742
11428
  this.#run = queryRunner(http, this.auth);
8743
11429
  this.storage = new StorageClient(http, this.auth);
11430
+ this.#newRealtime = () => new RealtimeClient({
11431
+ http,
11432
+ auth: this.auth,
11433
+ service,
11434
+ options: realtime
11435
+ });
8744
11436
  }
8745
11437
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
8746
11438
  from(table3) {
8747
11439
  return new TableBuilder(this.#run, table3);
8748
11440
  }
11441
+ /**
11442
+ * A Realtime channel (original spec §15): `.on()` listeners, then
11443
+ * `.subscribe()`. The same name and privacy give the same channel until
11444
+ * `removeChannel`. Nothing connects until the first `subscribe()`.
11445
+ */
11446
+ channel(name, options) {
11447
+ return this.#client().channel(name, options);
11448
+ }
11449
+ /** The channels made with `channel()` and not yet removed. */
11450
+ getChannels() {
11451
+ return this.#realtime?.getChannels() ?? [];
11452
+ }
11453
+ /**
11454
+ * Leaves the channel and forgets it, so `channel()` of its name makes a new
11455
+ * one; the connection closes with the last channel.
11456
+ */
11457
+ removeChannel(channel) {
11458
+ return this.#client().removeChannel(channel);
11459
+ }
11460
+ /** `removeChannel()` for every channel; the connection closes. */
11461
+ removeAllChannels() {
11462
+ if (this.#realtime === void 0)
11463
+ return Promise.resolve({ data: null, error: null });
11464
+ return this.#realtime.removeAllChannels();
11465
+ }
11466
+ #client() {
11467
+ this.#realtime ??= this.#newRealtime();
11468
+ return this.#realtime;
11469
+ }
8749
11470
  };
8750
11471
  function createClient(options) {
8751
11472
  return new NaroClient2(options);
@@ -8842,18 +11563,27 @@ function unwrap(result) {
8842
11563
  }
8843
11564
  var ONE_KEY = /^[\x21-\x7e]+$/;
8844
11565
  async function objects(context) {
8845
- const client = await context.client();
11566
+ const client = await dataClient(context);
8846
11567
  const projectId = await context.resolveProjectId();
8847
11568
  const backend = await client.backend.info(projectId);
8848
- if (backend.status === "none")
11569
+ const environment = dataEnvironment(context.flags);
11570
+ if (backend.status === "none") {
8849
11571
  throw new NaroError(
8850
11572
  "BACKEND_NOT_READY",
8851
- "This project has no backend yet. Run `naro backend enable`, then `naro storage enable`."
11573
+ environment === null ? "This project has no backend yet. Run `naro backend enable`, then `naro storage enable`." : `The ${environment} environment has no backend yet. Run \`naro environments provision ${environment}\` to make it.`
11574
+ );
11575
+ }
11576
+ if (!backend.enabled)
11577
+ throw new NaroError(
11578
+ "BACKEND_OFF",
11579
+ forEnvironment(BACKEND_OFF_MESSAGE, environment)
8852
11580
  );
8853
- if (!backend.enabled) throw new NaroError("BACKEND_OFF", BACKEND_OFF_MESSAGE);
8854
11581
  const state = await client.storage.info(projectId);
8855
11582
  if (state.disabledAt !== null)
8856
- throw new NaroError("STORAGE_OFF", STORAGE_OFF_MESSAGE);
11583
+ throw new NaroError(
11584
+ "STORAGE_OFF",
11585
+ forEnvironment(STORAGE_OFF_MESSAGE, environment)
11586
+ );
8857
11587
  let serviceKey = process.env.NARO_SERVICE_KEY?.trim() ?? "";
8858
11588
  if (serviceKey !== "" && !ONE_KEY.test(serviceKey))
8859
11589
  throw new NaroError(
@@ -8874,7 +11604,10 @@ async function objects(context) {
8874
11604
  } catch (error) {
8875
11605
  throw new NaroError(
8876
11606
  "BACKEND_NOT_READY",
8877
- `The project's backend url cannot be used (${printable(backend.url)}). Run \`naro backend\` to see where the backend got to.`,
11607
+ forEnvironment(
11608
+ `The project's backend url cannot be used (${printable(backend.url)}). Run \`naro backend\` to see where the backend got to.`,
11609
+ environment
11610
+ ),
8878
11611
  { cause: error }
8879
11612
  );
8880
11613
  }
@@ -8948,7 +11681,7 @@ async function storageLs(context) {
8948
11681
  "VALIDATION_FAILED",
8949
11682
  `--search needs a naro:// path. ${LS_USAGE}`
8950
11683
  );
8951
- const client = await context.client();
11684
+ const client = await dataClient(context);
8952
11685
  const projectId2 = await context.resolveProjectId();
8953
11686
  const buckets = await client.storage.listBuckets(projectId2);
8954
11687
  context.output.result(
@@ -9058,17 +11791,17 @@ function progressReporter(output, total) {
9058
11791
  let lastLength = 0;
9059
11792
  let open = false;
9060
11793
  return {
9061
- report(sent) {
9062
- const percent2 = Math.floor(sent / total * 100);
9063
- const line = `\u2191 ${binarySize(sent)} / ${binarySize(total)} (${String(percent2)}%)`;
11794
+ report(sent2) {
11795
+ const percent2 = Math.floor(sent2 / total * 100);
11796
+ const line = `\u2191 ${binarySize(sent2)} / ${binarySize(total)} (${String(percent2)}%)`;
9064
11797
  if (tty) {
9065
- open = sent !== total;
11798
+ open = sent2 !== total;
9066
11799
  output.progress(`\r${line.padEnd(lastLength)}${open ? "" : "\n"}`);
9067
11800
  lastLength = line.length;
9068
11801
  return;
9069
11802
  }
9070
11803
  const tenth = Math.floor(percent2 / 10);
9071
- if (tenth === lastTenth && sent !== total) return;
11804
+ if (tenth === lastTenth && sent2 !== total) return;
9072
11805
  lastTenth = tenth;
9073
11806
  output.progress(`${line}
9074
11807
  `);
@@ -9605,12 +12338,14 @@ Usage:
9605
12338
  naro env pull [file] Write env vars to .env.local
9606
12339
  target is production, preview, all or an environment name; add and import use all when none is given
9607
12340
 
9608
- naro environments List the project's environments: address, branch, protection, and how many the plan allows
12341
+ naro environments List the project's environments: address, branch, protection, data, and how many the plan allows
9609
12342
  naro environments create <slug> [--branch <b>] [--copy-env preview|production|none] [--database schema|empty]
9610
12343
  A QA or staging surface served at <subdomain>--<slug>; starts with preview's variables
9611
12344
  naro environments set <slug> [--branch <b> | --no-branch] [--protection on|off]
9612
12345
  Change the branch whose pushes deploy here, or who can open it (protection off asks first; --yes in CI)
9613
- naro environments delete <slug> Delete it with its worker and protection key (asks first; --yes in CI)
12346
+ naro environments delete <slug> Delete it with its worker, protection key and data (asks first; --yes in CI)
12347
+ naro environments provision <slug>
12348
+ Make its data \u2014 database, backend, Storage \u2014 or go on after a stop
9614
12349
 
9615
12350
  naro domains list | add <host> [--no-www] | remove <host>
9616
12351
  add example.com also connects www.example.com and redirects the apex to
@@ -9619,6 +12354,9 @@ Usage:
9619
12354
  naro domains verify [host] [--wait] Re-check DNS and print what is missing
9620
12355
  exits non-zero while a domain is not live yet
9621
12356
 
12357
+ The data commands below (db, migration, gen, backend, policy, storage, realtime) take --env <name>:
12358
+ a named environment's data instead of production's, which preview shares
12359
+ (naro-cli 0.21.0 and older ignore --env on them and act on production)
9622
12360
  naro db enable Create the project's database (Cloudflare D1)
9623
12361
  naro db info Database status, size, table count and limits
9624
12362
  and today's writes against the daily write limit (UTC)
@@ -9749,6 +12487,13 @@ Usage:
9749
12487
  Remove objects; --recursive removes what is under a folder (asks first):
9750
12488
  <path>/ is the folder alone, <path> also an object of that name
9751
12489
 
12490
+ naro realtime [status] Naro Realtime: on or not, published tables, connections now
12491
+ naro realtime enable Turn Realtime on: the project's hub, bound to its backend
12492
+ naro realtime tables [list] The tables whose Data API writes reach subscribers
12493
+ naro realtime tables add <table>\u2026 Publish tables' changes (within about 10 seconds)
12494
+ naro realtime tables remove <table>\u2026 [--yes]
12495
+ Stop publishing tables' changes (asks first)
12496
+
9752
12497
  naro promote <deployment-id> [--to <environment>]
9753
12498
  Publish a build to production, or to an environment with --to; it says what it will do, then asks.
9754
12499
  A build moving between an environment and another surface is built again from its commit with the
@@ -9777,7 +12522,8 @@ Global options:
9777
12522
  --browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
9778
12523
  --os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
9779
12524
  --to <environment> production (the default) or an environment name: where naro promote publishes the build (naro promote)
9780
- --env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name)
12525
+ --env <e> production or preview (naro logs, naro insights: production by default; naro env pull, naro deploy: preview by default, or an environment name);
12526
+ production or an environment name for the data commands (naro db, migration, gen, backend, policy, storage, realtime: production by default)
9781
12527
  --branch <b> naro environments create, set: the branch whose pushes deploy to that environment. naro deploy: the default
9782
12528
  branch of a project this deploy creates or updates (the deploy's own branch follows where it goes)
9783
12529
  --no-branch No branch deploys to the environment any more (naro environments set; not with --branch)
@@ -9967,13 +12713,94 @@ function routeStorage(words) {
9967
12713
  return null;
9968
12714
  }
9969
12715
  }
12716
+ function routeRealtime(words) {
12717
+ const [sub, action] = words;
12718
+ switch (sub) {
12719
+ case void 0:
12720
+ case "status":
12721
+ return words.length <= 1 ? { handler: runRealtimeStatus, positionals: [] } : null;
12722
+ case "enable":
12723
+ return words.length === 1 ? { handler: runRealtimeEnable, positionals: [] } : null;
12724
+ case "tables":
12725
+ switch (action) {
12726
+ case void 0:
12727
+ case "list":
12728
+ case "ls":
12729
+ return words.length <= 2 ? { handler: runRealtimeTablesList, positionals: [] } : null;
12730
+ case "add":
12731
+ return {
12732
+ handler: runRealtimeTablesAdd,
12733
+ positionals: words.slice(2)
12734
+ };
12735
+ case "remove":
12736
+ case "rm":
12737
+ return {
12738
+ handler: runRealtimeTablesRemove,
12739
+ positionals: words.slice(2)
12740
+ };
12741
+ default:
12742
+ return null;
12743
+ }
12744
+ default:
12745
+ return null;
12746
+ }
12747
+ }
9970
12748
  async function refuseTo() {
9971
12749
  throw new NaroError("VALIDATION_FAILED", "--to is only for naro promote.");
9972
12750
  }
12751
+ var DATA_COMMANDS = /* @__PURE__ */ new Set([
12752
+ "db",
12753
+ "migration",
12754
+ "gen",
12755
+ "backend",
12756
+ "policy",
12757
+ "storage",
12758
+ "realtime"
12759
+ ]);
12760
+ var PROJECT_WIDE_BACKEND = /* @__PURE__ */ new Set(["enable", "provision", "disable"]);
12761
+ function dataEnvRefusal(parsed) {
12762
+ if (parsed.flags.env === void 0 || !DATA_COMMANDS.has(parsed.command ?? ""))
12763
+ return null;
12764
+ const sub = parsed.positionals[0];
12765
+ if (parsed.command === "backend" && sub !== void 0 && PROJECT_WIDE_BACKEND.has(sub) && parsed.flags.env !== "production")
12766
+ return new NaroError(
12767
+ "VALIDATION_FAILED",
12768
+ DATA_ENV_MESSAGES.projectWide(`naro backend ${sub}`)
12769
+ );
12770
+ try {
12771
+ dataEnvironment(parsed.flags);
12772
+ return null;
12773
+ } catch (error) {
12774
+ return error;
12775
+ }
12776
+ }
12777
+ function guidedError(error, parsed) {
12778
+ if (!isNaroError(error) || !DATA_COMMANDS.has(parsed.command ?? ""))
12779
+ return error;
12780
+ if (dataEnvRefusal(parsed) !== null) return error;
12781
+ let environment;
12782
+ try {
12783
+ environment = dataEnvironment(parsed.flags);
12784
+ } catch {
12785
+ return error;
12786
+ }
12787
+ if (environment === null) return error;
12788
+ const usage = error.message.indexOf("Usage: ");
12789
+ error.message = usage === -1 ? forEnvironment(error.message, environment) : `${forEnvironment(error.message.slice(0, usage), environment)}${error.message.slice(usage)}`;
12790
+ return error;
12791
+ }
9973
12792
  function route(parsed) {
9974
12793
  const routed = routeCommand(parsed);
9975
12794
  if (routed && parsed.flags.to !== void 0 && parsed.command !== "promote" && parsed.command !== "rollback")
9976
12795
  return { handler: refuseTo, positionals: routed.positionals };
12796
+ const refusal = routed ? dataEnvRefusal(parsed) : null;
12797
+ if (routed && refusal)
12798
+ return {
12799
+ handler: async () => {
12800
+ throw refusal;
12801
+ },
12802
+ positionals: routed.positionals
12803
+ };
9977
12804
  return routed;
9978
12805
  }
9979
12806
  function routeCommand(parsed) {
@@ -10095,6 +12922,11 @@ function routeCommand(parsed) {
10095
12922
  handler: runEnvironmentsDelete,
10096
12923
  positionals: positionals.slice(1)
10097
12924
  };
12925
+ case "provision":
12926
+ return {
12927
+ handler: runEnvironmentsProvision,
12928
+ positionals: positionals.slice(1)
12929
+ };
10098
12930
  default:
10099
12931
  return null;
10100
12932
  }
@@ -10217,6 +13049,8 @@ function routeCommand(parsed) {
10217
13049
  }
10218
13050
  case "storage":
10219
13051
  return routeStorage(positionals);
13052
+ case "realtime":
13053
+ return routeRealtime(positionals);
10220
13054
  case "policy":
10221
13055
  switch (sub) {
10222
13056
  case void 0:
@@ -10319,7 +13153,7 @@ async function main(argv = process.argv.slice(2)) {
10319
13153
  try {
10320
13154
  await routed.handler(context);
10321
13155
  } catch (error) {
10322
- context.output.fail(error);
13156
+ context.output.fail(guidedError(error, parsed));
10323
13157
  }
10324
13158
  }
10325
13159
  void main();