This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,14 @@
|
||||
# Local development only. Replace every credential outside a developer workstation.
|
||||
POSTGRES_DB=ekklesia
|
||||
POSTGRES_USER=ekklesia
|
||||
POSTGRES_PASSWORD=ekklesia-development-only
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
|
||||
|
||||
S3_ACCESS_KEY=ekklesia-development
|
||||
S3_SECRET_KEY=ekklesia-development-secret
|
||||
S3_BUCKET=ekklesia-development
|
||||
S3_ENDPOINT=http://127.0.0.1:8333
|
||||
S3_PORT=8333
|
||||
S3_REGION=us-east-1
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
@@ -0,0 +1,8 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,38 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify repository
|
||||
run: pnpm ci
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.turbo/
|
||||
coverage/
|
||||
dist/
|
||||
.next/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
generated/*
|
||||
!generated/.gitkeep
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
24.18.0
|
||||
@@ -0,0 +1,5 @@
|
||||
engine-strict=true
|
||||
save-exact=true
|
||||
save-workspace-protocol=rolling
|
||||
shared-workspace-lockfile=true
|
||||
strict-peer-dependencies=true
|
||||
@@ -0,0 +1,32 @@
|
||||
# Contributing
|
||||
|
||||
Ekklesia is being built in verified, reviewable phases. Changes should preserve the dependency
|
||||
boundaries described in [repository-boundaries.md](docs/architecture/repository-boundaries.md).
|
||||
|
||||
Before opening a change, run:
|
||||
|
||||
```bash
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
If Docker is available, also validate the development infrastructure:
|
||||
|
||||
```bash
|
||||
pnpm infrastructure:config
|
||||
```
|
||||
|
||||
## Package rules
|
||||
|
||||
- Use strict TypeScript for production packages.
|
||||
- Expose supported entry points through package `exports`; do not import another package's
|
||||
private source paths.
|
||||
- Use `workspace:` ranges for internal package dependencies.
|
||||
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
|
||||
UI source files.
|
||||
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
|
||||
surface.
|
||||
- Add or update tests when changing a contract or architectural rule.
|
||||
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
||||
|
||||
Generated registries and schemas will be produced by later phases. Do not add a manually
|
||||
maintained feature-module registry.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Ekklesia
|
||||
|
||||
Ekklesia is an open, modular church technology platform for composing backend capabilities
|
||||
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other
|
||||
surfaces.
|
||||
|
||||
## Current status
|
||||
|
||||
Phase 1 establishes the repository foundation only. It includes reproducible workspace
|
||||
tooling, strict TypeScript defaults, executable architectural boundary checks, tests, CI, and
|
||||
local PostgreSQL/S3-compatible infrastructure. Core runtime, module discovery, identity,
|
||||
Module SDK, surfaces, and feature modules intentionally begin in later phases.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
cp .env.example .env
|
||||
pnpm infrastructure:up
|
||||
pnpm verify
|
||||
```
|
||||
|
||||
The development services bind only to loopback:
|
||||
|
||||
- PostgreSQL: `127.0.0.1:5432`
|
||||
- S3-compatible object storage: `127.0.0.1:8333`
|
||||
|
||||
The values in `.env.example` are development-only and must never be reused in a deployed
|
||||
environment.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [Platform architecture](docs/architecture/platform-architecture.md)
|
||||
- [Repository boundaries](docs/architecture/repository-boundaries.md)
|
||||
- [Repository workflow](docs/development/repository-workflow.md)
|
||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||
|
||||
No open-source license has been selected yet. A license should be added only after the project
|
||||
owner makes that decision.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!tests/fixtures", "!pnpm-lock.yaml"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# 0001: Four-layer platform boundary
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-12
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia must remain understandable and independently extensible after many backend capabilities
|
||||
and user experiences have been added. A conventional feature monolith or UI-bearing plugin model
|
||||
would couple business behavior to a particular presentation framework.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces,
|
||||
and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data
|
||||
and behavior only; every renderer and UI concern belongs to a surface.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- A conventional feature monolith was rejected because boundaries degrade as features grow.
|
||||
- UI-bearing plugins were rejected because they bind capabilities to frameworks and visual design.
|
||||
- Initial microservices were rejected because distribution adds failure modes before contracts
|
||||
have been proven.
|
||||
|
||||
## Consequences
|
||||
|
||||
Stable contracts, SDK ports, generated composition, and automated boundary checks are required.
|
||||
Some integrations take more deliberate service or event design, but modules and surfaces remain
|
||||
independently replaceable.
|
||||
|
||||
## Migration implications
|
||||
|
||||
Future boundary violations must move behind public contracts before the affected package can be
|
||||
treated as independently installable or upgradeable.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 0002: pnpm workspace and strict TypeScript
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-12
|
||||
|
||||
## Context
|
||||
|
||||
The platform needs atomic cross-package changes, reproducible installs, independent package
|
||||
builds, and guardrails that do not force every surface onto one framework.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a pnpm workspace with one committed lockfile and `workspace:` ranges for internal packages.
|
||||
Use Turborepo for task ordering and local caching. Standardize production packages on strict
|
||||
TypeScript and native ESM, with separate Node, browser, and React Native base configurations. Use
|
||||
Biome and Vitest for the initial repository quality gates.
|
||||
|
||||
Node 24 LTS and pnpm 11.12 are pinned for Phase 1. Package exports are the supported cross-package
|
||||
API; aliases may not expose another package's private `src` tree.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Polyrepos would slow coordinated contract changes. npm and Yarn workspaces are viable but do not
|
||||
match the selected pnpm workflow. A universal browser-oriented TypeScript configuration was
|
||||
rejected because server and native packages have different resolution and library requirements.
|
||||
|
||||
## Consequences
|
||||
|
||||
Repository-wide changes are straightforward, while package manifests and automated checks must
|
||||
prevent monorepo convenience from becoming hidden coupling. Remote cache and independent-release
|
||||
automation are deferred until they solve an observed need.
|
||||
|
||||
## Migration implications
|
||||
|
||||
Packages can move to separate repositories later because consumers use package names and public
|
||||
exports instead of filesystem aliases.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 0003: Development infrastructure
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-12
|
||||
|
||||
## Context
|
||||
|
||||
Developers need reproducible PostgreSQL and object-storage services without requiring host-level
|
||||
database installation. Application source does not yet exist, so containerizing the whole
|
||||
toolchain would add friction without value.
|
||||
|
||||
## Decision
|
||||
|
||||
Run PostgreSQL 18.4 and an S3-compatible object store in Docker Compose. Pin reviewed image
|
||||
versions, enable health checks, use persistent named volumes, bind host ports to loopback, and use
|
||||
explicitly development-only credentials. Run Node applications on the host during normal
|
||||
development.
|
||||
|
||||
This Compose file is a development baseline, not the production deployment topology.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Locally installed services make onboarding inconsistent. Containerizing editors and the complete
|
||||
Node toolchain is unnecessary at this phase. Kubernetes is premature for a modular monolith
|
||||
foundation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Docker is required for infrastructure testing but not for code-only validation. Production
|
||||
backups, high availability, secret injection, image digests, and deployment topology require a
|
||||
later decision.
|
||||
|
||||
## Migration implications
|
||||
|
||||
The future Core storage service targets S3 behavior rather than a provider-specific client, so
|
||||
development and production providers can change after contract testing.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 0004: Replace unmaintained MinIO baseline
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-12
|
||||
|
||||
## Context
|
||||
|
||||
The original Ekklesia brief selected MinIO as the default self-hosted S3-compatible service. The
|
||||
upstream `minio/minio` repository was archived on April 25, 2026 and states that historical
|
||||
community binary releases are no longer maintained. New church deployments may store highly
|
||||
sensitive content, so beginning on an unmaintained security-sensitive service is unacceptable.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep Ekklesia's future storage contract provider-neutral and use SeaweedFS 4.28 in the Phase 1
|
||||
development Compose environment. SeaweedFS is actively maintained and offers an S3-compatible
|
||||
endpoint and a small single-node development mode.
|
||||
|
||||
Before treating any provider as production-ready, add storage contract tests covering put, get,
|
||||
head, delete, ranges, multipart uploads, metadata/content type, signed URLs, bucket and prefix
|
||||
isolation, and denied access. A production provider decision remains separate.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- Retaining the final MinIO community image was rejected because it no longer receives upstream
|
||||
maintenance.
|
||||
- Provider-specific Core APIs were rejected because they would couple every module to this choice.
|
||||
- Deferring all object storage was rejected because a reproducible S3 development endpoint is part
|
||||
of the repository foundation.
|
||||
|
||||
## Consequences
|
||||
|
||||
The implementation intentionally uses `S3_*` configuration rather than `MINIO_*` names. Some
|
||||
SeaweedFS behavior may differ from AWS S3 or historical MinIO and must be captured by contract
|
||||
tests before the storage abstraction is implemented.
|
||||
|
||||
## Migration implications
|
||||
|
||||
No product data exists in Phase 1. Later provider changes must follow an object-copy and integrity
|
||||
verification plan while preserving stable Core storage contracts.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Architecture decision records
|
||||
|
||||
ADRs capture durable choices that affect more than one package or phase.
|
||||
|
||||
- Filenames use a four-digit sequence followed by a short kebab-case title.
|
||||
- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`.
|
||||
- Accepted ADRs are immutable except for status and cross-reference corrections.
|
||||
- A changed decision receives a new ADR that links to and supersedes the old one.
|
||||
|
||||
## Index
|
||||
|
||||
- [0001: Four-layer platform boundary](0001-four-layer-platform-boundary.md) — Accepted
|
||||
- [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted
|
||||
- [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted
|
||||
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted
|
||||
@@ -0,0 +1,66 @@
|
||||
# Platform architecture
|
||||
|
||||
Ekklesia is a long-lived platform from which churches can compose many applications and user
|
||||
experiences. It begins as a modular monolith so contracts can stabilize before distribution is
|
||||
introduced.
|
||||
|
||||
## Dependency model
|
||||
|
||||
The arrows below mean "depends on." Presentation and composition depend on stable lower-level
|
||||
contracts; infrastructure never learns individual feature names.
|
||||
|
||||
```text
|
||||
Surface Builder configuration -> Surfaces -> public capability contracts
|
||||
^
|
||||
|
|
||||
HTTP/API and client contracts
|
||||
|
|
||||
Feature Modules -> Module SDK / platform contracts <- Core implementations
|
||||
```
|
||||
|
||||
The four ownership layers are:
|
||||
|
||||
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
|
||||
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
|
||||
2. **Modules** own backend domain behavior, data schemas, resources, actions, routes, events,
|
||||
permissions, and jobs.
|
||||
3. **Surfaces** own every presentation concern: components, renderers, layout, navigation,
|
||||
accessibility, interaction, branding, and client state.
|
||||
4. **Surface Builder** stores validated declarative composition. It selects capabilities and
|
||||
surface-owned renderers but contains neither executable code nor module business logic.
|
||||
|
||||
Modules do not own UI. For example, Calendar data, actions, and permissions will live in
|
||||
`modules/calendar`; Calendar renderers for a dashboard or public website will live in those
|
||||
surfaces.
|
||||
|
||||
## Stable seams
|
||||
|
||||
- Modules use public SDK ports and injected Core services, never undocumented Core internals.
|
||||
- Modules do not import another module's schema or private code. Integrations use public service
|
||||
contracts, capabilities, events, or APIs.
|
||||
- Surfaces consume API/client and capability contracts, never feature-module packages.
|
||||
- Core discovers feature modules generically. It contains no feature-specific imports or
|
||||
conditional branches.
|
||||
- Deployable apps are thin composition roots and do not become domain-logic owners.
|
||||
- Generated bootstrap artifacts may connect discovered entry points at the application edge;
|
||||
they do not become manually maintained Core registries.
|
||||
|
||||
## Security invariants
|
||||
|
||||
- Authorization is enforced server-side.
|
||||
- Secrets are isolated from public configuration and logs.
|
||||
- Authentication tokens, private form content, prayer requests, and pastoral information are not
|
||||
logged.
|
||||
- Sensitive actions are designed for auditability without copying sensitive content into audit
|
||||
records.
|
||||
- Private objects use signed access and namespaced storage paths.
|
||||
- Every network service starts from least-privilege and local-only development defaults.
|
||||
|
||||
## Phase 1 status
|
||||
|
||||
Implemented now: monorepo/tooling, strict TypeScript policy, repository-boundary validation,
|
||||
tests, base CI, PostgreSQL, and a development S3-compatible object store.
|
||||
|
||||
Target architecture, not yet implemented: Core runtime, module discovery and lifecycle, Drizzle
|
||||
schema aggregation/migrations, Better Auth, permissions, storage service, Module SDK, Surface
|
||||
SDK, capability/resource/action registries, API server, dashboard, Builder, and reference modules.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Repository boundaries
|
||||
|
||||
These rules map platform ownership to filesystem and package boundaries. The executable check is
|
||||
`pnpm boundaries`; CI must reject violations.
|
||||
|
||||
| Area | Owns | May depend on |
|
||||
| --- | --- | --- |
|
||||
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||
| `modules/*` | Backend domain capability and data | Module SDK, public contracts, and external backend libraries |
|
||||
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
|
||||
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||
| `templates/module` | A small example backend module | The same dependencies allowed for any module |
|
||||
|
||||
## Mandatory rules
|
||||
|
||||
- Core never imports a feature module, surface, Builder implementation, or app.
|
||||
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
||||
- A module cannot contain `.tsx`, `.jsx`, `.css`, `.scss`, `.less`, `.vue`, or `.svelte` source
|
||||
and cannot depend on a UI framework. Modules expose data and behavior only.
|
||||
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||
- Separate surfaces do not import each other's internals.
|
||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||
surface internals.
|
||||
- Contract and SDK packages cannot depend upward on implementations.
|
||||
- Workspace package consumers use declared `exports`, not private subpaths.
|
||||
- Circular source and workspace-package dependencies are rejected.
|
||||
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
|
||||
direct table imports and foreign keys.
|
||||
|
||||
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
||||
rejects forbidden dependency directions even before production packages fill every directory.
|
||||
|
||||
## Generated code
|
||||
|
||||
Generated artifacts are reproducible outputs with a named generator and validation step. A
|
||||
generated module bootstrap may import discovered module entry points at an application
|
||||
composition edge, but Core itself remains feature-agnostic. Generated files are never an excuse
|
||||
for a hand-maintained central module registry.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Repository workflow
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24 LTS (`.node-version` and `.nvmrc` pin the reviewed patch)
|
||||
- pnpm 11.12.0
|
||||
- Docker with the Compose plugin for local infrastructure
|
||||
|
||||
Install dependencies from the committed lockfile:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
pnpm format:check # formatting without mutation
|
||||
pnpm lint # Biome recommended rules
|
||||
pnpm boundaries # architectural directions, UI ownership, and cycles
|
||||
pnpm typecheck # strict TypeScript
|
||||
pnpm test # Vitest
|
||||
pnpm build # Turborepo package build graph
|
||||
pnpm verify # every code gate above
|
||||
```
|
||||
|
||||
CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`.
|
||||
|
||||
## Development infrastructure
|
||||
|
||||
Create an untracked local environment file before using Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
pnpm infrastructure:up
|
||||
```
|
||||
|
||||
Stop services or follow their logs with:
|
||||
|
||||
```bash
|
||||
pnpm infrastructure:down
|
||||
pnpm infrastructure:logs
|
||||
```
|
||||
|
||||
PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes;
|
||||
`infrastructure:down` preserves data. The S3 service creates the configured development bucket
|
||||
idempotently at startup.
|
||||
|
||||
The checked-in credentials are safe only for local development. Deployed secrets must come from
|
||||
an environment-appropriate secret provider and must not be committed.
|
||||
|
||||
Docker is not installed in every contributor environment. `pnpm infrastructure:config` and live
|
||||
service health therefore run separately from the code-only `pnpm verify` gate.
|
||||
@@ -0,0 +1,46 @@
|
||||
name: ekklesia-development
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.24
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
|
||||
POSTGRES_INITDB_ARGS: --data-checksums
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
|
||||
TZ: UTC
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
object-storage:
|
||||
image: chrislusf/seaweedfs:4.28
|
||||
restart: unless-stopped
|
||||
command: ["mini", "-dir=/data"]
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:?S3_ACCESS_KEY is required}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:?S3_SECRET_KEY is required}
|
||||
S3_BUCKET: ${S3_BUCKET:?S3_BUCKET is required}
|
||||
TZ: UTC
|
||||
ports:
|
||||
- "127.0.0.1:${S3_PORT:-8333}:8333"
|
||||
volumes:
|
||||
- object-storage-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 18
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
object-storage-data:
|
||||
postgres-data:
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "ekklesia",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Open, modular church technology platform",
|
||||
"engines": {
|
||||
"node": ">=24.11.0 <25",
|
||||
"pnpm": ">=11.12.0 <12"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"boundaries": "node scripts/check-boundaries.ts",
|
||||
"build": "turbo run build",
|
||||
"ci": "pnpm verify && pnpm infrastructure:config",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
||||
"infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down",
|
||||
"infrastructure:logs": "docker compose --env-file .env -f infrastructure/compose.yaml logs --follow",
|
||||
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
||||
"lint": "biome lint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json",
|
||||
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"turbo": "2.10.4",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"useUnknownInCatchVariables": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2024",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@ekklesia/typescript-config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
"browser.json",
|
||||
"node.json",
|
||||
"react-native.json"
|
||||
],
|
||||
"exports": {
|
||||
"./base.json": "./base.json",
|
||||
"./browser.json": "./browser.json",
|
||||
"./node.json": "./node.json",
|
||||
"./react-native.json": "./react-native.json"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "tooling"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2024"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
Generated
+1155
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "core"
|
||||
- "modules/*"
|
||||
- "packages/*"
|
||||
- "surfaces/*"
|
||||
- "templates/*"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -0,0 +1,498 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface";
|
||||
|
||||
interface Location {
|
||||
layer: Layer;
|
||||
owner: string;
|
||||
relativePath: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface PackageManifest {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
ekklesia?: { layer?: string };
|
||||
exports?: unknown;
|
||||
name?: string;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface WorkspacePackage {
|
||||
directory: string;
|
||||
location: Location;
|
||||
manifest: PackageManifest;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BoundaryViolation {
|
||||
from: string;
|
||||
message: string;
|
||||
rule: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface BoundaryResult {
|
||||
filesChecked: number;
|
||||
packagesChecked: number;
|
||||
violations: BoundaryViolation[];
|
||||
}
|
||||
|
||||
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
||||
const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]);
|
||||
const MODULE_UI_PACKAGES = new Set([
|
||||
"@angular/core",
|
||||
"expo",
|
||||
"next",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-native",
|
||||
"solid-js",
|
||||
"svelte",
|
||||
"tailwindcss",
|
||||
"vue",
|
||||
]);
|
||||
const IGNORED_DIRECTORIES = new Set([
|
||||
".git",
|
||||
".next",
|
||||
".turbo",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
]);
|
||||
const DEPENDENCY_FIELDS = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
] as const;
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
export function describeRepositoryPath(relativePath: string): Location {
|
||||
const normalized = normalizePath(relativePath);
|
||||
const [area, owner = area] = normalized.split("/");
|
||||
|
||||
if (area === "core") {
|
||||
return { layer: "core", owner: "core", relativePath: normalized };
|
||||
}
|
||||
|
||||
if (area === "modules" && owner) {
|
||||
return { layer: "module", owner, relativePath: normalized };
|
||||
}
|
||||
|
||||
if (area === "surfaces" && owner) {
|
||||
return { layer: "surface", owner, relativePath: normalized };
|
||||
}
|
||||
|
||||
if ((area === "builders" || area === "surface-builder") && owner) {
|
||||
return { layer: "builder", owner, relativePath: normalized };
|
||||
}
|
||||
|
||||
if (area === "apps" && owner) {
|
||||
return { layer: "app", owner, relativePath: normalized };
|
||||
}
|
||||
|
||||
if (area === "packages" && owner) {
|
||||
return { layer: "shared", owner, relativePath: normalized, role: owner };
|
||||
}
|
||||
|
||||
if (area === "templates" && owner === "module") {
|
||||
return { layer: "module", owner: "template-module", relativePath: normalized };
|
||||
}
|
||||
|
||||
return { layer: "neutral", owner: area ?? "root", relativePath: normalized };
|
||||
}
|
||||
|
||||
function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
if (from.layer === "neutral" || to.layer === "neutral") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (from.layer === to.layer && from.owner === to.owner) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (from.layer === "core" && ["app", "builder", "module", "surface"].includes(to.layer)) {
|
||||
return "core-is-feature-agnostic";
|
||||
}
|
||||
|
||||
if (from.layer === "module") {
|
||||
if (["app", "builder", "core", "surface"].includes(to.layer)) {
|
||||
return "modules-use-public-platform-contracts";
|
||||
}
|
||||
if (to.layer === "module" && from.owner !== to.owner) {
|
||||
return "modules-do-not-import-other-modules";
|
||||
}
|
||||
}
|
||||
|
||||
if (from.layer === "surface") {
|
||||
if (["app", "builder", "core", "module"].includes(to.layer)) {
|
||||
return "surfaces-use-public-contracts";
|
||||
}
|
||||
if (to.layer === "surface" && from.owner !== to.owner) {
|
||||
return "surfaces-are-independent";
|
||||
}
|
||||
}
|
||||
|
||||
if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) {
|
||||
return "builder-composes-contracts";
|
||||
}
|
||||
|
||||
if (
|
||||
from.layer === "shared" &&
|
||||
from.role === "contracts" &&
|
||||
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||
) {
|
||||
return "contracts-do-not-depend-upward";
|
||||
}
|
||||
|
||||
if (
|
||||
from.layer === "shared" &&
|
||||
["module-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||
) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function packageNameFromSpecifier(specifier: string): string {
|
||||
if (specifier.startsWith("@")) {
|
||||
return specifier.split("/").slice(0, 2).join("/");
|
||||
}
|
||||
return specifier.split("/")[0] ?? specifier;
|
||||
}
|
||||
|
||||
function isModuleUiPackage(packageName: string): boolean {
|
||||
return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/");
|
||||
}
|
||||
|
||||
async function isFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(filePath)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function walkFiles(root: string, current = root): Promise<string[]> {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absolutePath = path.join(current, entry.name);
|
||||
const relativePath = normalizePath(path.relative(root, absolutePath));
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (relativePath === "tests/fixtures" || relativePath.startsWith("tests/fixtures/")) {
|
||||
continue;
|
||||
}
|
||||
files.push(...(await walkFiles(root, absolutePath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectImportSpecifiers(sourceText: string): string[] {
|
||||
const specifiers = new Set<string>();
|
||||
const staticImport =
|
||||
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu;
|
||||
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
|
||||
|
||||
for (const pattern of [staticImport, dynamicImport]) {
|
||||
for (const match of sourceText.matchAll(pattern)) {
|
||||
const specifier = match[1];
|
||||
if (specifier) {
|
||||
specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
async function resolveSourceImport(
|
||||
fromFile: string,
|
||||
specifier: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!specifier.startsWith(".")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidate = path.resolve(path.dirname(fromFile), specifier);
|
||||
const extension = path.extname(candidate);
|
||||
const withoutExtension = extension ? candidate.slice(0, -extension.length) : candidate;
|
||||
const candidates = [candidate];
|
||||
|
||||
if ([".js", ".jsx", ".mjs", ".cjs"].includes(extension)) {
|
||||
candidates.push(
|
||||
`${withoutExtension}.ts`,
|
||||
`${withoutExtension}.tsx`,
|
||||
`${withoutExtension}.mts`,
|
||||
`${withoutExtension}.cts`,
|
||||
);
|
||||
} else if (!extension) {
|
||||
for (const codeExtension of CODE_EXTENSIONS) {
|
||||
candidates.push(
|
||||
`${candidate}${codeExtension}`,
|
||||
path.join(candidate, `index${codeExtension}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const possibleFile of candidates) {
|
||||
if (await isFile(possibleFile)) {
|
||||
return possibleFile;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function findCycles(graph: Map<string, Set<string>>): string[][] {
|
||||
const visited = new Set<string>();
|
||||
const active = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const cycles: string[][] = [];
|
||||
const seenCycles = new Set<string>();
|
||||
|
||||
function visit(node: string): void {
|
||||
if (active.has(node)) {
|
||||
const start = stack.indexOf(node);
|
||||
const cycle = [...stack.slice(start), node];
|
||||
const key = [...new Set(cycle)].sort().join("|");
|
||||
if (!seenCycles.has(key)) {
|
||||
seenCycles.add(key);
|
||||
cycles.push(cycle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (visited.has(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visited.add(node);
|
||||
active.add(node);
|
||||
stack.push(node);
|
||||
for (const target of graph.get(node) ?? []) {
|
||||
if (graph.has(target)) {
|
||||
visit(target);
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
active.delete(node);
|
||||
}
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
visit(node);
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
function exportedSubpath(
|
||||
manifest: PackageManifest,
|
||||
specifier: string,
|
||||
packageName: string,
|
||||
): boolean {
|
||||
const suffix = specifier.slice(packageName.length);
|
||||
if (!suffix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!manifest.exports || typeof manifest.exports !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requested = `.${suffix}`;
|
||||
return Object.hasOwn(manifest.exports, requested);
|
||||
}
|
||||
|
||||
export async function checkBoundaries(rootDirectory: string): Promise<BoundaryResult> {
|
||||
const root = path.resolve(rootDirectory);
|
||||
const allFiles = await walkFiles(root);
|
||||
const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json");
|
||||
const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file)));
|
||||
const workspacePackages = new Map<string, WorkspacePackage>();
|
||||
const packageByDirectory = new Map<string, WorkspacePackage>();
|
||||
const violations: BoundaryViolation[] = [];
|
||||
|
||||
for (const manifestFile of manifestFiles) {
|
||||
const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest;
|
||||
if (!manifest.name || manifestFile === path.join(root, "package.json")) {
|
||||
continue;
|
||||
}
|
||||
const directory = path.dirname(manifestFile);
|
||||
const relativeDirectory = normalizePath(path.relative(root, directory));
|
||||
const location = describeRepositoryPath(relativeDirectory);
|
||||
if (manifest.ekklesia?.layer) {
|
||||
location.role = manifest.ekklesia.layer;
|
||||
}
|
||||
const workspacePackage = { directory, location, manifest, name: manifest.name };
|
||||
workspacePackages.set(manifest.name, workspacePackage);
|
||||
packageByDirectory.set(directory, workspacePackage);
|
||||
}
|
||||
|
||||
const packageGraph = new Map<string, Set<string>>();
|
||||
for (const workspacePackage of workspacePackages.values()) {
|
||||
packageGraph.set(workspacePackage.name, new Set());
|
||||
for (const dependencyField of DEPENDENCY_FIELDS) {
|
||||
for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) {
|
||||
if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) {
|
||||
violations.push({
|
||||
from: workspacePackage.location.relativePath,
|
||||
message: `Module package depends on UI framework ${dependencyName}.`,
|
||||
rule: "modules-do-not-own-ui",
|
||||
});
|
||||
}
|
||||
|
||||
const targetPackage = workspacePackages.get(dependencyName);
|
||||
if (!targetPackage) {
|
||||
continue;
|
||||
}
|
||||
packageGraph.get(workspacePackage.name)?.add(targetPackage.name);
|
||||
const rule = boundaryRule(workspacePackage.location, targetPackage.location);
|
||||
if (rule) {
|
||||
violations.push({
|
||||
from: workspacePackage.location.relativePath,
|
||||
message: `${workspacePackage.name} may not depend on ${targetPackage.name}.`,
|
||||
rule,
|
||||
to: targetPackage.location.relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const cycle of findCycles(packageGraph)) {
|
||||
violations.push({
|
||||
from: cycle[0] ?? "unknown-package",
|
||||
message: `Workspace package cycle: ${cycle.join(" -> ")}`,
|
||||
rule: "no-circular-package-dependencies",
|
||||
});
|
||||
}
|
||||
|
||||
for (const repositoryFile of allFiles) {
|
||||
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
||||
const location = describeRepositoryPath(relativeFile);
|
||||
if (location.layer === "module" && MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile))) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: "UI source files belong in a surface, not a module.",
|
||||
rule: "modules-do-not-own-ui",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceGraph = new Map<string, Set<string>>();
|
||||
for (const codeFile of codeFiles) {
|
||||
const relativeFile = normalizePath(path.relative(root, codeFile));
|
||||
const from = describeRepositoryPath(relativeFile);
|
||||
sourceGraph.set(relativeFile, new Set());
|
||||
|
||||
const sourceText = await readFile(codeFile, "utf8");
|
||||
for (const specifier of collectImportSpecifiers(sourceText)) {
|
||||
const dependencyName = packageNameFromSpecifier(specifier);
|
||||
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: `Module source imports UI framework ${dependencyName}.`,
|
||||
rule: "modules-do-not-own-ui",
|
||||
to: specifier,
|
||||
});
|
||||
}
|
||||
|
||||
let targetFile: string | undefined;
|
||||
let to: Location | undefined;
|
||||
if (specifier.startsWith(".")) {
|
||||
targetFile = await resolveSourceImport(codeFile, specifier);
|
||||
if (targetFile) {
|
||||
to = describeRepositoryPath(normalizePath(path.relative(root, targetFile)));
|
||||
if (await isFile(targetFile)) {
|
||||
sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, targetFile)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const targetPackage = workspacePackages.get(dependencyName);
|
||||
if (targetPackage) {
|
||||
to = targetPackage.location;
|
||||
if (!exportedSubpath(targetPackage.manifest, specifier, dependencyName)) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: `${specifier} is not a public package export.`,
|
||||
rule: "workspace-imports-use-public-exports",
|
||||
to: targetPackage.location.relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (to) {
|
||||
const rule = boundaryRule(from, to);
|
||||
if (rule) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: `${from.layer} code may not import ${to.layer} code directly.`,
|
||||
rule,
|
||||
to: to.relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const cycle of findCycles(sourceGraph)) {
|
||||
violations.push({
|
||||
from: cycle[0] ?? "unknown-source",
|
||||
message: `Source dependency cycle: ${cycle.join(" -> ")}`,
|
||||
rule: "no-circular-source-dependencies",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filesChecked: codeFiles.length,
|
||||
packagesChecked: packageByDirectory.size,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
|
||||
const result = await checkBoundaries(root);
|
||||
if (result.violations.length > 0) {
|
||||
console.error(`Boundary validation failed with ${result.violations.length} violation(s):`);
|
||||
for (const violation of result.violations) {
|
||||
const target = violation.to ? ` -> ${violation.to}` : "";
|
||||
console.error(`- [${violation.rule}] ${violation.from}${target}: ${violation.message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Boundary validation passed (${result.packagesChecked} packages, ${result.filesChecked} source files).`,
|
||||
);
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1];
|
||||
if (entryPoint && import.meta.url === pathToFileURL(path.resolve(entryPoint)).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkBoundaries } from "../scripts/check-boundaries.js";
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fixtures = path.join(testDirectory, "fixtures", "boundaries");
|
||||
|
||||
describe("repository dependency boundaries", () => {
|
||||
it("accepts public contracts below Core", async () => {
|
||||
const result = await checkBoundaries(path.join(fixtures, "legal"));
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["core-imports-module", "core-is-feature-agnostic"],
|
||||
["module-imports-surface", "modules-use-public-platform-contracts"],
|
||||
["cross-module-import", "modules-do-not-import-other-modules"],
|
||||
["module-has-ui", "modules-do-not-own-ui"],
|
||||
["cycle", "no-circular-source-dependencies"],
|
||||
])("rejects %s", async (fixture, expectedRule) => {
|
||||
const result = await checkBoundaries(path.join(fixtures, fixture));
|
||||
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
||||
});
|
||||
|
||||
it("rejects both component and stylesheet files inside a module", async () => {
|
||||
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
|
||||
expect(
|
||||
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { calendar } from "../../modules/calendar/src/index.js";
|
||||
|
||||
export const invalidCoreFeature = calendar;
|
||||
@@ -0,0 +1 @@
|
||||
export const calendar = "calendar";
|
||||
@@ -0,0 +1,3 @@
|
||||
import { media } from "../../../modules/media/src/index.js";
|
||||
|
||||
export const invalidModuleDependency = media;
|
||||
@@ -0,0 +1 @@
|
||||
export const media = "media";
|
||||
@@ -0,0 +1,3 @@
|
||||
import { b } from "./b.js";
|
||||
|
||||
export const a = b;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { a } from "./a.js";
|
||||
|
||||
export const b = a;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { HealthContract } from "@ekklesia/contracts";
|
||||
|
||||
export const health: HealthContract = { status: "ok" };
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "contracts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface HealthContract {
|
||||
readonly status: "ok";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.calendar {
|
||||
display: grid;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function Calendar(): string {
|
||||
return "calendar";
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { CalendarView } from "../../../surfaces/dashboard/src/calendar-view.js";
|
||||
|
||||
export const invalidModuleUi = CalendarView;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const CalendarView = "calendar-view";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
interface ComposeService {
|
||||
healthcheck?: unknown;
|
||||
image?: string;
|
||||
ports?: string[];
|
||||
volumes?: string[];
|
||||
}
|
||||
|
||||
interface ComposeDocument {
|
||||
services?: Record<string, ComposeService>;
|
||||
volumes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
describe("development infrastructure", () => {
|
||||
it("has valid, local-only, version-pinned Compose services", async () => {
|
||||
const source = await readFile(
|
||||
path.join(repositoryRoot, "infrastructure", "compose.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
const compose = parse(source) as ComposeDocument;
|
||||
const postgres = compose.services?.postgres;
|
||||
const objectStorage = compose.services?.["object-storage"];
|
||||
|
||||
expect(postgres?.image).toBe("postgres:18.4-alpine3.24");
|
||||
expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28");
|
||||
expect(postgres?.healthcheck).toBeDefined();
|
||||
expect(objectStorage?.healthcheck).toBeDefined();
|
||||
|
||||
for (const service of [postgres, objectStorage]) {
|
||||
expect(service).toBeDefined();
|
||||
expect(service?.image).not.toMatch(/:latest$/u);
|
||||
expect(service?.ports?.every((port) => port.startsWith("127.0.0.1:"))).toBe(true);
|
||||
expect(service?.volumes?.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
expect(compose.volumes).toHaveProperty("postgres-data");
|
||||
expect(compose.volumes).toHaveProperty("object-storage-data");
|
||||
});
|
||||
|
||||
it("has a parseable CI workflow with a single repository gate", async () => {
|
||||
const source = await readFile(
|
||||
path.join(repositoryRoot, ".github", "workflows", "ci.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const workflow = parse(source) as {
|
||||
jobs?: { verify?: { steps?: Array<{ run?: string }> } };
|
||||
};
|
||||
const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? [];
|
||||
|
||||
expect(workflow.jobs?.verify).toBeDefined();
|
||||
expect(commands).toContain("pnpm install --frozen-lockfile");
|
||||
expect(commands).toContain("pnpm ci");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./packages/typescript-config/node.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["scripts/**/*.ts", "tests/**/*.ts", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "tests/fixtures"]
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"ui": "stream",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"],
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^typecheck"],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
},
|
||||
include: ["tests/**/*.test.ts"],
|
||||
passWithNoTests: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user