@codaco/network-exporters

中文文档
4.0.0发布于 2026/10/13GPL-3.0-or-laterTypeScriptES Module构建来源已验证近 30 天下载 9410

Effect-TS pipeline for exporting Network Canvas interview data as CSV and GraphML.

原始使用文档
文档目录23 节
  1. Install
  2. Why a package
  3. Architecture
  4. Sparse attributes and stable output schemas
  5. CSV
  6. GraphML
  7. Public surface
  8. Usage
  9. 1. Provide an InterviewRepository
  10. 2. Provide a ProtocolRepository
  11. 3. Provide an Output
  12. 4. Run the pipeline
  13. Pipeline stages
  14. Concurrency
  15. Error model
  16. Fatal errors — Effect failure channel
  17. Partial failures — ExportFailure[]
  18. Progress events
  19. Local development
  20. Adding a new entry point
  21. Adding a runtime dependency
  22. Versioning
  23. License

@codaco/network-exporters

A runtime-agnostic Effect-TS pipeline that exports Network Canvas interview sessions to GraphML and CSV. Hosts plug in three Layers (InterviewRepository, ProtocolRepository, Output) and call exportPipeline. The package owns no persistence, pulls in no node:* modules from its core, and runs unchanged on Node, browsers, and Cloudflare Workers.

Install

effect is a peer dependency, so install it alongside the package. Effect 4 has not reached a stable release, so ask for the release candidate by tag:

npm install @codaco/network-exporters effect@rc
import { Effect, Layer, Queue } from 'effect';
import { exportPipeline } from '@codaco/network-exporters/pipeline';
import type { ExportEvent } from '@codaco/network-exporters/events';
import { makeZipOutput } from '@codaco/network-exporters/layers/ZipOutput';

const layer = Layer.mergeAll(
  MyInterviewRepository, // workspace-specific
  MyProtocolRepository, // workspace-specific
  makeZipOutput(mySink), // ships with this package
);

const program = Effect.gen(function* () {
  const queue = yield* Queue.unbounded<ExportEvent>();
  return yield* exportPipeline(interviewIds, exportOptions, queue);
});

const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
// → { status, successfulExports, failedExports, output }

Why a package

Export logic was historically embedded in Fresco and other Network Canvas applications. This package extracts the pipeline as a self-contained, host-agnostic library so:

  • Multiple applications can share the same export semantics and file formats.
  • The pipeline is testable in isolation (no Prisma, no S3, no temp files).
  • Database, protocol storage, and output destinations are pluggable via Effect Layers.
  • Streams flow end-to-end as AsyncIterable<Uint8Array> — the lowest common denominator across Node Readable, Web ReadableStream, and Workers — so the same package runs in every runtime.

Architecture

┌──────────────────── consumer (host application) ─────────────────────┐
│                                                                       │
│  Provides Layers for these Tags ─────────────┐                        │
│                                              ▼                        │
│              ┌───────────────────── exportPipeline ─────────────────┐ │
│              │                                                     │ │
│              │  fetching → formatting → generating → outputting    │ │
│              │     ▲           ▲             ▲            ▲        │ │
│              │     │           │             │            │        │ │
│   Interview ─┘  Protocol     pure         per-format    Output     │ │
│   Repository    Repository  (in-process)   fan-out      lifecycle  │ │
│                                                       (begin/write/│ │
│                                                          end)      │ │
│              └─────────────────────────────────────────────────────┘ │
│                                              │                        │
│                                              ▼                        │
│                            ExportReturn { status,                     │
│                                           successfulExports,          │
│                                           failedExports,              │
│                                           output: OutputResult }      │
└───────────────────────────────────────────────────────────────────────┘

Three injected services (Context.Services) form the package's input/output boundary:

ServiceProvided byResponsibility
InterviewRepositoryHostgetForExport(ids) returns sessions referencing protocols by hash
ProtocolRepositoryHostgetProtocols(hashes) returns Record<hash, ProtocolExportInput> for unique hashes
OutputHost (or shipped makeZipOutput)Stateful begin → writeEntry × N → end; consumes OutputEntry { name, data } per entry

