@xenon-device-management/xenon 2.15.0 → 2.16.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.
Files changed (183) hide show
  1. package/README.md +38 -26
  2. package/lib/package.json +2 -1
  3. package/lib/public/assets/{AnnotationOverlay-Btq3dZRI.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-C9OoAgg_.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DBC_xczB.js → DeviceMosaicView-DqODzkyo.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DqWU2-vM.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CveQ3xrA.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BfKDSfHz.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-CtdCybzA.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-Bsvj1tm2.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CRofOiyx.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-jNQHb2IR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C8HtTha-.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-D8K1LZNo.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-zNTX92Pn.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-CJ8LwZq9.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CgxQZqrc.js → ai-settings-DU_Ls06P.js} +1 -1
  18. package/lib/public/assets/{api-keys-ECpo-zTo.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-BjGymyf3.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BWfCbeHP.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DC7UGf_n.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-BOjwybDk.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CxMTo9aH.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-DOCm4HkA.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Cg08-EMT.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-CA1ikLJs.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-BMTaH72W.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-B2QOv9Nx.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CYXBoUqT.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DTwnSAnl.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-C8_HViQP.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-Be7LNMXV.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BOqtfGXz.js → device-explorer-BgGZ8AvV.js} +1 -1
  34. package/lib/public/assets/{download-B56lwxWP.js → download-BroHxNOh.js} +1 -1
  35. package/lib/public/assets/{forgot-password-CCU6bzLn.js → forgot-password-Bjmf49i6.js} +1 -1
  36. package/lib/public/assets/{h264Stream-C-OtmIuk.js → h264Stream-DeD__6wa.js} +1 -1
  37. package/lib/public/assets/{index-CfxsUslp.js → index-72oF89LJ.js} +1 -1
  38. package/lib/public/assets/{index-B9KQ6LsY.js → index-B0JeuZbg.js} +1 -1
  39. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  40. package/lib/public/assets/{input-CZHFjbSg.js → input-DIc4MSC7.js} +1 -1
  41. package/lib/public/assets/{line-chart-Gl_Dz3X_.js → line-chart-BcWm2vW8.js} +1 -1
  42. package/lib/public/assets/{list-checks-CCgjeyGY.js → list-checks-B06IJJad.js} +1 -1
  43. package/lib/public/assets/{lock-CkOwgDcx.js → lock-pVLvnN30.js} +1 -1
  44. package/lib/public/assets/{login-D5cc4w7M.js → login-CN77vy--.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-t7qr3fvX.js → maintenance-settings-CSdzQSkY.js} +1 -1
  46. package/lib/public/assets/{monitor-DEJixKQO.js → monitor-BtrN5IkI.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CtOrk4M8.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  48. package/lib/public/assets/{network-Dii9qdvk.js → network-BSfA8ZxG.js} +1 -1
  49. package/lib/public/assets/{overview-rolAACrO.js → overview-BfTebCK5.js} +1 -1
  50. package/lib/public/assets/{page-header-COQZxJfR.js → page-header-D8KUX6Qg.js} +1 -1
  51. package/lib/public/assets/{play-DrN1Se_R.js → play-Cvc8xzic.js} +1 -1
  52. package/lib/public/assets/{plus-5aq-9uRx.js → plus-C9Zj84UF.js} +1 -1
  53. package/lib/public/assets/{profile-page-6i5kY6kF.js → profile-page-Bi27STgs.js} +1 -1
  54. package/lib/public/assets/{reset-password-sp2IBrc7.js → reset-password-CPsOuwSu.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJDjeopU.js → runbook-page-DEQ-DBOX.js} +153 -102
  56. package/lib/public/assets/{select-Bpyn7rWO.js → select-BWpnhWXj.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BH74vmKv.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Cw_g218H.js → selector-health-page-MSk96Zz7.js} +1 -1
  59. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  60. package/lib/public/assets/{settings-C3uyGhyc.js → settings-V6x-cXwy.js} +1 -1
  61. package/lib/public/assets/{stat-tile-xVqAKIlI.js → stat-tile-Duex-GZD.js} +1 -1
  62. package/lib/public/assets/{tablet-BxUScAeO.js → tablet-BBcoqBmL.js} +1 -1
  63. package/lib/public/assets/{teams-B5ljGkY2.js → teams-ts8p5rEh.js} +1 -1
  64. package/lib/public/assets/{trash-2-B701i8sy.js → trash-2-CZ2ew6AZ.js} +1 -1
  65. package/lib/public/assets/{upload-C2gr44RS.js → upload-Dv99y_le.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-NeQOFEoy.js → use-builds-data-DrYC8JCF.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DEzJq5ef.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  68. package/lib/public/assets/{users-D_vv_yz4.js → users-D7tKMESH.js} +1 -1
  69. package/lib/public/assets/{users-MY6eNe6s.js → users-gJ5Y01yo.js} +1 -1
  70. package/lib/public/assets/{video-off-OK1lNZQn.js → video-off-CqZVuZGs.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BU-C9lBf.js → webhook-settings-DI5DSW99.js} +1 -1
  72. package/lib/public/assets/{zap-DO9ry1iz.js → zap-ClcHFxfo.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +11 -7
  75. package/lib/src/InternalHttpClient.js +27 -5
  76. package/lib/src/XenonCapabilityManager.js +10 -3
  77. package/lib/src/app/openapi/sessions.yaml +7 -5
  78. package/lib/src/app/routers/auth.js +2 -1
  79. package/lib/src/app/routers/users.js +2 -1
  80. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  81. package/lib/src/dashboard/commandLogFields.js +42 -0
  82. package/lib/src/dashboard/event-manager.js +26 -154
  83. package/lib/src/dashboard/services/failure-analysis-service.js +8 -86
  84. package/lib/src/dashboard/services/failureCategories.js +264 -0
  85. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  86. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  87. package/lib/src/device-utils.js +14 -6
  88. package/lib/src/gateway/forwardToNode.js +4 -11
  89. package/lib/src/gateway/hubSessionToken.js +27 -4
  90. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  91. package/lib/src/helpers/outboundProxy.js +219 -0
  92. package/lib/src/index.js +21 -0
  93. package/lib/src/interceptors/CommandInterceptor.js +13 -15
  94. package/lib/src/interceptors/xenonScripts.js +28 -7
  95. package/lib/src/logger.js +3 -1
  96. package/lib/src/plugin.js +8 -3
  97. package/lib/src/scripts/database-check.js +3 -3
  98. package/lib/src/scripts/initialize-database.js +35 -33
  99. package/lib/src/scripts/run-migrations.js +1079 -27
  100. package/lib/src/services/ApiKeyService.js +16 -5
  101. package/lib/src/services/InterceptorService.js +6 -2
  102. package/lib/src/services/OrphanSweeper.js +6 -0
  103. package/lib/src/services/SelectorStateService.js +11 -7
  104. package/lib/src/services/SelectorVerificationJob.js +6 -2
  105. package/lib/src/services/SessionLifecycleService.js +32 -18
  106. package/lib/src/services/SocketClient.js +11 -0
  107. package/lib/src/services/SocketServer.js +361 -42
  108. package/lib/src/services/TeamService.js +5 -1
  109. package/lib/src/services/TracingService.js +171 -60
  110. package/lib/src/services/UserService.js +14 -3
  111. package/lib/src/services/UserSessionService.js +16 -4
  112. package/lib/src/services/healing/HealingOrchestrator.js +3 -1
  113. package/lib/src/services/identity/identityChanges.js +41 -0
  114. package/lib/src/services/logcat/SessionDeviceLogs.js +97 -10
  115. package/lib/src/services/logcat/deviceLogBook.js +5 -4
  116. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  117. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  118. package/lib/src/services/token/JwtKeyService.js +8 -2
  119. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  120. package/lib/src/sessions/LocalSession.js +29 -1
  121. package/lib/src/sessions/RemoteSession.js +12 -2
  122. package/lib/src/sessions/SessionManager.js +5 -8
  123. package/lib/test/helpers/driver-log.js +38 -0
  124. package/lib/test/helpers/fake-http-proxy.js +106 -0
  125. package/lib/test/helpers/otlp-probe.js +107 -0
  126. package/lib/test/integration/androidDevices.spec.js +1 -1
  127. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  128. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  129. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  130. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  131. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  132. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  133. package/lib/test/unit/dashboard-events-device-logs.spec.js +59 -11
  134. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  135. package/lib/test/unit/database-check.spec.js +1 -0
  136. package/lib/test/unit/device-utils.spec.js +13 -4
  137. package/lib/test/unit/event-log-session-data.spec.js +11 -4
  138. package/lib/test/unit/failure-analysis.spec.js +22 -8
  139. package/lib/test/unit/failure-categories.spec.js +24 -22
  140. package/lib/test/unit/failure-categorization.spec.js +393 -0
  141. package/lib/test/unit/healing-tier-gate.spec.js +3 -2
  142. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +72 -1
  143. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  144. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  145. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  146. package/lib/test/unit/logcat-ws.spec.js +78 -37
  147. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  148. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  149. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  150. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  151. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  152. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  153. package/lib/test/unit/proxy-option.spec.js +358 -0
  154. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  155. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  156. package/lib/test/unit/run-migrations.test.js +864 -0
  157. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  158. package/lib/test/unit/selector-state-service.spec.js +72 -23
  159. package/lib/test/unit/selector-verification-clean-build.spec.js +1 -1
  160. package/lib/test/unit/selector-verification-job.spec.js +15 -6
  161. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  162. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  163. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  164. package/lib/test/unit/session-device-logs.spec.js +11 -2
  165. package/lib/test/unit/session-end-failure-analysis.spec.js +5 -5
  166. package/lib/test/unit/session-log-indexes.spec.js +1 -1
  167. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  168. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  169. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  170. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  171. package/lib/test/unit/unexpected-shutdown-reason.spec.js +2 -2
  172. package/lib/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +2 -1
  174. package/schema.json +11 -7
  175. package/scripts/dev/readme-screenshots.js +276 -23
  176. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  177. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  178. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  179. package/scripts/export-openapi.js +32 -0
  180. package/scripts/lib/openapi-for-site.js +79 -0
  181. package/lib/public/assets/index-BOxe43p1.js +0 -219
  182. package/lib/public/assets/session-detail-page-AZJE6XXE.js +0 -4
  183. package/lib/src/scripts/prepare-prisma.js +0 -89
@@ -0,0 +1,807 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ const chai_1 = require("chai");
16
+ const child_process_1 = require("child_process");
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const client_1 = require("../../src/generated/client");
21
+ const config_1 = require("../../src/config");
22
+ const sinon_1 = __importDefault(require("sinon"));
23
+ const run_migrations_1 = require("../../src/scripts/run-migrations");
24
+ // The startup schema step, run for real: the prisma CLI from node_modules
25
+ // against scratch SQLite files, never the server's own database.
26
+ //
27
+ // Through 2.15.0 the step chose its command from `databaseProvider` alone:
28
+ // `migrate deploy` for postgresql, `db push` for anything else. A database
29
+ // Xenon made with the default setting (or `npm run db:migrate`) has tables and
30
+ // no migration history, and `migrate deploy` refuses one of those (P3005), so a
31
+ // Xenon Control profile set to postgresql stopped the server. The default
32
+ // setting, in turn, ran `db push` on a database that keeps a migration history,
33
+ // moving its tables past that history, after which `migrate deploy` failed on
34
+ // the migration it had already applied (P3018) and recorded it as failed.
35
+ // The database now decides.
36
+ const ROOT = path_1.default.resolve(__dirname, '../..');
37
+ const PRISMA = path_1.default.join(ROOT, 'node_modules', '.bin', 'prisma');
38
+ const SCHEMA = path_1.default.join(ROOT, 'prisma', 'schema.prisma');
39
+ const MIGRATIONS = path_1.default.join(ROOT, 'prisma', 'migrations');
40
+ const LOCAL_MIGRATIONS = fs_1.default
41
+ .readdirSync(MIGRATIONS)
42
+ .filter((name) => fs_1.default.existsSync(path_1.default.join(MIGRATIONS, name, 'migration.sql')))
43
+ .sort();
44
+ function prismaCli(args, file, input) {
45
+ return (0, child_process_1.execFileSync)(PRISMA, args, {
46
+ cwd: ROOT,
47
+ env: Object.assign(Object.assign({}, process.env), { DATABASE_URL: `file:${file}`, CHECKPOINT_DISABLE: '1' }),
48
+ input,
49
+ stdio: 'pipe',
50
+ }).toString();
51
+ }
52
+ function sql(file, statements) {
53
+ prismaCli(['db', 'execute', '--url', `file:${file}`, '--stdin'], file, statements);
54
+ }
55
+ /** Whether the database's tables are exactly what a schema (prisma/schema.prisma) says. */
56
+ function matchesSchema(file, schema = SCHEMA) {
57
+ try {
58
+ prismaCli([
59
+ 'migrate',
60
+ 'diff',
61
+ '--from-url',
62
+ `file:${file}`,
63
+ '--to-schema-datamodel',
64
+ schema,
65
+ '--exit-code',
66
+ ], file);
67
+ return true;
68
+ }
69
+ catch (err) {
70
+ if ((err === null || err === void 0 ? void 0 : err.status) === 2)
71
+ return false;
72
+ throw err;
73
+ }
74
+ }
75
+ function query(file, statement) {
76
+ return __awaiter(this, void 0, void 0, function* () {
77
+ const client = new client_1.PrismaClient({ datasources: { db: { url: `file:${file}` } } });
78
+ try {
79
+ return (yield client.$queryRawUnsafe(statement));
80
+ }
81
+ finally {
82
+ yield client.$disconnect();
83
+ }
84
+ });
85
+ }
86
+ /** `_prisma_migrations`, or null when the database has none. */
87
+ function historyOf(file) {
88
+ return __awaiter(this, void 0, void 0, function* () {
89
+ const [{ n }] = yield query(file, "SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '_prisma_migrations'");
90
+ if (Number(n) === 0)
91
+ return null;
92
+ return query(file, 'SELECT migration_name, finished_at, rolled_back_at FROM "_prisma_migrations"');
93
+ });
94
+ }
95
+ /** `_prisma_migrations`, which the database must have. */
96
+ function recordedHistory(file) {
97
+ return __awaiter(this, void 0, void 0, function* () {
98
+ const rows = yield historyOf(file);
99
+ if (!rows)
100
+ throw new Error(`${file} has no migration history`);
101
+ return rows;
102
+ });
103
+ }
104
+ // A selector fingerprint too, so a migration that changes LocatorEtalon meets a row.
105
+ const SEED = `
106
+ INSERT INTO "Team" ("id", "name", "createdAt") VALUES ('team-1', 'Payments', 1759600000000);
107
+ INSERT INTO "Build" ("id", "name", "createdAt", "updatedAt") VALUES ('build-1', 'nightly #42', 1759600000000, 1759600000000);
108
+ INSERT INTO "LocatorEtalon" ("id", "selector", "strategy", "attributes", "nodeName", "lastSeen", "createdAt", "updatedAt") VALUES ('et-1', '//b', 'xpath', '{}', 'Button', 1759600000000, 1759600000000, 1759600000000);
109
+ `;
110
+ function seededRows(file) {
111
+ return __awaiter(this, void 0, void 0, function* () {
112
+ const teams = yield query(file, 'SELECT id, name FROM "Team"');
113
+ const builds = yield query(file, 'SELECT id, name FROM "Build"');
114
+ const etalons = yield query(file, 'SELECT id, selector AS name FROM "LocatorEtalon"');
115
+ return [...teams, ...builds, ...etalons].map((r) => `${r.id}=${r.name}`).sort();
116
+ });
117
+ }
118
+ const SEEDED = ['build-1=nightly #42', 'et-1=//b', 'team-1=Payments'];
119
+ describe('runMigrations against real databases', function () {
120
+ // Each start runs the prisma CLI two or three times.
121
+ this.timeout(120000);
122
+ let dir;
123
+ let saved;
124
+ /** The release before this one, with its migration history: what `migrate deploy` made. */
125
+ let withHistory;
126
+ /** The release before this one with no migration history: what `db push` made. */
127
+ let withoutHistory;
128
+ /** A history behind its tables: the default setting's `db push` ran on a `migrate deploy` database. */
129
+ let historyBehind;
130
+ /** A history that records a failed migration: `migrate deploy` ran on `historyBehind`. */
131
+ let historyFailed;
132
+ let copies = 0;
133
+ function copyOf(fixture) {
134
+ const file = path_1.default.join(dir, `start-${++copies}.db`);
135
+ if (fixture)
136
+ fs_1.default.copyFileSync(fixture, file);
137
+ return file;
138
+ }
139
+ function start(file, provider) {
140
+ return __awaiter(this, void 0, void 0, function* () {
141
+ config_1.config.databaseUrl = `file:${file}`;
142
+ config_1.config.databaseProvider = provider;
143
+ yield (0, run_migrations_1.runMigrations)();
144
+ });
145
+ }
146
+ before(() => {
147
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-run-migrations-'));
148
+ // The previous release: every migration but the newest, so a start has one to apply.
149
+ const previous = path_1.default.join(dir, 'previous');
150
+ fs_1.default.mkdirSync(path_1.default.join(previous, 'migrations'), { recursive: true });
151
+ fs_1.default.copyFileSync(SCHEMA, path_1.default.join(previous, 'schema.prisma'));
152
+ fs_1.default.copyFileSync(path_1.default.join(MIGRATIONS, 'migration_lock.toml'), path_1.default.join(previous, 'migrations', 'migration_lock.toml'));
153
+ for (const name of LOCAL_MIGRATIONS.slice(0, -1)) {
154
+ fs_1.default.mkdirSync(path_1.default.join(previous, 'migrations', name));
155
+ fs_1.default.copyFileSync(path_1.default.join(MIGRATIONS, name, 'migration.sql'), path_1.default.join(previous, 'migrations', name, 'migration.sql'));
156
+ }
157
+ withHistory = path_1.default.join(dir, 'with-history.db');
158
+ prismaCli(['migrate', 'deploy', '--schema', path_1.default.join(previous, 'schema.prisma')], withHistory);
159
+ withoutHistory = path_1.default.join(dir, 'without-history.db');
160
+ fs_1.default.copyFileSync(withHistory, withoutHistory);
161
+ sql(withoutHistory, 'DROP TABLE "_prisma_migrations";');
162
+ sql(withHistory, SEED);
163
+ sql(withoutHistory, SEED);
164
+ historyBehind = path_1.default.join(dir, 'history-behind.db');
165
+ fs_1.default.copyFileSync(withHistory, historyBehind);
166
+ prismaCli(['db', 'push', '--skip-generate', '--schema', SCHEMA], historyBehind);
167
+ historyFailed = path_1.default.join(dir, 'history-failed.db');
168
+ fs_1.default.copyFileSync(historyBehind, historyFailed);
169
+ try {
170
+ prismaCli(['migrate', 'deploy', '--schema', SCHEMA], historyFailed);
171
+ }
172
+ catch (_a) {
173
+ // P3018: the newest migration is already applied. That is the point.
174
+ }
175
+ });
176
+ after(() => {
177
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
178
+ });
179
+ beforeEach(() => {
180
+ saved = {
181
+ autoMigrate: config_1.config.autoMigrate,
182
+ databaseProvider: config_1.config.databaseProvider,
183
+ databaseUrl: config_1.config.databaseUrl,
184
+ };
185
+ config_1.config.autoMigrate = true;
186
+ });
187
+ afterEach(() => {
188
+ Object.assign(config_1.config, saved);
189
+ });
190
+ it('builds the fixtures it means to', () => __awaiter(this, void 0, void 0, function* () {
191
+ (0, chai_1.expect)(LOCAL_MIGRATIONS.length).to.be.greaterThan(1);
192
+ (0, chai_1.expect)(yield historyOf(withHistory)).to.have.length(LOCAL_MIGRATIONS.length - 1);
193
+ (0, chai_1.expect)(yield historyOf(withoutHistory)).to.equal(null);
194
+ (0, chai_1.expect)(matchesSchema(withoutHistory), 'one migration left to apply').to.equal(false);
195
+ (0, chai_1.expect)(matchesSchema(historyBehind)).to.equal(true);
196
+ const failed = (yield recordedHistory(historyFailed)).filter((r) => !r.finished_at);
197
+ (0, chai_1.expect)(failed.map((r) => r.migration_name)).to.deep.equal([
198
+ LOCAL_MIGRATIONS[LOCAL_MIGRATIONS.length - 1],
199
+ ]);
200
+ }));
201
+ // Through 2.15.0 the setting chose the command, so these two run under both.
202
+ for (const provider of ['sqlite', 'postgresql']) {
203
+ describe(`with databaseProvider ${provider}`, () => {
204
+ it('brings a database kept with db push up to date, keeps its rows, and keeps it on db push', () => __awaiter(this, void 0, void 0, function* () {
205
+ const file = copyOf(withoutHistory);
206
+ yield start(file, provider);
207
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
208
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
209
+ (0, chai_1.expect)(yield historyOf(file), 'no migration history was made up').to.equal(null);
210
+ yield start(file, provider);
211
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
212
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
213
+ }));
214
+ it('applies the missing migration to a database with migration history, and records it', () => __awaiter(this, void 0, void 0, function* () {
215
+ const file = copyOf(withHistory);
216
+ yield start(file, provider);
217
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
218
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
219
+ const history = yield recordedHistory(file);
220
+ (0, chai_1.expect)(history.map((r) => r.migration_name).sort()).to.deep.equal(LOCAL_MIGRATIONS);
221
+ (0, chai_1.expect)(history.every((r) => r.finished_at && !r.rolled_back_at)).to.equal(true);
222
+ yield start(file, provider);
223
+ (0, chai_1.expect)(yield recordedHistory(file)).to.have.length(LOCAL_MIGRATIONS.length);
224
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
225
+ }));
226
+ });
227
+ }
228
+ // The rest ran into trouble under postgresql, the setting that chose migrate deploy.
229
+ it('creates a new database', () => __awaiter(this, void 0, void 0, function* () {
230
+ const file = copyOf(null);
231
+ yield start(file, 'postgresql');
232
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
233
+ yield start(file, 'postgresql');
234
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
235
+ }));
236
+ /** Every local migration recorded as applied, none failed: what migrate deploy leaves. */
237
+ function expectFullHistory(file_1) {
238
+ return __awaiter(this, arguments, void 0, function* (file, migrations = LOCAL_MIGRATIONS) {
239
+ const history = yield recordedHistory(file);
240
+ const applied = history.filter((r) => r.finished_at && !r.rolled_back_at);
241
+ (0, chai_1.expect)(applied.map((r) => r.migration_name).sort()).to.deep.equal(migrations);
242
+ (0, chai_1.expect)(history.filter((r) => !r.finished_at && !r.rolled_back_at)).to.deep.equal([]);
243
+ });
244
+ }
245
+ /** The copies a start tries the migrations on, which it must not leave behind. */
246
+ function trialCopiesOf(file) {
247
+ const prefix = `${path_1.default.basename(file)}.xenon-trial-`;
248
+ return fs_1.default.readdirSync(path_1.default.dirname(file)).filter((n) => n.startsWith(prefix));
249
+ }
250
+ // Re-baselining: db push writes no history, so these two stayed on db push
251
+ // for good, and refused at the first release whose migration drops a column
252
+ // or adds a unique index (see the next releases below). Now their history
253
+ // records what the tables have, and migrate deploy takes over.
254
+ it('records what the tables of a database ahead of its history have, and puts it back on migrate deploy', () => __awaiter(this, void 0, void 0, function* () {
255
+ const file = copyOf(historyBehind);
256
+ yield start(file, 'postgresql');
257
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
258
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
259
+ yield expectFullHistory(file);
260
+ (0, chai_1.expect)(trialCopiesOf(file)).to.deep.equal([]);
261
+ }));
262
+ it('starts a database whose history records a failed migration, and clears it', () => __awaiter(this, void 0, void 0, function* () {
263
+ const file = copyOf(historyFailed);
264
+ yield start(file, 'postgresql');
265
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
266
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
267
+ yield expectFullHistory(file);
268
+ }));
269
+ it('records a migration that was rolled back when the tables have it', () => __awaiter(this, void 0, void 0, function* () {
270
+ const file = copyOf(historyFailed);
271
+ prismaCli(['migrate', 'resolve', '--rolled-back', LOCAL_MIGRATIONS[LOCAL_MIGRATIONS.length - 1]], file);
272
+ yield start(file, 'sqlite');
273
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
274
+ yield expectFullHistory(file);
275
+ }));
276
+ // A history database whose tables differ from its migrations for another
277
+ // reason than db push: a table with a row and an index added by hand. `db
278
+ // push --accept-data-loss` would drop them, and the default setting did.
279
+ // `migrate deploy`, the old `postgresql` path, kept them; a start now tries
280
+ // it on a copy and uses it when it works there. When it doesn't, db push
281
+ // without the flag refuses to drop the table, and the start stops with
282
+ // commands to paste. The cases that stop follow them as an operator would.
283
+ const HAND = 'CREATE TABLE "LabNotes" ("id" INTEGER PRIMARY KEY, "note" TEXT); ' +
284
+ 'INSERT INTO "LabNotes" ("note") VALUES (\'keep me\'); ' +
285
+ 'CREATE INDEX "lab_team_created" ON "Team"("createdAt");';
286
+ function startStops(file) {
287
+ return __awaiter(this, void 0, void 0, function* () {
288
+ try {
289
+ yield start(file, 'sqlite');
290
+ }
291
+ catch (e) {
292
+ return e.message;
293
+ }
294
+ throw new Error('the start was meant to stop');
295
+ });
296
+ }
297
+ /** The commands a failure message gives, one to an indented line, in its order. */
298
+ function commandsIn(message) {
299
+ return message
300
+ .split('\n')
301
+ .filter((line) => /^ {2}(DATABASE_URL=|echo |touch )/.test(line))
302
+ .map((line) => line.trim());
303
+ }
304
+ /** Runs a command from a message as an operator would paste it into a shell. */
305
+ function paste(command) {
306
+ return (0, child_process_1.execSync)(command, {
307
+ shell: '/bin/sh',
308
+ stdio: 'pipe',
309
+ env: Object.assign(Object.assign({}, process.env), { CHECKPOINT_DISABLE: '1' }),
310
+ }).toString();
311
+ }
312
+ /** The file a `VACUUM INTO '<file>'` command writes. */
313
+ function vacuumTarget(command) {
314
+ const match = /VACUUM INTO '([^']+)'/.exec(command);
315
+ if (!match)
316
+ throw new Error(`not a copy: ${command}`);
317
+ return match[1];
318
+ }
319
+ function handAdded(file) {
320
+ return __awaiter(this, void 0, void 0, function* () {
321
+ const [{ n }] = yield query(file, "SELECT count(*) AS n FROM sqlite_master WHERE name = 'LabNotes'");
322
+ const notes = Number(n) === 0
323
+ ? []
324
+ : (yield query(file, 'SELECT note FROM "LabNotes"')).map((r) => r.note);
325
+ const index = yield query(file, "SELECT name FROM sqlite_master WHERE name = 'lab_team_created'");
326
+ return { notes, index: index.length === 1 };
327
+ });
328
+ }
329
+ const KEPT = { notes: ['keep me'], index: true };
330
+ const GONE = { notes: [], index: false };
331
+ /** Whether the tables are a schema's plus what was added by hand. */
332
+ function matchesSchemaWithHandAdded(file, schema = SCHEMA) {
333
+ const probe = `${file}.probe`;
334
+ fs_1.default.copyFileSync(file, probe);
335
+ try {
336
+ sql(probe, 'DROP TABLE IF EXISTS "LabNotes"; DROP INDEX IF EXISTS "lab_team_created";');
337
+ return matchesSchema(probe, schema);
338
+ }
339
+ finally {
340
+ fs_1.default.rmSync(probe, { force: true });
341
+ }
342
+ }
343
+ function failedMigrations(file) {
344
+ return __awaiter(this, void 0, void 0, function* () {
345
+ const history = yield recordedHistory(file);
346
+ return history.filter((r) => !r.finished_at && !r.rolled_back_at).map((r) => r.migration_name);
347
+ });
348
+ }
349
+ it('keeps what was added by hand to a history database: migrate deploy worked on a copy', () => __awaiter(this, void 0, void 0, function* () {
350
+ const file = copyOf(withHistory);
351
+ sql(file, HAND);
352
+ yield start(file, 'sqlite');
353
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(KEPT);
354
+ (0, chai_1.expect)(matchesSchemaWithHandAdded(file)).to.equal(true);
355
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
356
+ yield expectFullHistory(file);
357
+ (0, chai_1.expect)(trialCopiesOf(file), 'the copy is gone').to.deep.equal([]);
358
+ yield start(file, 'sqlite');
359
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(KEPT);
360
+ }));
361
+ // db push drops an index without asking, with or without the flag.
362
+ it('keeps an index added by hand to a history database', () => __awaiter(this, void 0, void 0, function* () {
363
+ const file = copyOf(withHistory);
364
+ sql(file, 'CREATE INDEX "lab_team_created" ON "Team"("createdAt");');
365
+ yield start(file, 'sqlite');
366
+ (0, chai_1.expect)((yield handAdded(file)).index).to.equal(true);
367
+ yield expectFullHistory(file);
368
+ }));
369
+ // Tables ahead of the history and a table added by hand: no run of the
370
+ // migrations matches the tables, and migrate deploy fails on the copy on
371
+ // the change already there (P3018). The file never gets migrate deploy.
372
+ it('never sends migrate deploy to a database whose tables are ahead of its history', () => __awaiter(this, void 0, void 0, function* () {
373
+ const file = copyOf(historyBehind);
374
+ sql(file, HAND);
375
+ // Not what the umask gives: the backup gets the file's own.
376
+ fs_1.default.chmodSync(file, 0o640);
377
+ const message = yield startStops(file);
378
+ (0, chai_1.expect)(message).to.match(/stopped rather than delete/);
379
+ (0, chai_1.expect)(message).to.match(/failed on a copy/);
380
+ (0, chai_1.expect)(message).to.include('LabNotes');
381
+ (0, chai_1.expect)(yield failedMigrations(file), 'the file records no failed migration').to.deep.equal([]);
382
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(KEPT);
383
+ (0, chai_1.expect)(trialCopiesOf(file)).to.deep.equal([]);
384
+ const commands = commandsIn(message);
385
+ (0, chai_1.expect)(commands.filter((c) => c.includes('migrate deploy'))).to.deep.equal([]);
386
+ // One command: the backup, and only once it is made, the data let go.
387
+ const letGo = commands.find((c) => c.includes('--accept-data-loss'));
388
+ (0, chai_1.expect)(letGo, message).to.be.a('string');
389
+ (0, chai_1.expect)(letGo).to.match(/^touch \S+\.backup-\S+ && chmod 640 \S+ && echo .* \| .* db execute /);
390
+ (0, chai_1.expect)(letGo.endsWith(` && DATABASE_URL=file:${file} ${PRISMA} db push --skip-generate --accept-data-loss ` +
391
+ `--schema ${SCHEMA}`), letGo).to.equal(true);
392
+ // A backup that can't be made (its name is taken) lets nothing go.
393
+ const backup = vacuumTarget(letGo);
394
+ fs_1.default.writeFileSync(backup, 'taken');
395
+ (0, chai_1.expect)(() => paste(letGo)).to.throw();
396
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(KEPT);
397
+ fs_1.default.rmSync(backup);
398
+ // Let it go, as it says, and start: the history then catches up.
399
+ paste(letGo);
400
+ (0, chai_1.expect)(fs_1.default.statSync(backup).mode & 0o777).to.equal(0o640);
401
+ yield start(file, 'sqlite');
402
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(GONE);
403
+ (0, chai_1.expect)(yield handAdded(backup)).to.deep.equal(KEPT);
404
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
405
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
406
+ yield expectFullHistory(file);
407
+ }));
408
+ // A failed migration recorded: migrate deploy stops at once (P3009).
409
+ it('never suggests migrate deploy for a history that records a failed migration', () => __awaiter(this, void 0, void 0, function* () {
410
+ const file = copyOf(historyFailed);
411
+ sql(file, HAND);
412
+ const newest = LOCAL_MIGRATIONS[LOCAL_MIGRATIONS.length - 1];
413
+ const message = yield startStops(file);
414
+ (0, chai_1.expect)(message).to.match(/stopped rather than delete/);
415
+ (0, chai_1.expect)(message).to.include(newest);
416
+ (0, chai_1.expect)(message).to.include('LabNotes');
417
+ (0, chai_1.expect)(yield failedMigrations(file)).to.deep.equal([newest]);
418
+ const commands = commandsIn(message);
419
+ (0, chai_1.expect)(commands.filter((c) => c.includes('migrate deploy'))).to.deep.equal([]);
420
+ const letGo = commands.find((c) => c.includes('--accept-data-loss'));
421
+ (0, chai_1.expect)(letGo, message).to.include('.backup-');
422
+ paste(letGo);
423
+ yield start(file, 'sqlite');
424
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(GONE);
425
+ (0, chai_1.expect)(yield handAdded(vacuumTarget(letGo))).to.deep.equal(KEPT);
426
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
427
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
428
+ yield expectFullHistory(file);
429
+ }));
430
+ // A start that died while it tried the migrations on a copy left the copy.
431
+ it('removes a copy a start that died left behind', () => __awaiter(this, void 0, void 0, function* () {
432
+ const file = copyOf(withHistory);
433
+ const leftover = `${file}.xenon-trial-999993`;
434
+ fs_1.default.copyFileSync(file, leftover);
435
+ fs_1.default.writeFileSync(`${leftover}-journal`, '');
436
+ // Signal 0 only asks whether a process is there; stubbed all the same.
437
+ const kill = sinon_1.default.stub(process, 'kill').callsFake((() => {
438
+ throw Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' });
439
+ }));
440
+ try {
441
+ yield start(file, 'sqlite');
442
+ }
443
+ finally {
444
+ kill.restore();
445
+ }
446
+ (0, chai_1.expect)(trialCopiesOf(file)).to.deep.equal([]);
447
+ }));
448
+ // The two kinds of migration `db push` without --accept-data-loss refuses
449
+ // on a table with rows: one that drops a column, one that adds a unique
450
+ // index. Each is made as a release after this one would make it, with
451
+ // Prisma's own migration for the change.
452
+ describe('the next release', () => {
453
+ /**
454
+ * A plugin root for a release after this one: this schema, edited, and its
455
+ * migration. The migration is Prisma's own for the edit, unless `script`
456
+ * gives one written by hand (a backfill changes rows, and no table).
457
+ */
458
+ function nextRelease(name, edit, script, migrationName = `29990101000000_${name}`) {
459
+ const root = path_1.default.join(dir, `next-${name}`);
460
+ fs_1.default.mkdirSync(path_1.default.join(root, 'prisma'), { recursive: true });
461
+ fs_1.default.cpSync(MIGRATIONS, path_1.default.join(root, 'prisma', 'migrations'), { recursive: true });
462
+ const before = fs_1.default.readFileSync(SCHEMA, 'utf8');
463
+ const after = edit(before);
464
+ if (script === undefined)
465
+ (0, chai_1.expect)(after, 'the edit changes the schema').to.not.equal(before);
466
+ const schema = path_1.default.join(root, 'prisma', 'schema.prisma');
467
+ fs_1.default.writeFileSync(schema, after);
468
+ script !== null && script !== void 0 ? script : (script = (0, child_process_1.execFileSync)(PRISMA, [
469
+ 'migrate',
470
+ 'diff',
471
+ '--from-schema-datamodel',
472
+ SCHEMA,
473
+ '--to-schema-datamodel',
474
+ schema,
475
+ '--script',
476
+ ], { cwd: ROOT, env: Object.assign(Object.assign({}, process.env), { CHECKPOINT_DISABLE: '1' }), stdio: 'pipe' }).toString());
477
+ const migration = path_1.default.join(root, 'prisma', 'migrations', migrationName);
478
+ fs_1.default.mkdirSync(migration);
479
+ fs_1.default.writeFileSync(path_1.default.join(migration, 'migration.sql'), script);
480
+ fs_1.default.symlinkSync(path_1.default.join(ROOT, 'node_modules'), path_1.default.join(root, 'node_modules'));
481
+ return root;
482
+ }
483
+ let dropColumn;
484
+ let addUnique;
485
+ /** This release's database with its full history. */
486
+ let current;
487
+ /** This release's database with its full history, and a table and index added by hand. */
488
+ let currentWithHand;
489
+ before(() => __awaiter(this, void 0, void 0, function* () {
490
+ dropColumn = nextRelease('drop_attributes', (schema) => schema.replace(/(model LocatorEtalon \{[^}]*?)\n {2}attributes +String\n/, '$1\n'));
491
+ addUnique = nextRelease('unique_strategy_node', (schema) => schema.replace(/(model LocatorEtalon \{[^}]*?)\n\}/, '$1\n\n @@unique([strategy, nodeName])\n}'));
492
+ current = path_1.default.join(dir, 'current.db');
493
+ fs_1.default.copyFileSync(withHistory, current);
494
+ prismaCli(['migrate', 'deploy', '--schema', SCHEMA], current);
495
+ currentWithHand = path_1.default.join(dir, 'current-with-hand.db');
496
+ fs_1.default.copyFileSync(current, currentWithHand);
497
+ sql(currentWithHand, HAND);
498
+ }));
499
+ function startNext(file, root) {
500
+ return __awaiter(this, void 0, void 0, function* () {
501
+ config_1.config.databaseUrl = `file:${file}`;
502
+ config_1.config.databaseProvider = 'sqlite';
503
+ yield (0, run_migrations_1.runMigrations)(undefined, undefined, { root });
504
+ });
505
+ }
506
+ const migrationsOf = (root) => fs_1.default
507
+ .readdirSync(path_1.default.join(root, 'prisma', 'migrations'))
508
+ .filter((n) => !n.endsWith('.toml'))
509
+ .sort();
510
+ for (const [label, rootOf] of [
511
+ ['drops a column', () => dropColumn],
512
+ ['adds a unique index', () => addUnique],
513
+ ]) {
514
+ describe(`that ${label}`, () => {
515
+ it('updates a database ahead of its history with migrate deploy', () => __awaiter(this, void 0, void 0, function* () {
516
+ const root = rootOf();
517
+ const file = copyOf(historyBehind);
518
+ yield startNext(file, root);
519
+ (0, chai_1.expect)(matchesSchema(file, path_1.default.join(root, 'prisma', 'schema.prisma'))).to.equal(true);
520
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
521
+ yield expectFullHistory(file, migrationsOf(root));
522
+ }));
523
+ it('updates a database with a failed migration recorded with migrate deploy', () => __awaiter(this, void 0, void 0, function* () {
524
+ const root = rootOf();
525
+ const file = copyOf(historyFailed);
526
+ yield startNext(file, root);
527
+ (0, chai_1.expect)(matchesSchema(file, path_1.default.join(root, 'prisma', 'schema.prisma'))).to.equal(true);
528
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
529
+ yield expectFullHistory(file, migrationsOf(root));
530
+ }));
531
+ it('keeps what was added by hand to a database with its full history', () => __awaiter(this, void 0, void 0, function* () {
532
+ const root = rootOf();
533
+ const file = copyOf(currentWithHand);
534
+ yield startNext(file, root);
535
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(KEPT);
536
+ (0, chai_1.expect)(matchesSchemaWithHandAdded(file, path_1.default.join(root, 'prisma', 'schema.prisma'))).to.equal(true);
537
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
538
+ yield expectFullHistory(file, migrationsOf(root));
539
+ }));
540
+ });
541
+ }
542
+ function stopsNext(file, root) {
543
+ return __awaiter(this, void 0, void 0, function* () {
544
+ try {
545
+ yield startNext(file, root);
546
+ }
547
+ catch (e) {
548
+ return e.message;
549
+ }
550
+ throw new Error('the start was meant to stop');
551
+ });
552
+ }
553
+ function teamNames(file) {
554
+ return __awaiter(this, void 0, void 0, function* () {
555
+ return (yield query(file, 'SELECT name FROM "Team"')).map((r) => r.name);
556
+ });
557
+ }
558
+ /** What the history records as applied (sorted) and as failed. */
559
+ function recorded(file) {
560
+ return __awaiter(this, void 0, void 0, function* () {
561
+ const history = yield recordedHistory(file);
562
+ return {
563
+ applied: history
564
+ .filter((r) => r.finished_at && !r.rolled_back_at)
565
+ .map((r) => r.migration_name)
566
+ .sort(),
567
+ failed: yield failedMigrations(file),
568
+ };
569
+ });
570
+ }
571
+ const newestOf = (root) => migrationsOf(root)[migrationsOf(root).length - 1];
572
+ // Prisma's diff compares tables, so it can't see what a migration does to
573
+ // the rows (a backfill), nor a trigger, a view or an index's collation.
574
+ // Through #493, re-baselining recorded such a migration as applied when the
575
+ // tables matched it, so it never ran, and recorded a migration that had
576
+ // failed on the rows as applied at the start after.
577
+ describe('whose migration changes rows', () => {
578
+ let backfill;
579
+ let backfillFails;
580
+ let addThenFill;
581
+ before(() => {
582
+ backfill = nextRelease('backfill', (schema) => schema, 'UPDATE "Team" SET "name" = "name" || \' [backfilled]\';\n');
583
+ // One statement, failing on the team named Payments: SQLite undoes it whole.
584
+ backfillFails = nextRelease('backfill_fails', (schema) => schema, 'UPDATE "Team" SET "name" = CASE WHEN "name" = \'Payments\' THEN NULL ' +
585
+ 'ELSE "name" || \' [backfilled]\' END;\n');
586
+ // A column, then a backfill whose second statement fails on that team.
587
+ // Prisma doesn't run a SQLite migration in a transaction, so the column
588
+ // and the first statement stay.
589
+ addThenFill = nextRelease('team_slug', (schema) => schema.replace(/(model Team \{\n)/, '$1 slug String?\n'), 'ALTER TABLE "Team" ADD COLUMN "slug" TEXT;\n' +
590
+ 'UPDATE "Team" SET "slug" = lower("name");\n' +
591
+ 'UPDATE "Team" SET "name" = NULL WHERE "slug" = \'payments\';\n');
592
+ });
593
+ it('runs a backfill on a database ahead of its history, rather than record it', () => __awaiter(this, void 0, void 0, function* () {
594
+ const file = copyOf(historyBehind);
595
+ yield startNext(file, backfill);
596
+ (0, chai_1.expect)(yield teamNames(file)).to.deep.equal(['Payments [backfilled]']);
597
+ yield expectFullHistory(file, migrationsOf(backfill));
598
+ }));
599
+ it('runs a backfill on a database whose history records a failed migration', () => __awaiter(this, void 0, void 0, function* () {
600
+ const file = copyOf(historyFailed);
601
+ yield startNext(file, backfill);
602
+ (0, chai_1.expect)(yield teamNames(file)).to.deep.equal(['Payments [backfilled]']);
603
+ yield expectFullHistory(file, migrationsOf(backfill));
604
+ }));
605
+ // db push made the tables of both migrations after the history, the
606
+ // backfill's and a column's: the tables match the column's run, and say
607
+ // nothing of whether the backfill ran (db push never runs one).
608
+ it('leaves the history alone when a backfill is among what db push already made', () => __awaiter(this, void 0, void 0, function* () {
609
+ const root = nextRelease('backfill_then_column', (schema) => schema.replace(/(model Team \{\n)/, '$1 slug String?\n'), 'ALTER TABLE "Team" ADD COLUMN "slug" TEXT;\n', '29990102000000_team_slug');
610
+ const backfillDir = path_1.default.join(root, 'prisma', 'migrations', newestOf(backfill));
611
+ fs_1.default.mkdirSync(backfillDir);
612
+ fs_1.default.copyFileSync(path_1.default.join(backfill, 'prisma', 'migrations', newestOf(backfill), 'migration.sql'), path_1.default.join(backfillDir, 'migration.sql'));
613
+ const file = copyOf(current);
614
+ prismaCli(['db', 'push', '--skip-generate', '--schema', path_1.default.join(root, 'prisma', 'schema.prisma')], file);
615
+ yield startNext(file, root);
616
+ (0, chai_1.expect)(yield recorded(file)).to.deep.equal({ applied: LOCAL_MIGRATIONS, failed: [] });
617
+ (0, chai_1.expect)(yield teamNames(file)).to.deep.equal(['Payments']);
618
+ (0, chai_1.expect)(matchesSchema(file, path_1.default.join(root, 'prisma', 'schema.prisma'))).to.equal(true);
619
+ }));
620
+ it('keeps a backfill that failed on the rows failed until they change, then runs it', () => __awaiter(this, void 0, void 0, function* () {
621
+ const newest = newestOf(backfillFails);
622
+ const file = copyOf(current);
623
+ for (const attempt of [1, 2]) {
624
+ const message = yield stopsNext(file, backfillFails);
625
+ (0, chai_1.expect)(message, `start ${attempt}`).to.include(newest);
626
+ (0, chai_1.expect)(message).to.include('NOT NULL constraint failed');
627
+ (0, chai_1.expect)(message).to.match(/change those rows, and start again: the next start runs it/);
628
+ (0, chai_1.expect)(yield recorded(file), `start ${attempt}`).to.deep.equal({
629
+ applied: LOCAL_MIGRATIONS,
630
+ failed: [newest],
631
+ });
632
+ (0, chai_1.expect)(yield teamNames(file)).to.deep.equal(['Payments']);
633
+ }
634
+ sql(file, 'UPDATE "Team" SET "name" = \'Payments team\';');
635
+ yield startNext(file, backfillFails);
636
+ (0, chai_1.expect)(yield teamNames(file)).to.deep.equal(['Payments team [backfilled]']);
637
+ yield expectFullHistory(file, migrationsOf(backfillFails));
638
+ }));
639
+ it('never records a migration that failed part-way as applied', () => __awaiter(this, void 0, void 0, function* () {
640
+ const newest = newestOf(addThenFill);
641
+ const file = copyOf(current);
642
+ let message = '';
643
+ for (const attempt of [1, 2]) {
644
+ message = yield stopsNext(file, addThenFill);
645
+ (0, chai_1.expect)(message, `start ${attempt}`).to.include(newest);
646
+ (0, chai_1.expect)(message).to.include('NOT NULL constraint failed');
647
+ (0, chai_1.expect)(message).to.match(/part of it/);
648
+ (0, chai_1.expect)(message).to.not.match(/change those rows, and start again/);
649
+ (0, chai_1.expect)(yield recorded(file), `start ${attempt}`).to.deep.equal({
650
+ applied: LOCAL_MIGRATIONS,
651
+ failed: [newest],
652
+ });
653
+ }
654
+ // As the message says: change the rows, finish what the migration
655
+ // didn't do, record it, and start.
656
+ const commands = commandsIn(message);
657
+ const applied = commands.find((c) => c.includes(`migrate resolve --applied ${newest}`));
658
+ const rolledBack = commands.find((c) => c.includes(`migrate resolve --rolled-back ${newest}`));
659
+ (0, chai_1.expect)([applied, rolledBack].every(Boolean), message).to.equal(true);
660
+ sql(file, 'UPDATE "Team" SET "name" = \'Payments team\', "slug" = \'payments team\';');
661
+ paste(applied);
662
+ yield startNext(file, addThenFill);
663
+ yield expectFullHistory(file, migrationsOf(addThenFill));
664
+ (0, chai_1.expect)(matchesSchema(file, path_1.default.join(addThenFill, 'prisma', 'schema.prisma'))).to.equal(true);
665
+ }));
666
+ });
667
+ /** The columns a table has. */
668
+ function columnsOf(file, table) {
669
+ return __awaiter(this, void 0, void 0, function* () {
670
+ const rows = yield query(file, `SELECT name FROM pragma_table_info('${table}')`);
671
+ return rows.map((r) => r.name);
672
+ });
673
+ }
674
+ // `migrate deploy` applies migrations and nothing else, so that they work
675
+ // on a copy says nothing of what the tables lacked before them. Through
676
+ // #493 a copy that worked wrote the full history over tables that lacked
677
+ // part of the schema, and every later start took the history's word. A
678
+ // migration that redefines a table lacking a column filled the column
679
+ // with its own name: SQLite reads an unknown "path" as the string 'path'.
680
+ for (const [label, rootOf] of [
681
+ ['drops a column', () => dropColumn],
682
+ ['adds a unique index', () => addUnique],
683
+ ]) {
684
+ describe(`that ${label}, on tables that lack part of this release`, () => {
685
+ it('does not record it over an index dropped by hand', () => __awaiter(this, void 0, void 0, function* () {
686
+ const root = rootOf();
687
+ const file = copyOf(current);
688
+ sql(file, 'DROP INDEX "SessionLog_session_id_createdAt_idx";');
689
+ const message = yield stopsNext(file, root);
690
+ (0, chai_1.expect)(message).to.match(/differ from what its recorded migrations make: they lack part of what those make/);
691
+ (0, chai_1.expect)(message).to.include('session_id, createdAt');
692
+ (0, chai_1.expect)(yield recorded(file)).to.deep.equal({ applied: LOCAL_MIGRATIONS, failed: [] });
693
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
694
+ (0, chai_1.expect)(trialCopiesOf(file)).to.deep.equal([]);
695
+ }));
696
+ // An older release's db push --accept-data-loss (the default through
697
+ // 2.15.0) takes away the newest migration's column and leaves the history.
698
+ it('does not record it over a database an older release pushed back', () => __awaiter(this, void 0, void 0, function* () {
699
+ const root = rootOf();
700
+ const file = copyOf(current);
701
+ const older = path_1.default.join(dir, `older-${path_1.default.basename(root)}.prisma`);
702
+ fs_1.default.writeFileSync(older, fs_1.default
703
+ .readFileSync(SCHEMA, 'utf8')
704
+ .replace(/\n(\s*\/\/\/[^\n]*\n)*\s*path\s+String\?\n/, '\n'));
705
+ prismaCli(['db', 'push', '--skip-generate', '--accept-data-loss', '--schema', older], file);
706
+ (0, chai_1.expect)(matchesSchema(file, older), 'the older release pushed it').to.equal(true);
707
+ const message = yield stopsNext(file, root);
708
+ (0, chai_1.expect)(message).to.match(/differ from what its recorded migrations make: they lack part of what those make/);
709
+ (0, chai_1.expect)(message).to.include('LocatorEtalon');
710
+ (0, chai_1.expect)(yield recorded(file)).to.deep.equal({ applied: LOCAL_MIGRATIONS, failed: [] });
711
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
712
+ (0, chai_1.expect)(yield columnsOf(file, 'LocatorEtalon')).to.not.include('path');
713
+ (0, chai_1.expect)(yield columnsOf(file, 'LocatorEtalon')).to.include('attributes');
714
+ }));
715
+ });
716
+ }
717
+ // A schema with a change no migration makes (a build from a checkout
718
+ // someone edited, say): the migrations work on the copy, which still
719
+ // lacks the change, so the file gets neither them nor a full history.
720
+ it('does not record a release over tables that lack what its schema has beyond its migrations', () => __awaiter(this, void 0, void 0, function* () {
721
+ const dropScript = fs_1.default.readFileSync(path_1.default.join(dropColumn, 'prisma', 'migrations', newestOf(dropColumn), 'migration.sql'), 'utf8');
722
+ const unmigrated = nextRelease('drop_and_unmigrated_index', () => fs_1.default
723
+ .readFileSync(path_1.default.join(dropColumn, 'prisma', 'schema.prisma'), 'utf8')
724
+ .replace(/(model Team \{[^}]*?)\n\}/, '$1\n\n @@index([name, createdAt])\n}'), dropScript, newestOf(dropColumn));
725
+ const file = copyOf(currentWithHand);
726
+ const message = yield stopsNext(file, unmigrated);
727
+ (0, chai_1.expect)(message).to.match(/after this version's migrations a copy of it still lacked part/);
728
+ (0, chai_1.expect)(message).to.include('name, createdAt');
729
+ (0, chai_1.expect)(yield recorded(file)).to.deep.equal({ applied: LOCAL_MIGRATIONS, failed: [] });
730
+ (0, chai_1.expect)(yield handAdded(file)).to.deep.equal(KEPT);
731
+ (0, chai_1.expect)(yield columnsOf(file, 'LocatorEtalon')).to.include('attributes');
732
+ (0, chai_1.expect)(trialCopiesOf(file)).to.deep.equal([]);
733
+ }));
734
+ // A history that names a migration this release doesn't have (a later
735
+ // release's, or another branch's) belongs to another release. Through
736
+ // #493 only re-baselining left it alone: the copy ran this release's
737
+ // migrations on it and the file got them, dropping a column the other
738
+ // release still has.
739
+ it('leaves a database from another release alone', () => __awaiter(this, void 0, void 0, function* () {
740
+ const elsewhere = nextRelease('elsewhere', (schema) => schema.replace(/(model Team \{\n)/, '$1 elsewhere String?\n'), 'ALTER TABLE "Team" ADD COLUMN "elsewhere" TEXT;\n', '29980101000000_elsewhere');
741
+ const file = copyOf(current);
742
+ prismaCli(['migrate', 'deploy', '--schema', path_1.default.join(elsewhere, 'prisma', 'schema.prisma')], file);
743
+ sql(file, 'UPDATE "Team" SET "elsewhere" = \'other release\';');
744
+ const message = yield stopsNext(file, dropColumn);
745
+ (0, chai_1.expect)(message).to.match(/another release/);
746
+ (0, chai_1.expect)(message).to.include('29980101000000_elsewhere');
747
+ const { applied } = yield recorded(file);
748
+ (0, chai_1.expect)(applied).to.not.include(newestOf(dropColumn));
749
+ (0, chai_1.expect)(yield query(file, 'SELECT attributes FROM "LocatorEtalon"')).to.have.length(1);
750
+ (0, chai_1.expect)(trialCopiesOf(file)).to.deep.equal([]);
751
+ }));
752
+ // Minor: `npm run db:migrate` warns of a schema edit with no migration.
753
+ it('tells schema changes with no migration from a schema its migrations make', () => {
754
+ const unmigrated = path_1.default.join(dir, 'unmigrated');
755
+ fs_1.default.mkdirSync(path_1.default.join(unmigrated, 'prisma'), { recursive: true });
756
+ fs_1.default.cpSync(MIGRATIONS, path_1.default.join(unmigrated, 'prisma', 'migrations'), { recursive: true });
757
+ fs_1.default.copyFileSync(path_1.default.join(dropColumn, 'prisma', 'schema.prisma'), path_1.default.join(unmigrated, 'prisma', 'schema.prisma'));
758
+ fs_1.default.symlinkSync(path_1.default.join(ROOT, 'node_modules'), path_1.default.join(unmigrated, 'node_modules'));
759
+ (0, chai_1.expect)((0, run_migrations_1.warnOfUnmigratedSchemaChanges)(undefined, { root: ROOT })).to.equal(false);
760
+ (0, chai_1.expect)((0, run_migrations_1.warnOfUnmigratedSchemaChanges)(undefined, { root: dropColumn })).to.equal(false);
761
+ (0, chai_1.expect)((0, run_migrations_1.warnOfUnmigratedSchemaChanges)(undefined, { root: unmigrated })).to.equal(true);
762
+ });
763
+ });
764
+ // Prisma resolves a relative SQLite URL against the schema's directory,
765
+ // prisma/. Through the first cut of this change the history was read through
766
+ // a client that couldn't open a file in a directory that wasn't there yet,
767
+ // where the CLI makes it.
768
+ it('creates a new database at a relative file: URL in a directory that is not there yet', () => __awaiter(this, void 0, void 0, function* () {
769
+ const target = path_1.default.join(dir, 'relative', 'nested', 'xenon.db');
770
+ const relative = path_1.default.relative(path_1.default.dirname(SCHEMA), target);
771
+ (0, chai_1.expect)(path_1.default.isAbsolute(relative)).to.equal(false);
772
+ config_1.config.databaseUrl = `file:${relative}`;
773
+ config_1.config.databaseProvider = 'sqlite';
774
+ yield (0, run_migrations_1.runMigrations)();
775
+ (0, chai_1.expect)(fs_1.default.existsSync(target), 'the database is where the URL points').to.equal(true);
776
+ (0, chai_1.expect)(matchesSchema(target)).to.equal(true);
777
+ }));
778
+ // A `_prisma_migrations` table that isn't Prisma's (other columns) is no
779
+ // migration history. The default setting started on such a database.
780
+ it("starts a database whose _prisma_migrations table isn't Prisma's", () => __awaiter(this, void 0, void 0, function* () {
781
+ const file = copyOf(withoutHistory);
782
+ sql(file, 'CREATE TABLE "_prisma_migrations" ("id" TEXT PRIMARY KEY, "migration_name" TEXT);');
783
+ yield start(file, 'postgresql');
784
+ (0, chai_1.expect)(matchesSchema(file)).to.equal(true);
785
+ (0, chai_1.expect)(yield seededRows(file)).to.deep.equal(SEEDED);
786
+ }));
787
+ // `npm run db:migrate` checks the URL as a start does, before reading anything.
788
+ it('stops npm run db:migrate on a PostgreSQL URL with the database check, not a read error', () => {
789
+ var _a, _b;
790
+ let output = '';
791
+ let status = 0;
792
+ try {
793
+ output = (0, child_process_1.execFileSync)(path_1.default.join(ROOT, 'node_modules', '.bin', 'ts-node'), ['-T', path_1.default.join(ROOT, 'src', 'scripts', 'initialize-database.ts')], {
794
+ cwd: ROOT,
795
+ env: Object.assign(Object.assign({}, process.env), { DATABASE_URL: 'postgresql://xenon:pw@127.0.0.1:1/xenon', XENON_DB_PROVIDER: '' }),
796
+ stdio: 'pipe',
797
+ }).toString();
798
+ }
799
+ catch (err) {
800
+ status = err.status;
801
+ output = `${(_a = err.stdout) !== null && _a !== void 0 ? _a : ''}${(_b = err.stderr) !== null && _b !== void 0 ? _b : ''}`;
802
+ }
803
+ (0, chai_1.expect)(status, output).to.not.equal(0);
804
+ (0, chai_1.expect)(output).to.match(/built for SQLite/);
805
+ (0, chai_1.expect)(output).to.not.match(/could not read/);
806
+ });
807
+ });