@eik/core 2.1.78 → 2.1.79

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [2.1.79](https://github.com/eik-lib/core/compare/v2.1.78...v2.1.79) (2026-08-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * use atomic sink writes to prevent concurrent upload races ([#638](https://github.com/eik-lib/core/issues/638)) ([cf29a68](https://github.com/eik-lib/core/commit/cf29a6838622360f8c80bc83c86d07536d580f33)), closes [#1](https://github.com/eik-lib/core/issues/1) [#2](https://github.com/eik-lib/core/issues/2) [#3](https://github.com/eik-lib/core/issues/3) [#6](https://github.com/eik-lib/core/issues/6)
7
+
1
8
  ## [2.1.78](https://github.com/eik-lib/core/compare/v2.1.77...v2.1.78) (2026-08-17)
2
9
 
3
10
 
@@ -8,7 +8,7 @@ import {
8
8
  createFilePathToAlias,
9
9
  createFilePathToAliasOrigin,
10
10
  } from "../utils/path-builders-fs.js";
11
- import { decodeUriComponent, writeJSON } from "../utils/utils.js";
11
+ import { decodeUriComponent, writeJSONWithOptions } from "../utils/utils.js";
12
12
  import { createURIToAlias } from "../utils/path-builders-uri.js";
13
13
  import MultipartParser from "../multipart/parser.js";
14
14
  import HttpIncoming from "../classes/http-incoming.js";
@@ -109,8 +109,18 @@ const AliasPut = class AliasPut {
109
109
  );
110
110
 
111
111
  try {
112
- await writeJSON(this._sink, path, alias, "application/json");
112
+ await writeJSONWithOptions(
113
+ this._sink,
114
+ path,
115
+ alias,
116
+ "application/json",
117
+ { ifNotExists: true },
118
+ );
113
119
  } catch (error) {
120
+ const err = /** @type {any} */ (error);
121
+ if (err.code === "ALREADY_EXISTS") {
122
+ throw new HttpError.Conflict();
123
+ }
114
124
  this._log.error(
115
125
  `alias:put - Failed writing alias to sink - Pathname: ${path}`,
116
126
  );
@@ -20,7 +20,8 @@ import {
20
20
  decodeUriComponent,
21
21
  streamCollector,
22
22
  writeJSON,
23
- readJSON,
23
+ writeJSONWithOptions,
24
+ readJSONWithGeneration,
24
25
  } from "../utils/utils.js";
25
26
 
26
27
  /**
@@ -172,7 +173,7 @@ const MapPut = class MapPut {
172
173
 
173
174
  /**
174
175
  * @param {any} incoming
175
- * @returns {Promise<InstanceType<typeof Versions>>}
176
+ * @returns {Promise<{ versions: InstanceType<typeof Versions>, generation: string }>}
176
177
  */
177
178
  async _readVersions(incoming) {
178
179
  const path = createFilePathToVersion(incoming);
@@ -186,30 +187,65 @@ const MapPut = class MapPut {
186
187
  this._log.info(
187
188
  `map:put - Version meta file did not exist in sink - Create new - Pathname: ${path}`,
188
189
  );
189
- return new Versions(incoming);
190
+ return { versions: new Versions(incoming), generation: "" };
190
191
  }
191
192
 
192
193
  // File exists. Any failure here must propagate rather than be
193
194
  // swallowed — silently falling back to an empty Versions object
194
195
  // would cause _writeVersions to overwrite the index and erase the
195
196
  // entire version history.
196
- const obj = await readJSON(this._sink, path);
197
+ const { data, generation } = await readJSONWithGeneration(this._sink, path);
197
198
  this._log.info(
198
199
  `map:put - Successfully read version meta file from sink - Pathname: ${path}`,
199
200
  );
200
- return new Versions(obj);
201
+ return { versions: new Versions(data), generation };
201
202
  }
202
203
 
203
204
  /**
205
+ * Writes versions.json with compare-and-swap semantics when a generation
206
+ * token is available. On conflict the read-modify-write cycle is retried.
207
+ *
204
208
  * @param {any} incoming
205
- * @param {any} versions
209
+ * @param {InstanceType<typeof Versions>} versions
210
+ * @param {string} generation
211
+ * @param {string} pVersion
212
+ * @param {string} integrity
206
213
  */
207
- async _writeVersions(incoming, versions) {
214
+ async _writeVersions(incoming, versions, generation, pVersion, integrity) {
208
215
  const path = createFilePathToVersion(incoming);
209
- await writeJSON(this._sink, path, versions, "application/json");
210
- this._log.info(
211
- `map:put - Successfully wrote version meta file to sink - Pathname: ${path}`,
212
- );
216
+ const maxRetries = 3;
217
+ /** @type {{ ifGenerationMatch?: string | number }} */
218
+ let options = generation ? { ifGenerationMatch: generation } : {};
219
+ let currentVersions = versions;
220
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
221
+ try {
222
+ await writeJSONWithOptions(
223
+ this._sink,
224
+ path,
225
+ currentVersions,
226
+ "application/json",
227
+ options,
228
+ );
229
+ this._log.info(
230
+ `map:put - Successfully wrote version meta file to sink - Pathname: ${path}`,
231
+ );
232
+ return;
233
+ } catch (error) {
234
+ const err = /** @type {any} */ (error);
235
+ if (err.code === "CONFLICT" && attempt < maxRetries) {
236
+ this._log.info(
237
+ `map:put - versions.json conflict on attempt ${attempt + 1}, retrying`,
238
+ );
239
+ const { versions: fresh, generation: newGen } =
240
+ await this._readVersions(incoming);
241
+ fresh.setVersion(pVersion, integrity);
242
+ currentVersions = fresh;
243
+ options = newGen ? { ifGenerationMatch: newGen } : {};
244
+ continue;
245
+ }
246
+ throw error;
247
+ }
248
+ }
213
249
  }
214
250
 
215
251
  /**
@@ -258,11 +294,13 @@ const MapPut = class MapPut {
258
294
  org,
259
295
  });
260
296
 
261
- const versions = await this._readVersions(incoming).catch(() => {
262
- const e = new HttpError.BadGateway();
263
- end({ labels: { success: false, status: e.status, type: "map" } });
264
- throw e;
265
- });
297
+ const { versions, generation } = await this._readVersions(incoming).catch(
298
+ () => {
299
+ const e = new HttpError.BadGateway();
300
+ end({ labels: { success: false, status: e.status, type: "map" } });
301
+ throw e;
302
+ },
303
+ );
266
304
 
267
305
  if (!versions.check(pVersion)) {
268
306
  this._log.info(
@@ -277,7 +315,13 @@ const MapPut = class MapPut {
277
315
  versions.setVersion(pVersion, integrity);
278
316
 
279
317
  try {
280
- await this._writeVersions(incoming, versions);
318
+ await this._writeVersions(
319
+ incoming,
320
+ versions,
321
+ generation,
322
+ pVersion,
323
+ integrity,
324
+ );
281
325
  // eslint-disable-next-line no-unused-vars
282
326
  } catch (error) {
283
327
  const e = new HttpError.BadGateway();
@@ -13,7 +13,8 @@ import {
13
13
  import {
14
14
  decodeUriComponent,
15
15
  writeJSON,
16
- readJSON,
16
+ writeJSONWithOptions,
17
+ readJSONWithGeneration,
17
18
  readEikJson,
18
19
  } from "../utils/utils.js";
19
20
  import { createURIPathToPkgLog } from "../utils/path-builders-uri.js";
@@ -129,11 +130,12 @@ const PkgPut = class PkgPut {
129
130
  type: incoming.type,
130
131
  };
131
132
  const eikJsonPath = createFilePathToEikJson(incoming);
132
- const buffer = await writeJSON(
133
+ const buffer = await writeJSONWithOptions(
133
134
  this._sink,
134
135
  eikJsonPath,
135
136
  content,
136
137
  "application/json",
138
+ { ifNotExists: true },
137
139
  );
138
140
  this._log.info(
139
141
  `pkg:put - Successfully wrote eik.json to sink - Pathname: ${eikJsonPath}`,
@@ -155,7 +157,7 @@ const PkgPut = class PkgPut {
155
157
 
156
158
  /**
157
159
  * @param {any} incoming
158
- * @returns {Promise<InstanceType<typeof Versions>>}
160
+ * @returns {Promise<{ versions: InstanceType<typeof Versions>, generation: string }>}
159
161
  */
160
162
  async _readVersions(incoming) {
161
163
  const path = createFilePathToVersion(incoming);
@@ -169,18 +171,18 @@ const PkgPut = class PkgPut {
169
171
  this._log.info(
170
172
  `pkg:put - Version meta file did not exist in sink - Create new - Pathname: ${path}`,
171
173
  );
172
- return new Versions(incoming);
174
+ return { versions: new Versions(incoming), generation: "" };
173
175
  }
174
176
 
175
177
  // File exists. Any failure here (GCS error, corrupt JSON, network
176
178
  // timeout) must propagate rather than be swallowed — silently falling
177
179
  // back to an empty Versions object would cause _writeVersions to
178
180
  // overwrite the index and erase the entire version history.
179
- const obj = await readJSON(this._sink, path);
181
+ const { data, generation } = await readJSONWithGeneration(this._sink, path);
180
182
  this._log.info(
181
183
  `pkg:put - Successfully read version meta file from sink - Pathname: ${path}`,
182
184
  );
183
- return new Versions(obj);
185
+ return { versions: new Versions(data), generation };
184
186
  }
185
187
 
186
188
  /**
@@ -205,15 +207,50 @@ const PkgPut = class PkgPut {
205
207
  }
206
208
 
207
209
  /**
210
+ * Writes versions.json with compare-and-swap semantics when a generation
211
+ * token is available. On conflict the read-modify-write cycle is retried.
212
+ *
208
213
  * @param {any} incoming
209
- * @param {any} versions
214
+ * @param {InstanceType<typeof Versions>} versions
215
+ * @param {string} generation
216
+ * @param {string} pVersion
217
+ * @param {string} integrity
210
218
  */
211
- async _writeVersions(incoming, versions) {
219
+ async _writeVersions(incoming, versions, generation, pVersion, integrity) {
212
220
  const path = createFilePathToVersion(incoming);
213
- await writeJSON(this._sink, path, versions, "application/json");
214
- this._log.info(
215
- `pkg:put - Successfully wrote version meta file to sink - Pathname: ${path}`,
216
- );
221
+ const maxRetries = 3;
222
+ /** @type {{ ifGenerationMatch?: string | number }} */
223
+ let options = generation ? { ifGenerationMatch: generation } : {};
224
+ let currentVersions = versions;
225
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
226
+ try {
227
+ await writeJSONWithOptions(
228
+ this._sink,
229
+ path,
230
+ currentVersions,
231
+ "application/json",
232
+ options,
233
+ );
234
+ this._log.info(
235
+ `pkg:put - Successfully wrote version meta file to sink - Pathname: ${path}`,
236
+ );
237
+ return;
238
+ } catch (error) {
239
+ const err = /** @type {any} */ (error);
240
+ if (err.code === "CONFLICT" && attempt < maxRetries) {
241
+ this._log.info(
242
+ `pkg:put - versions.json conflict on attempt ${attempt + 1}, retrying`,
243
+ );
244
+ const { versions: fresh, generation: newGen } =
245
+ await this._readVersions(incoming);
246
+ fresh.setVersion(pVersion, integrity);
247
+ currentVersions = fresh;
248
+ options = newGen ? { ifGenerationMatch: newGen } : {};
249
+ continue;
250
+ }
251
+ throw error;
252
+ }
253
+ }
217
254
  }
218
255
 
219
256
  /**
@@ -275,11 +312,13 @@ const PkgPut = class PkgPut {
275
312
  throw e;
276
313
  }
277
314
 
278
- const versions = await this._readVersions(incoming).catch(() => {
279
- const e = new HttpError.BadGateway();
280
- end({ labels: { success: false, status: e.status, type } });
281
- throw e;
282
- });
315
+ const { versions, generation } = await this._readVersions(incoming).catch(
316
+ () => {
317
+ const e = new HttpError.BadGateway();
318
+ end({ labels: { success: false, status: e.status, type } });
319
+ throw e;
320
+ },
321
+ );
283
322
 
284
323
  const { pkg, eikJson } = await this._parser(incoming);
285
324
 
@@ -288,8 +327,15 @@ const PkgPut = class PkgPut {
288
327
  let eikJsonAsset;
289
328
  try {
290
329
  eikJsonAsset = await this._writeEikJson(incoming, eikJson);
291
- // eslint-disable-next-line no-unused-vars
292
330
  } catch (error) {
331
+ const err = /** @type {any} */ (error);
332
+ if (err.code === "ALREADY_EXISTS") {
333
+ // Another replica committed this version while this upload was in
334
+ // flight. Treat it as a conflict identical to the early existence check.
335
+ const e = new HttpError.Conflict();
336
+ end({ labels: { success: false, status: e.status, type } });
337
+ throw e;
338
+ }
293
339
  const e = new HttpError.BadGateway();
294
340
  end({ labels: { success: false, status: e.status, type } });
295
341
  throw e;
@@ -309,10 +355,14 @@ const PkgPut = class PkgPut {
309
355
  throw e;
310
356
  }
311
357
 
312
- versions.setVersion(pVersion, pkg.integrity);
313
-
314
358
  try {
315
- await this._writeVersions(incoming, versions);
359
+ await this._writeVersions(
360
+ incoming,
361
+ versions,
362
+ generation,
363
+ pVersion,
364
+ pkg.integrity,
365
+ );
316
366
  // eslint-disable-next-line no-unused-vars
317
367
  } catch (error) {
318
368
  const e = new HttpError.BadGateway();
@@ -107,9 +107,86 @@ const decodeUriComponent = (value) => {
107
107
  return decodeURIComponent(value);
108
108
  };
109
109
 
110
+ /**
111
+ * Like readJSON but also returns the generation token from the ReadFile,
112
+ * which can be passed back to writeJSONWithOptions as ifGenerationMatch
113
+ * for compare-and-swap semantics.
114
+ *
115
+ * @param {any} sink
116
+ * @param {string} path
117
+ * @returns {Promise<{ data: any, generation: string }>}
118
+ */
119
+ const readJSONWithGeneration = (sink, path) =>
120
+ // eslint-disable-next-line no-async-promise-executor
121
+ new Promise(async (resolve, reject) => {
122
+ try {
123
+ /** @type {any[]} */
124
+ const buffer = [];
125
+ const from = await sink.read(path);
126
+
127
+ const to = new Writable({
128
+ objectMode: false,
129
+ write(chunk, encoding, callback) {
130
+ buffer.push(chunk);
131
+ callback();
132
+ },
133
+ });
134
+
135
+ pipeline(from.stream, to, (error) => {
136
+ if (error) return reject(error);
137
+ const str = Buffer.concat(buffer).toString("utf8");
138
+ try {
139
+ const obj = JSON.parse(str);
140
+ return resolve({ data: obj, generation: from.generation ?? "" });
141
+ } catch (err) {
142
+ return reject(err);
143
+ }
144
+ });
145
+ } catch (error) {
146
+ reject(error);
147
+ }
148
+ });
149
+
150
+ /**
151
+ * Like writeJSON but accepts sink write options (e.g. { ifNotExists, ifGenerationMatch })
152
+ * for atomic conditional writes.
153
+ *
154
+ * @param {any} sink
155
+ * @param {string} path
156
+ * @param {any} obj
157
+ * @param {string} contentType
158
+ * @param {{ ifNotExists?: boolean, ifGenerationMatch?: string | number }} [options]
159
+ */
160
+ const writeJSONWithOptions = (sink, path, obj, contentType, options = {}) =>
161
+ // eslint-disable-next-line no-async-promise-executor
162
+ new Promise(async (resolve, reject) => {
163
+ try {
164
+ const buffer = Buffer.from(JSON.stringify(obj));
165
+
166
+ const from = new Readable({
167
+ objectMode: false,
168
+ read() {
169
+ this.push(buffer);
170
+ this.push(null);
171
+ },
172
+ });
173
+
174
+ const to = await sink.write(path, contentType, options);
175
+
176
+ pipeline(from, to, (error) => {
177
+ if (error) return reject(error);
178
+ return resolve(buffer);
179
+ });
180
+ } catch (error) {
181
+ reject(error);
182
+ }
183
+ });
184
+
110
185
  export {
111
186
  readJSON,
187
+ readJSONWithGeneration,
112
188
  writeJSON,
189
+ writeJSONWithOptions,
113
190
  streamCollector,
114
191
  etagFromFsStat,
115
192
  decodeUriComponent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eik/core",
3
- "version": "2.1.78",
3
+ "version": "2.1.79",
4
4
  "description": "Core server package",
5
5
  "main": "lib/main.js",
6
6
  "types": "./types/main.d.ts",
@@ -29,7 +29,7 @@
29
29
  "author": "",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
- "@eik/common": "5.1.34",
32
+ "@eik/common": "5.2.0",
33
33
  "@eik/sink": "1.2.7",
34
34
  "@eik/sink-file-system": "2.0.40",
35
35
  "@eik/sink-memory": "2.0.37",
@@ -42,14 +42,23 @@ declare const MapPut: {
42
42
  }): Promise<string>;
43
43
  /**
44
44
  * @param {any} incoming
45
- * @returns {Promise<InstanceType<typeof Versions>>}
45
+ * @returns {Promise<{ versions: InstanceType<typeof Versions>, generation: string }>}
46
46
  */
47
- _readVersions(incoming: any): Promise<InstanceType<typeof Versions>>;
47
+ _readVersions(incoming: any): Promise<{
48
+ versions: InstanceType<typeof Versions>;
49
+ generation: string;
50
+ }>;
48
51
  /**
52
+ * Writes versions.json with compare-and-swap semantics when a generation
53
+ * token is available. On conflict the read-modify-write cycle is retried.
54
+ *
49
55
  * @param {any} incoming
50
- * @param {any} versions
56
+ * @param {InstanceType<typeof Versions>} versions
57
+ * @param {string} generation
58
+ * @param {string} pVersion
59
+ * @param {string} integrity
51
60
  */
52
- _writeVersions(incoming: any, versions: any): Promise<void>;
61
+ _writeVersions(incoming: any, versions: InstanceType<typeof Versions>, generation: string, pVersion: string, integrity: string): Promise<void>;
53
62
  /**
54
63
  * @param {any} req
55
64
  * @param {any} user
@@ -82,18 +82,27 @@ declare const PkgPut: {
82
82
  _writeEikJson(incoming: any, capturedEikJson: Record<string, any> | null): Promise<InstanceType<typeof Asset>>;
83
83
  /**
84
84
  * @param {any} incoming
85
- * @returns {Promise<InstanceType<typeof Versions>>}
85
+ * @returns {Promise<{ versions: InstanceType<typeof Versions>, generation: string }>}
86
86
  */
87
- _readVersions(incoming: any): Promise<InstanceType<typeof Versions>>;
87
+ _readVersions(incoming: any): Promise<{
88
+ versions: InstanceType<typeof Versions>;
89
+ generation: string;
90
+ }>;
88
91
  /**
89
92
  * @param {any} incoming
90
93
  */
91
94
  _readVersion(incoming: any): Promise<boolean>;
92
95
  /**
96
+ * Writes versions.json with compare-and-swap semantics when a generation
97
+ * token is available. On conflict the read-modify-write cycle is retried.
98
+ *
93
99
  * @param {any} incoming
94
- * @param {any} versions
100
+ * @param {InstanceType<typeof Versions>} versions
101
+ * @param {string} generation
102
+ * @param {string} pVersion
103
+ * @param {string} integrity
95
104
  */
96
- _writeVersions(incoming: any, versions: any): Promise<void>;
105
+ _writeVersions(incoming: any, versions: InstanceType<typeof Versions>, generation: string, pVersion: string, integrity: string): Promise<void>;
97
106
  /**
98
107
  * @param {any} req
99
108
  * @param {any} user
@@ -3,6 +3,19 @@
3
3
  * @param {string} path
4
4
  */
5
5
  export function readJSON(sink: any, path: string): Promise<any>;
6
+ /**
7
+ * Like readJSON but also returns the generation token from the ReadFile,
8
+ * which can be passed back to writeJSONWithOptions as ifGenerationMatch
9
+ * for compare-and-swap semantics.
10
+ *
11
+ * @param {any} sink
12
+ * @param {string} path
13
+ * @returns {Promise<{ data: any, generation: string }>}
14
+ */
15
+ export function readJSONWithGeneration(sink: any, path: string): Promise<{
16
+ data: any;
17
+ generation: string;
18
+ }>;
6
19
  /**
7
20
  * @param {any} sink
8
21
  * @param {string} path
@@ -10,6 +23,20 @@ export function readJSON(sink: any, path: string): Promise<any>;
10
23
  * @param {string} contentType
11
24
  */
12
25
  export function writeJSON(sink: any, path: string, obj: any, contentType: string): Promise<any>;
26
+ /**
27
+ * Like writeJSON but accepts sink write options (e.g. { ifNotExists, ifGenerationMatch })
28
+ * for atomic conditional writes.
29
+ *
30
+ * @param {any} sink
31
+ * @param {string} path
32
+ * @param {any} obj
33
+ * @param {string} contentType
34
+ * @param {{ ifNotExists?: boolean, ifGenerationMatch?: string | number }} [options]
35
+ */
36
+ export function writeJSONWithOptions(sink: any, path: string, obj: any, contentType: string, options?: {
37
+ ifNotExists?: boolean;
38
+ ifGenerationMatch?: string | number;
39
+ }): Promise<any>;
13
40
  /**
14
41
  * @param {any} from
15
42
  */