Sessions reference protocols by hash, so the pipeline fetches each unique protocol exactly once per run regardless of how many sessions share it. The Output service is stateful: begin produces an opaque handle, the pipeline writes one entry per successful generation result, and end returns a host-defined OutputResult that flows out via ExportReturn.output.

The host's only responsibilities are:

  1. Implement the three service Layers.
  2. Call exportPipeline(ids, options, queue) and surface progress events.

Sparse attributes and stable output schemas

The formatting stage parses every session network with NcNetworkSchema before any CSV or GraphML formatter receives it. Nullish input entries become absent properties, while defined values such as false, 0, '', and [] remain. An invalid defined value produces a session-processing failure for that session; it does not abort other sessions in the run.

Output schema comes from both the protocol codebook and the sparse entity data:

  • Codebook declarations keep fully unanswered variables visible. This makes file structure stable instead of depending on which questions a participant happened to answer.
  • Defined entity attributes without an applicable codebook declaration are external attributes. They remain in CSV and GraphML rather than being discarded.
  • Absence controls data output only; it does not erase a declared column or key.

CSV

Node and edge CSV files are partitioned by represented entity type. Each file's headers include every variable declared for that type, even when the variable is absent from every row. Ego CSV headers similarly include every declared ego variable. When a session has zero nodes or zero edges, the header-only file uses all node or edge definitions in the codebook.

Categorical variables expand to one column per declared option, and layout variables expand to coordinate columns. An absent value produces empty cells. A defined empty categorical array produces explicit false option cells, so it remains distinguishable from an unanswered categorical variable. Present external attributes add data-driven columns using their attribute IDs.

GraphML

GraphML declares keys for all ego, node, and edge variables in the codebook, including variables belonging to unrepresented entity types and documents with zero nodes or zero edges. A fully unanswered declared variable therefore keeps its <key> but emits no <data> element.

Present external attributes receive deterministic hashed key IDs that cannot collide with codebook variable IDs. Key allocation happens once for the whole document, and every <data> element uses the allocated ID. If the same external attribute appears on more than one entity kind, GraphML emits one for="all" key rather than conflicting node and edge keys.


Public surface

Imports use sub-paths — the package has no barrel export.

Sub-pathExports
@codaco/network-exporters/pipelineexportPipeline, type ExportedProtocol
@codaco/network-exporters/optionsExportOptions, ExportOptionsSchema, type ExportFormat
@codaco/network-exporters/inputInterviewExportInput, ProtocolExportInput, plus session shape types (FormattedSession, SessionVariables, SessionWithNetworkEgo, NodeWithResequencedID, EdgeWithResequencedID, SessionWithResequencedIDs)
@codaco/network-exporters/outputExportResult, ExportSuccess, ExportFailure, ExportReturn, OutputEntry, OutputResult, OutputHandle
@codaco/network-exporters/eventsExportEvent, stageMessages
@codaco/network-exporters/errorsDatabaseError, OutputError, ExportGenerationError, ProtocolNotFoundError, SessionProcessingError, type ExportError, describeExportError
@codaco/network-exporters/services/InterviewRepositoryInterviewRepository Tag
@codaco/network-exporters/services/ProtocolRepositoryProtocolRepository Tag
@codaco/network-exporters/services/OutputOutput Tag
@codaco/network-exporters/layers/ZipOutputmakeZipOutput, type ZipSink

Everything else (formatters, session helpers, dispatch logic, internal zip stream helpers) is internal and not exported.


Usage

1. Provide an InterviewRepository

import { Effect, Layer } from 'effect';
import { NcNetworkSchema } from '@codaco/shared-consts';
import { DatabaseError } from '@codaco/network-exporters/errors';
import type { InterviewExportInput } from '@codaco/network-exporters/input';
import { InterviewRepository } from '@codaco/network-exporters/services/InterviewRepository';

