@flowpure/edlink-effect-sdk
v1.0.7
Published
Edlink SDK built with Effect-TS — functional, type-safe, stream-based
Maintainers
Readme
Edlink Effect SDK
Type-safe, functional SDK for the Edlink v2 API — built on Effect-TS.
Every list endpoint returns a lazy Stream with cursor-based pagination. Every response is schema-validated at the boundary. Errors are tagged for precise catchTag recovery. Ships .d.ts types — no @types/ package needed.
Install
pnpm add @flowpure/edlink-effect-sdk effect @effect/platformNote:
effectand@effect/platformare peer-level requirements. Add a platform-specific runtime package for your environment (see Runtime Compatibility).
Quick Start
import { Effect, Stream, Chunk } from "effect";
import { NodeRuntime } from "@effect/platform-node";
import { EdlinkClient, EdlinkLive } from "@flowpure/edlink-effect-sdk";
const program = Effect.gen(function* () {
const client = yield* EdlinkClient;
// Fetch up to 3 pages of classes (default)
const classes = yield* client.classes
.list()
.pipe(Stream.runCollect, Effect.map(Chunk.toArray));
console.log(`Fetched ${classes.length} classes`);
});
program.pipe(Effect.provide(EdlinkLive), NodeRuntime.runMain);Set EDLINK_CLIENT_SECRET in your environment — that's the only required variable. The SDK reads it via Effect's Config module (auto-redacted as Secret.Secret in logs).
Runtime Compatibility
The SDK itself is runtime-agnostic — it uses FetchHttpClient (global fetch) and ESM. Only the runner changes per platform.
Node.js (>= 18)
pnpm add @effect/platform-nodeimport { NodeRuntime } from "@effect/platform-node";
program.pipe(Effect.provide(EdlinkLive), NodeRuntime.runMain);Bun
bun add @effect/platform-bunimport { BunRuntime } from "@effect/platform-bun";
program.pipe(Effect.provide(EdlinkLive), BunRuntime.runMain);Deno
import { Effect } from "npm:effect";
import { EdlinkClient, EdlinkLive } from "npm:@flowpure/edlink-effect-sdk";
// Use Effect.runPromise directly — Deno has global fetch
Effect.runPromise(program.pipe(Effect.provide(EdlinkLive)));API Overview
The EdlinkClient service exposes 13 namespaced sub-services + an events stream:
const client = yield* EdlinkClient;
client.districts.list(); // → Stream<District>
client.schools.fetch(id); // → Effect<School>
client.people.listEnrollments(personId); // → Stream<Enrollment>
client.assignments.create(classId, body); // → Effect<Assignment>
client.assignments.update({ classId, assignmentId, body }); // → Effect<Assignment>
client.assignments.delete(classId, assignmentId); // → Effect<void>Supported Entities
| Entity | List | Fetch | Create | Update | Delete | |---|:---:|:---:|:---:|:---:|:---:| | Districts | ✓ | ✓ | | | | | Schools | ✓ | ✓ | | | | | Courses | ✓ | ✓ | | | | | Sessions | ✓ | ✓ | | | | | Sections | ✓ | ✓ | | | | | Classes | ✓ | ✓ | | | | | People | ✓ | ✓ | | | | | Enrollments | ✓ | ✓ | | | | | Agents | ✓ | ✓ | | | | | Licenses | ✓ | | | | | | Assignments | ✓ | ✓ | ✓ | ✓ | ✓ | | Categories | ✓ | ✓ | ✓ | ✓ | ✓ | | Submissions | ✓ | ✓ | ✓ | ✓ | | | Events | ✓ | | | | |
Many entities support nested listing — for example:
client.schools.listClasses(schoolId)/listCourses/listTeachers/listStudentsclient.classes.listSections(classId)/listEnrollments/listPeopleclient.people.listDistricts(personId)/listSchools/listClasses/listAgentsclient.sections.listEnrollments(sectionId)/listTeachers/listStudentsclient.districts.listAdministrators(districtId)
All nested endpoints also return Stream with configurable pagination.
Working with Streams
All list* methods return Stream.Stream<Entity, EdlinkApiError | EdlinkDecodeError> — a lazy, pull-based stream. Pages are only fetched when downstream consumers demand more items. This is a core Effect Stream pattern.
Collect to an Array
Best for small datasets. Pulls all pages into memory at once.
const schools = yield* client.schools
.list()
.pipe(Stream.runCollect, Effect.map(Chunk.toArray));
// schools: School[]Process One-by-One (Constant Memory)
Best for large datasets. Each item is processed and discarded — memory stays flat.
yield* Stream.runForEach(client.classes.list(), (cls) =>
Effect.log(`${cls.id}: ${cls.name}`)
);Transform Pipelines
Compose Stream operators for filtering, mapping, and batching before consumption.
const activeTeachers = client.people
.list()
.pipe(
Stream.filter((p) => p.roles.includes("teacher")),
Stream.take(100),
);
const names = yield* activeTeachers.pipe(
Stream.map((p) => p.display_name),
Stream.runCollect,
Effect.map(Chunk.toArray),
);Tip: Streams are lazy —
Stream.filterandStream.takedon't fetch extra pages once the downstream limit is reached.
Pagination
Every list* method accepts an optional PaginationConfig — a discriminated union with three strategies:
1. Cap by Pages (default)
client.districts.list({ type: "pages", maxPages: 5 })Stops after N pages. Default is 3 pages if no config is provided (controlled by EDLINK_DEFAULT_MAX_PAGES).
2. Cap by Record Count
client.classes.list({ type: "records", maxRecords: 50 })Stops at exactly N records — the SDK trims the last page if it would overshoot. Good for pagination UIs and batch processing with predictable output sizes.
3. Fetch Everything
client.enrollments.list({ type: "all" })Fetches every available record. Use with caution on entities that may have thousands of rows. Combine with Stream.runForEach for constant-memory processing on large datasets.
How It Works Under the Hood
The SDK uses Stream.unfoldEffect with a cursor-based loop. Each iteration:
- Checks if the pagination limit has been reached
- Fetches the next page via HTTP
- Decodes the response through Effect Schema
- Emits items and advances the cursor
Pages are fetched lazily — if you Stream.take(10) from a large dataset, only enough pages to fill 10 items are requested.
Examples
The repo includes 7 runnable examples covering the most common patterns. Clone the repo, set up .env.local, and run them to see the SDK in action.
Setup
git clone https://github.com/tvsudhir2/edlink-effect-sdk.git
cd edlink-effect-sdk && pnpm install
cp .env.example .env.local
# Edit .env.local → set EDLINK_CLIENT_SECRET
# For examples 5–8, also set CLASS_ID
# For examples 7–8, also set ASSIGNMENT_IDFetching Data (Examples 1–3)
| # | Run | What It Does | Best For | Limitations |
|---|---|---|---|---|
| 1 | pnpm ex-1 | Fetch classes with default 3-page limit; collects to an array | First-time setup, quick sanity check that your credentials work | Loads all items into memory; uses default pagination only |
| 2 | pnpm ex-2 | Fetch exactly 50 classes using { type: "records", maxRecords: 50 } | Batch processing, pagination UIs, predictable output sizes | Still collects everything in memory |
| 3 | pnpm ex-3 | Process classes one-by-one via Stream.runForEach with a plain let counter | Large datasets, ETL pipelines, memory-constrained environments | Slightly more complex code; still uses default 3-page limit (pass { type: "all" } for unlimited streaming) |
| 4 | pnpm ex-4 | Process classes concurrently via Stream.mapEffect with a Ref counter | High-throughput pipelines, enriching records via external API calls | Requires Ref for atomic counting across concurrent fibers |
Choosing between 1 / 2 / 3 / 4:
- Need a quick array of items? → Example 1
- Need a specific number of records? → Example 2
- Need to process a large dataset sequentially without loading everything into memory? → Example 3
- Need maximum throughput by processing many items concurrently? → Example 4
CRUD Operations (Examples 4–7)
| # | Run | What It Does | Best For | Limitations |
|---|---|---|---|---|
| 5 | pnpm ex-5 | List assignments for a class using client.assignments.list(classId) | Viewing class assignments, reading nested/scoped resources | Requires CLASS_ID env var; collects all results into memory |
| 6 | pnpm ex-6 | Create a new assignment with a full request body (title, points, due date, etc.) | LMS integrations that create homework/quizzes programmatically | The request body is passed as a plain object — see the example for all available fields |
| 7 | pnpm ex-7 | Fetch-then-update pattern: reads an assignment, patches its title/points/due date | Extending deadlines, adjusting point values, partial updates | Requires both CLASS_ID and ASSIGNMENT_ID; two sequential API calls |
| 8 | pnpm ex-8 | Verify-then-delete pattern: fetches an assignment to confirm it exists, then deletes it | Cleaning up drafts or cancelled assignments | Requires both CLASS_ID and ASSIGNMENT_ID; no undo — deletion is permanent |
Choosing between 5 / 6 / 7 / 8:
- Need to read entities scoped to a parent? → Example 5
- Need to create a new resource? → Example 6
- Need to partially update a resource? → Example 7
- Need to delete a resource? → Example 8
What the Examples Don't Cover
- Error handling — none of the examples use
Effect.catchTagfor recovery. See Error Handling below for patterns. - User API (OAuth2) — no runnable examples yet. See User API for usage.
{ type: "all" }pagination — Example 3 uses default 3-page limit. Pass{ type: "all" }as the pagination config to stream everything.
User API (OAuth2)
For per-user actions (e.g. fetching a student's own profile), the SDK provides an OAuth2 authorization code flow via EdlinkUserClient.
import { Effect } from "effect";
import { EdlinkUserClient, EdlinkUserLive } from "@flowpure/edlink-effect-sdk";
const program = Effect.gen(function* () {
const userClient = yield* EdlinkUserClient;
// 1. Generate the authorization URL — redirect your user here
const authUrl = userClient.authorize(["roster:read"]);
// 2. After consent, exchange the callback code for tokens
const tokenResponse = yield* userClient.handleCallback(code);
// 3. Fetch the user's profile (auto-refreshes token if expired)
const profile = yield* userClient.getProfile(userId);
});
program.pipe(Effect.provide(EdlinkUserLive));Required env vars: EDLINK_CLIENT_ID, EDLINK_CLIENT_SECRET, EDLINK_REDIRECT_URI
Token storage is pluggable. The SDK ships InMemoryTokenStoreLive for development. For production, provide your own Layer<TokenStore> backed by Redis, a database, or any persistent store:
import { Layer } from "effect";
import { FetchHttpClient } from "@effect/platform";
import { EdlinkUserClientLive, EdlinkUserConfig } from "@flowpure/edlink-effect-sdk";
const MyUserLive = EdlinkUserClientLive.pipe(
Layer.provide(EdlinkUserConfig.Live),
Layer.provide(FetchHttpClient.layer),
Layer.provide(MyRedisTokenStoreLive), // your implementation
);No runnable examples yet — contributions welcome.
Error Handling
The SDK exposes two tagged error types. Both extend Data.TaggedError, so you can use Effect's catchTag for selective recovery.
| Error | When |
|---|---|
| EdlinkApiError | HTTP failure, non-OK status, network error |
| EdlinkDecodeError | API response doesn't match the expected schema |
import { Effect } from "effect";
import { EdlinkApiError, EdlinkDecodeError } from "@flowpure/edlink-effect-sdk";
yield* client.schools.fetch(id).pipe(
Effect.catchTag("EdlinkApiError", (err) =>
Effect.logError(`API error: ${err.message}`)
),
Effect.catchTag("EdlinkDecodeError", (err) =>
Effect.logError(`Unexpected response shape: ${err.message}`)
),
);Schema validation happens at the boundary — if the Edlink API returns malformed data, you get an EdlinkDecodeError immediately instead of a silent type mismatch downstream.
Configuration
All config is loaded via Effect's Config module — zero process.env usage.
Graph API
| Variable | Required | Default | Description |
|---|---|---|---|
| EDLINK_CLIENT_SECRET | yes | — | Bearer token for the Edlink API (stored as Secret.Secret) |
| EDLINK_API_BASE_URL | no | https://ed.link/api | API base URL |
| EDLINK_DEFAULT_MAX_PAGES | no | 3 | Default page limit when no PaginationConfig is given |
User API (OAuth2)
| Variable | Required | Default | Description |
|---|---|---|---|
| EDLINK_CLIENT_ID | yes | — | OAuth2 client ID |
| EDLINK_CLIENT_SECRET | yes | — | OAuth2 client secret |
| EDLINK_REDIRECT_URI | yes | — | OAuth2 redirect URI |
Tip: For local development, pass env vars via
tsx --env-file=.env.local(like the examples do). In production, use your platform's native secret management.
TypeScript
- Ships with
.d.tsdeclarations — no separate@types/package needed. - Compiled to ES2022 with
NodeNextmodule resolution. - ESM only — no CommonJS build.
- Strict mode enabled:
strict,exactOptionalPropertyTypes,noUncheckedIndexedAccess.
Contributing
Contributions, bug reports, and feature requests are welcome. Please open an issue on GitHub before submitting a PR.
git clone https://github.com/tvsudhir2/edlink-effect-sdk.git
cd edlink-effect-sdk && pnpm install
pnpm buildChangelog
See GitHub Releases for version history.
License
MIT
