feat(plantare): schema v12 — bilateral signed columns + repo

Adds the deferred bilateral columns to Plantares (plantare-bilateral.md
§"Schema delta"), all nullable/defaulted so v1 local rows coexist
untouched:

- pledgeId (the shared id both parties key their row by), debtorKey /
  creditorKey, debtorSignature / creditorSignature, movementId (the
  shared hand-over Movement for the provenance DAG), remoteState
  (proposed/accepted/declined — the handshake, distinct from status),
  returnKind (similar/workHours/other, default similar) + workHours.

Repo: createPlantare grows optional bilateral params; plantareByPledgeId
looks a row up by the shared id; applyPlantareSignatures counter-stamps
an incoming accept; setPlantareRemoteState records a decline. The
JSON codec carries every new field so they ride backups.

Versioned migration is guarded/idempotent like the others; schema v12
dumped, test helper regenerated, migration test covers v1..v11 → v12.
db + data + services 295/295 green.
This commit is contained in:
vjrj 2026-07-14 10:59:35 +02:00
parent 3f622f7bf7
commit 01fba40ec2
11 changed files with 5322 additions and 17 deletions

View file

@ -230,6 +230,16 @@ class InventoryJsonCodec {
'status': p.status.name,
'settledOn': p.settledOn,
'note': p.note,
// Bilateral signed form (plantare-bilateral.md).
'pledgeId': p.pledgeId,
'debtorKey': p.debtorKey,
'creditorKey': p.creditorKey,
'debtorSignature': p.debtorSignature,
'creditorSignature': p.creditorSignature,
'movementId': p.movementId,
'remoteState': p.remoteState?.name,
'returnKind': p.returnKind.name,
'workHours': p.workHours,
},
],
'sales': [
@ -484,6 +494,17 @@ class InventoryJsonCodec {
_enumOr(PlantareStatus.values, m['status'], PlantareStatus.open),
settledOn: m['settledOn'] as int?,
note: m['note'] as String?,
pledgeId: m['pledgeId'] as String?,
debtorKey: m['debtorKey'] as String?,
creditorKey: m['creditorKey'] as String?,
debtorSignature: m['debtorSignature'] as String?,
creditorSignature: m['creditorSignature'] as String?,
movementId: m['movementId'] as String?,
remoteState:
_enumOrNull(PlantareRemoteState.values, m['remoteState']),
returnKind: _enumOr(PlantareReturnKind.values, m['returnKind'],
PlantareReturnKind.similar),
workHours: (m['workHours'] as num?)?.toDouble(),
);
}),
sales: _rows(root, 'sales', (m) {

View file

@ -1867,14 +1867,27 @@ class VarietyRepository {
String? owedDescription,
int? dueBy,
String? note,
// Bilateral signed form (plantare-bilateral.md) all optional so a plain
// local note stays a v1 row with these null.
String? id,
int? madeOn,
String? pledgeId,
String? debtorKey,
String? creditorKey,
String? debtorSignature,
String? creditorSignature,
String? movementId,
PlantareRemoteState? remoteState,
PlantareReturnKind returnKind = PlantareReturnKind.similar,
double? workHours,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
final rowId = id ?? idGen.newId();
await _db
.into(_db.plantares)
.insert(
PlantaresCompanion.insert(
id: id,
id: rowId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
@ -1882,12 +1895,75 @@ class VarietyRepository {
varietyId: Value(varietyId),
counterparty: Value(counterparty),
owedDescription: Value(owedDescription),
madeOn: created,
madeOn: madeOn ?? created,
dueBy: Value(dueBy),
note: Value(note),
pledgeId: Value(pledgeId),
debtorKey: Value(debtorKey),
creditorKey: Value(creditorKey),
debtorSignature: Value(debtorSignature),
creditorSignature: Value(creditorSignature),
movementId: Value(movementId),
remoteState: Value(remoteState),
returnKind: Value(returnKind),
workHours: Value(workHours),
),
);
return id;
return rowId;
}
/// The local row holding the bilateral pledge [pledgeId], or null. Both parties
/// key their own row by the shared pledge id so accepts/declines reconcile.
Future<Plantare?> plantareByPledgeId(String pledgeId) =>
(_db.select(_db.plantares)
..where((p) => p.pledgeId.equals(pledgeId))
..limit(1))
.getSingleOrNull();
/// Applies a counterparty's stub(s) and the new handshake [remoteState] to the
/// row for [pledgeId] (e.g. on receiving an `accept`). No-op if unknown.
Future<void> applyPlantareSignatures({
required String pledgeId,
String? debtorSignature,
String? creditorSignature,
PlantareRemoteState? remoteState,
String? movementId,
}) async {
final (_, updated) = await _stamp();
await (_db.update(_db.plantares)
..where((p) => p.pledgeId.equals(pledgeId)))
.write(
PlantaresCompanion(
debtorSignature:
debtorSignature == null ? const Value.absent() : Value(debtorSignature),
creditorSignature: creditorSignature == null
? const Value.absent()
: Value(creditorSignature),
movementId:
movementId == null ? const Value.absent() : Value(movementId),
remoteState:
remoteState == null ? const Value.absent() : Value(remoteState),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Moves the row for [pledgeId] to a new handshake [state] (e.g. `declined`).
Future<void> setPlantareRemoteState(
String pledgeId,
PlantareRemoteState state,
) async {
final (_, updated) = await _stamp();
await (_db.update(_db.plantares)
..where((p) => p.pledgeId.equals(pledgeId)))
.write(
PlantaresCompanion(
remoteState: Value(state),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// All live commitments, newest first (for the Plantares screen).