@elaraai/e3-cloud-cli 1.1.0 → 1.1.2

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.
@@ -3,34 +3,46 @@
3
3
  * Licensed under BSL 1.1. See LICENSE for details.
4
4
  */
5
5
  /**
6
- * `e3-cloud files push`: files from disk into a source, and taken in.
6
+ * `e3-cloud files push`: files from disk, and a repository's datasets as the
7
+ * files of their values, into a source, and taken in.
7
8
  *
8
9
  * Each file offered is read for its size, its SHA-256 (streamed) and the
9
10
  * root type its beast2 header declares (through ranged reads of its head; a
10
11
  * file that is no beast2 offers no type); a directory is walked, its
11
12
  * `.`-prefixed entries skipped, each file offered at its path relative to
12
- * the directory, with the directory's name. The plan comes from the
13
- * platform (`POST …/files/push`) for a repository URL, or from the same
14
- * engine over the local repository's declarations, bindings and ledgers for
15
- * a path; it says, per file, its target and key and how to put it. A `put`
16
- * is uploaded: `single` by one PUT with the plan's headers; `multipart` part
17
- * by part (four in flight, each read from the file by range), then
18
- * completed through the API; `external` through the bucket's own client
19
- * with the developer's own credentials (`--profile`, the binding's, or the
20
- * ambient chain), in parts past 64 MiB. Then one sync of the targets put, waited
21
- * on, and each file's row completed from the sync's report. Nothing is put
22
- * for a dry run.
13
+ * the directory, with the directory's name. A dataset (`dataset:<ws>.<name>`)
14
+ * is read from its repository, local or deployed, for the same, as the file
15
+ * of its value (`./dataset-source.ts`); one unassigned, not in its workspace
16
+ * or named twice there is refused in its own row and never planned. Rows keep
17
+ * the order the paths were given.
18
+ *
19
+ * The plan comes from the platform (`POST …/files/push`) for a repository
20
+ * URL, or from the same engine over the local repository's declarations,
21
+ * bindings and ledgers for a path; it says, per file, its target and key and
22
+ * how to put it. A `put` is uploaded: `single` by one PUT with the plan's
23
+ * headers, or `multipart` part by part then completed through the API, both
24
+ * by the client's `uploadFilesPush` (a file's parts four in flight, each read
25
+ * by range; a dataset's one after another from one read); `external` through
26
+ * the bucket's own client with the developer's own credentials (`--profile`,
27
+ * the binding's, or the ambient chain), in parts past 64 MiB, a dataset's
28
+ * file with the metadata naming it, which its seed's or feed's history
29
+ * records. Then one sync of the targets put, waited on, and each row
30
+ * completed from the sync's report. Nothing is put for a dry run.
31
+ *
32
+ * A push to a local repository is {@link pushLocal} whole: the plan, the puts
33
+ * and the sync, over the repository's own declarations, bindings and ledgers.
23
34
  */
24
35
  import { createHash } from 'node:crypto';
25
- import { createReadStream, openSync, closeSync, readSync, statSync, readdirSync, fstatSync } from 'node:fs';
36
+ import { createReadStream, openSync, closeSync, readSync, statSync, readdirSync } from 'node:fs';
26
37
  import { basename, join, relative, resolve, sep } from 'node:path';
27
- import { Readable } from 'node:stream';
28
38
  import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, PutObjectCommand, S3Client, UploadPartCommand, } from '@aws-sdk/client-s3';
29
39
  import { none, some } from '@elaraai/east';
30
- import { FILES_PUSH_PART_BYTES, filesPushRefusalText, filesTargetText, filesTypeWords, sameFilesTarget, } from '@elaraai/e3-cloud-types';
40
+ import { FILES_PUSH_PART_BYTES, filesDatasetMetadata, filesPushRefusalText, filesTargetText, filesTypeWords, sameFilesTarget, } from '@elaraai/e3-cloud-types';
31
41
  import { parseBucketUrl } from '@elaraai/e3-cloud-files/check';
32
42
  import { beast2HeaderTypeOf } from '@elaraai/e3-cloud-files/sync';
33
- /** Parts of a multipart upload in flight at once. */
43
+ import { DatasetMovedError, datasetArgOf, measuredDataset } from './dataset-source.js';
44
+ import { planLocal, syncLocal } from './files-local.js';
45
+ /** Uploads of an external multipart upload's parts in flight at once. */
34
46
  const PARTS_IN_FLIGHT = 4;
