1.0.0
This commit is contained in:
parent
990a129c02
commit
bb368721cf
10 changed files with 381 additions and 22 deletions
|
@ -0,0 +1,8 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="fires modified in all_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
|
||||||
|
<option name="filePath" value="$PROJECT_DIR$/test/all_test.dart" />
|
||||||
|
<option name="scope" value="GROUP_OR_TEST_BY_NAME" />
|
||||||
|
<option name="testName" value="fires modified" />
|
||||||
|
<method />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="fires removed in all_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
|
||||||
|
<option name="filePath" value="$PROJECT_DIR$/test/all_test.dart" />
|
||||||
|
<option name="scope" value="GROUP_OR_TEST_BY_NAME" />
|
||||||
|
<option name="testName" value="fires removed" />
|
||||||
|
<method />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
6
.idea/runConfigurations/tests_in_all_test_dart.xml
Normal file
6
.idea/runConfigurations/tests_in_all_test_dart.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="tests in all_test.dart" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
|
||||||
|
<option name="filePath" value="$PROJECT_DIR$/test/all_test.dart" />
|
||||||
|
<method />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
1
.travis.yml
Normal file
1
.travis.yml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
language: dart
|
2
CHANGELOG.md
Normal file
2
CHANGELOG.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# 1.0.0
|
||||||
|
* Created package + tests
|
42
README.md
42
README.md
|
@ -1,2 +1,42 @@
|
||||||
# poll
|
# poll
|
||||||
package:angel_client upport for "realtime" interactions with Angel via long polling.
|
[![Pub](https://img.shields.io/pub/v/angel_poll.svg)](https://pub.dartlang.org/packages/angel_poll)
|
||||||
|
[![build status](https://travis-ci.org/angel-dart/poll.svg?branch=master)](https://travis-ci.org/angel-dart/poll)
|
||||||
|
|
||||||
|
`package:angel_client` support for "realtime" interactions with Angel via long polling.
|
||||||
|
|
||||||
|
Angel supports [WebSockets](https://github.com/angel-dart/websocket) on the server and client, which
|
||||||
|
makes it very straightforward to implement realtime collections. However, not every user's browser
|
||||||
|
supports WebSockets. In such a case, applications might *gracefully degrade* to long-polling
|
||||||
|
the server for changes.
|
||||||
|
|
||||||
|
A `PollingService` wraps a client-side `Service` (typically a REST-based one), and calls its
|
||||||
|
`index` method at a regular interval. After indexing, the `PollingService` performs a diff
|
||||||
|
and identifies whether items have been created, modified, or removed. The updates are sent out
|
||||||
|
through `onCreated`, `onModified`, etc., effectively managing a real-time collection of data.
|
||||||
|
|
||||||
|
A common use-case would be passing this service to `ServiceList`, a class that manages the state
|
||||||
|
of a collection managed in real-time.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:angel_client/io.dart';
|
||||||
|
import 'package:angel_poll/angel_poll.dart';
|
||||||
|
|
||||||
|
main() {
|
||||||
|
var app = new Rest('http://localhost:3000');
|
||||||
|
|
||||||
|
var todos = new ServiceList(
|
||||||
|
new PollingService(
|
||||||
|
// Typically, you'll pass a REST-based service instance here.
|
||||||
|
app.service('api/todos'),
|
||||||
|
|
||||||
|
// `index` called every 5 seconds
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
todos.onChange.listen((_) {
|
||||||
|
// Something happened here.
|
||||||
|
// Maybe an item was created, modified, etc.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
21
example/main.dart
Normal file
21
example/main.dart
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:angel_client/io.dart';
|
||||||
|
import 'package:angel_poll/angel_poll.dart';
|
||||||
|
|
||||||
|
main() {
|
||||||
|
var app = new Rest('http://localhost:3000');
|
||||||
|
|
||||||
|
var todos = new ServiceList(
|
||||||
|
new PollingService(
|
||||||
|
// Typically, you'll pass a REST-based service instance here.
|
||||||
|
app.service('api/todos'),
|
||||||
|
|
||||||
|
// `index` called every 5 seconds
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
todos.onChange.listen((_) {
|
||||||
|
// Something happened here.
|
||||||
|
// Maybe an item was created, modified, etc.
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,13 +1,46 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:angel_client/angel_client.dart';
|
import 'package:angel_client/angel_client.dart';
|
||||||
|
|
||||||
class Poll extends Service {
|
/// A [Service] that facilitates real-time updates via the long polling of an [inner] service.
|
||||||
|
///
|
||||||
|
/// Works well with [ServiceList].
|
||||||
|
class PollingService extends Service {
|
||||||
|
/// The underlying [Service] that does the actual communication with the server.
|
||||||
final Service inner;
|
final Service inner;
|
||||||
|
|
||||||
|
/// Perform computations after polling to discern whether new items were created.
|
||||||
|
final bool checkForCreated;
|
||||||
|
|
||||||
|
/// Perform computations after polling to discern whether items were modified.
|
||||||
|
final bool checkForModified;
|
||||||
|
|
||||||
|
/// Perform computations after polling to discern whether items were removed.
|
||||||
|
final bool checkForRemoved;
|
||||||
|
|
||||||
|
/// An [EqualityBy] used to compare the ID's of two items.
|
||||||
|
///
|
||||||
|
/// Defaults to comparing the [idField] of two `Map` instances.
|
||||||
|
final EqualityBy compareId;
|
||||||
|
|
||||||
|
/// An [Equality] used to discern whether two items, with the same [idField], are the same item.
|
||||||
|
///
|
||||||
|
/// Defaults to [MapEquality], which deep-compares `Map` instances.
|
||||||
|
final Equality compareItems;
|
||||||
|
|
||||||
|
/// A [String] used as an index through which to compare `Map` instances.
|
||||||
|
///
|
||||||
|
/// Defaults to `id`.
|
||||||
final String idField;
|
final String idField;
|
||||||
|
|
||||||
|
/// If `true` (default: `false`), then `index` events will be handled as a [Map] containing a `data` field.
|
||||||
|
///
|
||||||
|
/// See https://github.com/angel-dart/paginate.
|
||||||
final bool asPaginated;
|
final bool asPaginated;
|
||||||
|
|
||||||
final List _items = [];
|
final List _items = [];
|
||||||
|
final List<StreamSubscription> _subs = [];
|
||||||
|
|
||||||
final StreamController _onIndexed = new StreamController(),
|
final StreamController _onIndexed = new StreamController(),
|
||||||
_onRead = new StreamController(),
|
_onRead = new StreamController(),
|
||||||
_onCreated = new StreamController(),
|
_onCreated = new StreamController(),
|
||||||
|
@ -15,7 +48,6 @@ class Poll extends Service {
|
||||||
_onUpdated = new StreamController(),
|
_onUpdated = new StreamController(),
|
||||||
_onRemoved = new StreamController();
|
_onRemoved = new StreamController();
|
||||||
|
|
||||||
bool Function(dynamic, dynamic) _compare;
|
|
||||||
Timer _timer;
|
Timer _timer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -39,15 +71,43 @@ class Poll extends Service {
|
||||||
@override
|
@override
|
||||||
Stream get onRemoved => _onRemoved.stream;
|
Stream get onRemoved => _onRemoved.stream;
|
||||||
|
|
||||||
Poll(this.inner, Duration interval,
|
PollingService(this.inner, Duration interval,
|
||||||
{this.idField: 'id', this.asPaginated: false, bool compare(a, b)}) {
|
{this.checkForCreated: true,
|
||||||
_timer = new Timer.periodic(interval, _timerCallback);
|
this.checkForModified: true,
|
||||||
_compare = compare ?? (a, b) => a[idField ?? 'id'] == b[idField ?? 'id'];
|
this.checkForRemoved: true,
|
||||||
|
this.idField: 'id',
|
||||||
|
this.asPaginated: false,
|
||||||
|
EqualityBy compareId,
|
||||||
|
this.compareItems: const MapEquality()})
|
||||||
|
: compareId = compareId ?? new EqualityBy((map) => map[idField ?? 'id']) {
|
||||||
|
_timer = new Timer.periodic(interval, (_) {
|
||||||
|
index().catchError(_onIndexed.addError);
|
||||||
|
});
|
||||||
|
|
||||||
|
var streams = <Stream, StreamController>{
|
||||||
|
inner.onRead: _onRead,
|
||||||
|
inner.onCreated: _onCreated,
|
||||||
|
inner.onModified: _onModified,
|
||||||
|
inner.onUpdated: _onUpdated,
|
||||||
|
inner.onRemoved: _onRemoved,
|
||||||
|
};
|
||||||
|
|
||||||
|
streams.forEach((stream, ctrl) {
|
||||||
|
_subs.add(stream.listen(ctrl.add, onError: ctrl.addError));
|
||||||
|
});
|
||||||
|
|
||||||
|
_subs.add(
|
||||||
|
inner.onIndexed.listen(
|
||||||
|
_handleIndexed,
|
||||||
|
onError: _onIndexed.addError,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future close() async {
|
Future close() async {
|
||||||
_timer.cancel();
|
_timer.cancel();
|
||||||
|
_subs.forEach((s) => s.cancel());
|
||||||
_onIndexed.close();
|
_onIndexed.close();
|
||||||
_onRead.close();
|
_onRead.close();
|
||||||
_onCreated.close();
|
_onCreated.close();
|
||||||
|
@ -59,35 +119,134 @@ class Poll extends Service {
|
||||||
@override
|
@override
|
||||||
Future index([Map params]) {
|
Future index([Map params]) {
|
||||||
return inner.index().then((data) {
|
return inner.index().then((data) {
|
||||||
var items = asPaginated == true ? data['data'] : data;
|
return asPaginated == true ? data['data'] : data;
|
||||||
_items
|
|
||||||
..clear()
|
|
||||||
..addAll(items);
|
|
||||||
_onIndexed.add(items);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future remove(id, [Map params]) {}
|
Future remove(id, [Map params]) {
|
||||||
|
return inner.remove(id, params).then((result) {
|
||||||
|
_items.remove(result);
|
||||||
|
return result;
|
||||||
|
}).catchError(_onRemoved.addError);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleUpdate(result) {
|
||||||
|
int index = -1;
|
||||||
|
|
||||||
|
for (int i = 0; i < _items.length; i++) {
|
||||||
|
if (compareId.equals(_items[i], result)) {
|
||||||
|
index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
_items[index] = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future update(id, data, [Map params]) {}
|
Future update(id, data, [Map params]) {
|
||||||
|
return inner
|
||||||
|
.update(id, data, params)
|
||||||
|
.then(_handleUpdate)
|
||||||
|
.catchError(_onUpdated.addError);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future modify(id, data, [Map params]) {}
|
Future modify(id, data, [Map params]) {
|
||||||
|
return inner
|
||||||
|
.modify(id, data, params)
|
||||||
|
.then(_handleUpdate)
|
||||||
|
.catchError(_onModified.addError);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future create(data, [Map params]) {}
|
Future create(data, [Map params]) {
|
||||||
|
return inner.create(data, params).then((result) {
|
||||||
|
_items.add(result);
|
||||||
|
return result;
|
||||||
|
}).catchError(_onCreated.addError);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future read(id, [Map params]) {}
|
Future read(id, [Map params]) {
|
||||||
|
return inner.read(id, params);
|
||||||
|
}
|
||||||
|
|
||||||
void _timerCallback(Timer timer) {
|
void _handleIndexed(data) {
|
||||||
index().then((data) {
|
|
||||||
var items = asPaginated == true ? data['data'] : data;
|
var items = asPaginated == true ? data['data'] : data;
|
||||||
|
bool changesComputed = false;
|
||||||
|
|
||||||
// TODO: Check create, modify, remove
|
if (checkForCreated != false) {
|
||||||
|
var newItems = <int, dynamic>{};
|
||||||
|
|
||||||
}).catchError(_onIndexed.addError);
|
for (int i = 0; i < items.length; i++) {
|
||||||
|
var item = items[i];
|
||||||
|
|
||||||
|
if (!_items.any((i) => compareId.equals(i, item))) {
|
||||||
|
newItems[i] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems.forEach((index, item) {
|
||||||
|
_items.insert(index, item);
|
||||||
|
_onCreated.add(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
changesComputed = newItems.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkForRemoved != false) {
|
||||||
|
var removedItems = <int, dynamic>{};
|
||||||
|
|
||||||
|
for (int i = 0; i < _items.length; i++) {
|
||||||
|
var item = _items[i];
|
||||||
|
|
||||||
|
if (!items.any((i) => compareId.equals(i, item))) {
|
||||||
|
removedItems[i] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removedItems.forEach((index, item) {
|
||||||
|
_items.removeAt(index);
|
||||||
|
_onRemoved.add(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
changesComputed = changesComputed || removedItems.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkForModified != false) {
|
||||||
|
var modifiedItems = <int, dynamic>{};
|
||||||
|
|
||||||
|
for (var item in items) {
|
||||||
|
for (int i = 0; i < _items.length; i++) {
|
||||||
|
var localItem = _items[i];
|
||||||
|
|
||||||
|
if (compareId.equals(item, localItem)) {
|
||||||
|
if (!compareItems.equals(item, localItem)) {
|
||||||
|
modifiedItems[i] = item;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedItems.forEach((index, item) {
|
||||||
|
_onModified.add(_items[index] = item);
|
||||||
|
});
|
||||||
|
|
||||||
|
changesComputed = changesComputed || modifiedItems.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changesComputed) {
|
||||||
|
_items
|
||||||
|
..clear()
|
||||||
|
..addAll(items);
|
||||||
|
_onIndexed.add(items);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
name: angel_poll
|
name: angel_poll
|
||||||
|
version: 1.0.0
|
||||||
|
description: package:angel_client support for "realtime" interactions with Angel via long polling.
|
||||||
|
author: Tobe O <thosakwe@gmail.com>
|
||||||
|
environment:
|
||||||
|
sdk: ">=1.19.0"
|
||||||
|
homepage: https://github.com/angel-dart/poll
|
||||||
dependencies:
|
dependencies:
|
||||||
angel_client: ^1.0.0
|
angel_client: ^1.0.0
|
||||||
|
async: ">=1.10.0 <3.0.0"
|
||||||
|
collection: ^1.0.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
angel_test: ^1.1.0
|
angel_test: ^1.1.0
|
||||||
test: ^0.12.0
|
test: ^0.12.0
|
106
test/all_test.dart
Normal file
106
test/all_test.dart
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:angel_framework/angel_framework.dart' as srv;
|
||||||
|
import 'package:angel_poll/angel_poll.dart';
|
||||||
|
import 'package:angel_test/angel_test.dart';
|
||||||
|
import 'package:async/async.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
srv.Service store;
|
||||||
|
TestClient client;
|
||||||
|
PollingService pollingService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
var app = new srv.Angel();
|
||||||
|
app.logger = new Logger.detached('angel_poll')
|
||||||
|
..onRecord.listen((rec) {
|
||||||
|
print(rec);
|
||||||
|
if (rec.error != null) {
|
||||||
|
print(rec.error);
|
||||||
|
print(rec.stackTrace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
store = app.use(
|
||||||
|
'/api/todos',
|
||||||
|
new srv.MapService(
|
||||||
|
autoIdAndDateFields: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
client = await connectTo(app);
|
||||||
|
|
||||||
|
pollingService = new PollingService(
|
||||||
|
client.service('api/todos'),
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => client.close());
|
||||||
|
|
||||||
|
group('events', () {
|
||||||
|
var created;
|
||||||
|
StreamQueue onCreated, onModified, onRemoved;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
onCreated = new StreamQueue(pollingService.onCreated);
|
||||||
|
onModified = new StreamQueue(pollingService.onModified);
|
||||||
|
onRemoved = new StreamQueue(pollingService.onRemoved);
|
||||||
|
|
||||||
|
created = await store.create({
|
||||||
|
'id': '0',
|
||||||
|
'text': 'Clean your room',
|
||||||
|
'completed': false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
onCreated.cancel();
|
||||||
|
onModified.cancel();
|
||||||
|
onRemoved.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires indexed', () async {
|
||||||
|
var indexed = await pollingService.index();
|
||||||
|
print(indexed);
|
||||||
|
expect(await pollingService.onIndexed.first, indexed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires created', () async {
|
||||||
|
var result = await onCreated.next;
|
||||||
|
print(result);
|
||||||
|
expect(created, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires modified', () async {
|
||||||
|
await pollingService.index();
|
||||||
|
await store.modify('0', {
|
||||||
|
'text': 'go to school',
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await onModified.next;
|
||||||
|
print(result);
|
||||||
|
expect(result, new Map.from(created)..['text'] = 'go to school');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual modify', () async {
|
||||||
|
await pollingService.index();
|
||||||
|
await pollingService.modify('0', {
|
||||||
|
'text': 'eat',
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await onModified.next;
|
||||||
|
print(result);
|
||||||
|
expect(result, new Map.from(created)..['text'] = 'eat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fires removed', () async {
|
||||||
|
await pollingService.index();
|
||||||
|
var removed = await store.remove('0');
|
||||||
|
var result = await onRemoved.next;
|
||||||
|
print(result);
|
||||||
|
expect(result, removed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue