Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
91a5ff4
fix(api): wire QR size param, normalize URLs in updateLink, use enabl…
claude May 1, 2026
d19edf2
fix(sdk/typescript): qr() size param type string -> number
claude May 1, 2026
7932123
fix(sdk/python): sentinel pattern for nullable fields in update()
claude May 1, 2026
63d2d6e
fix(sdk/dart): sentinel pattern for nullable fields in update()
claude May 1, 2026
711a998
constants: harmonize slug-length cap at 128 via MAX_SLUG_LENGTH
DennisAlund May 7, 2026
8d6f3e4
api/qr: reject non-positive and out-of-range size
DennisAlund May 7, 2026
1cf7013
sdk: bump x-spec-hash for slug-length and QR size schema changes
DennisAlund May 7, 2026
f4145bd
sdk/dart: switch update() to take a model and add copyWith
DennisAlund May 7, 2026
1c9dc03
test/api-schemas: track new MAX_SLUG_LENGTH boundary
DennisAlund May 7, 2026
7423f55
test(handler/redirect-kv): label POST to suppress autoLabelLink fetch
DennisAlund May 7, 2026
fbac310
api/qr: hoist size bounds to constants, enforce max on both routes
DennisAlund May 7, 2026
b596f84
api/qr,mcp: default to primary slug, not first custom
DennisAlund May 7, 2026
c51d7fc
sdk/dart: bump 1.1.0 -> 2.0.0 for breaking update() signature
DennisAlund May 7, 2026
0f02798
qr: enforce MAX_QR_SIZE at the renderer too
DennisAlund May 7, 2026
73ef262
api/qr: reject empty ?slug= with 400 on the admin route
DennisAlund May 7, 2026
df19b9a
services/links: map enable() race-condition null to 404
DennisAlund May 7, 2026
888d18e
sdk/dart: docs polish for 2.0.0
DennisAlund May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions sdk/dart/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 2.0.0 (2026-05-07)

**Breaking change to `update()` methods.** `LinksResource.update` and `BundlesResource.update` now take a `Link` / `Bundle` object instead of an id with optional named parameters. To edit a record, call `copyWith` on the value returned by `get()` (or any other read), then pass it to `update()`.

```dart
// 1.x
await client.links.update(42, label: null);

// 2.0
final link = await client.links.get(42);
await client.links.update(link.copyWith(label: null));
```

`Link`, `Bundle`, and `BundleWithSummary` now expose a `copyWith` method. Pass `null` to a nullable field to clear it; omit the parameter to preserve the current value. The disambiguation between "omit" and "explicit null" is encapsulated in `copyWith` via a private sentinel, so callers never see it. `update()` always sends the full writable payload, which sidesteps the omit-vs-clear ambiguity at the wire level.

The Python and TypeScript SDKs keep their existing partial-update shapes; each language implements the same set/clear/omit feature in its own idiom.

## 1.0.1 (2026-04-30)

Packaging and documentation only. No public surface changes.
Expand Down
112 changes: 112 additions & 0 deletions sdk/dart/lib/src/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@

import 'package:meta/meta.dart';

// ---- copyWith sentinel ----

// Distinguishes "argument not passed" from "argument explicitly null" inside
// copyWith. A private named class is used (not `const Object()`) so the
// sentinel cannot be matched by any value an external caller could construct.
class _Unset {
const _Unset();
}

const _Unset _unset = _Unset();

// ---- Enum types ----

/// Accent color applied to a bundle.
Expand Down Expand Up @@ -240,6 +251,36 @@ class Link {
/// Click count change as a percentage versus the previous equivalent period.
/// Absent when comparison data is unavailable.
final double? deltaPct;

/// Returns a copy with selected fields replaced.
///
/// Pass `null` to a nullable field to clear it. Omit the parameter to
/// preserve the current value.
Link copyWith({
int? id,
String? url,
Object? label = _unset,
int? createdAt,
Object? expiresAt = _unset,
Object? createdVia = _unset,
String? createdBy,
List<Slug>? slugs,
int? totalClicks,
Object? deltaPct = _unset,
}) {
return Link(
id: id ?? this.id,
url: url ?? this.url,
label: identical(label, _unset) ? this.label : label as String?,
createdAt: createdAt ?? this.createdAt,
expiresAt: identical(expiresAt, _unset) ? this.expiresAt : expiresAt as int?,
Comment on lines +259 to +276
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this. The Object? = _unset shape is the standard Dart idiom for omit-vs-explicit-null in copyWith: it's exactly what freezed generates, and the equivalents (Optional<T>, record-based (T,) wrappers) make every call site noisier even for the common non-null set case (copyWith(label: const Maybe('x')) vs copyWith(label: 'x')).

The model fields themselves stay typed (Link.label is String?). The Object? only leaks at the copyWith parameter, the runtime cast as String? throws on a wrong-type pass, and IDE auto-complete picks the field's declared type. That seems like a fair trade for the call-site ergonomics, and the test suite covers omit/clear/set across both classes.

createdVia: identical(createdVia, _unset) ? this.createdVia : createdVia as String?,
createdBy: createdBy ?? this.createdBy,
slugs: slugs ?? this.slugs,
totalClicks: totalClicks ?? this.totalClicks,
deltaPct: identical(deltaPct, _unset) ? this.deltaPct : deltaPct as double?,
);
}
}

