diff --git a/package.json b/package.json
index 8cf84dfc1..ad6a6e138 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
- "build:ts": "tsc -p tsconfig.json && node ./tools/write-dist-package-json.mjs",
+ "build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
"postinstall": "node ./tools/extensionSetup.mjs"
},
"workspaces": [
diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts
index fcd8e6a7c..6499510a1 100644
--- a/src/backend/clients/email/templates.ts
+++ b/src/backend/clients/email/templates.ts
@@ -72,7 +72,7 @@ The Puter Team
Please update {{app_title}}.
Requested updates:
-{{message}}
+{{nl2br message}}
Best,
The Puter Team
diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts
index 12bbf1794..aac47c762 100644
--- a/src/backend/stores/fs/FSEntryStore.ts
+++ b/src/backend/stores/fs/FSEntryStore.ts
@@ -1836,8 +1836,14 @@ export class FSEntryStore extends PuterStore {
);
}
+ // Use INSERT IGNORE so a row that lost the (parent_id,
+ // name) race against another writer doesn't take down
+ // the whole chunk. Skipped rows are recovered below by
+ // detecting uuids missing from the post-INSERT SELECT
+ // and running the same overwrite-or-409 logic the
+ // upfront existence check uses.
await this.clients.db.write(
- `INSERT INTO fsentries (
+ `${this.#insertIgnoreIntoFsentriesSql()} (
uuid,
bucket,
bucket_region,
@@ -1908,6 +1914,132 @@ export class FSEntryStore extends PuterStore {
}
}
+ // Recovery for INSERT IGNORE skips. An insertCandidate whose
+ // uuid is missing from the post-INSERT SELECT lost the
+ // (parent_id, name) race against a concurrent writer. Re-check
+ // primary-aware and either UPDATE (overwrite=true) or surface
+ // a clear 409. Other rows in the batch already persisted.
+ const racedCandidates = insertCandidates.filter(
+ (entry) => !insertedEntriesByUuid.has(entry.input.uuid),
+ );
+ const insertConflictErrors: Error[] = [];
+ if (racedCandidates.length > 0) {
+ const racedExistingByPath =
+ await this.#readEntriesByPathsForUser(
+ userId,
+ racedCandidates.map((entry) => entry.targetPath),
+ { useTryHardRead: true, skipCache: true },
+ );
+ for (const entry of racedCandidates) {
+ const parentEntry = parentByPath.get(entry.parentPath);
+ if (!parentEntry) {
+ throw new Error(
+ `Failed to resolve parent directory for ${entry.targetPath}`,
+ );
+ }
+ const existing = racedExistingByPath.get(entry.targetPath);
+ if (!existing) {
+ insertConflictErrors.push(
+ new HttpError(
+ 409,
+ `Entry already exists at ${entry.targetPath}`,
+ ),
+ );
+ continue;
+ }
+ if (existing.isDir) {
+ insertConflictErrors.push(
+ new HttpError(
+ 409,
+ `Cannot overwrite a directory at ${entry.targetPath}`,
+ ),
+ );
+ continue;
+ }
+ if (!entry.input.overwrite) {
+ insertConflictErrors.push(
+ new HttpError(
+ 409,
+ `Entry already exists at ${entry.targetPath}`,
+ ),
+ );
+ continue;
+ }
+
+ const updatedEntry: FSEntry = {
+ ...existing,
+ bucket: entry.bucket,
+ bucketRegion: entry.bucketRegion,
+ parentId: parentEntry.id,
+ parentUid: parentEntry.uuid,
+ associatedAppId: entry.input.associatedAppId ?? null,
+ isPublic:
+ entry.input.isPublic === undefined
+ ? null
+ : Boolean(entry.input.isPublic),
+ thumbnail: entry.input.thumbnail ?? null,
+ immutable: Boolean(entry.input.immutable),
+ name: entry.fileName,
+ path: entry.targetPath,
+ metadata: entry.metadataJson,
+ modified: now,
+ accessed: now,
+ size: entry.size,
+ };
+ await this.clients.db.write(
+ `UPDATE fsentries
+ SET bucket = ?,
+ bucket_region = ?,
+ parent_id = ?,
+ parent_uid = ?,
+ associated_app_id = ?,
+ is_public = ?,
+ thumbnail = ?,
+ immutable = ?,
+ name = ?,
+ path = ?,
+ metadata = ?,
+ modified = ?,
+ accessed = ?,
+ size = ?
+ WHERE id = ?`,
+ [
+ entry.bucket,
+ entry.bucketRegion,
+ parentEntry.id,
+ parentEntry.uuid,
+ entry.input.associatedAppId ?? null,
+ entry.input.isPublic === undefined
+ ? null
+ : entry.input.isPublic
+ ? 1
+ : 0,
+ entry.input.thumbnail ?? null,
+ entry.input.immutable ? 1 : 0,
+ entry.fileName,
+ entry.targetPath,
+ entry.metadataJson,
+ now,
+ now,
+ entry.size,
+ existing.id,
+ ],
+ );
+ await this.#invalidateEntryCache(existing);
+ await this.#writeEntryToCache(updatedEntry);
+ updatedResultsByIndex.set(entry.index, updatedEntry);
+ }
+ }
+
+ if (insertConflictErrors.length > 0) {
+ // Other rows in this user's batch already persisted (the
+ // INSERT IGNORE landed them and overwrite=true racers ran
+ // UPDATE above). Surface the first non-recoverable conflict
+ // as a clean 409 instead of letting ER_DUP_ENTRY escape
+ // through SQLBatcher as a 500.
+ throw insertConflictErrors[0]!;
+ }
+
for (const entry of userEntries) {
const updatedResult = updatedResultsByIndex.get(entry.index);
if (updatedResult) {
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 000000000..292eb7403
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "strict": false,
+ "noCheck": true,
+ "noImplicitAny": true
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 5e4408ec5..5dbeafdb4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,7 +19,7 @@
},
"allowJs": true,
"checkJs": false,
- "noCheck": true,
+ "noCheck": false,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
@@ -29,7 +29,7 @@
"removeComments": true,
"noEmit": false,
"noEmitOnError": false,
- "noImplicitAny": false
+ "noImplicitAny": true
},
"include": [
"src/backend/**/*",
@@ -44,17 +44,6 @@
"**/node_modules/**",
"dist/**",
"volatile/**",
- "extensions/**/node_modules/**",
- "src/backend/test/**",
- "src/backend/tools/**",
- "src/backend/vitest.config.ts",
- "src/backend/vitest.bench.config.ts",
- "src/backend/vitest.bench.config.js",
- "src/backend/services/worker/template/puter-portable.js",
- "src/backend/services/DynamoKVStore/DynamoKVStore.ts",
- "src/backend/clients/s3/S3Client.js",
- "src/backend/clients/s3/s3ClientProvider.js",
- "src/backend/clients/redis/RedisClient.js",
- "src/backend/clients/dynamodb/DDBClient.js"
- ]
+ "extensions/**/node_modules/**"
+ ]
}