35
47
  /**
36
48
  * The root type a file's beast2 header declares, through reads of its head
@@ -70,6 +82,15 @@ async function sha256Of(path) {
70
82
  hash.update(chunk);
71
83
  return hash.digest('hex');
72
84
  }
85
+ /** A file's bytes from an offset to its end. */
86
+ async function* fileFrom(path, start) {
87
+ for await (const chunk of createReadStream(path, { start }))
88
+ yield chunk;
89
+ }
90
+ /** A file's bytes as an upload reads them: from any offset, for nothing. */
91
+ function fileBytes(path, size) {
92
+ return { size, ranged: true, read: (start) => fileFrom(path, start) };
93
+ }
73
94
  /** The files under a directory, walked, `.`-prefixed entries skipped, each path relative to it. */
74
95
  function walk(dir) {
75
96
  const files = [];
@@ -105,7 +126,12 @@ export async function offeredFiles(paths) {
105
126
  const one = async (file, name, directory) => {
106
127
  const size = statSync(file).size;
107
128
  const type = await fileBeast2Type(file, size);
108
- offered.push({ path: file, offered: { name, directory: directory === null ? none : some(directory), size: BigInt(size), sha256: await sha256Of(file), type: type === null ? none : some(type) } });
129
+ offered.push({
130
+ label: directory === null ? name : `${directory}/${name}`,
131
+ offered: { name, directory: directory === null ? none : some(directory), size: BigInt(size), sha256: await sha256Of(file), type: type === null ? none : some(type), dataset: none },
132
+ bytes: fileBytes(file, size),
133
+ origin: null,
134
+ });
109
135
  };
110
136
  if (stat.isDirectory()) {
111
137
  for (const name of walk(path))
@@ -120,50 +146,84 @@ export async function offeredFiles(paths) {
120
146
  }
121
147
  return offered;
122
148
  }
123
- /** A file's bytes as a web stream, for a PUT. */
124
- function body(path, start, end) {
125
- return Readable.toWeb(createReadStream(path, start === undefined ? {} : { start, end }));
149
+ /**
150
+ * A dataset found, as a push offers it: its value read once for its size,
151
+ * its SHA-256 and its type, and named by its path and what it is.
152
+ *
153
+ * @param found - The dataset
154
+ * @returns The file of its value
155
+ * @throws {DatasetMovedError} When it moves while it is read
156
+ */
157
+ export async function offeredDataset(found) {
158
+ const measured = await measuredDataset(found);
159
+ return {
160
+ label: `${found.arg.arg} (${found.kind})`,
161
+ offered: {
162
+ name: found.arg.arg,
163
+ directory: none,
164
+ size: BigInt(measured.size),
165
+ sha256: measured.sha256,
166
+ type: some(measured.type),
167
+ dataset: some({ workspace: found.arg.workspace, name: found.arg.name, hash: found.hash }),
168
+ },
169
+ bytes: measured.bytes,
170
+ origin: measured.origin,
171
+ };
126
172
  }
127
- /** One PUT of a file to a presigned URL, with the plan's headers. */
128
- async function putSingle(path, size, url, headers) {
129
- const response = await fetch(url, {
130
- method: 'PUT',
131
- headers: { ...Object.fromEntries(headers), 'content-length': String(size) },
132
- body: body(path),
133
- // A streamed request body
134
- duplex: 'half',
135
- });
136
- if (!response.ok)
137
- throw new Error(`the upload answered HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
173
+ /** A row refusing what was offered before any plan. */
174
+ function refusedRow(label, size, put) {
175
+ return { label, size, target: null, kind: '', put, sync: '' };
138
176
  }
139
- /** A multipart upload part by part, each read from the file by range; answers each part's ETag. */
140
- async function putParts(path, size, partBytes, parts) {
141
- const etags = [];
142
- let next = 0;
143
- const worker = async () => {
144
- for (;;) {
145
- const i = next++;
146
- if (i >= parts.length)
147
- return;
148
- const part = parts[i];
149
- const start = i * partBytes;
150
- const end = Math.min(size, start + partBytes) - 1;
151
- const response = await fetch(part.url, {
152
- method: 'PUT',
153
- headers: { 'content-length': String(end - start + 1) },
154
- body: body(path, start, end),
155
- duplex: 'half',
156
- });
157
- if (!response.ok)
158
- throw new Error(`part ${part.number} answered HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
159
- const etag = response.headers.get('etag');
160
- if (etag === null)
161
- throw new Error(`part ${part.number} answered no ETag`);
162
- etags.push({ number: part.number, etag: etag.replace(/^"|"$/g, '') });
177
+ /**
178
+ * What a push offers, in the order its paths were given: each file, each
179
+ * directory's files, and each dataset (`dataset:<ws>.<name>`) read from the
180
+ * repository `--from` names; a dataset that is unassigned, not in its
181
+ * workspace or named twice there, or that moves while it is read, refused in
182
+ * its own row.
183
+ *
184
+ * @param paths - Files, directories and datasets
185
+ * @param repository - Where datasets are read, opened once, only when a path names one
186
+ * @returns Each offering, in order
187
+ * @throws {Error} When a path is neither a file, a directory nor a dataset's name
188
+ */
189
+ export async function offerings(paths, repository) {
190
+ let opened = null;
191
+ const found = [];
192
+ for (const given of paths) {
193
+ const arg = datasetArgOf(given);
194
+ if (arg === null) {
195
+ for (const file of await offeredFiles([given]))
196
+ found.push({ file });
197
+ continue;
163
198
  }
164
- };
165
- await Promise.all(Array.from({ length: Math.min(PARTS_IN_FLIGHT, parts.length) }, worker));
166
- return etags.sort((a, b) => (a.number < b.number ? -1 : a.number > b.number ? 1 : 0));
199
+ opened ??= await repository();
200
+ const [lookup] = await opened.find([arg]);
201
+ if ('refused' in lookup) {
202
+ found.push({ refused: refusedRow(given, null, `refused: ${lookup.message}`) });
203
+ continue;
204
+ }
205
+ try {
206
+ found.push({ file: await offeredDataset(lookup.found) });
207
+ }
208
+ catch (err) {
209
+ if (!(err instanceof DatasetMovedError))
210
+ throw err;
211
+ found.push({ refused: refusedRow(`${given} (${lookup.found.kind})`, null, `failed: ${err.message}`) });
212
+ }
213
+ }
214
+ return found;
215
+ }
216
+ /**
217
+ * Each offering's row, in the order the paths were given: a planned file's
218
+ * from the plan's rows, in turn; a refused dataset's its own.
219
+ *
220
+ * @param offered - The offerings
221
+ * @param planned - The rows of the files planned, in the plan's order
222
+ * @returns The rows
223
+ */
224
+ export function rowsInOrder(offered, planned) {
225
+ let next = 0;
226
+ return offered.map((o) => ('file' in o ? planned[next++] : o.refused));
167
227
  }
168
228
  /**
169
229
  * A client of the developer's own credentials for a bucket: the profile
@@ -178,88 +238,129 @@ export function ownClient(binding, profile) {
178
238
  const chosen = profile ?? (binding.profile.type === 'some' ? binding.profile.value : undefined);
179
239
  return new S3Client({ ...(chosen !== undefined && { profile: chosen }), ...(region !== undefined && { region }), followRegionRedirects: true });
180
240
  }
241
+ /** Bytes, one after another, in one buffer. */
242
+ function joined(parts, length) {
243
+ const out = new Uint8Array(length);
244
+ let at = 0;
245
+ for (const part of parts) {
246
+ out.set(part, at);
247
+ at += part.length;
248
+ }
249
+ return out;
250
+ }
251
+ /**
252
+ * A source's bytes in parts of `partBytes` (the last the rest), in order, from
253
+ * one read: each part whole, for its checksum. A whole part is given once the
254
+ * byte after it has come, so the last is given only after the read has ended,
255
+ * and a source that checks its bytes as they end (a dataset's) is heard
256
+ * before an upload of them is completed.
257
+ *
258
+ * @throws {Error} When the bytes are not as many as planned: a file that changed while it was read
259
+ */
260
+ async function* partsOf(bytes, partBytes) {
261
+ let held = [];
262
+ let heldBytes = 0;
263
+ let total = 0;
264
+ for await (const chunk of bytes.read(0)) {
265
+ total += chunk.length;
266
+ if (total > bytes.size)
267
+ throw new Error(`the bytes run past the ${bytes.size} planned: it changed while it was read`);
268
+ let at = 0;
269
+ while (at < chunk.length) {
270
+ if (heldBytes === partBytes) {
271
+ // A whole part, with more to come
272
+ yield joined(held, heldBytes);
273
+ held = [];
274
+ heldBytes = 0;
275
+ }
276
+ const take = Math.min(chunk.length - at, partBytes - heldBytes);
277
+ held.push(chunk.subarray(at, at + take));
278
+ heldBytes += take;
279
+ at += take;
280
+ }
281
+ }
282
+ if (total !== bytes.size)
283
+ throw new Error(`the bytes end ${bytes.size - total} short of the ${bytes.size} planned: it changed while it was read`);
284
+ if (heldBytes > 0 || bytes.size === 0)
285
+ yield joined(held, heldBytes);
286
+ }
287
+ /** A SHA-256 as the bucket's checksum takes it: base64. */
288
+ const checksumOf = (sha256) => Buffer.from(sha256, 'hex').toString('base64');
181
289
  /**
182
290
  * A put with the developer's own credentials: one PUT up to the part size,
183
- * else a multipart upload in parts, the file read by range.
291
+ * its bytes held to the SHA-256 the plan was given; else a multipart upload in
292
+ * parts, each with its own checksum, read from one read and four sent at once.
293
+ * A dataset's file carries the metadata naming the dataset.
184
294
  *
185
295
  * @param client - The client
186
296
  * @param bucket - The bucket
187
297
  * @param key - The object's key
188
- * @param path - The file
189
- * @param size - Its size
298
+ * @param file - The file or dataset
190
299
  */
191
- export async function putExternal(client, bucket, key, path, size) {
300
+ export async function putExternal(client, bucket, key, file) {
301
+ const size = file.bytes.size;
302
+ const metadata = file.origin === null ? undefined : filesDatasetMetadata(file.origin);
192
303
  if (size <= FILES_PUSH_PART_BYTES) {
193
- const bytes = new Uint8Array(size);
194
- const fd = openSync(path, 'r');
195
- try {
196
- let done = 0;
197
- while (done < size) {
198
- const read = readSync(fd, bytes, done, size - done, done);
199
- if (read === 0)
200
- break;
201
- done += read;
202
- }
203
- if (fstatSync(fd).size !== size)
204
- throw new Error(`${path} changed while it was read`);
205
- }
206
- finally {
207
- closeSync(fd);
208
- }
209
- await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: bytes, ContentLength: size, ChecksumAlgorithm: 'SHA256' }));
304
+ let body = null;
305
+ for await (const part of partsOf(file.bytes, Math.max(size, 1)))
306
+ body = part;
307
+ await client.send(new PutObjectCommand({
308
+ Bucket: bucket, Key: key, Body: body, ContentLength: size, ChecksumSHA256: checksumOf(file.offered.sha256),
309
+ ...(metadata !== undefined && { Metadata: metadata }),
310
+ }));
210
311
  return;
211
312
  }
212
- const created = await client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ChecksumAlgorithm: 'SHA256' }));
313
+ const created = await client.send(new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ChecksumAlgorithm: 'SHA256', ...(metadata !== undefined && { Metadata: metadata }) }));
213
314
  const uploadId = created.UploadId;
