Skip to content

Instantly share code, notes, and snippets.

@jbgutierrez
Created April 20, 2026 13:00
Show Gist options
  • Select an option

  • Save jbgutierrez/92b4d842075e314d1c7d37d2008576c0 to your computer and use it in GitHub Desktop.

Select an option

Save jbgutierrez/92b4d842075e314d1c7d37d2008576c0 to your computer and use it in GitHub Desktop.
Plan: Explicit Enhancement Asset Resolution — CREMA cross-system change

Plan: Explicit Enhancement Asset Resolution

Problem Statement

Currently, lib-creativitiesjs loads enhancement bundles (JS/CSS) by constructing URLs via convention:

// helpers.js:49 — TODO comment already exists acknowledging this debt
const path = `${context.staticContent}/contents/${enhancementName}/index.js`;

The enhancement name comes from CREMA's spot response (variant.advanced.enhancementName), but the actual asset paths are NEVER resolved by the backend. The frontend guesses the URL shape.

Meanwhile, the asset paths DO exist in the system — they're stored in Job.assets.zipAssets[].blobInformation.path after upload — but this data is completely disconnected from the Spot/enhancement flow.

The user's requirement: "cuando se entrega un contenido, ademas de la data del contenido tiene que llegar los enhancements" — when content is delivered, enhancement asset paths must come resolved from the backend.


Sequence Diagrams

Upload Flow (Stories → CREMA → Enhancement Registration)

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Actions
    participant Stories as lib-creativitiesstories
    participant CREMA as mic-creativitiesmanager
    participant Azure as Azure Blob Storage
    participant DB as MongoDB

    Dev->>GH: Push to main branch
    GH->>Stories: Checkout + npm ci + build (rspack)
    Stories->>Stories: rspack outputs dist/{name}/index.js
    GH->>Stories: node bin/createJob.js
    Stories->>Stories: Zip dist/{name}/ folder
    Stories->>CREMA: POST /api/v1/jobs/uploadZip (zip file)

    CREMA->>CREMA: Unzip, extract files
    loop Each file in zip
        CREMA->>Azure: uploadFile(filename, buffer, destPath)
        Azure-->>CREMA: { blobInformation, path: "destPath/filename" }
    end

    CREMA->>DB: Upsert Job record (assets with blobInfo paths)

    Note over CREMA,DB: NEW: Enhancement auto-registration
    CREMA->>CREMA: Detect index.js in zip assets
    CREMA->>DB: Enhancement.findOneAndUpdate({ name: downloadName },<br/>{ assets: { js: path }, jobId }, { upsert: true })
    DB-->>CREMA: Enhancement record created/updated

    CREMA-->>Stories: 200 OK (Job response)
    GH-->>Dev: Deploy workflow complete
Loading

Publish Flow (Spot → Snapshot → Enhancement Resolution)

sequenceDiagram
    participant Editor as SPA Editor
    participant CREMA as mic-creativitiesmanager
    participant DB as MongoDB
    participant CMNG as Content Manager (cmng)

    Editor->>CREMA: POST /spots/{id}/publish
    CREMA->>DB: SpotRepository.findById(id) + populate

    CREMA->>CREMA: _publicationValidate(spot)
    CREMA->>CREMA: obtainESpots(spot) → espots, references

    Note over CREMA,DB: generateSpotSnapshot()
    CREMA->>DB: SpotSnapshotRepository.findOne (previous)
    CREMA->>DB: ConfigurationRepository.findByQuery (customizations)
    CREMA->>CREMA: _reduceVariants() → variants, contentsMap, etc.

    Note over CREMA,DB: NEW: _resolveEnhancementAssets()
    CREMA->>CREMA: Collect enhancementNames from variants
    CREMA->>DB: Enhancement.find({ name: { $in: [...names] } })
    DB-->>CREMA: [{ name, assets: { js, css } }]
    CREMA->>CREMA: Inject enhancementAssets into variant.advanced

    CREMA->>CREMA: Build spotSnapshot (with enriched variants)

    Note over CREMA,CMNG: Push to Content Manager
    CREMA->>CREMA: getCmngTemplate(spotSnapshot) → serialize to EJS
    CREMA->>CMNG: updateResource("content", contentId, payload)
    CMNG-->>CREMA: 200 OK

    CREMA->>DB: SpotRepository.update (metadata.publishedAt)
    CREMA->>DB: SpotSnapshotService.saveSpotSnapshot()
    CREMA-->>Editor: { message: "Spot published" }
