@roster-lock/match-agent 0.1.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 (174) hide show
  1. package/LICENSE.md +11 -0
  2. package/dist/authentication.js +53 -0
  3. package/dist/authentication.js.map +1 -0
  4. package/dist/config/constants.js +12 -0
  5. package/dist/config/constants.js.map +1 -0
  6. package/dist/config/findFile.js +32 -0
  7. package/dist/config/findFile.js.map +1 -0
  8. package/dist/config/index.js +29 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/manage.js +41 -0
  11. package/dist/config/manage.js.map +1 -0
  12. package/dist/cors.js +20 -0
  13. package/dist/cors.js.map +1 -0
  14. package/dist/editor-support/index.js +247 -0
  15. package/dist/editor-support/index.js.map +1 -0
  16. package/dist/handle-room/version-1/file-routes.js +53 -0
  17. package/dist/handle-room/version-1/file-routes.js.map +1 -0
  18. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
  19. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
  20. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
  21. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
  22. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
  23. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
  24. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
  25. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
  26. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
  27. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
  28. package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
  29. package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
  30. package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
  31. package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
  32. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
  33. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
  34. package/dist/handle-room/version-1/globals/Room/index.js +18 -0
  35. package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
  36. package/dist/handle-room/version-1/globals/Room/room.js +3 -0
  37. package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
  38. package/dist/handle-room/version-1/globals/index.js +20 -0
  39. package/dist/handle-room/version-1/globals/index.js.map +1 -0
  40. package/dist/handle-room/version-1/globals/types.js +3 -0
  41. package/dist/handle-room/version-1/globals/types.js.map +1 -0
  42. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
  43. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
  44. package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
  45. package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
  46. package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
  47. package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
  48. package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
  49. package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
  50. package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
  51. package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
  52. package/dist/handle-room/version-1/index.js +28 -0
  53. package/dist/handle-room/version-1/index.js.map +1 -0
  54. package/dist/handle-room/version-1/piece-sort.js +71 -0
  55. package/dist/handle-room/version-1/piece-sort.js.map +1 -0
  56. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
  57. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
  58. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
  59. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
  60. package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
  61. package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
  62. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
  63. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
  64. package/dist/handle-room/version-1/schema/count.js +16 -0
  65. package/dist/handle-room/version-1/schema/count.js.map +1 -0
  66. package/dist/handle-room/version-1/schema/lock.js +46 -0
  67. package/dist/handle-room/version-1/schema/lock.js.map +1 -0
  68. package/dist/handle-room/version-1/schema/selected.js +13 -0
  69. package/dist/handle-room/version-1/schema/selected.js.map +1 -0
  70. package/dist/handle-room/version-1/select.js +94 -0
  71. package/dist/handle-room/version-1/select.js.map +1 -0
  72. package/dist/index.js +180 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/server.js +88 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/utils/fs.js +32 -0
  77. package/dist/utils/fs.js.map +1 -0
  78. package/dist/utils/http-router.js +49 -0
  79. package/dist/utils/http-router.js.map +1 -0
  80. package/dist/utils/pagination.js +23 -0
  81. package/dist/utils/pagination.js.map +1 -0
  82. package/dist/utils/websocket-router.js +15 -0
  83. package/dist/utils/websocket-router.js.map +1 -0
  84. package/package.json +66 -0
  85. package/src/authentication.ts +48 -0
  86. package/src/config/constants.ts +7 -0
  87. package/src/config/findFile.ts +34 -0
  88. package/src/config/index.ts +17 -0
  89. package/src/config/manage.ts +48 -0
  90. package/src/cors.ts +23 -0
  91. package/src/editor-support/index.ts +289 -0
  92. package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
  93. package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
  94. package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
  95. package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
  96. package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
  97. package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
  98. package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
  99. package/src/handle-room/version-0/piece-download/index.ts +5 -0
  100. package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
  101. package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
  102. package/src/handle-room/version-0/piece-selection/index.ts +31 -0
  103. package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
  104. package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
  105. package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
  106. package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
  107. package/src/handle-room/version-1/constants.ts +44 -0
  108. package/src/handle-room/version-1/file-routes.ts +68 -0
  109. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
  110. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
  111. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
  112. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
  113. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
  114. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
  115. package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
  116. package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
  117. package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
  118. package/src/handle-room/version-1/globals/Room/index.ts +3 -0
  119. package/src/handle-room/version-1/globals/Room/room.ts +8 -0
  120. package/src/handle-room/version-1/globals/index.ts +4 -0
  121. package/src/handle-room/version-1/globals/types.ts +7 -0
  122. package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
  123. package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
  124. package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
  125. package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
  126. package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
  127. package/src/handle-room/version-1/index.ts +33 -0
  128. package/src/handle-room/version-1/piece-sort.ts +96 -0
  129. package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
  130. package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
  131. package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
  132. package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
  133. package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
  134. package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
  135. package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
  136. package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
  137. package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
  138. package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
  139. package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
  140. package/src/handle-room/version-1/schema/count.ts +13 -0
  141. package/src/handle-room/version-1/schema/lock.ts +47 -0
  142. package/src/handle-room/version-1/schema/selected.ts +14 -0
  143. package/src/handle-room/version-1/select.ts +130 -0
  144. package/src/handle-room/version-1/validateUnknown.ts +11 -0
  145. package/src/index.old.ts +71 -0
  146. package/src/index.ts +231 -0
  147. package/src/server.ts +85 -0
  148. package/src/utils/fs.ts +43 -0
  149. package/src/utils/http-router.ts +55 -0
  150. package/src/utils/pagination.ts +17 -0
  151. package/src/utils/websocket-router.ts +23 -0
  152. package/tests/integration/editor-pieces-search.test.ts +147 -0
  153. package/tests/integration/game-complete.test.ts +72 -0
  154. package/tests/integration/handle-full-selection.test.ts +143 -0
  155. package/tests/integration/helpers/bridge.ts +22 -0
  156. package/tests/integration/helpers/fakeFolderDB.ts +37 -0
  157. package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
  158. package/tests/integration/helpers/lockConfig.ts +71 -0
  159. package/tests/integration/helpers/make-tar-gz.ts +41 -0
  160. package/tests/integration/helpers/piece.ts +98 -0
  161. package/tests/integration/helpers/plugin-dir.ts +26 -0
  162. package/tests/integration/helpers/room.ts +42 -0
  163. package/tests/integration/helpers/server.ts +51 -0
  164. package/tests/integration/helpers/validLockConfig.ts +80 -0
  165. package/tests/integration/piece-download-and-list.test.ts +194 -0
  166. package/tests/integration/piece-ensure-ws.test.ts +256 -0
  167. package/tests/integration/piece-files.test.ts +139 -0
  168. package/tests/integration/piece-sort-list.test.ts +113 -0
  169. package/tests/integration/protocol.test.ts +129 -0
  170. package/tests/integration/root-and-auth.test.ts +100 -0
  171. package/tests/integration/sync-dl.test.ts +62 -0
  172. package/tests/tsconfig.json +14 -0
  173. package/tsconfig.json +22 -0
  174. package/vitest.config.ts +10 -0