/// A collection of links grouped to show combined engagement.
Expand Down Expand Up @@ -302,6 +343,36 @@ class Bundle {

/// Unix seconds when the bundle was last modified.
final int updatedAt;

/// Returns a copy with selected fields replaced.
///
/// Pass `null` to a nullable field to clear it. Omit the parameter to
/// preserve the current value.
Bundle copyWith({
int? id,
String? name,
Object? description = _unset,
Object? icon = _unset,
BundleAccent? accent,
Object? archivedAt = _unset,
Object? createdVia = _unset,
String? createdBy,
int? createdAt,
int? updatedAt,
}) {
return Bundle(
id: id ?? this.id,
name: name ?? this.name,
description: identical(description, _unset) ? this.description : description as String?,
icon: identical(icon, _unset) ? this.icon : icon as String?,
accent: accent ?? this.accent,
archivedAt: identical(archivedAt, _unset) ? this.archivedAt : archivedAt as int?,
createdVia: identical(createdVia, _unset) ? this.createdVia : createdVia as String?,
createdBy: createdBy ?? this.createdBy,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

/// Bundle enriched with range-scoped summary data.
Expand Down Expand Up @@ -367,6 +438,47 @@ class BundleWithSummary extends Bundle {

/// Top member links by click count.
final List<BundleTopLink> topLinks;

/// Returns a copy with selected fields replaced.
///
/// Pass `null` to a nullable field to clear it. Omit the parameter to
/// preserve the current value.
@override
BundleWithSummary copyWith({
int? id,
String? name,
Object? description = _unset,
Object? icon = _unset,
BundleAccent? accent,
Object? archivedAt = _unset,
Object? createdVia = _unset,
String? createdBy,
int? createdAt,
int? updatedAt,
int? linkCount,
int? totalClicks,
Object? deltaPct = _unset,
List<int>? sparkline,
List<BundleTopLink>? topLinks,
}) {
return BundleWithSummary(
id: id ?? this.id,
name: name ?? this.name,
description: identical(description, _unset) ? this.description : description as String?,
icon: identical(icon, _unset) ? this.icon : icon as String?,
accent: accent ?? this.accent,
archivedAt: identical(archivedAt, _unset) ? this.archivedAt : archivedAt as int?,
createdVia: identical(createdVia, _unset) ? this.createdVia : createdVia as String?,
createdBy: createdBy ?? this.createdBy,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
linkCount: linkCount ?? this.linkCount,
totalClicks: totalClicks ?? this.totalClicks,
deltaPct: identical(deltaPct, _unset) ? this.deltaPct : deltaPct as double?,
sparkline: sparkline ?? this.sparkline,
topLinks: topLinks ?? this.topLinks,
);
}
}

/// Top-link entry preview shown in a [BundleWithSummary].
Expand Down
31 changes: 17 additions & 14 deletions sdk/dart/lib/src/resources/bundles.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,24 @@ class BundlesResource {
return Bundle.fromJson(json! as Map<String, dynamic>);
}

/// Update a bundle's name, description, icon, or accent.
Future<Bundle> update(
int id, {
String? name,
String? description,
String? icon,
BundleAccent? accent,
}) async {
final body = <String, dynamic>{};
if (name != null) body['name'] = name;
if (description != null) body['description'] = description;
if (icon != null) body['icon'] = icon;
if (accent != null) body['accent'] = accent.wireValue;
/// Update a bundle with the values held by [bundle].
///
/// The writable fields ([Bundle.name], [Bundle.description], [Bundle.icon],
/// [Bundle.accent]) are always sent on the wire; pass a [Bundle] produced
/// by [Bundle.copyWith] with the desired changes (use `null` on a nullable
/// field to clear it). [BundleWithSummary] is also accepted because it
/// extends [Bundle]; only the writable fields above are put on the wire,
/// so the summary fields never reach the server (whose update schema is
/// strict and would reject them).
Future<Bundle> update(Bundle bundle) async {
final body = <String, dynamic>{
'name': bundle.name,
'description': bundle.description,
'icon': bundle.icon,
'accent': bundle.accent.wireValue,
};
final json =
await _http.requestJson('PUT', '/_/api/bundles/$id', body: body);
await _http.requestJson('PUT', '/_/api/bundles/${bundle.id}', body: body);
return Bundle.fromJson(json! as Map<String, dynamic>);
}

Expand Down
27 changes: 15 additions & 12 deletions sdk/dart/lib/src/resources/links.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,21 @@ class LinksResource {
return Link.fromJson(json! as Map<String, dynamic>);
}

/// Update a link's URL, label, or expiry.
Future<Link> update(
int id, {
String? url,
String? label,
int? expiresAt,
}) async {
final body = <String, dynamic>{};
if (url != null) body['url'] = url;
if (label != null) body['label'] = label;
if (expiresAt != null) body['expires_at'] = expiresAt;
final json = await _http.requestJson('PUT', '/_/api/links/$id', body: body);
/// Update a link with the values held by [link].
///
/// The writable fields ([Link.url], [Link.label], [Link.expiresAt]) are
/// always sent on the wire; pass a [Link] produced by [Link.copyWith] with
/// the desired changes (use `null` on a nullable field to clear it).
/// Read-only fields like [Link.id] are used for routing; server-managed
/// fields like slugs and click counts are not included in the request body
/// (the server's update schema is strict and would reject them).
Future<Link> update(Link link) async {
final body = <String, dynamic>{
'url': link.url,
'label': link.label,
'expires_at': link.expiresAt,
};
final json = await _http.requestJson('PUT', '/_/api/links/${link.id}', body: body);
return Link.fromJson(json! as Map<String, dynamic>);
}

Expand Down
4 changes: 2 additions & 2 deletions sdk/dart/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: shrtnr
description: Dart client for the shrtnr URL shortener API. Create short links, manage custom slugs, and read click analytics.
version: 1.0.1
version: 2.0.0
homepage: https://oddb.it/shrtnr-website-pub
repository: https://github.com/oddbit/shrtnr
issue_tracker: https://github.com/oddbit/shrtnr/issues
# x-spec-hash: sha256:3b6e9163bac619b9bbef7ba774b2cd06a9a968f1223b85ba302a018c4bee3b57
# x-spec-hash: sha256:0d54031654375b3a269cd23d05d66ff44a4b10a894d09b55ed2c1c66405c25b8
Comment on lines 1 to +7
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, breaking signature change on 1.x should bump to 2.x under SemVer. Bumped to 2.0.0 in pubspec.yaml and updated the CHANGELOG heading.

# Stored as a comment rather than a top-level key to avoid a pana score deduction for unknown manifest fields.

topics:
Expand Down
Loading