@xtrable-ltd/nanoesis 0.1.33 → 0.1.34

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.
@@ -23,11 +23,23 @@ interface BlobContainer {
23
23
  /**
24
24
  * Create or overwrite a blob with the given bytes and content type. `cacheControl`, when
25
25
  * given, is stored as the blob's `Cache-Control` so a static-website read serves the right
26
- * freshness (DESIGN §11); omitted for the editor's working blobs.
26
+ * freshness (DESIGN §11); omitted for the editor's working blobs. `hash`, when given, is
27
+ * stored as blob metadata (`nanohash`) so a later publish can tell whether the content is
28
+ * unchanged and skip the upload (see docs/change-detection-on-publish.md); a write *without*
29
+ * a `hash` clears any prior stored hash, matching Azure's overwrite-replaces-metadata
30
+ * semantics, so a stale hash can never outlive the bytes it described.
27
31
  */
28
- write(name: string, data: Uint8Array, contentType: string, cacheControl?: string): Promise<void>;
32
+ write(name: string, data: Uint8Array, contentType: string, cacheControl?: string, hash?: string): Promise<void>;
29
33
  /** Delete a blob; a missing blob is a no-op (idempotent). */
30
34
  remove(name: string): Promise<void>;
35
+ /**
36
+ * Every blob name beginning with `prefix` mapped to its stored content hash (the `nanohash`
37
+ * metadata, `undefined` for a blob written without one), for change detection on publish.
38
+ * Reads metadata from the same listing as {@link list} (one round trip, no blob bodies), so
39
+ * a host can diff a candidate artifact's hash against what is live and skip an unchanged
40
+ * upload. Sorted by name, like {@link list}, to keep any downstream walk deterministic.
41
+ */
42
+ listWithHashes(prefix: string): Promise<ReadonlyMap<string, string | undefined>>;
31
43
  /**
32
44
  * A blob's bytes plus its current version tag (the Azure ETag), or null if absent. Pairs
33
45
  * with {@link writeConditional} for the optimistic-concurrency the content index needs
@@ -53,6 +65,8 @@ interface BlobContainer {
53
65
  declare class InMemoryBlobContainer implements BlobContainer {
54
66
  private readonly blobs;
55
67
  private readonly cacheControls;
68
+ /** Per-blob `nanohash` metadata, the in-memory stand-in for the real blob metadata. */
69
+ private readonly hashes;
56
70
  /** Per-blob version, bumped on every write, the in-memory stand-in for an ETag. */
57
71
  private readonly versions;
58
72
  private nextVersion;
@@ -60,8 +74,9 @@ declare class InMemoryBlobContainer implements BlobContainer {
60
74
  private store;
61
75
  list(prefix: string): Promise<readonly string[]>;
62
76
  read(name: string): Promise<Uint8Array | null>;
63
- write(name: string, data: Uint8Array, contentType?: string, cacheControl?: string): Promise<void>;
77
+ write(name: string, data: Uint8Array, contentType?: string, cacheControl?: string, hash?: string): Promise<void>;
64
78
  remove(name: string): Promise<void>;
79
+ listWithHashes(prefix: string): Promise<ReadonlyMap<string, string | undefined>>;
65
80
  readVersioned(name: string): Promise<{
66
81
  bytes: Uint8Array;
67
82
  version: string;
@@ -71,6 +86,8 @@ declare class InMemoryBlobContainer implements BlobContainer {
71
86
  get names(): readonly string[];
72
87
  /** Test helper: the `Cache-Control` a blob was written with, if any. */
73
88
  cacheControlOf(name: string): string | undefined;
89
+ /** Test helper: the `nanohash` metadata a blob was written with, if any. */
90
+ hashOf(name: string): string | undefined;
74
91
  }
75
92
 
76
93
  /**
@@ -88,8 +105,9 @@ declare class AzureBlobContainer implements BlobContainer {
88
105
  /** Create the container if it does not exist (idempotent host start-up step). */
89
106
  ensureContainer(): Promise<void>;
90
107
  list(prefix: string): Promise<readonly string[]>;
108
+ listWithHashes(prefix: string): Promise<ReadonlyMap<string, string | undefined>>;
91
109
  read(name: string): Promise<Uint8Array | null>;
92
- write(name: string, data: Uint8Array, contentType: string, cacheControl?: string): Promise<void>;
110
+ write(name: string, data: Uint8Array, contentType: string, cacheControl?: string, hash?: string): Promise<void>;
93
111
  remove(name: string): Promise<void>;
94
112
  readVersioned(name: string): Promise<{
95
113
  bytes: Uint8Array;
@@ -133,6 +151,19 @@ declare class BlobArtifactSink implements ArtifactSink {
133
151
  write(path: string, contents: string | Uint8Array, cacheControl?: string): Promise<void>;
134
152
  }
135
153
 
154
+ /** Options for {@link azureStorage}. */
155
+ interface AzureStorageOptions {
156
+ /**
157
+ * Skip re-uploading a blob whose content is byte-identical to what is already stored
158
+ * (docs/change-detection-on-publish.md). On every `put` the store hashes the bytes and
159
+ * compares against the live `nanohash` metadata (read once per publish, no blob bodies);
160
+ * an unchanged blob is not written, a changed/new one is written and re-stamped. Default
161
+ * **off**, so existing behaviour (always write) is unchanged. Enable it only on the
162
+ * **published** container, never the working store, whose conditional index writes must
163
+ * not be skipped.
164
+ */
165
+ readonly changeDetection?: boolean;
166
+ }
136
167
  /**
137
168
  * A ready-made {@link Storage} over an Azure Blob container: get/put/delete (via
138
169
  * {@link BlobContainerStore}) plus `wipe` (delete every blob) and `prune` (delete every blob
@@ -140,8 +171,11 @@ declare class BlobArtifactSink implements ArtifactSink {
140
171
  * as `editorFiles` (the working container) and `website` (the published `$web` container).
141
172
  * For the working container, also pass `enumerate: () => container.list('')` so the editor
142
173
  * can reconcile its content index.
174
+ *
175
+ * Pass `{ changeDetection: true }` on the **published** container to skip uploads of unchanged
176
+ * blobs (see {@link AzureStorageOptions.changeDetection}).
143
177
  */
144
- declare function azureStorage(container: BlobContainer): Storage & ConditionalBlobStore;
178
+ declare function azureStorage(container: BlobContainer, options?: AzureStorageOptions): Storage & ConditionalBlobStore;
145
179
 
146
180
  /**
147
181
  * Pure path/name helper for the blob adapters. Blob storage is a *flat* namespace, a blob
@@ -1,4 +1,5 @@
1
1
  import {
2
+ contentHash,
2
3
  contentTypeFor
3
4
  } from "./chunk-P6NDWIKK.js";
4
5
 
@@ -6,6 +7,8 @@ import {
6
7
  var InMemoryBlobContainer = class {
7
8
  blobs = /* @__PURE__ */ new Map();
8
9
  cacheControls = /* @__PURE__ */ new Map();
10
+ /** Per-blob `nanohash` metadata, the in-memory stand-in for the real blob metadata. */
11
+ hashes = /* @__PURE__ */ new Map();
9
12
  /** Per-blob version, bumped on every write, the in-memory stand-in for an ETag. */
10
13
  versions = /* @__PURE__ */ new Map();
11
14
  nextVersion = 1;
@@ -31,14 +34,22 @@ var InMemoryBlobContainer = class {
31
34
  async read(name) {
32
35
  return this.blobs.get(name) ?? null;
33
36
  }
34
- async write(name, data, contentType, cacheControl) {
37
+ async write(name, data, contentType, cacheControl, hash) {
35
38
  void contentType;
36
39
  this.store(name, data);
37
40
  if (cacheControl !== void 0) this.cacheControls.set(name, cacheControl);
41
+ if (hash !== void 0) this.hashes.set(name, hash);
42
+ else this.hashes.delete(name);
38
43
  }
39
44
  async remove(name) {
40
45
  this.blobs.delete(name);
41
46
  this.versions.delete(name);
47
+ this.hashes.delete(name);
48
+ }
49
+ async listWithHashes(prefix) {
50
+ const out = /* @__PURE__ */ new Map();
51
+ for (const name of await this.list(prefix)) out.set(name, this.hashes.get(name));
52
+ return out;
42
53
  }
43
54
  async readVersioned(name) {
44
55
  const bytes = this.blobs.get(name);
@@ -60,6 +71,10 @@ var InMemoryBlobContainer = class {
60
71
  cacheControlOf(name) {
61
72
  return this.cacheControls.get(name);
62
73
  }
74
+ /** Test helper: the `nanohash` metadata a blob was written with, if any. */
75
+ hashOf(name) {
76
+ return this.hashes.get(name);
77
+ }
63
78
  };
64
79
 
65
80
  // ../../adapters/azure-blob/src/azure-container.ts
@@ -95,6 +110,14 @@ var AzureBlobContainer = class _AzureBlobContainer {
95
110
  }
96
111
  return names.sort();
97
112
  }
113
+ async listWithHashes(prefix) {
114
+ const entries = [];
115
+ for await (const blob of this.client.listBlobsFlat({ prefix, includeMetadata: true })) {
116
+ entries.push([blob.name, blob.metadata?.["nanohash"]]);
117
+ }
118
+ entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
119
+ return new Map(entries);
120
+ }
98
121
  async read(name) {
99
122
  try {
100
123
  const buffer = await this.client.getBlockBlobClient(name).downloadToBuffer();
@@ -104,12 +127,16 @@ var AzureBlobContainer = class _AzureBlobContainer {
104
127
  throw error;
105
128
  }
106
129
  }
107
- async write(name, data, contentType, cacheControl) {
130
+ async write(name, data, contentType, cacheControl, hash) {
108
131
  await this.client.getBlockBlobClient(name).uploadData(Buffer.from(data), {
109
132
  blobHTTPHeaders: {
110
133
  blobContentType: contentType,
111
134
  ...cacheControl !== void 0 && { blobCacheControl: cacheControl }
112
- }
135
+ },
136
+ // Stamp the content hash as metadata for change detection on the next publish. Azure
137
+ // lowercases metadata keys, so the key is already lowercase. An upload with no `hash`
138
+ // omits metadata, which (since uploadData replaces the blob) clears any prior hash.
139
+ ...hash !== void 0 && { metadata: { nanohash: hash } }
113
140
  });
114
141
  }
115
142
  async remove(name) {
@@ -200,32 +227,58 @@ var BlobArtifactSink = class {
200
227
  };
201
228
 
202
229
  // ../../adapters/azure-blob/src/azure-storage.ts
203
- function azureStorage(container) {
230
+ function azureStorage(container, options = {}) {
204
231
  const store = new BlobContainerStore(container);
205
- return {
232
+ const wipe = async () => {
233
+ for (const name of await container.list("")) await container.remove(name);
234
+ };
235
+ const prune = async (keep) => {
236
+ for (const name of await container.list("")) {
237
+ if (!keep.has(name)) await container.remove(name);
238
+ }
239
+ };
240
+ const common = {
206
241
  get: (key) => store.get(key),
207
- // Forward `cacheControl` (DESIGN §11): this is the published-site sink `createEditor`
208
- // actually wires (`asSink(azureStorage(website))`), so dropping the arg here silently
209
- // strips the publish Cache-Control before it reaches the blob.
210
- put: (key, bytes, cacheControl) => store.put(key, bytes, cacheControl),
211
242
  delete: (key) => store.delete(key),
212
243
  // Optimistic-concurrency capability (DESIGN §11d): surfaced so a horizontally-scaled
213
244
  // host's IndexedStore compare-and-sets the content index instead of blind-overwriting
214
245
  // it, which is what stops two instances losing each other's updates
215
246
  // (NANOESIS-MCP-ISSUES Issue 1). Azure blobs back this with native ETag conditions.
216
247
  getVersioned: (key) => store.getVersioned(key),
217
- putConditional: (key, bytes, expected) => store.putConditional(key, bytes, expected),
248
+ putConditional: (key, bytes, expected) => store.putConditional(key, bytes, expected)
249
+ };
250
+ if (!options.changeDetection) {
251
+ return {
252
+ ...common,
253
+ // Forward `cacheControl` (DESIGN §11): this is the published-site sink `createEditor`
254
+ // actually wires (`asSink(azureStorage(website))`), so dropping the arg here silently
255
+ // strips the publish Cache-Control before it reaches the blob.
256
+ put: (key, bytes, cacheControl) => store.put(key, bytes, cacheControl),
257
+ wipe,
258
+ prune
259
+ };
260
+ }
261
+ let live = null;
262
+ const reset = () => {
263
+ live = null;
264
+ };
265
+ return {
266
+ ...common,
267
+ put: async (key, bytes, cacheControl) => {
268
+ if (live === null) live = new Map(await container.listWithHashes(""));
269
+ const name = normalizeBlobName(key);
270
+ const hash = contentHash(bytes);
271
+ if (live.get(name) === hash) return;
272
+ await container.write(name, bytes, contentTypeFor(name), cacheControl, hash);
273
+ live.set(name, hash);
274
+ },
218
275
  wipe: async () => {
219
- for (const name of await container.list("")) await container.remove(name);
276
+ await wipe();
277
+ reset();
220
278
  },
221
- // Zero-downtime sweep (DESIGN §11): the publish overwrites every artifact in place, then
222
- // calls this with the just-written path set so only orphaned blobs (pages the new publish
223
- // did not emit) are removed. Every live URL keeps serving throughout, the old wipe-first
224
- // path blanked the whole site for the upload window.
225
279
  prune: async (keep) => {
226
- for (const name of await container.list("")) {
227
- if (!keep.has(name)) await container.remove(name);
228
- }
280
+ await prune(keep);
281
+ reset();
229
282
  }
230
283
  };
231
284
  }
package/dist/mcp.js CHANGED
@@ -56,7 +56,7 @@ function createMcpServer(deps, identity) {
56
56
  }
57
57
 
58
58
  // ../../hosts/host-mcp/src/http.ts
59
- var DEFAULT_VERSION = true ? "0.1.33" : "0.0.0-workspace";
59
+ var DEFAULT_VERSION = true ? "0.1.34" : "0.0.0-workspace";
60
60
  async function handleMcpRequest(deps, request, opts) {
61
61
  const server = createMcpServer(deps, {
62
62
  name: opts?.name ?? "nanoesis",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtrable-ltd/nanoesis",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "nanoesis: a static-first publishing compiler. The engine, the mountable editor API, the storage/image/auth adapters, and the editor UI bundle, in one package (DESIGN 11c).",
5
5
  "license": "MIT",
6
6
  "author": "Xtrable Ltd",