first commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# `partitions/partition-manager/partition-manager.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PartitionManager` orchestrates the lifecycle of partition records. It accepts a `PartitionStore` for persistence and a `KeyRing` for per-user key material, validates every mutation against the active policy via `validateAction`, and emits `partition:*` and `member:*` events on a caller-supplied `EventBusLike`. The manager is the single throat through which all partition state changes pass; downstream modules (grants, tokens, transfer) treat the partition as immutable input.
|
||||
|
||||
## Features
|
||||
|
||||
- Five mutating operations (`create`, `delete`, `addMember`, `removeMember`, `update`) plus three read-only operations (`get`, `list`, `getKeyRing`).
|
||||
- Zod-validated inputs at every public entrypoint via `CreatePartitionOptionsSchema` / `AddMemberOptionsSchema` / `UpdatePartitionOptionsSchema`.
|
||||
- Injectable `Clock` for deterministic timestamping and an injectable id generator for deterministic IDs in tests.
|
||||
- `EventBusLike<PartitionManagerEvents>` carries `partition:created`, `partition:deleted`, `partition:updated`, `member:added`, `member:removed`. No key material in event payloads.
|
||||
- OTel spans on every mutating operation; counter `share.partition.member_total` per partition; counter `share.partition.create_total`.
|
||||
- Graceful-degradation read-only toggle (`setReadOnly`) — mutations throw `SHARE_POLICY_VIOLATION` while the flag is true.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PartitionManager } from "@suaveplan/drive-sharing/partitions/partition-manager";
|
||||
import { InMemoryPartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
|
||||
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
|
||||
import { userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
});
|
||||
|
||||
const partition = await manager.create({
|
||||
name: "research",
|
||||
type: "shared",
|
||||
ownerId: userId("alice"),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PartitionManager } from "@suaveplan/drive-sharing/partitions/partition-manager";
|
||||
import { InMemoryPartitionStore } from "@suaveplan/drive-sharing/partitions/partition-store";
|
||||
import { KeyRing } from "@suaveplan/drive-sharing/encryption/key-ring";
|
||||
import { partitionId, userId } from "@suaveplan/drive-sharing/types/common";
|
||||
|
||||
let now = 0;
|
||||
let counter = 0;
|
||||
const events = new Map<string, unknown[]>();
|
||||
const bus = {
|
||||
on: () => () => {},
|
||||
emit: (k: string, p: unknown) => {
|
||||
const arr = events.get(k) ?? [];
|
||||
arr.push(p);
|
||||
events.set(k, arr);
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new PartitionManager({
|
||||
store: new InMemoryPartitionStore(),
|
||||
keyRing: new KeyRing(),
|
||||
clock: () => (now += 1000),
|
||||
idGenerator: () => `id-${(counter += 1)}`,
|
||||
events: bus,
|
||||
});
|
||||
|
||||
const p = await manager.create({
|
||||
name: "vault",
|
||||
type: "encrypted",
|
||||
ownerId: userId("alice"),
|
||||
policy: { maxMembers: 3 },
|
||||
});
|
||||
|
||||
await manager.addMember(p.id, userId("bob"), { permissions: ["read"] });
|
||||
manager.setReadOnly(true);
|
||||
// further mutations throw SHARE_POLICY_VIOLATION
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PartitionManager`
|
||||
|
||||
- `constructor(config: PartitionManagerConfig)`
|
||||
- `create(options): Promise<Partition>`
|
||||
- `get(id): Promise<Partition>` — throws `SHARE_PARTITION_NOT_FOUND`.
|
||||
- `list(): Promise<Partition[]>`
|
||||
- `delete(id): Promise<void>`
|
||||
- `addMember(id, userId, options?): Promise<Partition>` — throws `SHARE_POLICY_VIOLATION` when policy denies.
|
||||
- `removeMember(id, userId): Promise<Partition>` — idempotent.
|
||||
- `update(id, options): Promise<Partition>`
|
||||
- `setReadOnly(value): void` / `isReadOnly(): boolean`
|
||||
- `getKeyRing(): KeyRing`
|
||||
|
||||
### `interface CreatePartitionOptions`
|
||||
|
||||
`{ name, type, ownerId, policy? }`.
|
||||
|
||||
### `interface AddMemberOptions`
|
||||
|
||||
`{ permissions?, wrappedKey? }`. Default permissions are `["read"]`.
|
||||
|
||||
### `interface UpdatePartitionOptions`
|
||||
|
||||
`{ name?, policy? }`.
|
||||
|
||||
### `interface PartitionManagerEvents`
|
||||
|
||||
`partition:created` / `partition:deleted` / `partition:updated` / `member:added` / `member:removed`. No key material.
|
||||
|
||||
### `interface PartitionManagerConfig`
|
||||
|
||||
`{ store, keyRing, policy?, clock?, events?, idGenerator? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default policy is computed once in the constructor (`createPolicy(config.policy ?? {})`). Per-partition policies are layered on top via `mergePolicies` in `create` and `update`.
|
||||
- `addMember` validates the partition's policy against the action `share` (the canonical "I am modifying membership" verb); when the policy denies, the call throws `SHARE_POLICY_VIOLATION`. Importantly, the check is performed against the partition's CURRENT membership — adding the Nth member when `maxMembers` is N triggers the denial branch.
|
||||
- `removeMember` is intentionally idempotent. Calling it twice with the same userId returns the same partition the second time and does NOT emit `member:removed`. This matches the spec scenario "RemoveMember restores count".
|
||||
- `setReadOnly(true)` is the spec's documented graceful-degradation hook; the partition store's health check is the trigger but the toggle is exposed publicly so consumers can wire any policy they like.
|
||||
- Event emission is fire-and-forget — the `EventBusLike.emit` contract is synchronous and does not surface listener errors. Asynchronous side effects belong to the listener, not the manager.
|
||||
Reference in New Issue
Block a user