@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.
- package/LICENSE.md +11 -0
- package/dist/authentication.js +53 -0
- package/dist/authentication.js.map +1 -0
- package/dist/config/constants.js +12 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/findFile.js +32 -0
- package/dist/config/findFile.js.map +1 -0
- package/dist/config/index.js +29 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/manage.js +41 -0
- package/dist/config/manage.js.map +1 -0
- package/dist/cors.js +20 -0
- package/dist/cors.js.map +1 -0
- package/dist/editor-support/index.js +247 -0
- package/dist/editor-support/index.js.map +1 -0
- package/dist/handle-room/version-1/file-routes.js +53 -0
- package/dist/handle-room/version-1/file-routes.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
- package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
- package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
- package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/index.js +18 -0
- package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/room.js +3 -0
- package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
- package/dist/handle-room/version-1/globals/index.js +20 -0
- package/dist/handle-room/version-1/globals/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/types.js +3 -0
- package/dist/handle-room/version-1/globals/types.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
- package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
- package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
- package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
- package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
- package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
- package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
- package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
- package/dist/handle-room/version-1/index.js +28 -0
- package/dist/handle-room/version-1/index.js.map +1 -0
- package/dist/handle-room/version-1/piece-sort.js +71 -0
- package/dist/handle-room/version-1/piece-sort.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
- package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
- package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
- package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
- package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
- package/dist/handle-room/version-1/schema/count.js +16 -0
- package/dist/handle-room/version-1/schema/count.js.map +1 -0
- package/dist/handle-room/version-1/schema/lock.js +46 -0
- package/dist/handle-room/version-1/schema/lock.js.map +1 -0
- package/dist/handle-room/version-1/schema/selected.js +13 -0
- package/dist/handle-room/version-1/schema/selected.js.map +1 -0
- package/dist/handle-room/version-1/select.js +94 -0
- package/dist/handle-room/version-1/select.js.map +1 -0
- package/dist/index.js +180 -0
- package/dist/index.js.map +1 -0
- package/dist/server.js +88 -0
- package/dist/server.js.map +1 -0
- package/dist/utils/fs.js +32 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/http-router.js +49 -0
- package/dist/utils/http-router.js.map +1 -0
- package/dist/utils/pagination.js +23 -0
- package/dist/utils/pagination.js.map +1 -0
- package/dist/utils/websocket-router.js +15 -0
- package/dist/utils/websocket-router.js.map +1 -0
- package/package.json +66 -0
- package/src/authentication.ts +48 -0
- package/src/config/constants.ts +7 -0
- package/src/config/findFile.ts +34 -0
- package/src/config/index.ts +17 -0
- package/src/config/manage.ts +48 -0
- package/src/cors.ts +23 -0
- package/src/editor-support/index.ts +289 -0
- package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
- package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
- package/src/handle-room/version-0/piece-download/index.ts +5 -0
- package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
- package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
- package/src/handle-room/version-0/piece-selection/index.ts +31 -0
- package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
- package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
- package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
- package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
- package/src/handle-room/version-1/constants.ts +44 -0
- package/src/handle-room/version-1/file-routes.ts +68 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
- package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
- package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
- package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
- package/src/handle-room/version-1/globals/Room/index.ts +3 -0
- package/src/handle-room/version-1/globals/Room/room.ts +8 -0
- package/src/handle-room/version-1/globals/index.ts +4 -0
- package/src/handle-room/version-1/globals/types.ts +7 -0
- package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
- package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
- package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
- package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
- package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
- package/src/handle-room/version-1/index.ts +33 -0
- package/src/handle-room/version-1/piece-sort.ts +96 -0
- package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
- package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
- package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
- package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
- package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
- package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
- package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
- package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
- package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
- package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
- package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
- package/src/handle-room/version-1/schema/count.ts +13 -0
- package/src/handle-room/version-1/schema/lock.ts +47 -0
- package/src/handle-room/version-1/schema/selected.ts +14 -0
- package/src/handle-room/version-1/select.ts +130 -0
- package/src/handle-room/version-1/validateUnknown.ts +11 -0
- package/src/index.old.ts +71 -0
- package/src/index.ts +231 -0
- package/src/server.ts +85 -0
- package/src/utils/fs.ts +43 -0
- package/src/utils/http-router.ts +55 -0
- package/src/utils/pagination.ts +17 -0
- package/src/utils/websocket-router.ts +23 -0
- package/tests/integration/editor-pieces-search.test.ts +147 -0
- package/tests/integration/game-complete.test.ts +72 -0
- package/tests/integration/handle-full-selection.test.ts +143 -0
- package/tests/integration/helpers/bridge.ts +22 -0
- package/tests/integration/helpers/fakeFolderDB.ts +37 -0
- package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
- package/tests/integration/helpers/lockConfig.ts +71 -0
- package/tests/integration/helpers/make-tar-gz.ts +41 -0
- package/tests/integration/helpers/piece.ts +98 -0
- package/tests/integration/helpers/plugin-dir.ts +26 -0
- package/tests/integration/helpers/room.ts +42 -0
- package/tests/integration/helpers/server.ts +51 -0
- package/tests/integration/helpers/validLockConfig.ts +80 -0
- package/tests/integration/piece-download-and-list.test.ts +194 -0
- package/tests/integration/piece-ensure-ws.test.ts +256 -0
- package/tests/integration/piece-files.test.ts +139 -0
- package/tests/integration/piece-sort-list.test.ts +113 -0
- package/tests/integration/protocol.test.ts +129 -0
- package/tests/integration/root-and-auth.test.ts +100 -0
- package/tests/integration/sync-dl.test.ts +62 -0
- package/tests/tsconfig.json +14 -0
- package/tsconfig.json +22 -0
- 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,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
|
+
|