export const PrismaInterviewRepository = Layer.succeed(InterviewRepository, {
  getForExport: (ids) =>
    Effect.gen(function* () {
      const rows = yield* Effect.tryPromise({
        try: () =>
          prisma.interview.findMany({
            where: { id: { in: [...ids] } },
            include: { participant: true },
          }),
        catch: (error) => new DatabaseError({ cause: error }),
      });

      const inputs: InterviewExportInput[] = rows.map((row) => ({
        id: row.id,
        participantIdentifier: row.participant.identifier,
        startTime: row.startTime,
        finishTime: row.finishTime,
        network: NcNetworkSchema.parse(row.network),
        protocolHash: row.protocolHash,
      }));

      return inputs;
    }),
});

The repository returns sessions referencing protocols by hash. There is no protocol: true join — the pipeline resolves protocols separately via ProtocolRepository. Hosts validate the network with NcNetworkSchema.parse() directly; the package no longer ships a parseNcNetwork helper.

2. Provide a ProtocolRepository

import { Effect, Layer } from 'effect';
import { DatabaseError } from '@codaco/network-exporters/errors';
import type { ProtocolExportInput } from '@codaco/network-exporters/input';
import { ProtocolRepository } from '@codaco/network-exporters/services/ProtocolRepository';

export const PrismaProtocolRepository = Layer.succeed(ProtocolRepository, {
  getProtocols: (hashes) =>
    Effect.gen(function* () {
      const rows = yield* Effect.tryPromise({
        try: () =>
          prisma.protocol.findMany({ where: { hash: { in: [...hashes] } } }),
        catch: (error) => new DatabaseError({ cause: error }),
      });

      const map: Record<string, ProtocolExportInput> = {};
      for (const row of rows) {
        map[row.hash] = {
          hash: row.hash,
          name: row.name,
          codebook: row.codebook,
        };
      }
      return map;
    }),
});

The pipeline calls getProtocols once per run with the deduplicated hash set extracted from the session list. Hashes the host doesn't return are treated as missing — sessions referencing them are routed to failedExports with kind: "protocol-missing" rather than aborting the run.

3. Provide an Output

Output replaces the old FileStorage + FileSystem Tags with a single stateful lifecycle. The package ships makeZipOutput(sink) for hosts that want today's bundled-zip behaviour: it streams entries through pure-JS fflate and forwards the resulting zip bytes (as an AsyncIterable<Uint8Array>) to a host-supplied sink callback. Hosts that don't want a zip implement Output directly.

S3 with ZipOutput (Node)

import { Readable } from 'node:stream';
import { Upload } from '@aws-sdk/lib-storage';
import { Effect } from 'effect';
import { OutputError } from '@codaco/network-exporters/errors';
import { makeZipOutput } from '@codaco/network-exporters/layers/ZipOutput';

const S3ZipOutput = makeZipOutput((stream, fileName) =>
  Effect.tryPromise({
    try: async () => {
      await new Upload({
        client: s3,
        params: { Bucket, Key: fileName, Body: Readable.from(stream) },
      }).done();
      return { key: fileName, url: await presign(fileName) };
    },
    catch: (cause) => new OutputError({ cause }),
  }),
);

Readable.from(asyncIterable) adapts the package's AsyncIterable<Uint8Array> to the Node Readable the AWS SDK expects. @aws-sdk/lib-storage's Upload handles multipart upload and per-part retries internally.

Browser blob with ZipOutput

import { Effect } from 'effect';
import { OutputError } from '@codaco/network-exporters/errors';
import { makeZipOutput } from '@codaco/network-exporters/layers/ZipOutput';

const BlobZipOutput = makeZipOutput((stream) =>
  Effect.tryPromise({
    try: async () => {
      const chunks: Uint8Array[] = [];
      for await (const chunk of stream) chunks.push(chunk);
      const blob = new Blob(chunks, { type: 'application/zip' });
      return { blob, url: URL.createObjectURL(blob) };
    },
    catch: (cause) => new OutputError({ cause }),
  }),
);

fflate is pure JS, so this layer works in browsers and Cloudflare Workers without polyfills.

OPFS folder (no zip)

import { Effect, Layer } from 'effect';
import { OutputError } from '@codaco/network-exporters/errors';
import { Output } from '@codaco/network-exporters/services/Output';

const OPFSFolderOutput = Layer.succeed(Output, {
  begin: () =>
    Effect.tryPromise({
      try: async () => ({
        dir: await navigator.storage
          .getDirectory()
          .then((root) =>
            root.getDirectoryHandle(`export-${Date.now()}`, { create: true }),
          ),
      }),
      catch: (cause) => new OutputError({ cause }),
    }),
  writeEntry: (handle, entry) =>
    Effect.tryPromise({
      try: async () => {
        const file = await handle.dir.getFileHandle(entry.name, {
          create: true,
        });
        const writable = await file.createWritable();
        for await (const chunk of entry.data) await writable.write(chunk);
        await writable.close();
      },
      catch: (cause) => new OutputError({ cause }),
    }),
  end: (handle) => Effect.succeed({ folderHandle: handle.dir }),
});

This implementation skips makeZipOutput entirely: each successful generation result is written as one OPFS file, and the returned OutputResult carries the directory handle back to the host. It compiles against the same Output Tag — no special-casing in the pipeline.

4. Run the pipeline

import { type Cause, Effect, Fiber, Layer, Queue, Stream } from 'effect';
import { exportPipeline } from '@codaco/network-exporters/pipeline';
import type { ExportEvent } from '@codaco/network-exporters/events';
import {
  describeExportError,
  type ExportError,
} from '@codaco/network-exporters/errors';

const exportLayer = Layer.mergeAll(
  PrismaInterviewRepository,
  PrismaProtocolRepository,
  S3ZipOutput,
);

const result = await Effect.scoped(
  Effect.gen(function* () {
    // `Cause.Done` in the error type is what lets `Queue.end` close the queue
    // below: `Queue.end` takes an `Enqueue<A, E | Done>`.
    const queue = yield* Queue.unbounded<ExportEvent, Cause.Done>();

    // Drain the queue into the UI (e.g. an SSE response). Hold the fiber:
    // finishing the export is not the same as having delivered every event,
    // and the join below is what makes delivery a property rather than a
    // matter of how the scheduler happened to interleave.
    const drain = yield* Effect.forkScoped(
      Stream.fromQueue(queue).pipe(Stream.runForEach(renderEventToClient)),
    );

    const pipelineResult = yield* exportPipeline(
      ['interview-1', 'interview-2'],
      {
        exportGraphML: true,
        exportCSV: true,
        globalOptions: {
          useScreenLayoutCoordinates: true,
          screenLayoutHeight: 1080,
          screenLayoutWidth: 1920,
        },
        // optional: defaults to os.cpus().length on Node, 4 elsewhere
        concurrency: 4,
        // optional: only used to populate session metadata
        appVersion: '3.0.0',
        commitHash: process.env.COMMIT_HASH,
      },
      queue,
    );

    // `end` lets the buffered events drain and then terminates the stream;
    // `Queue.shutdown` would discard whatever is still buffered.
    yield* Queue.end(queue);
    yield* Fiber.join(drain);

    return pipelineResult;
  }),
).pipe(
  Effect.provide(exportLayer),
  Effect.catch((error: ExportError) =>
    Effect.succeed({
      status: 'error' as const,
      message: describeExportError(error, 'running export'),
    }),
  ),
  Effect.runPromise,
);

result.output carries whatever the Output.end implementation returned — for S3ZipOutput above, that's { key, url }. There are no zipUrl or zipKey fields on ExportReturn; hosts that want a URL read result.output.url.


Pipeline stages

StageWhat happensEvent emitted
fetchingInterviewRepository.getForExport(ids) resolves to InterviewExportInput[]{ type: "stage", stage: "fetching" }
formattingPer-stage Effects: getProtocols, partition missing, build session variables, insert ego, group by protocol hash, resequence ids. Per-session catches route bad sessions to failedExports{ type: "stage", stage: "formatting" }
generatingPer-file fan-out (CSV generators + GraphML doc), bounded concurrency, each producing an AsyncIterable<Uint8Array>{ type: "stage", stage: "generating" } plus { type: "progress", stage: "generating", current, total }
outputtingOutput.begin(), then Output.writeEntry(handle, entry) per successful file, then Output.end(handle){ type: "stage", stage: "outputting" } plus { type: "progress", stage: "outputting", current, total }

