Files
OpenNest/docs/nest-storage.md
T
ajandClaude Opus 5.5 f847b216f2 feat(desktop): show a server nest's MaterialLedger holds and closeout note
Server nests in Database mode get a read-only status bar label comparing their
open MaterialLedger holds with the sheets they cut, e.g. "Ledger: 2 of 4 held,
2 short". Nothing writes to the ledger and nothing waits on it.

- MaterialLedgerClient.GetHoldsAsync reads GET /api/stock/holds?openNestNestId=
  with the closed LedgerHoldsResult set; unknown status strings are kept. The
  request plumbing is shared with GetStockAsync.
- LedgerHoldSummary (OpenNest.Data) counts Proposed/Approved/Released/
  AwaitingCloseout holds against the plate quantities, skipping the trailing
  empty plate as /sheets does.
- NestHoldStatus refreshes on server open, after save, after storage or
  Options changes and on label click; it never polls. The tooltip lists holds
  in nest units. File-mode, unsaved and ledger-less nests show nothing.
- Setting Has Been Cut in Nest Info with holds still open shows a non-modal
  note linking to {ledgerUrl}/Nests/{id}. Posting is not gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:45 -04:00

1391 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Nest storage: File mode vs. Database mode
The desktop app can save nests two ways:
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP
archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
- **Database mode**: Open browses server records 100 at a time. The Find box
searches name, customer, material, grade, made by, comments and status on the server
after a short typing pause (Enter in the box searches at once); clicking a
column header sorts every match on the server (click again to reverse); the
arrow buttons move between pages and the title and find bar show the range and
total. Dates and numbers are not searched as text; sort their column instead.
The selected sort column and direction are remembered for the current Windows
user when the browser closes, including across app restarts. Until a selection
is saved, nests appear newest-saved first.
Below the list, the **Details** tabs show the highlighted nest's plates
(duplicates, size, parts, drawings, utilization), drawings (required, nested,
remaining, area), and **Versions** (bounded current and earlier snapshots).
Plates and drawings come from the archive downloaded shortly after the
highlight stops moving; Versions loads on selection and follows the selected
nest without opening another window. Beside the tabs, a preview draws one
plate of the highlighted nest; the arrow buttons below it step through the
plates, and selecting a row on the Plates tab shows that plate. Enter on the
nest list or a double-click opens the highlighted nest; Esc closes. Save creates a record on
first save and updates that same record afterward. Save As creates a new record
(a copy). A failed save keeps the previous record association; switching server
URLs creates a record on the new server rather than updating an id from the
previous one. On the Versions tab, **Open read-only...** previews one snapshot
without binding an editable document, and **Download selected version...** saves
its exact archived bytes.
Select a nest and click **Nest Information...** above the list (or use the
row's right-click menu) to edit the same information as **Nest > Nest Info**,
including the name, without opening the nest in the main editor. **OK** saves immediately to the
same database ID as a new version, then refreshes the list, preview and history;
**Cancel** makes no change. The dialog starts with the latest saved metadata.
Plate defaults follow the same rules as the open editor: populated sheets are
not resized or re-nested. This action requires a version-aware server and
refuses to overwrite a newer save from another PC. After a conflict or network
error, refresh and reopen the information dialog before trying again; a lost
reply can mean the save already reached the server. An already-open editor is
not modified; reload it before saving further changes to that same nest.
A separate confirmed **Restore selected version...** creates a new current
revision of the same ID and keeps all intervening versions. **Delete...** hides
the record from ordinary browsing but retains every archive; **Deleted nests...**
opens an explicit recovery view. Neither action purges data. An old desktop
without a revision precondition cannot update or delete on an upgraded server.
A separate **File > Export .nest...** command is always available (in both
modes) for producing a local file to share or back up. Export does not change
the document's file save path or database record association.
Dragging one or more `.nest` files onto the main window (or onto an open
plate) opens them from disk directly, in either mode — this is the only way
to open a local file while in Database mode, since Open there browses server
records instead of showing a file dialog. A document opened this way is not
bound to a server record, so a subsequent Save in Database mode creates a new
record on first save, same as any other unbound document.
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing
installs are unaffected until an operator opts in via **File > Storage Mode...**.
## Wire contract
`OpenNest.Data.NestRecord` is the metadata DTO shared by the client
(`RemoteNestRepository`) and the server (`OpenNest.Server`). JSON is camelCase
with enums serialized as strings (`JsonSerializerDefaults.Web` +
`JsonStringEnumConverter(JsonNamingPolicy.CamelCase)`), matching the rest of
`OpenNest.Data`'s local JSON stores. Uploaded metadata (POST and both PUTs) must
carry string text fields (`null` is 400) and a defined `status`: a name, or for
existing callers its integer value (0 quote, 1 toBeCut, 2 hasBeenCut, 3 draft); an
undefined value is 400 and stores nothing. A server older than `draft` refuses it,
so upgrade the server before desktops that can set it.
An uploaded `file` (POST and PUT `/file`) must be a `.nest` archive with a readable
`nest.json`, or the request is 400 and stores nothing. The server reads only that
entry, never programs or geometry, and takes the archive-derived fields below from it.
| Field | Type | Notes |
|---|---|---|
| `id` | guid | Client-generated on first save; kept on updates. |
| `name` | string | |
| `customer` | string | |
| `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. |
| `material` | string | Material name (type), e.g. `Stainless`. Read from the uploaded archive (nest material, else plate defaults' on an explicit null); a metadata-only PUT may change it, and the desktop applies the record's value when opening the nest. |
| `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. |
| `thickness` | number | Read from the uploaded archive (nest thickness, else plate defaults' when not positive); editable like `material`. |
| `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. |
| `plateCount` / `partCount` | integer | Server-computed from the uploaded archive (non-cutoff parts only; a trailing plate with no parts or cut-offs is not counted); ignored on upload and kept by a metadata-only PUT. |
| `comments` | string | |
| `madeBy` | string | |
| `fileSize` | integer | Server-computed; ignored on upload. |
| `savedAt` | datetime | Server-computed; ignored on upload. |
| `revision` | integer | Current immutable revision; zero on a legacy server without history. |
| `deleted` | boolean | Hidden from ordinary reads when true; retained in recovery/backup. |
| `restoredFromRevision` | nullable integer | Historical source promoted by Restore; not the preceding revision. |
## Endpoints
| Method | Path | Body | Response |
|---|---|---|---|
| GET | `/healthz` | — | 200 `{ "status": "ok" }` after a live database query; 503 `{ "status": "unavailable" }` on storage failure (no internal details) |
| GET | `/api/nests` | — | `NestRecord[]`, newest `savedAt` first. Full, unbounded enumeration for backup manifests, restore checks and the container smoke; browsing uses `/api/nests/query` |
| GET | `/api/nests/query?search=&status=&material=&materialGrade=&thickness=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter |
| GET | `/api/nests/{id}` | — | `NestRecord` or 404 |
| GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 |
| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }], "plates": [{ "index", "width", "length", "quantity", "stockItemCodes", "expectedRemnant" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable |
| POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid; 409 with the current record (active or deleted) when the `id` already exists, storing nothing |
| PUT | `/api/nests/{id}/file` | multipart: `metadata` + `file` | Updated `NestRecord`, or 404 if `id` is unknown |
| PUT | `/api/nests/{id}/metadata` | JSON `NestRecord` | Updated `NestRecord` (archive untouched), or 404 |
| DELETE | `/api/nests/{id}` | — | Updated hidden `NestRecord`, or 404 |
| GET | `/api/nests/{id}/revisions?offset=0&limit=100` | — | Metadata page, newest first; limit 1–200 |
| GET | `/api/nests/{id}/revisions/{revision}/file` | — | Exact immutable archive bytes |
| POST | `/api/nests/{id}/revisions/{revision}/restore` | — | New current revision of the same ID, with source metadata/bytes |
| GET | `/api/nests/deleted/query` | — | Bounded deleted-record page; same query parameters as ordinary browsing |
| GET | `/api/nests/deleted/{id}` / `/{id}/file` / `/{id}/revisions` / `/{id}/revisions/{revision}/file` | — | Recovery equivalents of current/history reads |
| POST | `/api/nests/deleted/{id}/recover` | — | New nondeleted revision of the same ID |
| POST | `/api/nest-names/next?year=` | — | `{ "year", "number", "name" }`: the year's next generated nest number (see below); 400 outside 2000–2099 |
All existing-record mutations require `If-Match: "<current revision>"`.
Missing precondition returns 428 (upgrade/reload), malformed precondition 400,
stale precondition 409 with current metadata; none mutate data. Ordinary reads
of hidden records return 404. Changed file/metadata saves and visibility changes
append snapshots atomically; an identical save at the current revision is a no-op.
Restore appends a new snapshot, never rewrites its source or intervening history.
### Sheets and part counts
`/sheets` lists what a nest needs cut, for inventory holds (MaterialLedger). It
skips the trailing empty new-plate slot, as `plateCount` does. Sheets group by
size with `width` the shorter side, sizes as stored (unrounded), `quantity` the
sum of those plates' quantities, ordered by width then length. `parts` has one
entry per drawing, ordered by name: `required` is the drawing's required
quantity, `nested` its parts on each plate times that plate's quantity; parts
whose drawing is missing are skipped.
`plates` lists the same plates one by one, in nest order: `index` (0-based
position), `width` (the shorter side) and `length` as stored, `quantity`, and
`stockItemCodes`, the inventory item each sheet of that plate is cut from
(entry *i* is sheet *i*; sheets past the end are uncoded and a bundle repeats
its code; empty when none is coded). Codes come from the plate's optional
`stockItemCodes` in `nest.json` (see the [nest file format](nest-file-format.md)),
read as `NestReader` reads them. MaterialLedger can hold exactly those items and
match uncoded sheets by size.
Each plate also reports `expectedRemnant`, `{ "width", "length", "source" }`
(`width` the shorter side, in the nest's units, `source` `cutoff` or `offcut`), the
rectangular drop each of its sheets is expected to leave, or `null` when none. The
server has no geometry: it reports the plate's derived `expectedRemnant` that the
desktop wrote at save (see [expected remnants](nest-file-format.md#expected-remnants)).
A file without it, or with a malformed or non-positive value or an unknown source,
reports `null`.
### MaterialLedger holds (desktop)
MaterialLedger creates one hold per physical sheet of a server nest, from
`/sheets`, and stores the nest's ID. The desktop shows those holds read-only; it
never creates, approves or closes them, and nothing waits on the ledger.
**Where.** A status bar label after the material label, only for a nest saved to
the server in Database mode (`Document.RemoteId`, not a first save) while
Tools > Options > MaterialLedger has a URL ([setup](automatic-nesting.md#stock-from-materialledger)).
File-mode nests, unsaved nests and PCs without a ledger show nothing. The tooltip lists
each hold: item code, size in the nest's units, location code and status.
**When.** The holds are read when a server nest opens, after each Save, after the
storage mode or Options change, and when you click the label. The status bar never polls.
Each read uses `MaterialLedgerClient.GetHoldsAsync` (`GET /api/stock/holds?openNestNestId=`,
`X-Api-Key`, 5 s), whose result is `NotConfigured`, `Unreachable(reason)`,
`Unauthorized` or `Ok(holds)`. A newer read replaces an older one still running.
**Summary.** `LedgerHoldSummary` (`OpenNest.Data/MaterialLedger/`) compares the
open holds with the sheets of the last open or save: the sum of plate quantities,
skipping the trailing empty plate as `/sheets` does. Proposed, Approved, Released
and AwaitingCloseout holds are open. Closed, cancelled, exception and unknown
statuses are listed but not counted; unknown status strings are kept as sent.
| Holds | Label |
|-------|-------|
| One open hold per sheet | `Ledger: 4 of 4 sheets held` |
| More open holds than sheets | `Ledger: 4 of 4 sheets held, 1 extra` |
| Some sheets without one | `Ledger: 2 of 4 held, 2 short` |
| None open | `Ledger: no holds`, or `Ledger: no open holds (3 closed)` |
| No answer or key rejected | `Ledger: unavailable` (the tooltip gives the reason) |
The label prefix is `Ledger:` rather than `Material:` so it is not confused with the
nest's material label beside it.
**Has Been Cut.** When Nest Info changes the status to Has Been Cut, the desktop
rereads the holds. If any are still open, it shows a non-modal note, "Close out N holds
in MaterialLedger", with a link to `{ledgerUrl}/Nests/{id}`. Closeout happens there.
The note never blocks the dialog, save or post, and posting is not gated on holds.
### Generated nest names
**File > New** names a nest `N<yy>-<seq>` (`OpenNest.Data.NestNames`): the
two-digit year and the year's sequence number in a base-30 alphabet without
look-alike characters, padded to three digits (27,000 a year before a fourth
appears). The sequence runs for the whole year. In Database mode the desktop
asks `POST /api/nest-names/next` for the number, so every PC shares one
counter per year (`nest_sequences`, schema version 6). The server also keeps
the number above every stored generated name for that year, active or
deleted, so names created offline or before the server counted are not
reissued; renamed nests and other years' names do not move it.
When the server is unreachable within 3 seconds, has no such endpoint, or the
app is in File mode, the PC's own counter (`NestNumber` user setting) names
the nest. It resets when the year changes and is raised past each number the
server hands out. Two PCs naming nests offline at the same time can still pick
the same name; a nest's identity on the server is its `id`, never its name.
A nest's name may already be on posted CNC programs or labels, so it is never
renamed silently. When **Save** (not Save As, and not the reconciliation of a
pending upload) creates a nest's first record on the current server, it first
searches active nests for one with exactly the same name (case-sensitive;
deleted nests do not count, `NestNameConflicts`). Only on a match does it
allocate the year's next name and show it in an editable dialog: OK uploads
under the chosen name, Cancel saves nothing. If the check or the allocation
fails or takes longer than 3 seconds, the nest uploads under its current name.
In Database mode the name can be edited in **Nest > Nest Info** (it uploads with
the next Save) and in the saved-nest browser's **Nest Information...** (saved
immediately). In File mode it stays read-only, since the file name is the nest
name. If another active nest already has the exact new name, a warning asks
before using it; **No** reopens the dialog with the edits kept. A name accepted
this way is not asked about again by the nest's first Save. A rename that the
server cannot check within 3 seconds goes ahead without a warning.
There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one.
### Browsing query
`GET /api/nests/query` filters, orders and pages in SQLite, so a client receives
only the requested page of metadata (never archive bytes):
- `search` (optional): trimmed, at most 200 characters, no NUL characters;
blank means no filter.
Case-insensitive substring of the whole text in `name`, `customer`, `material`,
`materialGrade`, `madeBy`, `comments` or the status (`ToBeCut` or the display name `To Be Cut`).
`%`, `_` and `\` are literal. Dates and numbers are not matched as text.
Case folding is ASCII-only (SQLite `LIKE`).
- Exact filters (optional, all must match; used to find draft nests a release
can merge into): `status` (`draft`, `quote`, `toBeCut` or `hasBeenCut`),
`material` and `materialGrade` (compared by `SharedListNames.Key`, so case and
spacing are ignored; a present but blank `materialGrade` asks for nests with no
grade) and `thickness` (a number, zero or greater, matched at four decimals).
The client sends them only when set, so plain browsing still works against
servers without them.
- `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500).
- `sort` (default `savedAt`): one of `savedAt`, `name`, `customer`, `status`,
`material`, `dateCreated`, `dateModified`, `thickness`, `plateCount`,
`partCount`, `madeBy`, `comments`, `fileSize`, `materialGrade` (names, case-insensitive; no
numbers). Text columns sort case-insensitively (ASCII); dates sort as their
stored ISO text. `order` is `asc` or `desc` (default `desc`).
- Ties are broken by `id` in the same direction, so pages partition the matches
deterministically. `total` counts every match and is read with the page in one
database hold. A save between two page requests can move a row across a page
boundary.
- Unknown, repeated, non-integer or out-of-range parameters return 400 instead of
being ignored or clamped. `RemoteNestRepository.QueryAsync` checks the same
bounds before sending, and reports an HTTP 404 from an older server as a
server version that must be updated.
## Drawing library endpoints
The server also keeps a shared library of drawings (schema v5). The desktop
adds library drawings to a nest and saves nest drawings to the library (below).
A drawing's geometry travels as an opaque
**drawing package**: a `.nest` archive holding exactly one drawing and no plates,
written and read on the client by `OpenNest.IO.DrawingPackage`. The server never
parses it.
`OpenNest.Data.DrawingLibraryRecord` fields: `id` (client-allocated before the
first upload), `revision`, `name`, `customer`, `materialName`, `materialGrade`,
`thickness` (stored rounded to 4 decimals), `units` (`inches` | `millimeters`),
`cutType` (`laser` | `waterjet` | `plasma`), `notes`, `settings`, `archived`,
`savedBy`, and the server-computed `packageSha256` (lower-case hex), `packageSize`
and `savedAt`.
`settings` holds the drawing's nesting settings, applied when it is added to a
nest: `cutFirst` (sequence its parts first, for example test squares), `priority`,
`stepAngle`, `startAngle`, `endAngle` (radians), `allow180Equivalent` and
`colorArgb` (32-bit ARGB; alpha 0 means no stored color, so the nest assigns one).
They are record fields, not package content (see
[drawing packages](nest-file-format.md#drawing-packages)), so changing them adds a
revision without new geometry. Default quantity is not stored: quantity belongs to
each nest.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | `/api/drawings` | multipart: `metadata` (JSON record with a nonempty `id`) + `package` | Revision 1; 409 with the current record when the id exists |
| GET | `/api/drawings/query?search=&customer=&materialName=&materialGrade=&thickness=&cutType=&cutFirst=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }` |
| GET | `/api/drawings/{id}` / `/{id}/file` | — | Current record / package bytes (archived drawings included), or 404 |
| GET | `/api/drawings/{id}/revisions?offset=0&limit=100` | — | Revision page, newest first; limit 1–200 |
| GET | `/api/drawings/{id}/revisions/{revision}/file` | — | Exact immutable package bytes |
| PUT | `/api/drawings/{id}/file` | multipart: `metadata` + `package` | New revision |
| PUT | `/api/drawings/{id}/metadata` | JSON record (all details and `settings`; an omitted `settings` means defaults) | New revision with the same package |
| POST | `/api/drawings/{id}/archive` / `/restore` | — | New revision with `archived` changed |
- Existing-drawing writes require `If-Match` exactly as for nests: 428 missing,
400 malformed, 409 stale with the current record. Writing to an archived
drawing, archiving an archived one or restoring an active one is also 409.
None of these change data. There is no delete; archive hides a drawing from
the query unless `includeArchived=true`.
- Every change appends an immutable revision in the same transaction that
updates the current row, including metadata-only edits and archive/restore.
An identical save (details, settings and package hash) at the current
revision is a no-op.
- Packages are content-addressed: each distinct package is stored once, keyed by
its SHA-256, and revisions point at it. Only a new package adds a blob;
details, settings, archive and restore revisions add none.
- Validation (400, no state change): `cutType` and `units` must be single enum
names (no numbers or combinations); `thickness` finite and at least 0; `name`
nonblank; name, customer, material, grade and savedBy at most 200 characters,
notes at most 4000; no NUL characters; `settings` not null, angles finite
and `stepAngle` at least 0; package nonempty, at most 16 MiB and
starting with a ZIP signature. Metadata `id` on a PUT must be empty or match
the route.
- Query: `search` is a literal, case-insensitive (ASCII) substring of name,
customer or notes (`%`, `_`, `\` literal). `customer`, `materialName` and
`materialGrade` match exactly after `SharedListNames.Key` normalization
(trimmed, collapsed spaces, case-insensitive); blank means any. `thickness`
matches exactly after rounding to 4 decimals. `sort` is one of `savedAt`
(default), `name`, `customer`, `materialName`, `materialGrade`, `thickness`,
`cutType`, `revision`; ties break by `id`. `cutFirst=true|false` keeps only
drawings with that setting. Paging, `order` and the refusal of
unknown, repeated or out-of-range parameters follow the nest query.
- An older server answers 404 on these routes; the client reports that the
server must be updated and never falls back to another store.
Schema v5 keeps three drawing tables in the same file: `drawing_packages`
(`sha256`, `size`, `package`), `drawing_revisions` (every immutable revision's
details, settings, archived flag and package hash) and `drawings` (each
drawing's current revision, indexed for browsing). Upgrading from v4 runs in
one transaction: each distinct v4 blob is stored once by its hash, every
revision and current row is copied as it was with default nesting settings
(v4 had none), and the v4 drawing tables are dropped. A pre-v4 database gets
the v5 tables directly. The nest tables are untouched. A server older than v5 refuses
to open a v5 database, so rolling back after the upgrade needs the pre-upgrade backup (see
[Backup, restore, and upgrade](#backup-restore-and-upgrade)). The stopped-volume
`tar` backup includes the drawing tables, but `nest_manifest.py` and
`restore_check` do not verify drawings yet.
### Add from Drawing Library (desktop)
**Nest > Add from Drawing Library...** and the **Library** button above the
drawing list open a paged browser of active library drawings (Database mode
only; in File mode both are disabled with "Database mode only"). It starts
filtered to the nest's material, grade and thickness; **Show all materials**
lifts that filter. Selecting a drawing fills the details pane beside the list:
its details, notes and a preview of its geometry. The preview downloads the
listed revision's package shortly after the selection settles (arrowing through
the list does not download each row), caches it per revision while the dialog is
open, and is best-effort: a failed download shows "Preview unavailable" and
never blocks adding. **Add to Nest** (or double-click /
Enter) downloads that exact revision, checks its SHA-256 against the record and
plans the insert (`OpenNest.Data.LibraryDrawingInsert`) before changing anything:
- Material, grade or thickness differing from the nest: a warning names each
difference and the operator confirms. The drawing is added, but its cut pins
will not apply; the nest's cutting settings do.
- Different units: refused (no conversion).
- The same revision from the same server already in the nest: only the
quantity is raised. A different revision of that drawing: refused; change
revisions through Update Drawings.
- Another drawing with the same name: refused, naming both.
The added drawing is an independent copy carrying a library link (server key,
id, revision and a snapshot of the library details) that is saved with the nest
(see [the nest file format](nest-file-format.md#drawing-library-links)). Editing it
in the nest never changes the library. The copy takes its name, customer,
material and nesting settings from the library record, not from the package.
The **In This Nest** tab (its title counts them) lists the nest's drawings,
library or not, cutoffs excluded: name, library revision ("Not from the library"
or "(other server)" when it is linked elsewhere), and the Required, Nested and
Remaining quantities, with totals below. It refreshes after every add. Required
is edited in place (0 to 99,999; one click starts editing, a bad number keeps
the editor open with an error) and applies to the nest at once, like an add;
the main window's drawing list refreshes when the dialog closes.
The browser's **Cut first** column shows the setting.
**File > Drawing Library...** (Database mode only, like Drawing Sets) opens the
drawing sets window on its **Library** tab ([below](#drawing-sets-desktop)),
the same browser without a nest: it lists every active drawing and has no Add to
Nest or In This Nest tab. There a double-click, Enter or **Add to Release** adds
the selected drawing on its own, at the entered **Quantity**, to the window's
release panel ([below](#make-nests-the-release-panel)); **Edit Drawing...** (the
button or the right-click menu) edits it. Its right-click menu also has the set
and release items below.
### Edit Drawing (desktop)
**Edit Drawing...** (`OpenNest.Data.DrawingLibraryEditSession`) edits one active
library drawing. Its **Details** are the name, customer, material, grade,
thickness and cut type; **Units** are shown read-only, since changing them would
change what the geometry means. Its **Nesting settings** are Cut first, priority,
rotation step/start/end (degrees), 180° equivalent and color, then the notes.
Text is trimmed and thickness rounded as the server stores it; Save stays
disabled until something changed and every value passes the library rules.
When the new details would give the drawing another identity (D4: normalized
customer, name, material and grade, exact thickness, units and cut type), Save
first looks up active library drawings that already have them and, if any do,
names them and asks before saving; a failed lookup also asks. Editing only notes
or settings never asks.
Save goes through `PUT /api/drawings/{id}/metadata`, guarded by the revision
the dialog opened with: a new revision with the same package. A material or
thickness change is an edit of the same drawing, not a new one. A 409 (someone
else saved first) is shown, nothing is saved and the dialog stays open; close it
and edit again. Nests that already hold the drawing keep their copy and
revision. Sets list drawings by id, so they release the new revision next time.
Edit Drawing opens from:
- the Drawing Library dialog (the **Edit Drawing...** button or the drawing's
right-click menu), which reloads the page after a save;
- the set editor's member grid: double-click a member (outside its quantity),
right-click it, or **Edit Drawing...** beside the grid. It first reads the
member's current record, so a set read earlier does not cause a conflict; the
row then shows what was saved. The set itself is unchanged and needs no save;
- the release panel: a group refused for its drawings (no material or
thickness, or two drawings with one name) has **Fix Drawings...**, which opens
Edit Drawing on each drawing it is refused for in turn (Cancel stops), as does
a double-click on the group; with a releasable group selected, it takes the
first refused one. Its right-click menu lists Edit Drawing for each of
the group's drawings. Each edit reads the current record first, and the plan
is rebuilt with what was saved, so a fixed drawing joins its material group.
### Save to Drawing Library (desktop)
Right-click a drawing in the drawing list > **Save to Drawing Library...**
(Database mode only; disabled with "Database mode only" in File mode). The
dialog (`OpenNest.Data.DrawingLibrarySaveSession`) prefills name and customer
from the drawing (the nest's customer when the drawing has none), material,
grade, thickness and units from the nest (the drawing's material when the nest
has none), and the cut type and notes of the linked revision when the drawing
came from this server's library, otherwise the cut type last saved on this PC.
Units follow the nest and cannot be changed. The drawing's own nesting
settings (Cut first, priority, rotation, color) are saved in the record and
summarized read-only in the dialog; change them later with Edit Drawing.
Whenever the details change, the dialog looks up active library drawings with
the same normalized customer, name, material and grade and the exact thickness,
units and cut type, and offers **Save as**:
- No match: a separate (new) library drawing.
- One match, or the drawing's own linked library drawing among several: the
next revision of it, guarded by `If-Match` on the revision listed.
- Several matches: no default; the operator chooses one or a separate drawing.
- A linked drawing whose details no longer match is offered but never the
default, so changing thickness cannot silently rewrite it.
Save stays disabled until the lookup for the current details has finished. A
409 (someone else saved first) is shown and the choices are refreshed. A save
that fails without a definite answer stays pending: the next Save first looks
for the revision it would have produced and adopts it when the package hash
and details match, so it is never stored twice and a retried new drawing keeps
its client-allocated id. Saving identical content at the current revision is a
no-op. On success only the nest drawing's library link changes, to the saved
revision; save the nest to keep it.
### Drawing sets (desktop)
**File > Drawing Sets...** (Database mode only) opens the drawing sets window;
it needs no open nest, since releasing a set makes new nests or merges into
draft ones and opens each in its own tab. It is a window of its own beside the
main window (in the taskbar, with its own minimize button), not a tab inside it,
since the main window's menus and toolbar are for editing nests. There is one
at a time: the menu items bring it forward on the tab asked for. It opens
maximized the first time, then where and as it was last closed, and closes with
the main window. The window has three tabs, **Library**
(**File > Drawing Library...** opens on it), **Sets** and **Releases**
([below](#releases-tab)); **File > Releases...** is a shortcut that opens the
same window on the Releases tab. Beside the Library and Sets tabs is the
release panel ([below](#make-nests-the-release-panel)), which builds one release
from both. Each tab reads its list when first
shown, and Releases re-reads its page each time it is shown again. Sets lists sets a page at a time
(`OpenNest.Data.DrawingSetBrowseSession`): search by name, customer or notes,
sort by name, customer, revision or saved time, and **Show archived**. Selecting
a set shows its notes. A double-click or Enter adds the set to the release at
one kit (again adds another kit). Below the list are **New Set...**, **Edit...**,
**More ▾** and **Add to Release** in bold, which adds every selected set
(Ctrl- or Shift-click selects several). **More ▾**, also the list's right-click
menu (the menu key or Shift+F10 opens it from the keyboard), holds **Add to
Release** and **Edit Set...**, then **Archive** or **Restore**, **History...**
and **Releases...** for the selected set, then **Import BOM...**
([below](#import-bom-to-a-set-desktop)), which needs no selection.
- **New Set...** and **Edit...** open the set editor
(`OpenNest.Data.DrawingSetEditSession`): name, customer, notes and the member
grid: drawing, material (name, grade, thickness and units in one column, such
as "Steel A36 0.25 in"), quantity per set and a note. A cut type column
appears only when the set mixes cut types.
The editor's left pane browses the library in place, with no second dialog:
every active drawing (never filtered to a nest), searched, sorted and paged
like the Drawing Library dialog. Select one or several and choose **Add to Set**,
double-click or press Enter to add them at the entered **Qty per set**.
Drawings already in the set are greyed; one picked again is skipped and
named, so there is one member per drawing. Quantities are edited
in the grid (1 to 100,000) and **Remove** drops a member. Members stay in
the order they were added (and stored in that order); nothing a release
makes depends on it, since materials are grouped and sorted and nests list
drawings by name, so the editor has no Move Up/Down. Drag the divider to
widen the library pane. The note column flags drawings archived in the
library (skipped on release unless included) and drawings with no material or thickness (cannot be
released until set; double-click to open
[Edit Drawing](#edit-drawing-desktop)).
- **Save** adds a revision guarded by the revision the set was read at; a 409 is
shown and the operator cancels and reloads. A new set's id is allocated
before its first save, and a save that fails without a definite answer stays
pending: the next Save reads that id and adopts the stored set (saving any
later edit as its next revision) instead of creating it twice. Closing with
unsaved edits asks first. Editing a saved set, the editor's **History...**
lists the set's revisions (below).
- **Archive** (after confirming) and **Restore** add a revision; nothing is
deleted. **History...** (in the set editor, and in the Sets tab's More menu
for the selected set) lists revisions newest first with the members of the selected
one, shown with their drawings' current library details.
### Import BOM to a set (desktop)
**Import BOM...** (the Sets tab's **More ▾** menu; **File > Import BOM...** in
Database mode opens the drawing sets window straight into it) turns a BOM into a
new drawing set. It sits in More rather than beside New Set because it is another
way to start a set, used once per job, and the Sets tab keeps New Set, Edit and
Add to Release as its only buttons; File > Import BOM stays the familiar way in.
1. Choose the BOM (`.xlsx`, read by `BomReader`) and the folder of its drawings,
the **Set name** (prefilled from the BOM file name without a trailing " BOM")
and an optional **Customer**, then **Read BOM**. Rows resolve to drawing files
as in File-mode BOM import (`BomImportRows`). Rows that name the same file with
the same material (ignoring case) and thickness are one drawing needing their
total quantity, which becomes its quantity per set (`BomSetDrawings`). Rows
with no file name, no drawing file or a quantity below 1, and files that fail
to import, are listed greyed and left out. Rows with no material or thickness
are kept: the drawing is stored with a blank material and thickness 0, and the
set editor's note column leads to Edit Drawing to fix it before Make Nests.
2. Each drawing is matched to the library (`OpenNest.Data.DrawingSetImport`) by
its identity (D4: normalized customer, name, material and grade, exact
thickness, units and cut type; the name is the file name without extension,
the grade blank, the units the new-nest default and the cut type the one last
saved to the library on this PC) and its geometry:
- an active drawing with the same identity and geometry is **used as it is**;
- one active drawing with the same identity and other geometry gets a **new
revision** with the new package, keeping its details, notes and settings;
- no active match: a **new library drawing**;
- several matches, none with this geometry, or a second BOM drawing with the
same identity as an earlier one: left out with a reason (add the right
drawing in the set editor).
Geometry is compared by package hash, then by
`DrawingPackage.GeometryKey` on the matching drawing's current package
(see [drawing packages](nest-file-format.md#drawing-packages)), since a file read
from another folder records another source path (and drawings stored before
entity ids were stable carry random ones). Reading only queries and downloads.
Changing the BOM, folder or customer afterwards asks for Read BOM again.
3. **Create Set...**: when the import would upload drawings or add revisions, a
confirmation lists each one first; Cancel stores nothing. On OK they are
stored in order (each revision guarded by the revision it was matched
against), and then the set editor opens with a new, unsaved set holding
every imported drawing at its BOM quantity, named and with the customer as
entered and notes naming the BOM file. The set is stored only by the editor's
**Save**; cancelling the editor leaves the confirmed library drawings and no
set. A failed write stops the import, names what was already stored and
leaves the set unchanged; Read BOM again then reuses those drawings, so
importing twice never stores a drawing twice.
Releasing the set (**Make Nests** in the release panel) then makes the nests, replacing File-mode
BOM import's Create Nests for Database mode. In File mode, **File > Import BOM...**
still makes nests directly, unchanged.
In the Drawing Library dialog, right-click a drawing for **Used in Sets...** (the sets
whose current revision contains it) or **Add to Set...**: choose an active set
or **New Set**, and the set editor opens with the drawing added at the entered
quantity for review and Save. **Releases...** opens the drawing sets window on
its Releases tab, filtered to the releases that had the drawing on its own (see
below), with Undo Release. In the drawing sets window, **Add to Release** adds
the drawing to the release panel. When the dialog was opened from a nest,
**Make Nests...** reads the drawing's current record and opens the release panel
in a dialog of its own with the drawing on its own at the entered quantity;
**Add Drawings...** there adds more library drawings (sets are released from the
drawing sets window).
The nest's drawing list has **Add Library Drawings to Set...** (right-click any
drawing; it acts on the whole nest, `OpenNest.Data.NestSetMerge`). It takes each
drawing copied from this server's library once, in list order, with its required
quantity (copies of one library drawing are summed). Drawings never saved to the
library, linked to another server or with no required quantity are skipped and
named; cutoffs are left out. If none remain it says so before anything else.
Otherwise choose an active set or **New Set** (named after the nest, with its
customer): the set's current library records are read, drawings the set already
lists **keep the set's quantity** (a released nest carries its multiplier, so
copying its quantities back would multiply them again), and members are added at the
library's latest revision. Anything kept, skipped, archived in the library or
newer there than in the nest is listed first, then the set editor opens for
review and Save; nothing is stored before then.
<a id="make-nests-the-release-panel"></a>
### Make Nests: the release panel (desktop)
The release panel (`OpenNest.Controls.ReleasePanel`) sits beside the Library and
Sets tabs and holds one release in progress for both. Adding is a double-click
(or Enter) in either list: a set is read with its members' current drawings and
listed at one kit; a library drawing is read afresh and listed on its own at the
Library tab's **Quantity**. Adding a set or drawing already listed adds to its
quantity instead, so double-clicking a set twice makes it ×2 kits. An archived
set or drawing is refused. Each line shows its name, its quantity (edited in
place: 1 to 1000 kits of a set, multiplying its quantities, or 1 to 100,000
pieces of a drawing) and **kits** or **pcs**; **✕** (or Delete) removes it. A
drawing that a set also lists becomes one item with both quantities. Above the
lines, the heading reads as a sentence of what is listed, such as "Release set
'Frame kit' ×2", or the first few lines when there are several. Below them are
the release's **Customer** (the one every line names, or left blank when they
differ) and **Notes**. Closing the window keeps a release not yet made for the
next time it opens in this run of OpenNest; it is not saved to disk.
**Nests it will make** previews, live as lines change, one row per material
(name, grade, thickness, units and cut type) across all the sets and drawings
(`OpenNest.Data.SetReleasePlan`): a tick, the material, its parts, its **Nest**
and **What happens**. Untick a group to leave it out; the choice survives
changing the lines. Refused groups (two drawings with one name, or drawings
without material or thickness) cannot be ticked and say why at once.
**Fix Drawings...** opens [Edit Drawing](#edit-drawing-desktop) on the selected
refused group's drawings, or the first refused group's when another row is
selected, and rebuilds the plan with what was saved. Archived members are left
out and named below the grid, where **Include archived drawings** appears to put
them back in.
A group **merges into a Draft nest by default**, which is what Draft status is
for: the plan looks up the Draft nests of the group's material, grade and
thickness (`SetReleasePlan.FindDraftsAsync`) and
- with exactly one, merges into it: the row says "→ merges into N26-0007";
- with several, merges into the most recently saved; the **Nest** list in the
row offers the others (newest first) and **New Draft nest**, and the row says
how many match;
- with none, makes a new Draft nest ("→ new Draft nest");
- when the draft it would merge into is open in a tab of this desktop, the row
says "N26-0007 is open — save and close it to merge into it" and **Make Nests**
waits: the tab could hold edits the merge would leave behind, and a parallel
nest is never made silently. Close the tab (the panel checks again when the
window is activated), choose New Draft nest for the group, or untick it.
Make Nests also waits until every chosen group's drafts have been looked up. A
merge adds the demand to that draft's drawings and renests the whole draft: every
sheet (placements, cutoffs, cut plans) is discarded and rebuilt, keeping
drawing-level setup such as cut pins; **Undo Release** restores it. With **Nest
after release** unticked, the row says the merged draft is left without sheets
until it is nested.
**Nest after release** (beside the button, ticked by default; the panel
remembers the operator's last choice) runs Auto Nest on every released
group, new or merged, before anything is saved (`OpenNest.Engine.Jobs.NestRenest`):
every drawing's required quantity, the selected engine, sheets from the nest's
plate defaults and plate-size options, as the Auto Nest dialog would offer them.
A layout is kept only when it is valid; otherwise the nest is saved without
sheets and its status says "Auto Nest could not keep a layout: open it and run
Auto Nest". The row lists each group's sheets and utilization, and any
copies left unplaced, which stay as remaining quantity. Unticked, new nests are
saved without sheets and merged drafts are left cleared for Auto Nest.
Ticked, the nesting runs where the operator can watch it. When Make Nests
starts, the drawing sets window minimizes and the main window comes forward.
Each group's nest opens in its own main-window tab as soon as it is built, before
it is saved, and Auto Nest runs on it there with the usual progress window and
live plan strip (`AutoNestCommand.SolveAsync`), one nest at a time in plan order,
switching to each tab in turn. The release keeps the layout as headless
`NestRenest` would (only a valid one, with no Keep or validation review). Stop in
the progress window (or switching tabs, as for Auto Nest) stops the whole release,
as the panel's Stop does: nothing is saved. Until Make Nests finishes the main
window's Nest and Plate menus are locked and Save is refused in those tabs, since
the release saves them. After saving, each tab is bound to its server record
(no second tab opens). A tab whose nest was not saved (a later group refused or
failed, or the release stopped before upload) is **closed**, since it is not part
of any release. The main window stays in front with the saved nests, and the
drawing sets window keeps the outcomes; its taskbar button flashes when a group
failed or was refused or the release was not recorded. A renest that throws
still leaves the nest without sheets and a note, and the release goes on.
**Make Nests (n)**, in bold, with n the groups ticked, is the only step that
writes; **Clear** empties the panel. It runs `OpenNest.Data.SetReleaseSession`:
every ticked group's new nest is made as File > New would (the server's next nest
name, the saved plate defaults and clamps), in the group's units, with the saved
plate size and spacings converted (25.4 mm per inch) when those differ; the nest
takes the group's material, grade and thickness, the release's customer when
there is one, and the **Draft** status. A merged draft is saved only while nobody
else has saved it since it was listed. Each created or merged nest opens in its
own tab of the main window (or keeps the tab it was nested in), bound to its server record, the main window comes
forward, and the release (with its notes) is recorded. A release with drawings
on their own first checks that the server can record them (see below) and, on an
older server, saves nothing and says so. Each row then shows its group's outcome:
Created or Merged into with the nest name, Refused or Failed with the reason, or
Not started. **Stop** (Clear while running) during preparing or nesting saves
nothing; once saving starts it stops between saves, never during one, and the
window does not close while it runs. When every nest was saved but recording
failed, the button becomes **Try Again** and posts the same release again
without making the nests again; until then nothing more can be added. After a
release, the next double-click starts the next one.
<a id="releases-tab"></a>
The **Releases** tab lists every release on the server, newest first, 100 at a
time (**< Newer** / **Older >**): when, by whom, a **Released** column naming what
each one had ("Frame A ×2, Frame B; Bracket ×4": sets with their quantity past
one, then drawings on their own with their quantities), the number of nests,
progress such as "2 of 3 cut" (deleted nests are not counted) or "Undone", and the
notes. The selected release's nests show their **Material**, the **Action**
("Created", "Merged (revision 3 → 4)", and what undo did) and current status, or
Deleted. **Undo Release...** deletes the nests the release created (they can be
recovered) and restores the drafts it merged into. When some nests were changed
after the release, it lists them and offers to undo the others, leaving those as
they are. **New Release** switches to the Sets tab, where the release panel
builds the next release; the list re-reads from the newest when the tab is
shown again.
A filter narrows the tab to one set or one drawing, shown above the list
("Releases of set 'Frame A'") until **Clear Filter** lists every release again:
- **Releases...** in the Sets tab's More menu filters to the releases that include the
selected set, with the set's **Revision** and the **Quantity** of this set (and
how many other sets and drawings the release had) in place of Released.
- **Releases...** in the Drawing Library's right-click menu opens the window
filtered to the releases that had that drawing on its own, with its
**Quantity** (and how many sets and other drawings the release had).
**Nest > Edit** (Nest Info) shows, beside its buttons, the newest release that
made or merged into a server nest ("Released from set 'Frame A' revision 3 ×2 on
10/20/2026 by ...", "Released from drawing 'Bracket' ×4", or "Merged from sets
..."), with a count of earlier ones. It
is read in the background and left blank when the nest came from none or the
server cannot be reached.
## Drawing set endpoints
A **drawing set** (schema v7) is a named, versioned list of library drawings
with a quantity each: an assembly, a job or a recurring order. Members name
drawings by id and always mean each drawing's **current** revision; there is no
per-member revision pin. Sets travel as JSON (`application/json`, at most 1 MiB).
The desktop browses, edits and releases them in File > Drawing Sets
([Drawing sets (desktop)](#drawing-sets-desktop)).
`OpenNest.Data.DrawingSetRecord` fields: `id` (client-allocated before the
first save), `revision`, `name`, `customer`, `notes`, `archived`, `savedBy`,
the server-computed `savedAt` and `memberCount`, and `members`, in order, each
`{ drawingId, quantity, drawing }`. `drawing` is the member drawing's current
library record (archived or not), joined by the server on every read so a client
can group members by material without one request per drawing; it is ignored on
save, so a record read from the server may be sent back as it is.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | `/api/sets` | JSON set with a nonempty `id` and `members` | Revision 1; 409 with the current record when the id exists |
| GET | `/api/sets/query?search=&customer=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }`; items have `memberCount` and no `members` |
| GET | `/api/sets/{id}` | — | Current record with joined members (archived sets included), or 404 |
| GET | `/api/sets/{id}/revisions?offset=0&limit=100` | — | Revision page without members, newest first; limit 1–200 |
| GET | `/api/sets/{id}/revisions/{revision}` | — | That revision's details and members, each joined to the drawing's current record |
| PUT | `/api/sets/{id}` | JSON set (`id` empty or matching the route) | New revision |
| POST | `/api/sets/{id}/archive` / `/restore` | — | New revision with `archived` changed and the same members |
| GET | `/api/drawings/{id}/sets?offset=0&limit=100` | — | Sets, archived included, whose current revision contains the drawing, by name; 404 for an unknown drawing |
- Existing-set writes follow the drawing contract: `If-Match` 428 missing, 400
malformed, 409 stale with the current record; writing to an archived set,
archiving an archived one or restoring an active one is also 409. There is no
delete.
- Every change appends an immutable revision, with its own copy of the members,
in the transaction that updates the current row. An identical save (name,
customer, notes and members in order) at the current revision is a no-op;
reordering members is a change.
- Validation (400, no state change): `name` nonblank; name, customer and savedBy
at most 200 characters, notes at most 4000, no NUL characters; `members`
present and not null, at most 500, each with a nonempty `drawingId` that is in
the library (archived drawings allowed), an integer `quantity` from 1 to
100000, and no drawing listed twice (one member carries the whole quantity). An
empty set is allowed.
- Query: `search` matches name, customer or notes and `customer` matches by
`SharedListNames.Key`, exactly as for drawings. `sort` is one of `savedAt`
(default), `name`, `customer`, `revision`; ties break by `id`. Paging, `order`
and the refusal of unknown, repeated or out-of-range parameters follow the
drawing query.
### Set releases
A set is never added to a nest, because a nest is one material. It is
**released**: the client makes one nest per material group with every quantity
multiplied, uploads each nest, and only then posts a **release record** naming
the set revisions and the nests. A failure part-way leaves ordinary nests and no
release, and nothing is rolled back silently.
Since schema v9 a release covers one or more sets (`release_sets`), and a nest
may be one the release **created** or an existing draft it **merged** into
(`release_nests`, with the nest revision before and after). The drawing
quantities each set added to each nest are kept in `release_nest_items`, so a
set can still be traced once sets share a nest. The v8 → v9 migration turns each
v7 release into one set line (its multiplier is the quantity) and created nests
at their first revision, with no items. Since schema v11 a release may also
name library drawings **on their own** (`release_drawings`), with or without
sets; their items in `release_nest_items` have no set.
`OpenNest.Data.SetReleaseRecord` fields: `id` (client-allocated, so an
ambiguous post can be retried), `sets` (each `{ setId, setRevision, quantity,
setName }`; `setName` is the set's name at that revision, joined on read),
`drawings` (each `{ drawingId, quantity, drawingName }`, the drawings released on
their own; `drawingName` is the drawing's current library name, joined on read),
`notes`, `releasedBy`, the server-computed `releasedAt`, `undoneAt` and
`undoneBy`, and `nests`, in order, each `{ nestId, kind, revisionBefore,
revisionAfter, materialName, materialGrade, thickness, units, cutType, items,
undo, undoRevision, nest }`. `kind` is `created` (no `revisionBefore`) or
`merged`; `revisionAfter` is the nest revision the release wrote. `items` are
`{ setId, drawingId, drawingRevision, quantity }`. `undo` and `undoRevision`
are server-computed. An item's `setId` is null for a drawing released on its own;
every other item names one of the release's sets. `nest` is the nest's current metadata (deleted or not),
joined on every read so status and deletion are always current. Joined and
server-computed values are ignored on post.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | `/api/releases` | JSON release with a nonempty `id` | The stored release; 400 for an unknown set or revision; 409 with the release that already holds the id or one of the created nests |
| GET | `/api/releases/query?setId=&nestId=&drawingId=&offset=0&limit=100` | — | Release page, newest first, optionally only releases including the set, the nest or the drawing on its own; limit 1–200 |
| GET | `/api/releases/{id}` | — | The release, or 404 |
| POST | `/api/releases/{id}/undo` | `{ "undoneBy": "...", "skipNests": [...] }` | The undone release; 404 for an unknown release; 409 with `{ reason, release, changedNests }` |
- Releases are append-only: there is no edit or delete. Undo marks the release
undone and records what it did to each nest.
- Set revisions and drawings on their own must exist but need not be current, and
sets and drawings may be archived: the
nests already exist by the time the release is posted, so it is never refused
for a state the operator cannot fix. A merged nest need not still be at
`revisionAfter` when the release is posted.
- A created nest belongs to that release only; a merged nest may be in several.
- **Undo** runs in one transaction. Each created nest is soft-deleted and each
merged nest restored to `revisionBefore`, except the nests in `skipNests`
(recorded as `skipped`). Every other nest must still hold `revisionAfter`: be
at it, or have been restored to it as its latest save. Otherwise nothing
changes and the 409 lists the changed nests (`reason` `nestsChanged`), so the
caller can skip them and try again. A second undo is refused with
`alreadyUndone`. Because each undo writes a new nest revision, releases that
share a nest are undone newest first.
- Validation (400, no state change): at most 50 `sets`, no set twice, each
quantity 1–1000; `drawings` (may be left out, as older clients do) at most 500,
no drawing twice, each quantity 1–100,000; at least one set or drawing; notes at most 4000 and releasedBy at most 200 characters; `nests`
present, 1–500 entries, no nest twice, each an existing, not deleted nest whose
named revisions exist, with material name and grade at most 200 characters,
`thickness` finite and at least 0 (stored rounded to 4 decimals), and `kind`,
`units` and `cutType` single enum names; each item names one of the release's
sets, or has no set and names one of its drawings on their own, at most once per
set (or no set) and drawing, with a positive revision and quantity.
- **Compatibility.** A v11 server accepts releases from older clients (no
`drawings`). A server older than v11 knows no `drawingId` parameter and answers
`400` for it, so clients probe `GET /api/releases/query?drawingId=<any id>&limit=1`
before building a release with drawings on their own; a 400 or 404 there means
the server is too old, and nothing is saved.
#### Client and release session
`OpenNest.Data.RemoteDrawingSetStore` is the client for every route above. It
checks `DrawingSetRules`/`SetReleaseRules` before sending, raises
`ServerTooOldException` on a server without sets (or, for releases, without
`/api/releases`, and for drawings on their own, without the `drawingId` query:
`EnsureDrawingReleasesSupportedAsync`), `DrawingSetSaveConflictException` or
`SetReleasePostConflictException` (carrying the current record) on 409,
`SetReleaseUndoConflictException` (carrying the refusal) when an undo is
refused, and refuses replies whose id, revision, members, sets or nests differ
from what was sent.
`SetReleasePlan.Build(lines, drawings, includeArchived)` splits sets read from
the server, each with a quantity (`SetReleasePlanLine`), and library
drawings on their own, each with a quantity (`SetReleaseDrawingLine`), into
groups without any I/O; either list may be empty, not both (`Build(lines,
includeArchived)` and `Build(set, multiplier, includeArchived)` are the
sets-only forms). A drawing listed by several sets, or by a set and on its own,
becomes one item with the quantities summed and each share kept (`Sources`; a
share with no set is the drawing on its own), using the newest revision it was
read with. A drawing listed twice on its own is refused (give it a larger
quantity). A group is the items whose drawings share material name and grade (by
`SharedListNames.Key`), thickness (rounded to 4 decimals), units and cut type.
The plan's customer is the one every set and drawing on its own names, or blank. A set listed twice is
refused (give it a larger quantity instead). Archived drawings are skipped unless included. A group is refused when two of its drawings share a
name (compared ordinally, as a nest keys drawings), and every drawing without a
material or a positive thickness goes to one refused "unassigned" group, listed last.
`SetReleasePlan.FindDraftsAsync(nests, group)` lists the Draft nests a group
could merge into, using the nest query's status, material, grade and thickness
filters. Units and cut type are not in nest metadata, so they are checked on the
downloaded nest. The desktop merges into the most recently saved of them by
default and never into one open in a tab ([release panel](#make-nests-the-release-panel)).
`SetReleaseSession.RunAsync` releases the chosen releasable groups in plan order.
A group named in its `mergeInto` map merges into that draft instead of making a
new nest. When the plan has drawings on their own it first asks the server
whether it can record them (`IDrawingSetStore.EnsureDrawingReleasesSupportedAsync`):
an older server refuses the first group with the reason, a failed check fails
it, and nothing is built or saved.
1. **Build every group first.** For a new nest the caller's factory makes an
empty nest in the group's units; the session sets material, grade,
thickness, the plan's customer (when there is one) and `Draft`. For a merge
the session downloads the draft at the revision it was chosen at (reading it
with the caller's nest reader) and refuses it unless it is still a Draft of
the group's material, grade, thickness and units with no drawing for another
cut type; then it removes every plate, since a merge renests the whole
demand. Either way it downloads each item's exact revision and inserts it
through `LibraryDrawingInsert` (a drawing the draft already holds at that
revision gains quantity; another revision is refused, pointing at Update
Drawings). When the caller supplies a renest step ("Nest after release"),
every group is then nested from scratch; a step that throws leaves the nest
without plates and a note, and the release goes on. A refusal or a download
failure stops here, so nothing is uploaded. The factory may already have
allocated nest numbers.
2. **Upload in order** under client-allocated ids, never cancelling an upload in
flight; cancellation is checked between uploads. A failed upload is checked by
its id, so one stored before its reply was lost still counts as created. A
merged draft is saved with the revision it was chosen at as `If-Match`; a
failed save counts only when the server holds exactly the bytes sent, and
otherwise says whether someone saved the draft meanwhile. The first real
failure stops the rest.
3. **Post the release** only when every chosen group was created or merged,
under a client-allocated id. It names every set with its quantity, every drawing
on its own with its quantity, each nest as created or merged with the
revisions before and after, and the drawing revisions and quantities each set
(or no set, for a drawing on its own) put in each nest. When the post fails, the nests stay and
`RetryReleaseAsync` posts the same id again; a 409 naming that id is adopted
as the stored release.
The result lists each group's outcome (`Created`, `Refused`, `Failed`,
`NotStarted`), the created or merged nests' records and in-memory `Nest` objects
(so the desktop can open them without downloading), the draft each merged into,
what nesting after release did, and the release or why it was not recorded.
`RemoteDrawingSetStore.UndoReleaseAsync` undoes a release (see above);
`SetReleaseText.UndoRefusal` words a refusal for the operator, naming each changed nest.
Schema v7 adds `drawing_set_revisions` (every revision's details and archived
flag), `drawing_set_members` (each revision's members by position, unique per
drawing), `drawing_sets` (each set's current revision), `set_releases` and
`set_release_nests`. Upgrading from v6 only creates these tables; every other
table is untouched. A server older than
v7 refuses to open a v7 database, so rolling back needs the pre-upgrade backup.
The stopped-volume `tar` backup includes the set and release tables; `nest_manifest.py` and
`restore_check` do not verify them yet.
Schema v8 adds a `materialGrade` text column (default blank) to `nests` and
`nest_revisions`; existing records keep a blank grade until they are next saved.
A server older than v8 refuses to open a v8 database, so rolling back needs the
pre-upgrade backup.
## Material list endpoints
The server keeps the company material list: each material with its grades. Both
are keyed by `SharedListNames.Key`, so names that differ only in case or spacing
are the same entry, and an entry keeps the spelling it was first added with
(trimmed, internal spaces collapsed). Nests, drawings and releases keep their own
free-text material and grade. The list only suggests names and records the
company spelling; it does not check or rewrite stored records. A grade belongs to
one material, and the same grade name may be listed under several materials.
Wire types are `OpenNest.Data.MaterialListEntry` and `MaterialListAddition`.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | `/api/materials` | — | `[{ "name", "grades": [...] }]`, materials and grades in case-insensitive name order |
| POST | `/api/materials` | `{ "name": "...", "grade": "..." }` (`grade` optional) | The material as listed: 201 when the material or grade was added, 200 when both were already listed |
Validation (400, no state change, `MaterialListRules`): a JSON object with no
other properties; `name` not blank; name and grade at most 200 characters with
no NUL characters.
Schema v10 adds empty `materials` and `material_grades` tables. Existing
records are not read into the list; fill it through `POST /api/materials`. No
other table changes. A server older than v10 refuses to open a v10 database, so
rolling back needs the pre-upgrade backup.
Schema v11 adds `release_drawings` (each release's drawings on their own, by
position, unique per drawing) and rebuilds `release_nest_items` with a nullable
`setId`, copying every row (SQLite cannot drop `NOT NULL` from a column). No
other table changes. A server older than v11 refuses to open a v11 database, so
rolling back needs the pre-upgrade backup.
## Storage
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with
`journal_mode=WAL`. Each row holds the metadata columns plus the `.nest`
archive as a `BLOB`. The database file path comes from `--database=<path>` or
`--database <path>`, then `OPENNEST_DB`, defaulting to `./data/nests.db`; a
`--database` without a path, or given twice, fails startup. The server opens the
database at startup, so an unusable path fails startup rather than the first request.
Run **one service instance with its database on a local filesystem**. Multiple PCs
may use that instance, but do not share the SQLite file between replicas or place
it on SMB/NFS storage. A private reentrant lock serializes complete database
operations (including readers, write readbacks, health checks, and disposal);
HTTP upload/body reading happens outside that lock. `/healthz` executes `SELECT 1`
on the live connection; it is a connection check, not a backup, integrity scan,
or guarantee of future disk capacity.
Revision preconditions refuse stale edits; the database monitor is not a document
edit lock. After a conflict, explicitly reload the newer revision or Save As a
separate nest. After an ambiguous update failure, reload the current version before another save.
For an ambiguous first upload (including Save As or a server switch), OpenNest
keeps the client-generated pending ID and blocks another upload. The next Save
checks that exact ID on the original server without writing: if it exists with
the exact attempted archive and metadata, the document binds to its revision;
if it is absent, another explicit Save (or Save As for a pending copy) may
retry with the same ID. If it differs, reopen the server record to review it;
do not discard the pending identity or blindly create another record. Older
servers remain read-only for existing nests until upgraded.
## Server integration tests
```sh
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
```
Cross-platform; runs in both CI workflows. Each test hosts the production routes in
memory (`WebApplicationFactory`) against its own temporary SQLite file and drives
them through the real `RemoteNestRepository` and `NestSaveSession`: create, list,
download, same-record update, copy, metadata-only update (archive and `fileSize`
unchanged), and delete. Missing/invalid upload parts must return 400 and unknown ids
404, both leaving every stored record and archive hash unchanged. Tests never open
`data/nests.db` or `OPENNEST_DB`; the temporary directory is removed when the host is
disposed. Concurrent HTTP clients and database-level readers/writers must retain
exact record membership, metadata, and archive bytes; a monitor-ownership test
checks every operation without relying on stress timing. Health tests check the
unchanged success response and a detail-free 503 after the database is disposed.
The [container smoke](#isolated-container-smoke) remains the image-level check.
## Running
Locally:
```sh
dotnet run --project OpenNest.Server/OpenNest.Server.csproj
```
Listens on `ASPNETCORE_URLS` (default Kestrel ports) unless overridden.
### Docker image
Docker must be built from the repository root: the server references
`OpenNest.Data`, which references `OpenNest.Core`. The Dockerfile copies all three
project files before restore and their source/resources before publish, including
Data's embedded `Defaults/CL-980.json`. The allowlist `.dockerignore` excludes
other projects, host `bin`/`obj`, local databases, environment files, and launch
settings. The server image uses the .NET 8 SDK to build and the .NET 8 ASP.NET
runtime to run; IO/Engine are used only by the host-side smoke tool, not the image.
```sh
docker build --pull -f OpenNest.Server/Dockerfile -t opennest-server:local .
# A release build stamps its version and exact source commit:
docker build --pull -f OpenNest.Server/Dockerfile --build-arg VERSION=X.Y.Z \
--build-arg SOURCE_REVISION="$(git rev-parse HEAD)" -t opennest-server:X.Y.Z .
```
Without build arguments the image is labeled version `0.0.0-dev`, revision
`unknown`: a development build, never a release. The OCI labels are
`org.opencontainers.image.source`, `version`, `revision` and `base.name`.
`SDK_IMAGE`/`RUNTIME_IMAGE` build arguments accept digest-pinned base images;
for a release, record the base digests the build log resolved.
Runtime contract:
- Container port 8090 (`ASPNETCORE_URLS=http://+:8090`). Change the host port
mapping, not the container port: the healthcheck probes 8090.
- Runs as the .NET base image's non-root `app` user (UID/GID 1654). Application
files are root-owned and read-only to it; within `/app` only `/app/data` is
writable, and a fresh named volume inherits that directory's ownership. The
database stays `OPENNEST_DB=/app/data/nests.db`. The server also needs the
container's writable `/tmp`: multipart uploads over 64 KiB are buffered there.
- `HEALTHCHECK` runs `curl --fail http://127.0.0.1:8090/healthz` (interval 30 s,
timeout 5 s, start period 10 s, retries 3, and a 2 s start interval during the
start period on Docker 25+). curl is in the image only for it.
- A read-only or unwritable data location fails startup: the process exits
rather than serving requests it cannot store.
- Uploads: Kestrel's default 30,000,000-byte request limit covers the whole
multipart request, so an archive must be slightly smaller (the smoke stores a
29,000,000-byte one). A larger request gets 413 and nothing is stored. The
desktop client streams without `Expect: 100-continue`, so it typically reports
a closed connection instead of the 413 status. The save still fails.
Data written by a root-run image or a root-owned bind mount is not writable by
UID 1654, so startup fails. With the operator's approval, change ownership of
that data location only, once, for example
`docker run --rm --user 0 --entrypoint chown -v <volume>:/app/data <image> -R 1654:1654 /app/data`
(or `chown -R 1654:1654` on the bind-mounted directory itself, never its
parents). The image has no root entrypoint that changes ownership.
### Deploying with Compose
`compose.server.yaml` runs a published image (no local build) with
`no-new-privileges`, all capabilities dropped, and a named data volume. Copy it and
`OpenNest.Server/server.env.example` to a deployment directory, save the env file
under a local name such as `server.env`, and set:
- `OPENNEST_SERVER_IMAGE`: `ghcr.io/ajisaacs/opennest-server:X.Y.Z` or, preferably,
`ghcr.io/ajisaacs/opennest-server@sha256:<verified-manifest-digest>` from the
successful clean pulled-image verification job. Do not substitute Docker's
store-dependent image `Id` or the config blob digest for this verified registry
manifest digest; see [server image releases](releasing.md#server-image-separate-from-windows-candidates).
- `OPENNEST_BIND_ADDRESS`: `127.0.0.1` for testing on the host; this host's
trusted LAN address for shop PCs. Do not use `0.0.0.0`. The bind address is only
one layer: verify that the network/firewall admits only trusted clients.
- `OPENNEST_HOST_PORT` (default 8090) and `OPENNEST_DATA_VOLUME` (default `opennest-data`).
Publication requires an existing verified private package; first-package
initialization needs separate owner authorization outside the release workflow.
An authorized deployment operator must obtain a
`read:packages` token out of band and log in on the deployment host using
`docker login ghcr.io -u <github-user> --password-stdin`, piping the token from a
secret manager or a protected prompt (never a token literal in shell history).
Keep Docker's credentials protected on that host; do not put credentials in
`server.env`, Compose, the image, or source control. Login/pull success does not
authorize making the package public. Until an approved image has passed the
published-digest smoke, the reference below is only a template, not an available
verified image.
```sh
compose="docker compose --env-file server.env -f compose.server.yaml"
$compose pull || { printf 'STOP: image pull failed\n' >&2; exit 1; }
$compose up -d
$compose ps # STATUS shows (healthy)
curl --fail http://<bind-address>:<port>/healthz # {"status":"ok"}
```
In the desktop app choose **File > Storage Mode...**, Database, and enter the base
URL `http://<bind-address>:<port>`, without `/healthz` or `/api/nests`.
To set or change just the saved server address without changing File/Database mode,
use **Tools > Options > Nest server URL > Set / Change Server**. This saves the URL
immediately, independently of the Options Save button; it does not migrate nests
or enable Database mode. Canceling Options afterward does not undo that address.
### Redeploying a development build
Until a verified image is published, `scripts/Deploy-Server.ps1` redeploys a
development build of this checkout to a host's Compose stack over SSH, using the
local Docker engine (no build on the host, no registry):
```powershell
powershell -ExecutionPolicy Bypass -File scripts/Deploy-Server.ps1 `
-DeployHost <host> -StackDir ~/docker/opennest `
-LocalComposeFile <config-repo>\opennest\compose.yml
```
It builds `opennest-server:<yyyyMMdd>-<short sha>` (`-dirty` and a warning when
`OpenNest.Server`, `OpenNest.Data` or `OpenNest.Core` have uncommitted changes;
`VERSION` stays `0.0.0-dev`, `SOURCE_REVISION` is the full commit), copies it with
`docker save`/`scp`/`docker load`, stops the container, tars the data volume to
`<BackupRoot>/opennest-<tag>/backup/` with the previous `compose.yml`, rewrites the
stack's `image: opennest-server:<tag>` line, starts the stack and waits for
`healthy`. The stack's `compose.yml` must name the image literally on that line,
not through a variable as `compose.server.yaml` does. `-LocalComposeFile` updates
a config repository's copy, uncommitted. Host, user and paths are parameters so
no private address is in source; `-SkipBackup` and `-NoPull` are available but
the backup is the only way back across a schema upgrade.
Build from a pushed commit (a clean worktree is simplest) so the running image
can be traced. When the build raises the schema version, first run the new image
in a throwaway loopback container on a snapshot of the live database and check
`user_version`, integrity and record counts. To roll back, stop the stack,
restore the tarball into the volume and set the printed previous tag: an older
server refuses a newer schema.
### Backup, restore, and upgrade
SQLite runs in WAL mode, so copying `nests.db` from a running service is not a
backup. Back up during a maintenance period with ALL client writes disabled,
including metadata edits, delete, recover and restore, until capture finishes.
The HTTP manifest is not a transaction across requests; writes during capture
can invalidate it. Keep the entire stopped data directory, including any WAL/SHM
files, original image digest and deployment configuration. Copy
`scripts/Server.Tests/nest_manifest.py` from this source version to the deployment
directory as `nest_manifest.py` (Python 3 standard library only). It enumerates
bounded active AND deleted pages plus ALL revision pages, then streams every
current/historical archive through SHA-256. Canonical JSON includes complete
metadata, IDs, current pointers, visibility, timestamps, byte sizes and restore
source relationships. Missing pages, legacy endpoints, failed downloads,
inconsistent totals and invalid revision chains fail closed, not as empty history.
Protect manifests like the database: they contain names and comments.
Each step below is a Bash
function that checks every command and stops at the first failure, however it is
called; define them in the shell where `compose` is set:
```bash
url=http://<bind-address>:<port>
# Canonical complete manifest; stdout is emitted only after all reads succeed.
nest_manifest() (
python3 ./nest_manifest.py "$1" || exit 1
)
# Records the expected manifest and metadata, then archives the stopped data directory.
# The service is restarted even if the archive fails.
backup_nests() ( # usage: backup_nests opennest-data-YYYY-MM-DD
set -o pipefail
fail() { echo "STOP: $*" >&2; exit 1; }
for suffix in tar manifest metadata.json; do
[[ ! -e "$1.$suffix" ]] || fail "$1.$suffix already exists"
done
nest_manifest "$url" > "$1.manifest" || fail "could not record the manifest"
curl -fsS "$url/api/nests" > "$1.metadata.json" || fail "could not record the metadata"
$compose stop || fail "could not stop the service"
$compose run --rm --no-deps -T --entrypoint tar opennest-server -C /app/data -cf - . > "$1.tar"
status=$?
$compose start || fail "could not restart the service"
curl -fs --retry 30 --retry-all-errors --retry-delay 1 "$url/healthz" > /dev/null \
|| fail "the restarted service is not healthy"
[[ $status == 0 ]] || fail "the archive failed; $1.tar is incomplete"
echo "Backed up all records and revisions to $1.tar"
)
backup_nests opennest-data-YYYY-MM-DD
```
Restore only into a volume created for that restore, never into an existing one
(least of all the active volume). `restore_check` refuses an existing volume,
extracts the backup into a new one, starts a temporary loopback-only container on
it, and requires complete active/deleted revision metadata and archive hashes to match. It
removes only the container it started; the new volume is kept either way:
```bash
restore_check() ( # usage: restore_check opennest-data-YYYY-MM-DD <image> [new-volume]
set -o pipefail
fail() { echo "STOP: $*" >&2; exit 1; }
[[ -r "$1.tar" && -r "$1.manifest" && -r "$1.metadata.json" ]] || fail "backup is incomplete"
restore=${3:-opennest-data-restore-$(date +%Y%m%d-%H%M%S)}
existing=$(docker volume ls --format '{{.Name}}') || fail "could not inspect Docker volumes"
while IFS= read -r volume; do
[[ "$volume" != "$restore" ]] || fail "volume $restore already exists"
done <<< "$existing"
owner=$(python3 -c 'import secrets; print(secrets.token_hex(16))') || fail "could not create ownership token"
docker volume create --label "com.opennest.restore.owner=$owner" "$restore" > /dev/null \
|| fail "could not create volume $restore"
[[ $(docker volume inspect --format '{{index .Labels "com.opennest.restore.owner"}}' "$restore") == "$owner" ]] \
|| fail "restore volume ownership differs"
echo "Restoring into new volume $restore"
docker run --rm -i --network none --entrypoint tar -v "$restore:/app/data" "$2" \
-C /app/data -xf - < "$1.tar" || fail "extraction into $restore failed"
check=$(docker create --cap-drop ALL --security-opt no-new-privileges:true \
-p 127.0.0.1::8090 -v "$restore:/app/data" "$2") \
|| fail "could not create the check container"
trap 'docker rm -f "$check" > /dev/null' EXIT
docker start "$check" > /dev/null || fail "the check container did not start"
port=$(docker port "$check" 8090/tcp | cut -d: -f2) && [[ -n $port ]] || fail "no check port"
curl -fs --retry 30 --retry-all-errors --retry-delay 1 "http://127.0.0.1:$port/healthz" > /dev/null \
|| fail "the restored service is not healthy"
curl -fsS "http://127.0.0.1:$port/api/nests" | cmp - "$1.metadata.json" || fail "metadata differs"
nest_manifest "http://127.0.0.1:$port" | diff - "$1.manifest" || fail "archives differ"
echo "Verified $restore. Keep it isolated until the operator explicitly approves switching volumes."
)
restore_check opennest-data-YYYY-MM-DD <image>
```
Switch only after `restore_check` prints `Verified`. If it stops, inspect or remove
the new volume it named; the active volume is untouched.
To upgrade, take a stopped-service backup and keep a copy of the current
`server.env`. Prefer a digest in `OPENNEST_SERVER_IMAGE` so that file records
exactly what ran; for a tag, record
`docker image inspect --format '{{join .RepoDigests " "}}' <image>` first. Change
only `OPENNEST_SERVER_IMAGE` after migration/restore rehearsal on a disposable copy,
then run `$compose up -d` only with separate deployment authorization.
**Never run an old image against a migrated database**, even if its current-table
GETs still work: old writers can overwrite snapshots or physically delete rows
without updating history. Rollback requires a NEW volume restored from the verified
PRE-migration backup and the exact old image/configuration. Test its old current
metadata/byte manifest on that isolated copy before switching; the revision-aware
manifest deliberately refuses old endpoints. Archive the migrated volume unchanged
for recovery; reverting loses post-backup changes from the old server's view, so
reconcile them deliberately, never replay them automatically. A pre-version server
backup needs its own current-record metadata/byte oracle before migration; verify
those records become byte-identical revision 1 on the disposable upgraded copy.
Never run
`docker compose down -v`: it deletes the data volume.
#### Capacity and retention
Every revision currently stores a full archive BLOB, including metadata-only
and visibility revisions. Budget approximately the sum of ALL revision archive
sizes plus current-table archives, SQLite indexes/pages, WAL and backups; do not
estimate capacity from active current records alone. Measure volume/database and
WAL growth during the disposable rehearsal, reserve space for migration and a
separate verified restore copy, and monitor free space before rollout. There is
no automatic retention or purge; no revision deletion is authorized. Deduplication
requires a separately reviewed migration after measuring actual growth. An old
backup is not a substitute for retaining the migrated volume during rollback.
## Isolated container smoke
Prerequisites: a local Linux Docker daemon on the default Unix-socket context,
Bash, Python 3, curl, GNU `timeout`, `mktemp`, `tar`, `cmp`, and a .NET SDK able to build `net8.0` projects
(.NET 8 or newer). Restore needs NuGet access or cached packages. Run from the
repository root after building the image:
```sh
scripts/Test-ServerContainer.sh --image opennest-server:local
# Optional durable logs outside the default ignored .hermes/progress directory:
scripts/Test-ServerContainer.sh --image opennest-server:local --results /path/to/results
```
Manifest refusal tests run without Docker. The optional image variables also
exercise the Bash backup/restore blocks extracted verbatim from this page in a
unique disposable Compose project, including existing-file/volume refusal and
tampered-history rejection. The old image test restores ONLY a synthetic
pre-migration backup to another fresh volume and compares every old record's
metadata/bytes; it never mounts a migrated database into the old image.
```sh
python3 -m unittest discover -s scripts/Server.Tests -p test_revision_backup.py -v
OPENNEST_TEST_IMAGE=opennest-server:local \
OPENNEST_TEST_OLD_IMAGE=<local-pre-version-image> \
python3 -m unittest discover -s scripts/Server.Tests -p test_revision_backup.py -v
```
Local smoke evidence does not imply registry publication or production deployment.
The wrapper accepts an image, not a URL or an existing volume/container. It uses
only the local default Docker context, creates uniquely labeled disposable
resources, and publishes to `127.0.0.1` on a Docker-allocated port. Every container
runs with the Compose example's `--cap-drop ALL` and `no-new-privileges`. It builds
the smoke tool once into a private temporary directory, honoring `TMPDIR`;
readiness, HTTP requests, and child commands have watchdogs.
Before starting a container it checks the image's numeric non-root `USER`, the
documented `HEALTHCHECK` command and timings (including the start interval), and
the OCI source/version/revision labels. A service is ready only when Docker reports the image's own healthcheck
`healthy` and the host receives `{"status":"ok"}`. Each service then checks that
the server process (PID 1) runs as the image user with no effective capabilities,
owns `nests.db`, and can write `/app/data` but not `/app` or the server DLL.
The test-only `scripts/Server.Tests/Server.Tests.csproj` console exercises the
existing .NET storage client; it is not included in the server image.
Before seeding, the wrapper restores two synthetic legacy-schema rows into its
fresh volume, verifies byte-identical revision-1 migration against a precomputed
complete metadata/hash oracle, then checks repeated startup preserves history.
The synthetic fixture is generated in code: one plate and one rectangular part,
with no customer files. `seed` uses the actual `RemoteNestRepository` and
`NestSaveSession` to create, update the same ID, and save a copy with a new ID.
It also creates metadata-only, historical-restore, delete/recover/delete revisions,
leaving a six-revision hidden record with a restore-source relationship.
It checks read-back metadata, session binding, exact list membership, raw
camelCase/string-enum JSON, identical downloaded bytes, and `NestReader` geometry
and counts. `reject` snapshots every record's metadata and archive hash, then
checks 400 responses for missing/invalid/null metadata and missing/empty files on
both upload and file-update routes; unknown metadata/file IDs must return 404.
Every rejected operation must leave the snapshot unchanged.
`limits` stores, downloads byte-for-byte, and soft-deletes a 29,000,000-byte synthetic
archive (buffered to disk by the non-root runtime), then requires 413 for a
request over 30,000,000 bytes sent with `Expect: 100-continue`, and a reported
failure from the real client's oversized upload and file update. The snapshot
must be unchanged after each.
The persistence stage stops and removes the first container while retaining its
fresh named volume, starts a replacement on another allocated loopback port, and runs
`verify` against saved IDs, **all** metadata (including server-assigned `savedAt`),
archive hashes, and exact list membership. It then follows the documented
stopped-service backup: `tar` of the whole data directory through the image,
restored into a second owned volume whose service must pass the same `verify`.
The Python manifest is captured after all mutations and compared exactly after
restart and volume restore, with page size 1 to exercise every pagination boundary.
It covers active and deleted current records and ALL revisions, not just the
ordinary `/api/nests` enumeration; the large soft-deleted archive remains included.
The original volume mounted read-only, and a root-owned `tmpfs`, must each make
the server exit nonzero at startup with a SQLite error and never report healthy.
A missing/invalid state file or failed assertion exits nonzero. Direct tool use is
test-only: it accepts plain loopback URLs, and `seed` refuses a nonempty server
unless `--test-allow-nonempty` is explicitly supplied for an owned disposable
target. Prefer the wrapper; loopback alone is not proof that an existing server is
disposable.
Each invocation writes a fresh `run.*` results directory containing build, smoke,
container, image, runtime, ownership, and cleanup logs. On failure those logs remain
for diagnosis. The exit trap removes only invocation-owned containers and named
volumes; transient state, the backup archive, synthetic databases, and build
artifacts are removed on success or failure. It never prunes Docker or alters
preexisting services/volumes.
Cleanup requires successful empty Docker inventory readback, including for an
already-removed container. Unresolved ownership, lookup, removal, or readback
errors make the wrapper fail without deleting unproven resources; inspect
`cleanup.log` and `outcome.log` for diagnostics. An original smoke failure is
preserved even if cleanup also fails.
The focused cleanup regression needs only Python 3 and Bash:
```sh
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
```
It executes the wrapper's actual cleanup functions with explicitly mocked Docker
transport, including timeouts and missing cidfiles; it does not replace the real
container smoke or contact a daemon.