Loading

Render Flow (Frontend → Load Enhancement)

sequenceDiagram
    participant User as End User Browser
    participant FE as lib-creativitiesjs
    participant CMNG as Content Manager (cmng)
    participant CDN as Azure CDN / Blob

    User->>FE: Page load → init creativities
    FE->>CMNG: GET spot content (via getSpot)
    CMNG-->>FE: Spot JSON response

    Note over FE: Response includes:<br/>variant.advanced = {<br/>  enhancement: true,<br/>  enhancementName: "ss26-joinlife-care",<br/>  enhancementAssets: {<br/>    js: "ss26-joinlife-care/index.js"<br/>  }<br/>}

    FE->>FE: loadEnhancement({ name, assets }, context)

    alt Assets resolved (new behavior)
        FE->>FE: jsPath = staticContent/contents/{assets.js}
    else Fallback (legacy behavior)
        FE->>FE: jsPath = staticContent/contents/{name}/index.js
    end

    FE->>CDN: <script src="{jsPath}">
    CDN-->>FE: Enhancement JS bundle

    opt CSS asset available
        FE->>CDN: <link href="{cssPath}">
        CDN-->>FE: Enhancement CSS
    end

    FE->>FE: window.zara.enhancements[name].default
    FE->>User: Render enhancement component
Loading

Current Architecture

┌─────────────────────────┐
│  lib-creativitiesstories │
│  (rspack build)          │
│  dist/{name}/index.js    │
└───────────┬─────────────┘
            │ bin/deploy (LOCAL)
            │ → createJob.js
            │ → POST /api/v1/jobs/uploadZip
            ▼
┌─────────────────────────────────────────────────────┐
│  mic-creativitiesmanager                             │
│                                                      │
│  uploadZip.js:                                       │
│    → unzip → upload each file to Azure Blob Storage  │
│    → creates Job record with assets[].blobInfo.path  │
│                                                      │
│  Azure path: static/contents/{destPath}/{filename}   │
│  Stored as:  job.assets.zipAssets[].blobInfo.path    │
│              = "{destPath}/{filename}"                │
└───────────┬─────────────────────────────────────────┘
            │ (NO CONNECTION)
            ▼
┌─────────────────────────────────────────────────────┐
│  Spot model                                          │
│  variant.advanced = {                                │
│    enhancement: true,                                │
│    enhancementName: "ss26-joinlife-care"             │
│  }                                                   │
│  (NO asset paths stored here)                        │
└───────────┬─────────────────────────────────────────┘
            │ publish → generateSpotSnapshot
            │ → spotSnapshot serialized into EJS template
            │ → pushed to cmng (Content Manager)
            ▼
┌─────────────────────────────────────────────────────┐
│  Frontend (lib-creativitiesjs)                       │
│                                                      │
│  Receives spot with: enhancement.name = "xxx"        │
│  Constructs URL: staticContent/contents/xxx/index.js │
│  Loads via <script> tag                              │
└─────────────────────────────────────────────────────┘

The GAP: Job has the paths. Spot has the name. Nothing connects them.


Proposed Architecture

┌─────────────────────────┐
│  lib-creativitiesstories │
│  (rspack build)          │
│  dist/{name}/index.js    │
└───────────┬─────────────┘
            │ GitHub Action (REPLACES local bin/deploy)
            │ → createJob.js
            │ → POST /api/v1/jobs/uploadZip
            ▼
┌─────────────────────────────────────────────────────┐
│  mic-creativitiesmanager                             │
│                                                      │
│  uploadZip.js:                                       │
│    → unzip → upload to Azure                         │
│    → creates Job record (unchanged)                  │
│    → NEW: if zip has index.js, upsert Enhancement    │
│                                                      │
│  NEW Enhancement model:                              │
│  {                                                   │
│    name: "ss26-joinlife-care",                       │
│    assets: { js: "ss26-joinlife-care/index.js" },    │
│    jobId: ObjectId,                                  │
│    uploadedAt: Date                                  │
│  }                                                   │
└───────────┬─────────────────────────────────────────┘
            │
            ▼