Four stages, four stage event values. outputting covers begin, every writeEntry, and end. The pipeline allocates no persistent state — there is no separate cleanup phase.


Concurrency

generateOutputFilesEffect fans out across the cross-product of (session × format × type-partition). Concurrency is configurable via ExportOptions.concurrency. The default is os.cpus().length on Node (resolved through a guarded dynamic import) and a small fixed value elsewhere. Set it lower for memory-constrained runtimes (small serverless functions), higher for benchmarking.

{ ...options, concurrency: 2 }

The outputting stage is inherently sequential — one zip stream, written to in entry order.


Error model

Two distinct error tracks, both tagged.

Fatal errors — Effect failure channel

The pipeline raises into Effect's failure channel for unrecoverable conditions: database fetch fails, output begin/write/end fails. Each is a tagged Data.TaggedError instance:

ClassTagRaised by
DatabaseErrorNetworkExporters/DatabaseErrorInterviewRepository, ProtocolRepository
OutputErrorNetworkExporters/OutputErrorOutput.begin / Output.writeEntry / Output.end (covers former FileStorageError and ArchiveError)

The ExportError union type covers both fatal classes.

A failure inside Output.writeEntry is fatal — once partial bytes are in the host's bundle, retrying inline is the host's job, not the pipeline's. Hosts that need retry semantics implement them inside their Output layer; fflate failures and host sink-callback failures both surface as OutputError, with the original error available on error.cause.

describeExportError(error, stage?) produces a human-readable message. It dispatches on the tag and inspects error.cause for known runtime patterns (code === "ENOSPC", OOM messages, timeouts, connection refused) before falling back to a tag-aware default. Use it at the consumer boundary — the package itself never builds user-facing strings.

Effect.catch((error) =>
  Effect.succeed({
    status: 'error' as const,
    message: describeExportError(error, 'running export'),
  }),
);

Partial failures — ExportFailure[]

A single bad session or a single failing file does not abort the pipeline. Three things can land in failedExports:

kindSource errorWhen
protocol-missingProtocolNotFoundErrorSession's protocolHash not present in ProtocolRepository.getProtocols result
session-processingSessionProcessingErrorPer-session catch in format, insertEgo, or resequence stages
generationExportGenerationErrorA per-file generator (e.g. one CSV partition) throws while the rest succeed

Successful files still get written to Output and reported.

const result = await Effect.runPromise(...);
//   ↓ ExportReturn
// {
//   status: "success" | "partial",
//   successfulExports: ExportSuccess[],
//   failedExports:     ExportFailure[],
//   output:            OutputResult,   // host-defined
// }

if (result.status === "partial") {
    for (const failure of result.failedExports) {
        switch (failure.kind) {
            case "protocol-missing":
            case "session-processing":
            case "generation":
                console.warn(describeExportError(failure.error));
                break;
        }
    }
}

status is "partial" whenever failedExports.length > 0, regardless of which kind populated it. This split — fatal-vs-partial — is intentional: consumers can give the user a usable bundle even when one CSV failed, while still surfacing what was lost.


Progress events

exportPipeline writes ExportEvents to the queue you provide. Two event shapes:

type ExportStageEvent = {
  type: 'stage';
  stage: 'fetching' | 'formatting' | 'generating' | 'outputting';
  message: string;
};

type ExportProgressEvent = {
  type: 'progress';
  stage: 'generating' | 'outputting';
  current: number;
  total: number;
};

Per-entry progress events are emitted during both generating (per file produced) and outputting (per file written). Existing UI code that drew a bar for generating works unchanged for outputting — the shape is the same, only the stage value differs.