315
+ const sent = [];
316
+ const inFlight = new Set();
317
+ let failure = null;
214
318
  try {
215
- const count = Math.ceil(size / FILES_PUSH_PART_BYTES);
216
- const sent = new Map();
217
- let next = 0;
218
- const worker = async () => {
219
- for (;;) {
220
- const i = next++;
221
- if (i >= count)
222
- return;
223
- const start = i * FILES_PUSH_PART_BYTES;
224
- const end = Math.min(size, start + FILES_PUSH_PART_BYTES) - 1;
225
- const chunks = [];
226
- for await (const chunk of createReadStream(path, { start, end }))
227
- chunks.push(chunk);
228
- const part = Buffer.concat(chunks);
229
- // An upload created with a checksum algorithm completes with each part's checksum
230
- const checksum = createHash('sha256').update(part).digest('base64');
231
- const uploaded = await client.send(new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: i + 1, Body: part, ContentLength: part.length, ChecksumSHA256: checksum }));
232
- sent.set(i + 1, { ETag: uploaded.ETag, ChecksumSHA256: checksum });
233
- }
234
- };
235
- await Promise.all(Array.from({ length: Math.min(PARTS_IN_FLIGHT, count) }, worker));
319
+ let number = 0;
320
+ for await (const part of partsOf(file.bytes, FILES_PUSH_PART_BYTES)) {
321
+ if (failure !== null)
322
+ break;
323
+ const partNumber = ++number;
324
+ // An upload created with a checksum algorithm completes with each part's checksum
325
+ const checksum = createHash('sha256').update(part).digest('base64');
326
+ const sending = client.send(new UploadPartCommand({
327
+ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: partNumber, Body: part, ContentLength: part.length, ChecksumSHA256: checksum,
328
+ })).then((uploaded) => { sent.push({ PartNumber: partNumber, ETag: uploaded.ETag, ChecksumSHA256: checksum }); }, (err) => { failure ??= err; })
329
+ .finally(() => { inFlight.delete(sending); });
330
+ inFlight.add(sending);
331
+ // A part read only while fewer than four are being sent: four parts held at most
332
+ if (inFlight.size >= PARTS_IN_FLIGHT)
333
+ await Promise.race(inFlight);
334
+ }
335
+ await Promise.all(inFlight);
336
+ if (failure !== null)
337
+ throw failure;
236
338
  await client.send(new CompleteMultipartUploadCommand({
237
339
  Bucket: bucket, Key: key, UploadId: uploadId,
238
- MultipartUpload: { Parts: [...sent].sort(([a], [b]) => a - b).map(([PartNumber, part]) => ({ PartNumber, ...part })) },
340
+ MultipartUpload: { Parts: sent.sort((a, b) => a.PartNumber - b.PartNumber) },
239
341
  }));