┌─────────────────────────────────────────────────────┐
│  generateSpotSnapshot.js                             │
│                                                      │
│  When building variant with enhancement:             │
│    → lookup Enhancement by enhancementName           │
│    → inject enhancementAssets into variant.advanced   │
│                                                      │
│  Snapshot variant.advanced becomes:                  │
│  {                                                   │
│    enhancement: true,                                │
│    enhancementName: "ss26-joinlife-care",            │
│    enhancementAssets: {                              │
│      js: "ss26-joinlife-care/index.js",              │
│      css: null                                       │
│    }                                                 │
│  }                                                   │
└───────────┬─────────────────────────────────────────┘
            │ existing serialization → cmng
            ▼
┌─────────────────────────────────────────────────────┐
│  Frontend (lib-creativitiesjs)                       │
│                                                      │
│  Receives: enhancement.assets.js resolved path       │
│  Builds URL: staticContent/contents/{assets.js}      │
│  Fallback: convention if assets not present          │
└─────────────────────────────────────────────────────┘

Detailed Changes

1. New Model: Enhancement

Repo: mic-creativitiesmanager
File: code/src/models/Enhancement.js (NEW)

const mongoose = require("mongoose");
const { Schema, model } = mongoose;

const EnhancementSchema = new Schema(
  {
    name: {
      type: String,
      required: true,
      unique: true,
      index: true,
      trim: true,
    },
    assets: {
      type: new Schema(
        {
          js: { type: String, trim: true },
          css: { type: String, trim: true },
        },
        { _id: false, minimize: false },
      ),
      required: true,
    },
    jobId: {
      type: Schema["ObjectId"],
      ref: "Job",
    },
    uploadedAt: {
      type: Date,
      default: Date.now,
    },
  },
  { minimize: false, timestamps: true },
);

const Enhancement = model("Enhancement", EnhancementSchema);
module.exports = Enhancement;

Purpose: Explicit registry mapping enhancement names to their resolved asset paths. Decouples the "what paths exist" knowledge from the Job model (which is a generic upload record).

Why a separate model instead of adding fields to Spot:

  • The same enhancement can be referenced by multiple spots/variants
  • The enhancement lifecycle (upload/redeploy) is independent from spot lifecycle (publish/unpublish)
  • Avoids fan-out updates to N spots when an enhancement is redeployed

2. Modify uploadZip.js — Auto-register Enhancement on upload

Repo: mic-creativitiesmanager
File: code/src/api/job/service/uploadZip.js

Change location: After successful Azure upload (lines 116-125), add Enhancement upsert logic.

Current code (lines 115-125):

logger.info(`File ${file.originalFilename} uploaded to azure`);
await _upsertJob({
  storedJob: updatedJob,
  assets,
  downloadName: updatedJob.downloadName,
  // ...
  status: STATUS.completed,
});

Modified code:

logger.info(`File ${file.originalFilename} uploaded to azure`);
const completedJob = await _upsertJob({
  storedJob: updatedJob,
  assets,
  downloadName: updatedJob.downloadName,
  // ...
  status: STATUS.completed,
});

// Auto-register enhancement if zip contains index.js
await _registerEnhancementIfApplicable(assets, updatedJob.downloadName, completedJob._id);

New helper function:

const Enhancement = require("../../../models/Enhancement");

const _registerEnhancementIfApplicable = async (assets, downloadName, jobId) => {
  const jsAsset = assets.zipAssets.find(a => a.filename === "index.js");
  if (!jsAsset) return;

  const cssAsset = assets.zipAssets.find(a => a.filename === "index.css");

  await Enhancement.findOneAndUpdate(
    { name: downloadName },
    {
      name: downloadName,
      assets: {
        js: jsAsset.blobInformation.path,
        ...(cssAsset ? { css: cssAsset.blobInformation.path } : {}),
      },
      jobId,
      uploadedAt: new Date(),
    },
    { upsert: true, new: true },
  );

  logger.info(`Enhancement "${downloadName}" registered with assets: js=${jsAsset.blobInformation.path}`);
};