@@ -0,0 +1,547 @@
1
+
2
+ import { RosterLockV1Config } from "@roster-lock/types";
3
+ import { DatabaseSync } from "node:sqlite";
4
+
5
+ type RosterLockPiece = RosterLockV1Config["rosters"][string][number];
6
+ type StoredPiece = (
7
+ & {
8
+ engineName: string,
9
+ pieceType: string,
10
+ }
11
+ & Pick<RosterLockPiece, "version" | "humanInfo" | "pathVariables" | "downloadSources">
12
+ );
13
+ /*
14
+ type JSONType = {
15
+ engineName: string,
16
+ pieceType: string,
17
+ version: {
18
+ logic: string;
19
+ media: string;
20
+ docs: string;
21
+ },
22
+ humanInfo: {
23
+ name: string;
24
+ author: string;
25
+ url: string;
26
+ image?: string | undefined;
27
+ },
28
+ pathVariables: Record<string, string>,
29
+ downloadSources: Array<string>
30
+ }
31
+ */
32
+
33
+ type PieceIndex = {
34
+ engineName: string,
35
+ pieceType: string,
36
+ logic: string,
37
+ media: string
38
+ }
39
+
40
+ type HumanInfo = RosterLockPiece["humanInfo"];
41
+
42
+ export type PieceRow = {
43
+ engine_name: string,
44
+ piece_type: string,
45
+ logic_hash: string,
46
+ media_hash: string,
47
+ docs_hash: string,
48
+ human_info: HumanInfo,
49
+ path_variables: Record<string, string>,
50
+ folder_name: string,
51
+ status: "pending" | "complete" | "error",
52
+ created_at: number,
53
+ completed_at: number | null,
54
+ }
55
+
56
+ // human_info and path_variables as they come off the wire - JSON text.
57
+ type PieceRowRaw = Omit<PieceRow, "human_info" | "path_variables"> & {
58
+ human_info: string,
59
+ path_variables: string,
60
+ };
61
+
62
+ export type DownloadSourceRow = {
63
+ source: string,
64
+ // unixepoch seconds; null = never tested
65
+ last_test: number | null,
66
+ success: 0 | 1 | null,
67
+ error: string | null,
68
+ }
69
+
70
+ export const SCHEMA = `
71
+ CREATE TABLE IF NOT EXISTS pieces (
72
+ engine_name TEXT NOT NULL,
73
+ piece_type TEXT NOT NULL,
74
+ logic_hash TEXT NOT NULL,
75
+ media_hash TEXT NOT NULL,
76
+ docs_hash TEXT NOT NULL,
77
+ human_info TEXT NOT NULL,
78
+ path_variables TEXT,
79
+ folder_name TEXT NOT NULL,
80
+ status TEXT NOT NULL CHECK(status IN ('pending', 'complete', 'error')),
81
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
82
+ completed_at INTEGER,
83
+ PRIMARY KEY (engine_name, piece_type, logic_hash, media_hash)
84
+ );
85
+
86
+ CREATE INDEX IF NOT EXISTS idx_status ON pieces(status);
87
+ CREATE INDEX IF NOT EXISTS idx_folder ON pieces(folder_name);
88
+
89
+ CREATE TABLE IF NOT EXISTS download_sources (
90
+ engine_name TEXT NOT NULL,
91
+ piece_type TEXT NOT NULL,
92
+ logic_hash TEXT NOT NULL,
93
+ media_hash TEXT NOT NULL,
94
+ source TEXT NOT NULL,
95
+ last_test INTEGER,
96
+ success INTEGER,
97
+ error TEXT,
98
+ PRIMARY KEY (engine_name, piece_type, logic_hash, media_hash, source),
99
+ FOREIGN KEY (engine_name, piece_type, logic_hash, media_hash)
100
+ REFERENCES pieces(engine_name, piece_type, logic_hash, media_hash)
101
+ );
102
+ `;
103
+
104
+ const PIECE_WHERE = `
105
+ WHERE engine_name = @engineName
106
+ AND piece_type = @pieceType
107
+ AND logic_hash = @logic
108
+ AND media_hash = @media
109
+ `;
110
+
111
+
112
+ function parsePieceRow(row: PieceRowRaw, sources: Array<DownloadSourceRow>): StoredPiece {
113
+ return {
114
+ engineName: row.engine_name,
115
+ pieceType: row.piece_type,
116
+ version: {
117
+ logic: row.logic_hash,
118
+ media: row.media_hash,
119
+ docs: row.docs_hash
120
+ },
121
+ humanInfo: JSON.parse(row.human_info),
122
+ pathVariables: JSON.parse(row.path_variables),
123
+ downloadSources: sources.map(({ source })=>(source))
124
+ };
125
+ }
126
+
127
+ export function prepareDatabase(dbLocation: string){
128
+ const db = new DatabaseSync(dbLocation);
129
+ db.exec("PRAGMA journal_mode = WAL;");
130
+ db.exec(SCHEMA);
131
+ migrateLegacyDatabase(db);
132
+ return {
133
+ db,
134
+
135
+ close(){
136
+ db.close();
137
+ },
138
+
139
+ getPiece(
140
+ pieceInfo: PieceIndex
141
+ ): undefined | StoredPiece {
142
+ const item = db.prepare(
143
+ `SELECT * FROM pieces ${PIECE_WHERE}`
144
+ ).get(pieceInfo) as PieceRowRaw | undefined;
145
+ if(!item) return undefined;
146
+ const sources = db.prepare(
147
+ `SELECT * FROM download_sources ${PIECE_WHERE}`
148
+ ).all(pieceInfo) as Array<DownloadSourceRow>;
149
+ return parsePieceRow(item, sources);
150
+ },
151
+
152
+ // Agent-side bookkeeping about a piece's download - the part of a row
153
+ // that isn't config-shaped and so doesn't belong on StoredPiece.
154
+ getPieceState(
155
+ pieceInfo: PieceIndex
156
+ ): undefined | {
157
+ status: PieceRow["status"],
158
+ folderName: string,
159
+ // unixepoch seconds; null for rows that predate completion tracking
160
+ completedAt: number | null,
161
+ } {
162
+ const item = db.prepare(
163
+ `SELECT status, folder_name, completed_at FROM pieces ${PIECE_WHERE}`
164
+ ).get(pieceInfo) as Pick<PieceRow, "status" | "folder_name" | "completed_at"> | undefined;
165
+ if(!item) return undefined;
166
+ return {
167
+ status: item.status,
168
+ folderName: item.folder_name,
169
+ completedAt: item.completed_at,
170
+ };
171
+ },
172
+
173
+ getDownloadSources(
174
+ pieceInfo: PieceIndex
175
+ ){
176
+ return db.prepare(
177
+ `
178
+ SELECT source, last_test, success, error FROM download_sources
179
+ ${PIECE_WHERE}
180
+ ORDER BY rowid ASC
181
+ `
182
+ ).all(pieceInfo) as Array<DownloadSourceRow>;
183
+ },
184
+
185
+ // Batched form of getPieceState for a page of rows - one query instead
186
+ // of one per piece. Keyed by "pieceType\x00logic\x00media".
187
+ getPieceStatesFor(
188
+ engineName: string,
189
+ pieces: Array<{ pieceType: string, logic: string, media: string }>
190
+ ): Map<string, { status: PieceRow["status"], folderName: string, completedAt: number | null }> {
191
+ const result = new Map<string, { status: PieceRow["status"], folderName: string, completedAt: number | null }>();
192
+ if(pieces.length === 0) return result;
193
+ const values = pieces.map((_, i)=>`(@pieceType${i}, @logic${i}, @media${i})`).join(", ");
194
+ const params: Record<string, string> = { engineName };
195
+ pieces.forEach((piece, i)=>{
196
+ params[`pieceType${i}`] = piece.pieceType;
197
+ params[`logic${i}`] = piece.logic;
198
+ params[`media${i}`] = piece.media;
199
+ });
200
+ const rows = db.prepare(
201
+ `
202
+ SELECT piece_type, logic_hash, media_hash, status, folder_name, completed_at
203
+ FROM pieces
204
+ WHERE engine_name = @engineName
205
+ AND (piece_type, logic_hash, media_hash) IN (VALUES ${values})
206
+ `
207
+ ).all(params) as Array<
208
+ Pick<PieceRow, "status" | "folder_name" | "completed_at">
209
+ & { piece_type: string, logic_hash: string, media_hash: string }
210
+ >;
211
+ for(const row of rows){
212
+ const key = `${row.piece_type}\x00${row.logic_hash}\x00${row.media_hash}`;
213
+ result.set(key, {
214
+ status: row.status, folderName: row.folder_name, completedAt: row.completed_at,
215
+ });
216
+ }
217
+ return result;
218
+ },
219
+
220
+ // Batched form of getDownloadSources for a page of rows - one query
221
+ // instead of one per piece. Keyed by "pieceType\x00logic\x00media" so
222
+ // callers can group results back onto the piece they came from.
223
+ getDownloadSourcesFor(
224
+ engineName: string,
225
+ pieces: Array<{ pieceType: string, logic: string, media: string }>
226
+ ): Map<string, Array<DownloadSourceRow>> {
227
+ const result = new Map<string, Array<DownloadSourceRow>>();
228
+ if(pieces.length === 0) return result;
229
+ const values = pieces.map((_, i)=>`(@pieceType${i}, @logic${i}, @media${i})`).join(", ");
230
+ const params: Record<string, string> = { engineName };
231
+ pieces.forEach((piece, i)=>{
232
+ params[`pieceType${i}`] = piece.pieceType;
233
+ params[`logic${i}`] = piece.logic;
234
+ params[`media${i}`] = piece.media;
235
+ });
236
+ const rows = db.prepare(
237
+ `
238
+ SELECT piece_type, logic_hash, media_hash, source, last_test, success, error
239
+ FROM download_sources
240
+ WHERE engine_name = @engineName
241
+ AND (piece_type, logic_hash, media_hash) IN (VALUES ${values})
242
+ ORDER BY rowid ASC
243
+ `
244
+ ).all(params) as Array<DownloadSourceRow & {
245
+ piece_type: string, logic_hash: string, media_hash: string
246
+ }>;
247
+ for(const row of rows){
248
+ const key = `${row.piece_type}\x00${row.logic_hash}\x00${row.media_hash}`;
249
+ const existing = result.get(key);
250
+ if(existing) existing.push(row);
251
+ else result.set(key, [row]);
252
+ }
253
+ return result;
254
+ },
255
+
256
+ insertNewPiece(
257
+ piece: StoredPiece,
258
+ folderName: string,
259
+ ){
260
+ const pieceInfo = {
261
+ engineName: piece.engineName,
262
+ pieceType: piece.pieceType,
263
+ logic: piece.version.logic,
264
+ media: piece.version.media
265
+ };
266
+ const result = db.prepare(
267
+ `INSERT INTO pieces (
268
+ engine_name, piece_type, logic_hash, media_hash,
269
+ docs_hash,
270
+ human_info,
271
+ path_variables,
272
+ folder_name,
273
+ status
274
+ ) VALUES (
275
+ @engineName, @pieceType, @logic, @media,
276
+ @docsHash,
277
+ @humanInfo,
278
+ @pathVariables,
279
+ @folderName,
280
+ 'pending'
281
+ )`
282
+ ).run({
283
+ ...pieceInfo,
284
+ docsHash: piece.version.docs,
285
+ humanInfo: JSON.stringify(piece.humanInfo),
286
+ pathVariables: JSON.stringify(piece.pathVariables),
287
+ folderName,
288
+ });
289
+ const insertSource = db.prepare(
290
+ `INSERT OR IGNORE INTO download_sources (
291
+ engine_name, piece_type, logic_hash, media_hash, source
292
+ ) VALUES (@engineName, @pieceType, @logic, @media, @source)`
293
+ );
294
+ for(const source of piece.downloadSources){
295
+ insertSource.run({ ...pieceInfo, source });
296
+ }
297
+ return result;
298
+ },
299
+
300
+ recordSourceTest(
301
+ pieceInfo: PieceIndex,
302
+ source: string,
303
+ result: { success: boolean, error?: string },
304
+ ){
305
+ return db.prepare(
306
+ `INSERT INTO download_sources (
307
+ engine_name, piece_type, logic_hash, media_hash, source,
308
+ last_test, success, error
309
+ ) VALUES (
310
+ @engineName, @pieceType, @logic, @media, @source,
311
+ unixepoch(), @success, @error
312
+ )
313
+ ON CONFLICT (engine_name, piece_type, logic_hash, media_hash, source)
314
+ DO UPDATE SET
315
+ last_test = excluded.last_test,
316
+ success = excluded.success,
317
+ error = excluded.error`
318
+ ).run({
319
+ ...pieceInfo,
320
+ source,
321
+ success: result.success ? 1 : 0,
322
+ error: result.error ?? null,
323
+ });
324
+ },
325
+
326
+ resetPieceStatus(
327
+ pieceInfo: PieceIndex
328
+ ){
329
+ // Mark as error so we can retry
330
+ db.prepare(
331
+ `UPDATE pieces SET status = 'error' ${PIECE_WHERE}`
332
+ ).run(pieceInfo);
333
+ },
334
+
335
+ pieceSuccessfullyDownloaded(
336
+ pieceInfo: PieceIndex,
337
+ downloadSource: string,
338
+ ){
339
+ this.recordSourceTest(pieceInfo, downloadSource, { success: true });
340
+ return db.prepare(
341
+ `UPDATE pieces SET status = 'complete', completed_at = unixepoch() ${PIECE_WHERE}`
342
+ ).run(pieceInfo);
343
+ },
344
+
345
+ pieceFailedToDownload(
346
+ pieceInfo: PieceIndex,
347
+ downloadSource: string,
348
+ error: string,
349
+ ){
350
+ this.recordSourceTest(pieceInfo, downloadSource, { success: false, error });
351
+ db.prepare(
352
+ `UPDATE pieces SET status = 'error' ${PIECE_WHERE}`
353
+ ).run(pieceInfo);
354
+ },
355
+
356
+ listPieces(
357
+ engineName: string,
358
+ pieceType: string,
359
+ logicIds: Array<string>,
360
+ { page, limit }: { page: number, limit: number }
361
+ ): Array<StoredPiece>{
362
+ if(logicIds.length === 0) return [];
363
+ const logicParams = logicIds.map((_, i)=>`@logic${i}`).join(", ");
364
+ const stmt = db.prepare(
365
+ `
366
+ SELECT * FROM pieces
367
+ WHERE engine_name = @engineName
368
+ AND piece_type = @pieceType
369
+ AND status = 'complete'
370
+ AND logic_hash IN (${logicParams})
371
+ ORDER BY completed_at ASC
372
+ LIMIT @limit OFFSET @offset
373
+ `
374
+ );
375
+ const params: Record<string, string | number> = {
376
+ engineName,
377
+ pieceType,
378
+ limit,
379
+ offset: page * limit,
380
+ };
381
+ logicIds.forEach((logicId, i)=>{ params[`logic${i}`] = logicId; });
382
+ const items = stmt.all(params) as Array<PieceRowRaw>;
383
+ const sourcesByPiece = this.getDownloadSourcesFor(engineName, items.map((item)=>({
384
+ pieceType: item.piece_type, logic: item.logic_hash, media: item.media_hash,
385
+ })));
386
+ return items.map((piece)=>{
387
+ const key = `${piece.piece_type}\x00${piece.logic_hash}\x00${piece.media_hash}`;
388
+ return parsePieceRow(piece, sourcesByPiece.get(key) ?? []);
389
+ });
390
+ },
391
+
392
+ // Editor browsing of everything ever downloaded - unlike listPieces this
393
+ // isn't scoped to a selection's logicIds. The text search covers human
394
+ // info, logic hash, and download sources.
395
+ searchPieces(
396
+ { engineName, pieceType, search, page, limit }: {
397
+ engineName: string,
398
+ pieceType?: string,
399
+ search?: string,
400
+ page: number,
401
+ limit: number,
402
+ }
403
+ ): { total: number, items: Array<StoredPiece> } {
404
+ const where = `
405
+ WHERE engine_name = @engineName
406
+ AND status = 'complete'
407
+ AND (@pieceType IS NULL OR piece_type = @pieceType)
408
+ AND (
409
+ @search IS NULL
410
+ OR human_info LIKE '%' || @search || '%'
411
+ OR logic_hash LIKE @search || '%'
412
+ OR EXISTS (
413
+ SELECT 1 FROM download_sources ds
414
+ WHERE ds.engine_name = pieces.engine_name
415
+ AND ds.piece_type = pieces.piece_type
416
+ AND ds.logic_hash = pieces.logic_hash
417
+ AND ds.media_hash = pieces.media_hash
418
+ AND ds.source LIKE '%' || @search || '%'
419
+ )
420
+ )
421
+ `;
422
+ const params = {
423
+ engineName,
424
+ pieceType: pieceType ?? null,
425
+ search: search || null,
426
+ };
427
+ const { total } = db.prepare(
428
+ `SELECT COUNT(*) as total FROM pieces ${where}`
429
+ ).get(params) as { total: number };
430
+ const items = db.prepare(
431
+ `SELECT * FROM pieces ${where}
432
+ ORDER BY completed_at DESC
433
+ LIMIT @limit OFFSET @offset`
434
+ ).all({ ...params, limit, offset: page * limit }) as Array<PieceRowRaw>;
435
+ const sourcesByPiece = this.getDownloadSourcesFor(engineName, items.map((item)=>({
436
+ pieceType: item.piece_type, logic: item.logic_hash, media: item.media_hash,
437
+ })));
438
+ return {
439
+ total,
440
+ items: items.map((piece)=>{
441
+ const key = `${piece.piece_type}\x00${piece.logic_hash}\x00${piece.media_hash}`;
442
+ return parsePieceRow(piece, sourcesByPiece.get(key) ?? []);
443
+ }),
444
+ };
445
+ }
446
+ }
447
+ }
448
+
449
+ // Databases in the wild predate docs_hash/human_info/download_sources - they
450
+ // carry a single download_source column, a piece_json blob, and a
451
+ // download_errors log. Fold all of that into the new shape, then drop it.
452
+ function migrateLegacyDatabase(db: DatabaseSync){
453
+ const pieceColumns = (db.prepare(
454
+ "SELECT name FROM pragma_table_info('pieces')"
455
+ ).all() as Array<{ name: string }>).map((column)=>column.name);
456
+ const hasErrorsTable = !!db.prepare(
457
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_errors'"
458
+ ).get();
459
+ const needsMigration = (
460
+ !pieceColumns.includes("docs_hash")
461
+ || !pieceColumns.includes("human_info")
462
+ || pieceColumns.includes("piece_json")
463
+ || pieceColumns.includes("download_source")
464
+ || hasErrorsTable
465
+ );
466
+ if(!needsMigration) return;
467
+
468
+ db.exec("BEGIN");
469
+ try {
470
+ if(!pieceColumns.includes("docs_hash")){
471
+ db.exec("ALTER TABLE pieces ADD COLUMN docs_hash TEXT NOT NULL DEFAULT ''");
472
+ }
473
+ if(!pieceColumns.includes("human_info")){
474
+ db.exec(`ALTER TABLE pieces ADD COLUMN human_info TEXT NOT NULL DEFAULT '{"name":"","author":"","url":""}'`);
475
+ }
476
+ if(pieceColumns.includes("piece_json")){
477
+ db.exec(`
478
+ UPDATE pieces SET
479
+ docs_hash = COALESCE(json_extract(piece_json, '$.version.docs'), docs_hash),
480
+ human_info = COALESCE(json_extract(piece_json, '$.humanInfo'), human_info)
481
+ WHERE piece_json IS NOT NULL;
482
+ `);
483
+ db.exec(`
484
+ INSERT OR IGNORE INTO download_sources
485
+ (engine_name, piece_type, logic_hash, media_hash, source)
486
+ SELECT p.engine_name, p.piece_type, p.logic_hash, p.media_hash, je.value
487
+ FROM pieces p, json_each(p.piece_json, '$.downloadSources') je
488
+ WHERE p.piece_json IS NOT NULL;
489
+ `);
490
+ db.exec("ALTER TABLE pieces DROP COLUMN piece_json");
491
+ }
492
+ if(pieceColumns.includes("download_source")){
493
+ db.exec(`
494
+ INSERT OR IGNORE INTO download_sources
495
+ (engine_name, piece_type, logic_hash, media_hash, source)
496
+ SELECT engine_name, piece_type, logic_hash, media_hash, download_source
497
+ FROM pieces WHERE download_source != '';
498
+ `);
499
+ // The source a completed row was fetched from is a passed test.
500
+ db.exec(`
501
+ UPDATE download_sources
502
+ SET success = 1, last_test = COALESCE(p.completed_at, unixepoch())
503
+ FROM pieces p
504
+ WHERE p.status = 'complete'
505
+ AND p.engine_name = download_sources.engine_name
506
+ AND p.piece_type = download_sources.piece_type
507
+ AND p.logic_hash = download_sources.logic_hash
508
+ AND p.media_hash = download_sources.media_hash
509
+ AND p.download_source = download_sources.source
510
+ AND download_sources.success IS NULL;
511
+ `);
512
+ db.exec("ALTER TABLE pieces DROP COLUMN download_source");
513
+ }
514
+ if(hasErrorsTable){
515
+ db.exec(`
516
+ INSERT OR IGNORE INTO download_sources
517
+ (engine_name, piece_type, logic_hash, media_hash, source)
518
+ SELECT DISTINCT engine_name, piece_type, logic_hash, media_hash, download_source
519
+ FROM download_errors;
520
+ `);
521
+ // Carry each source's most recent error over as its latest test, unless
522
+ // a newer success already claimed the slot.
523
+ db.exec(`
524
+ UPDATE download_sources
525
+ SET success = 0, last_test = e.timestamp, error = e.error_message
526
+ FROM (
527
+ SELECT *, ROW_NUMBER() OVER (
528
+ PARTITION BY engine_name, piece_type, logic_hash, media_hash, download_source
529
+ ORDER BY timestamp DESC
530
+ ) AS rn FROM download_errors
531
+ ) e
532
+ WHERE e.rn = 1
533
+ AND e.engine_name = download_sources.engine_name
534
+ AND e.piece_type = download_sources.piece_type
535
+ AND e.logic_hash = download_sources.logic_hash
536
+ AND e.media_hash = download_sources.media_hash
537
+ AND e.download_source = download_sources.source
538
+ AND (download_sources.last_test IS NULL OR e.timestamp > download_sources.last_test);
539
+ `);
540
+ db.exec("DROP TABLE download_errors");
541
+ }
542
+ db.exec("COMMIT");
543
+ }catch(e){
544
+ db.exec("ROLLBACK");
545
+ throw e;
546
+ }
547
+ }
@@ -0,0 +1,6 @@
1
+
2
+ export type PieceInfo = {
3
+ pieceType: string,
4
+ logic: string, media: string,
5
+ pathVariables: Record<string, string>
6
+ }
@@ -0,0 +1,3 @@
1
+
2
+ export { IFolderDB } from "./types";
3
+ export * from "./SQLFolderDB";
@@ -0,0 +1,72 @@
1
+ import { RosterLockPiece, RosterLockV1Config } from "@roster-lock/types";
2
+ import { ProgressHandlers } from "../../handleDownloads/types";
3
+ import { Readable } from "node:stream";
4
+
5
+ // A download source with the agent's latest test result for it. lastTest and
6
+ // success are null for sources that were declared but never attempted.
7
+ export type StoredDownloadSource = {
8
+ source: string,
9
+ // ms since epoch
10
+ lastTest: number | null,
11
+ success: boolean | null,
12
+ error?: string,
13
+ };
14
+
15
+ // A downloaded piece as the agent stores it - identified by engine, piece
16
+ // type, and version hashes rather than a config-assigned id. downloadSources
17
+ // carries per-source test state on top of the config-declared list.
18
+ export type StoredPiece = (
19
+ & Pick<RosterLockPiece, "version" | "humanInfo" | "pathVariables">
20
+ & {
21
+ engineName: string,
22
+ pieceType: string,
23
+ downloadSources: Array<StoredDownloadSource>,
24
+ }
25
+ );
26
+
27
+ // A downloaded piece as the config editor browses it.
28
+ export type StoredPieceListing = {
29
+ total: number,
30
+ items: Array<{
31
+ piece: StoredPiece,
32
+ // ms since epoch; null for rows that predate completion tracking
33
+ completedAt: number | null,
34
+ }>,
35
+ };
36
+
37
+ export interface IFolderDB {
38
+ ensurePieceExists(
39
+ lockConfig: RosterLockV1Config["engine"],
40
+ pieceType: string,
41
+ selectedPiece: RosterLockPiece,
42
+ progressHandlers: ProgressHandlers,
43
+ ): Promise<string>,
44
+ getFilesofAsset(
45
+ engineConfig: RosterLockV1Config["engine"],
46
+ pieceType: string,
47
+ selectedPiece: Pick<RosterLockPiece, "version" | "pathVariables">,
48
+ assetName: string
49
+ ): AsyncIterable<string>
50
+ getPieceFileContents(
51
+ engineConfig: RosterLockV1Config["engine"],
52
+ pieceType: string,
53
+ selectedPiece: Pick<RosterLockPiece, "version" | "pathVariables">,
54
+ filePath: string
55
+ ): Promise<Readable>
56
+ listPieces(
57
+ engineName: string,
58
+ pieceType: string,
59
+ logicIds: Array<string>,
60
+ pagination: { page: number, limit: number, }
61
+ ): Promise<Array<Pick<RosterLockPiece, "version" | "pathVariables">>>
62
+ // Paginated browsing of every completed download - backs the config
63
+ // editor's "add a piece you've already downloaded" flow.
64
+ searchPieces(query: {
65
+ engineName: string,
66
+ pieceType?: string,
67
+ search?: string,
68
+ page: number,
69
+ limit: number,
70
+ }): Promise<StoredPieceListing>
71
+ }
72
+