The package emits only these two event types — anything else (e.g. SSE-shaped complete/error events for client-side delivery) is the host's concern.

stageMessages is exported as a Record<ExportStage, string> of pre-localised English strings; consumers may ignore it and emit their own.


Local development

This package lives in the network-canvas-monorepo and follows the standard scripts.

# from monorepo root
pnpm --filter @codaco/network-exporters build       # vite build
pnpm --filter @codaco/network-exporters test        # vitest --run
pnpm --filter @codaco/network-exporters test:watch
pnpm --filter @codaco/network-exporters typecheck   # tsc --noEmit

Internal consumers resolve package.json#exports directly against src/*.ts — no build step is required to pick up source changes. The build script still runs vite build, which emits dist/<entry>.{js,d.ts} per public sub-path defined in vite.config.ts; that output is consumed only by the published npm artifact, via package.json#publishConfig.exports swapped in at pack time. All runtime dependencies (effect, fflate, @codaco/shared-consts, @codaco/protocol-validation, es-toolkit, sanitize-filename, zod, @xmldom/xmldom, ohash) are externalised at build time. effect is a peer rather than a dependency, so there is exactly one copy — and one fiber runtime — in an application that also uses Effect directly.

Adding a new entry point

  1. Add the source file under src/<path>.ts.
  2. Add the entry to vite.config.ts build.lib.entry (needed for the published dist/ build).
  3. Add the corresponding sub-path to package.json#exports, pointing at the new src/<path>.ts file.
  4. Add the matching sub-path to package.json#publishConfig.exports, pointing at ./dist/<path>.js/.d.ts, so the published artifact keeps exposing it.
  5. Run pnpm --filter @codaco/network-exporters build and verify a matching dist/<path>.js + .d.ts lands (this validates the publish artifact only — internal consumers already see the new entry via src/).

Adding a runtime dependency

Prefer the workspace catalog. If the dep already has a catalog entry in pnpm-workspace.yaml, reference it as "<dep>": "catalog:". Otherwise add it to the catalog with a pinned version, then reference it. Make sure to externalise it in vite.config.ts so it isn't bundled. Browser/Workers compatibility means avoiding node:* imports from any new code in src/ — node:os inside the guarded dynamic import in session/generateOutputFiles.ts is the sole exception.


Versioning

This package uses Changesets, like the rest of the monorepo. After making user-facing changes:

pnpm changeset

Pick @codaco/network-exporters and choose the bump type. Don't bump package.json#version by hand.


License

GPL-3.0-or-later. See LICENSE at the monorepo root.

版本与发布记录

@codaco/network-exporters 共发布 18 个版本,首次发布于 2022/04/05;过去 12 个月发布了 16 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
4.0.0latest2026/10/1327 天前
3.0.02026/09/291 个月前
2.0.12026/08/312 个月前
1.1.72026/08/053 个月前
1.1.62026/07/303 个月前
1.1.52026/07/203 个月前

关于 @codaco/network-exporters 的常见问题

@codaco/network-exporters 是什么?

Effect-TS pipeline for exporting Network Canvas interview data as CSV and GraphML.

如何安装 @codaco/network-exporters?

在项目目录运行 npm install @codaco/network-exporters,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(^4.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@codaco/network-exporters 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@codaco/network-exporters 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@codaco/network-exporters 可以商用吗?

@codaco/network-exporters 使用 GPL-3.0-or-later 许可。强 Copyleft:分发包含该代码的软件时,整个作品需以 GPL-3.0 兼容许可开源,并附带专利与反锁定条款。仅内部使用不触发此义务。以上为概括,不构成法律意见,请以许可原文为准。

@codaco/network-exporters 的下载量有多少?

根据 npm 官方下载统计,@codaco/network-exporters 在 2026/10/10 – 2026/11/08 这 30 天内被下载 9,410 次,日均约 314 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@codaco/network-exporters 还在维护吗?

最新版本 4.0.0 发布于 2026/10/13(27 天前);至今共发布 18 个版本,其中过去 12 个月发布了 16 个;首次发布于 2022/04/05。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。