Detection heuristic: If the uploaded zip contains a file named index.js, treat it as an enhancement bundle. This matches the rspack output convention from stories (dist/{name}/index.js).

Why this heuristic works:

  • Stories outputs: dist/ss26-joinlife-care/index.js → zip root has index.js inside folder
  • Regular content uploads (images, fonts, PDFs) won't have an index.js
  • The zip filename (= downloadName) equals the enhancement name by convention in createJob.js

Edge case: If a non-enhancement zip happens to contain index.js, a spurious Enhancement record is created. This is harmless — it won't be consumed unless a Spot explicitly references that name in variant.advanced.enhancementName.


3. Modify generateSpotSnapshot.js — Resolve paths at publish time

Repo: mic-creativitiesmanager
File: code/src/api/spotSnapshot/shared/generateSpotSnapshot.js

Change location: After _reduceVariants() returns filteredVariants (line 271-273 area), enrich variants with enhancement assets.

Current code (lines 71-76):

snapshot.variants = variants;
snapshot.contentsMap = Object.values(contentsMap);
snapshot.rulesMap = Object.values(rulesMap);
snapshot.entriesMap = Object.values(entriesMap);
snapshot.isMultiSpot = isMultiSpot;
return snapshot;

Modified code:

snapshot.variants = await _resolveEnhancementAssets(variants);
snapshot.contentsMap = Object.values(contentsMap);
snapshot.rulesMap = Object.values(rulesMap);
snapshot.entriesMap = Object.values(entriesMap);
snapshot.isMultiSpot = isMultiSpot;
return snapshot;

New helper function:

const Enhancement = require("../../../models/Enhancement");

const _resolveEnhancementAssets = async (variants) => {
  // Collect unique enhancement names to batch-query
  const enhancementNames = [
    ...new Set(
      variants
        .filter(v => v.advanced?.enhancement && v.advanced?.enhancementName)
        .map(v => v.advanced.enhancementName),
    ),
  ];

  if (!enhancementNames.length) return variants;

  // Single batch query for all enhancement names
  const enhancements = await Enhancement.find({ name: { $in: enhancementNames } }).lean();
  const enhancementMap = enhancements.reduce((map, e) => {
    map[e.name] = e.assets;
    return map;
  }, {});

  // Inject resolved assets into each variant
  return variants.map(variant => {
    if (variant.advanced?.enhancement && variant.advanced?.enhancementName) {
      const assets = enhancementMap[variant.advanced.enhancementName];
      if (assets) {
        return {
          ...variant,
          advanced: {
            ...variant.advanced,
            enhancementAssets: assets,
          },
        };
      }
    }
    return variant;
  });
};

Performance note: Uses a single $in query for all enhancement names in the spot (typically 1-2 names). Negligible overhead vs the existing N content/rule/entry queries in the same function.


4. Update SpotSnapshot model — Allow enhancementAssets field

Repo: mic-creativitiesmanager
File: code/src/models/SpotSnapshot.js

Change location: The variant.advanced schema (lines 98-113).

Current:

advanced: {
  type: new Schema(
    {
      enhancement: { type: Boolean, default: false },
      enhancementName: {
        type: String,
        validate: { validator: whiteSpaceValidator, message: ... },
      },
    },
    { _id: false, minimize: false },
  ),
  default: {},
},

Modified (add enhancementAssets):

advanced: {
  type: new Schema(
    {
      enhancement: { type: Boolean, default: false },
      enhancementName: {
        type: String,
        validate: { validator: whiteSpaceValidator, message: ... },
      },
      enhancementAssets: {
        type: new Schema(
          {
            js: { type: String, trim: true },
            css: { type: String, trim: true },
          },
          { _id: false, minimize: false },
        ),
      },
    },
    { _id: false, minimize: false },
  ),
  default: {},
},

5. Modify lib-creativitiesjs — Consume resolved paths

Repo: lib-creativitiesjs
File: code/src/helpers.js

Current loadEnhancement (lines 46-64):

export const loadEnhancement = (enhancement, context) => {
  return new Promise((resolve, reject) => {
    // TODO: debería venir la ruta del js y el css resuelta en la respuesta del spot
    const path = `${context.staticContent}/contents/${enhancement}/index.js`;
    const script = document.createElement("script");
    script.type = "text/javascript";
    script.src = path;
    script.onload = () => {
      const enhancementName = enhancement.split("/").pop();
      resolve(window.zara.enhancements[enhancementName].default);
    };
    script.onerror = err => reject(err);
    document.head.appendChild(script);
  });
};