240
342
  }
241
343
  catch (err) {
344
+ await Promise.allSettled(inFlight);
242
345
  await client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId })).catch(() => undefined);
243
346
  throw err;
244
347
  }
245
348
  }
246
- /** The kind a mapping fills, as a row says it: the type, `seed`, the mutation, or the shape. */
247
- function kindOf(plan, outcome) {
248
- const { target } = outcome.value;
249
- if (target.type === 'record')
250
- return 'seed';
251
- if (target.type === 'mutation')
252
- return target.value.mutation;
253
- void plan;
254
- return 'input';
349
+ /** The kind a mapping fills, as a row says it: the type, `seed`, or the mutation. */
350
+ function kindOf(file, target) {
351
+ switch (target.type) {
352
+ case 'input': return typeOfOffered(file);
353
+ case 'record': return 'seed';
354
+ case 'mutation': return target.value.mutation;
355
+ }
255
356
  }
256
357
  /**
257
- * Uploads each planned put, four parts in flight, and answers a row per
258
- * file: put, unchanged, refused or failed.
358
+ * Uploads each planned put and answers a row per file: put, unchanged,
359
+ * refused or failed.
259
360
  *
260
361
  * @param plan - The plan
261
362
  * @param files - The files offered, in the plan's order
262
- * @param uploads - The completion, and the profile for external puts
363
+ * @param uploads - The platform's uploads, and the credentials for external puts
263
364
  * @param dryRun - Put nothing
264
365
  * @returns A row per file, and the targets put
265
366
  */
@@ -269,30 +370,30 @@ export async function uploadPlanned(plan, files, uploads, dryRun) {
269
370
  const bindingOf = (source) => plan.bindings.find((b) => b.source === source);
270
371
  for (const [i, planned] of plan.files.entries()) {
271
372
  const file = files[i];
373
+ const row = (target, kind, put) => ({ label: file.label, size: file.offered.size, target, kind, put, sync: '' });
272
374
  const { outcome } = planned;
273
375
  if (outcome.type === 'refused') {
274
- rows.push({ file, target: null, kind: '', put: `refused: ${filesPushRefusalText(outcome.value)}`, sync: '' });
376
+ rows.push(row(null, '', `refused: ${filesPushRefusalText(outcome.value)}`));
275
377
  continue;
276
378
  }
277
379
  const { target } = outcome.value;
278
- const kind = outcome.value.target.type === 'input' ? typeOfOffered(file) : kindOf(plan, outcome);
380
+ const kind = kindOf(file, target);
279
381
  if (outcome.type === 'unchanged') {
280
- rows.push({ file, target, kind, put: 'unchanged', sync: '' });
382
+ rows.push(row(target, kind, 'unchanged'));
281
383
  continue;
282
384
  }
283
385
  if (dryRun) {
284
- rows.push({ file, target, kind, put: 'would put', sync: '' });
386
+ rows.push(row(target, kind, 'would put'));
285
387
  continue;
286
388
  }
287
- const size = Number(file.offered.size);
288
389
  try {
289
- await upload(outcome.value.upload, outcome.value.key, bindingOf(outcome.value.source), file.path, size, uploads);
290
- rows.push({ file, target, kind, put: 'put', sync: '' });
390
+ await upload(plan, outcome.value, bindingOf(outcome.value.source), file, uploads);
391
+ rows.push(row(target, kind, 'put'));
291
392
  if (!targets.some((t) => sameFilesTarget(t, target)))
292
393
  targets.push(target);
293
394
  }
294
395
  catch (err) {
295
- rows.push({ file, target, kind, put: `failed: ${err instanceof Error ? err.message : String(err)}`, sync: '' });
396
+ rows.push(row(target, kind, `failed: ${err instanceof Error ? err.message : String(err)}`));
296
397
  }
297
398
  }
298
399
  return { rows, targets };
@@ -305,27 +406,36 @@ function typeOfOffered(file) {
305
406
  return words.length <= 40 ? words : `${words.slice(0, 39)}…`;
306
407
  }
307
408
  /** One put, as the plan says to upload it. */
308
- async function upload(how, key, binding, path, size, uploads) {
309
- switch (how.type) {
409
+ async function upload(plan, put, binding, file, uploads) {
410
+ switch (put.upload.type) {
310
411
  case 'single':
311
- await putSingle(path, size, how.value.url, how.value.headers);
312
- return;
313
- case 'multipart': {
314
- const etags = await putParts(path, size, Number(how.value.partBytes), how.value.parts);
315
- await uploads.complete(key, how.value.uploadId, etags);
412
+ case 'multipart':
413
+ if (uploads.signed === undefined)
414
+ throw new Error('the plan signed an upload, which only a deployed repository\'s push makes');
415
+ await uploads.signed(plan, put, file.bytes);
316
416
  return;
317
- }
318
417
  case 'external': {
319
418
  if (binding === undefined)
320
419
  throw new Error('the plan names no binding to put to');
420
+ if (uploads.external !== undefined) {
421
+ await uploads.external(binding, put.key, file);
422
+ return;
423
+ }
321
424
  const location = parseBucketUrl(binding.url);
322
425
  if ('problem' in location)
323
426
  throw new Error(`${binding.url} ${location.problem}`);
324
- await putExternal(ownClient(binding, uploads.profile), location.bucket, `${location.prefix}${key}`, path, size);
427
+ await putExternal(ownClient(binding, uploads.profile), location.bucket, `${location.prefix}${put.key}`, file);
325
428
  return;
326
429
  }
327
430
  }
328
431
  }
432
+ /** A seed's commit as a row says it: `$init #1`, `$seed #2`; `$seed` where its ledger did not count. */
433
+ function seedWords(m) {
434
+ if (m.seed.type === 'none')
435
+ return null;
436
+ const { commit, number } = m.seed.value;
437
+ return number.type === 'some' ? `${commit} #${number.value}` : commit;
438
+ }
329
439
  /** What a sync did to a row's target, in words. */
330
440
  export function syncWords(report, target) {
331
441
  const m = report?.mappings.find((r) => sameFilesTarget(r.target, target));
@@ -337,13 +447,61 @@ export function syncWords(report, target) {
337
447
  return `waiting: ${m.waiting.value}`;
338
448
  if (m.kept.type === 'some')
339
449
  return `kept: ${m.kept.value}`;
340
- const rows = m.taken.type === 'some' && m.taken.value.rows.type === 'some' ? ` ${m.taken.value.rows.value.toLocaleString('en-US')} rows` : '';
450
+ const count = m.taken.type === 'some' && m.taken.value.rows.type === 'some' ? m.taken.value.rows.value : null;
451
+ const rows = count === null ? '' : ` ${count.toLocaleString('en-US')} ${count === 1n ? 'row' : 'rows'}`;
452
+ const seed = seedWords(m);
453
+ if (seed !== null)
454
+ return `${seed}${rows}`;
341
455
  if (m.commit.type === 'some')
342
456
  return `committed ${m.commit.value.slice(0, 12)}${rows}`;
343
457
  if (m.updated)
344
458
  return `taken in${rows}`;
345
459
  return 'unchanged';
346
460
  }
461
+ /**
462
+ * Each offering's row, in the order the paths were given, each put's with
463
+ * what the sync did to its target.
464
+ *
465
+ * @param offered - The offerings
466
+ * @param planned - The rows of the files planned, in the plan's order
467
+ * @param report - The sync of what was put; null when nothing was
468
+ * @returns The rows
469
+ */
470
+ export function completedRows(offered, planned, report) {
471
+ const rows = rowsInOrder(offered, planned);
472
+ for (const row of rows)
473
+ if (row.target !== null && row.put === 'put')
474
+ row.sync = syncWords(report, row.target);
475
+ return rows;
476
+ }
477
+ /**
478
+ * A push to a local repository's workspace: the plan over its declarations,
479
+ * bindings and ledgers ({@link planLocal}), each put with the developer's own
480
+ * credentials, then one sync of the targets put ({@link syncLocal}), as a
481
+ * deployed workspace's push does through its API.
482
+ *
483
+ * @param repoPath - The repository, opened (`openLocalRepository`)
484
+ * @param ws - The workspace
485
+ * @param offered - What the push offers ({@link offerings})
486
+ * @param options - The target named, the records reset, a dry run, the credentials
487
+ * @returns The bindings, the rows and the sync's report
488
+ * @throws {Error} When the workspace has no declarations deployed
489
+ */
490
+ export async function pushLocal(repoPath, ws, offered, options = {}) {
491
+ const files = offered.flatMap((o) => ('file' in o ? [o.file] : []));
492
+ if (files.length === 0)
493
+ return { bindings: [], rows: completedRows(offered, [], null), report: null };
494
+ const plan = await planLocal(repoPath, ws, files, { ...(options.as !== undefined && { as: options.as }), ...(options.reader !== undefined && { reader: options.reader }) });
495
+ const dryRun = options.dryRun === true;
496
+ const { rows, targets } = await uploadPlanned(plan, files, {
497
+ ...(options.profile !== undefined && { profile: options.profile }),
498
+ ...(options.external !== undefined && { external: options.external }),
499
+ }, dryRun);
500
+ const report = !dryRun && targets.length > 0
501
+ ? await syncLocal(repoPath, ws, { targets, ...(options.reset !== undefined && { reset: options.reset }), ...(options.reader !== undefined && { reader: options.reader }) })
502
+ : null;
503
+ return { bindings: plan.bindings, rows: completedRows(offered, rows, report), report };
504
+ }
347
505
  /** A count of bytes, as the rows show it. */
348
506
  function sizeWords(bytes) {
349
507
  const n = Number(bytes);
@@ -358,22 +516,22 @@ function sizeWords(bytes) {
358
516
  }
359
517
  /**
360
518
  * A push's rows, as the CLI prints them: the sources' bucket lines, a row
361
- * per file, and the dataflow hint when anything was taken.
519
+ * per file or dataset, and the dataflow hint when anything was taken.
362
520
  *
363
- * @param plan - The plan
364
- * @param rows - The rows
521
+ * @param bindings - The bindings the plan put to; none when nothing was planned
522
+ * @param rows - The rows, in the order the paths were given
365
523
  * @param next - The dataflow run hint's command, when anything changed; null for none
366
524
  * @returns The lines
367
525
  */
368
- export function pushText(plan, rows, next) {
526
+ export function pushText(bindings, rows, next) {
369
527
  const lines = [];
370
- for (const binding of plan.bindings)
528
+ for (const binding of bindings)
371
529
  lines.push(`${binding.source} ${binding.url}`);
372
530
  const cells = rows.map((row) => [
373
- row.file.offered.directory.type === 'some' ? `${row.file.offered.directory.value}/${row.file.offered.name}` : row.file.offered.name,
531
+ row.label,
374
532
  row.target === null ? '' : `→ ${filesTargetText(row.target)}`,
375
533
  row.kind,
376
- sizeWords(row.file.offered.size),
534
+ row.size === null ? '' : sizeWords(row.size),
377
535
  row.put,
378
536
  row.sync,
379
537
  ]);