Modified:

/**
 * Loads an enhancement bundle.
 * @param {Object|string} enhancement - Enhancement config object or name (legacy)
 * @param {string} enhancement.name - Enhancement name
 * @param {Object} [enhancement.assets] - Resolved asset paths from backend
 * @param {string} [enhancement.assets.js] - JS bundle relative path
 * @param {string} [enhancement.assets.css] - CSS bundle relative path
 * @param {Object} context - Rendering context with staticContent base URL
 */
export const loadEnhancement = (enhancement, context) => {
  return new Promise((resolve, reject) => {
    const basePath = `${context.staticContent}/contents`;

    // Support both new object format and legacy string format
    const name = typeof enhancement === "string" ? enhancement : enhancement.name;
    const assets = typeof enhancement === "string" ? null : enhancement.assets;

    // Use resolved path from backend, fallback to convention
    const jsPath = assets?.js
      ? `${basePath}/${assets.js}`
      : `${basePath}/${name}/index.js`;

    // Load CSS if resolved path available
    if (assets?.css) {
      const link = document.createElement("link");
      link.rel = "stylesheet";
      link.href = `${basePath}/${assets.css}`;
      document.head.appendChild(link);
    }

    const script = document.createElement("script");
    script.type = "text/javascript";
    script.src = jsPath;
    script.onload = () => {
      const enhancementKey = name.split("/").pop();
      resolve(window.zara.enhancements[enhancementKey].default);
    };
    script.onerror = err => reject(err);
    document.head.appendChild(script);
  });
};

6. Update call sites in lib-creativitiesjs

File: code/src/components/Creativity/creativityHelpers.js

Current (line ~143 in getContents):

const enhancementModule = await loadEnhancement(content.project.enhancement.name, context);

Modified:

const enhancementModule = await loadEnhancement({
  name: content.project.enhancement.name,
  assets: content.project.enhancement.assets,
}, context);

Same pattern for lines ~256 and ~289 in resolveMixedContents().

File: code/src/components/Creativity/useCreativity.js (line ~35):

Current:

const module = await loadEnhancement(data.externalBundle, context);

Modified:

const module = await loadEnhancement(
  typeof data.externalBundle === "string"
    ? data.externalBundle
    : { name: data.externalBundle.name, assets: data.externalBundle.assets },
  context,
);

7. GitHub Action — Replace local deploy in stories

Repo: lib-creativitiesstories
File: .github/workflows/deploy.yml (NEW)

name: Deploy Enhancements

on:
  push:
    branches: [main]
    paths:
      - "code/src/**"
      - "code/rspack.config.js"
      - "code/package.json"
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "pre"
        type: choice
        options:
          - pre
          - pro

env:
  NODE_VERSION: "22"

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment || 'pre' }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"
          cache-dependency-path: code/package-lock.json

      - name: Install dependencies
        run: npm ci
        working-directory: code

      - name: Lint
        run: npm run lint
        working-directory: code

      - name: Build
        run: npm run build
        working-directory: code

      - name: Deploy to CREMA
        run: node bin/createJob.js
        working-directory: code
        env:
          CREMA_URL: ${{ secrets.CREMA_URL }}
          CREMA_USER: ${{ secrets.CREMA_USER }}
          CREMA_PASSWORD: ${{ secrets.CREMA_PASSWORD }}

Also modify: code/bin/deploy — add deprecation warning at the top:

echo "⚠️  WARNING: Local deploy is deprecated. Use GitHub Actions instead."
echo "   Push to main branch or trigger workflow_dispatch manually."
echo ""

Data Flow Trace (End-to-End)

Upload flow:

1. Developer pushes to lib-creativitiesstories main branch
2. GH Action triggers: npm ci → lint → build (rspack) → node bin/createJob.js
3. createJob.js: zips dist/ss26-joinlife-care/ → POST /api/v1/jobs/uploadZip
4. uploadZip.js:
   a. Unzips → uploads each file to Azure Blob Storage
   b. Azure returns: blobInformation.path = "ss26-joinlife-care/index.js"
   c. Creates/updates Job record with assets
   d. NEW: Detects index.js → upserts Enhancement { name: "ss26-joinlife-care", assets: { js: "ss26-joinlife-care/index.js" } }

Publish flow:

5. Editor publishes spot in SPA (variant has enhancement: true, enhancementName: "ss26-joinlife-care")
6. spot/service.js → _publishInSession() → generateSpotSnapshot()
7. generateSpotSnapshot.js:
   a. Builds snapshot as before
   b. NEW: _resolveEnhancementAssets() → queries Enhancement model
   c. Injects enhancementAssets into variant.advanced
8. Snapshot serialized → EJS template → pushed to cmng
9. cmng stores and serves the content

Render flow:

10. User visits page → frontend requests spot from cmng
11. Response includes: variant.advanced.enhancementAssets.js = "ss26-joinlife-care/index.js"
12. lib-creativitiesjs receives spot data
13. loadEnhancement() reads assets.js → builds full URL → loads via <script>
14. Enhancement module executes

Backward Compatibility Matrix

Scenario Behavior
Old stories deploy (no Enhancement record exists) Frontend falls back to convention URL — works as before
New stories deploy, old frontend Frontend ignores enhancementAssets field, uses convention — works as before
New stories deploy, new frontend, old spot snapshot (not re-published) Frontend sees no enhancementAssets in response, uses convention — works as before
New stories deploy, new frontend, re-published spot Frontend uses resolved enhancementAssets.js path — new behavior

Zero-downtime migration path: Deploy backend → re-publish affected spots → deploy frontend. Any order is safe due to fallbacks.


Rollback Plan

Layer Rollback action
Frontend (lib-creativitiesjs) Revert to previous version — convention mode resumes
Backend (mic-creativitiesmanager) Remove Enhancement lookup from generateSpotSnapshot — snapshots stop including assets
Enhancement model Leave in place (harmless) or drop collection
GitHub Action (stories) Disable workflow — deploy locally with bin/deploy as before

Testing Strategy

Unit tests (mic-creativitiesmanager):

  • Enhancement model: CRUD, unique constraint on name, upsert behavior
  • uploadZip.js: Verify Enhancement upserted when zip has index.js; NOT upserted when zip lacks index.js
  • generateSpotSnapshot.js: Verify enhancementAssets present when Enhancement exists; absent when not found

Unit tests (lib-creativitiesjs):

  • loadEnhancement: with assets object → uses resolved path
  • loadEnhancement: with string (legacy) → uses convention path
  • loadEnhancement: with assets = null → uses convention path
  • CSS loading: link element created when assets.css present

Integration test:

  • Upload zip with index.js → verify Enhancement record created
  • Publish spot with enhancementName matching Enhancement → verify snapshot contains enhancementAssets
  • Serve spot to frontend → verify response includes paths

Open Decisions

# Question Recommendation Impact if deferred
1 Should Enhancement have a REST API (CRUD endpoints)? Yes — useful for SPA to show deployed enhancements Low — can add later without schema changes
2 Should createJob.js explicitly pass isEnhancement flag? No — auto-detection via index.js is sufficient and backward compatible None
3 Should we support CSS bundles now? Yes — field exists, loader handles it, cost is zero None — field is optional
4 Should we add a version field to Enhancement for cache busting? Defer — current flow re-uploads same path (Azure overwrites), no versioning needed yet Low — add when needed
5 Should old bin/deploy be deleted or deprecated? Deprecated with warning — some devs may need local testing Keep as dev tool

Effort Estimate

Task Effort Risk
Enhancement model Small (1 file, ~30 lines) None
uploadZip modification Small (~15 lines added) Low — additive, existing tests cover happy path
generateSpotSnapshot modification Medium (~30 lines, needs batch query) Low — additive, fallback if Enhancement not found
SpotSnapshot model update Small (5 lines) None — additive field
lib-creativitiesjs changes Medium (modify 4 files, maintain backward compat) Medium — must verify all call sites
GitHub Action Small (1 new file, standard workflow) Low — secrets setup required
Testing Medium

Total: ~1-2 days implementation, ~0.5 days testing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment